Skip to content

dtdoctor: reverse-engineer four more error classes, report disabled ancestors - #501

Draft
kartben wants to merge 9 commits into
dtdoctor-testsfrom
claude/error-macro-reverse-engineer-t29qdc
Draft

dtdoctor: reverse-engineer four more error classes, report disabled ancestors#501
kartben wants to merge 9 commits into
dtdoctor-testsfrom
claude/error-macro-reverse-engineer-t29qdc

Conversation

@kartben

@kartben kartben commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Builds on zephyrproject-rtos#117239 (dtdoctor-tests).

DT Doctor currently understands exactly one symbol shape: __device_dts_ord_<N>, i.e. DEVICE_DT_GET() on a node identifier that does resolve but has no struct device. That is only the first of the failure modes doc/build/dts/troubleshooting.rst documents.

The errors users hit more often are the ones where the node identifier itself never resolves, so the unexpanded macro leaks straight into the compiler output:

error: '__device_dts_ord_DT_N_NODELABEL_my_serial_ORD' undeclared   # DEVICE_DT_GET(DT_NODELABEL(my_serial))
error: 'DT_N_S_soc_S_uart_40002000_P_currentspeed' undeclared       # DT_PROP(..., currentspeed)
error: '__device_dts_ord_DT_N_INST_0_vnd_foo_ORD' undeclared        # DT_DRV_INST(0) with no instance
error: 'DT_N_S_leds_S_led_0_P_gpios_IDX_0_VAL_pen' undeclared       # DT_GPIO_PIN(LED, gpios)

Today the wrapper's regexes require __device_dts_ord_\d+, so none of these are even detected. The user gets the raw macro soup and nothing else. Everything needed to explain them is already in edt.pickle.

What's added

Family Symbol shape Diagnosis
Node identifier naming no node DT_N_NODELABEL_*, DT_N_ALIAS_*, DT_CHOSEN_*, unknown DT_N_S_* Which label, alias, chosen entry or path is missing, fuzzy "did you mean" drawn from the devicetree, and the overlay snippet to write
Property a node does not have <node-id>_P_<prop> Node's real properties in both DTS and C spellings, binding path, and a typo distinguished from a property the binding declares but the node never sets (DT_PROP_OR() / DT_NODE_HAS_PROP())
DT_INST() / DT_DRV_COMPAT DT_N_INST_<i>_<compat> How many instances the compatible has and each one's status in index order, or unknown-compatible plus the DT_DRV_COMPAT lowercase-and-underscores rule
Specifier index, name or cell <node-id>_P_<pha>_IDX_<i>_VAL_<cell>, _IRQ_*, _REG_* The controller that defines the cells, its cell list with this node's values, and the closest match. A bad index or name lists the entries that do exist.
Device below a disabled parent __device_dts_ord_<N> (resolved node) Both node diagnoses now walk the ancestor chain and name every disabled ancestor with the file and line where its status is set — enabling the node or its driver alone would not be enough

How

One reverse map: every node identifier macro gen_defines.py emits, mapped back to its edtlib.Node. Match a failing macro against it. The longest part that does name a node tells us what exists, and the leftover tells us what does not.

The name mangling is taken from gen_defines.node_z_path_id() / str2ident() rather than reimplemented, since the two drifting apart would silently resolve macros to the wrong node.

A node identifier that resolves now falls through to the existing enabled/disabled diagnosis, so DEVICE_DT_GET(DT_NODELABEL(x)) gets the same answer the ordinal form already got.

Example output
+-----------------------------------------------------------------------------------+
| DT Doctor                                                                         |
+===================================================================================+
| No node label 'my_serail' exists in this build's devicetree.                      |
|                                                                                   |
| Did you mean one of these?                                                        |
|                                                                                   |
|  - my_serial                                                                      |
|                                                                                   |
| Node labels are the 'name:' part written in front of a node in a DTS file, e.g.   |
| 'my_serial: uart@40002000'. In C they are lowercased, so DT_NODELABEL(my_serial). |
|                                                                                   |
| Node labels can be added to an existing node from a devicetree overlay:           |
|                                                                                   |
|     my_serial: &uart0 {};                                                         |
|                                                                                   |
| See <build>/zephyr/zephyr.dts for the devicetree this build actually used.        |
+-----------------------------------------------------------------------------------+

and for a cell, where the answer lives in a binding the user never mentioned:

+-------------------------------------------------------------------------------------+
| DT Doctor                                                                           |
+=====================================================================================+
| 'led0: /leds/led_0' has no 'pen' cell in entry 0 of the 'gpios' property.           |
|                                                                                     |
| Entry 0 is controlled by 'gpio0: /soc/gpio@50000000', which defines these cells:    |
|                                                                                     |
|  - pin     (currently 13)                                                           |
|  - flags   (currently 1)                                                            |
|                                                                                     |
| Did you mean one of these?                                                          |
|                                                                                     |
|  - pin                                                                              |
|                                                                                     |
| Cell names come from the controller's binding, not this node's. See its             |
| '*-cells:' list in                                                                  |
| zephyr/dts/bindings/gpio/nordic,nrf-gpio.yaml                                       |
+-------------------------------------------------------------------------------------+

Subtleties worth a look during review

  • Path identifiers are prefixes of each other and of the alternate-ID namespaces. A naive longest-prefix match resolved DT_N_NODELABEL_foo_ORD to the root node and DT_N_S_soc_S_uart_9999 to /soc. Matching is scoped to the namespace the macro is written in, plus a leftover-_S_ check so a partially matched path blames the node the full path names rather than the ancestor that happened to match.

  • Recovering a name from a macro that has no generated counterpart. Trimming trailing _-separated components until something fuzzy-matched over-trims, and would report DT_N_ALIAS_no_such_alias_ORD as an alias named no_such: a wrong name next to a suggestion, which is worse than no suggestion. There is no need to guess. What <devicetree.h> appends always starts with an upper case component (_ORD, _P_, _REG_IDX_0), and gen_defines.py lower-cases every name it builds identifiers from, so the first upper case component is exactly where the name ends. The specifier parser leans on the same invariant to keep _VAL_ from being read as part of a name.

  • Nothing keys off gpios in particular. The specifier handler runs on any phandle-array property, and Zephyr's in-tree bindings declare several hundred distinct ones (reset-gpios, int-gpios, clocks, pwms, dmas). Only interrupts and reg are a fixed pair, since those hang off the node rather than off a property. The fixture carries a second phandle-array with a different specifier space so this stays pinned.

  • Specifier entry names are keyed by the specifier space, not the property name. cs-gpios entries are named through gpio-names, not cs-gpio-names. The integration fixture uses a dtdoctor-gpios property with gpio-names so this is pinned end to end rather than only in a unit test.

  • A node below a disabled parent still counts as okay. edtlib and gen_defines.py only look at a node's own status, so its driver builds, its instance macros exist, and the failure surfaces on the parent's ordinal. Reporting disabled ancestors in both node diagnoses is what keeps the user from fixing one node per rebuild all the way up the chain.

Testing

Every commit in the series is green on its own, with the suites growing 37 → 83 → 105 → 110 unit and 7 → 20 → 26 → 27 integration.

  • tests/misc/dtdoctor/unit/ fixture devicetrees carry node labels, an alias, a /chosen entry, two instances of one compatible, properties that are set / declared-but-unset / of a type with no plain value macro, all three specifier spaces (a named phandle-array, a second one in a different space, a deliberately unnamed interrupt, a register), and enabled/disabled nodes below disabled parents and grandparents. One test module per family. 110 tests pass.
  • tests/misc/dtdoctor/unit/test_dtdoctor_wrapper.py covers the new symbol shapes across the gcc, GNU ld, clang and lld message formats, transcribed from real toolchain output, plus a check that unrelated undeclared symbols are still ignored.
  • tests/misc/dtdoctor/e2e/ exercises each family against the build's real edt.pickle, and compiles real macro uses (DEVICE_DT_GET(DT_NODELABEL(dtdoctor_no_such_label)), DT_PROP(…, no_such_prop), DT_PHA_BY_IDX(…)) with the application's own compile commands replayed from compile_commands.json, in C and C++, so the leaked-macro shapes come from the real <devicetree.h> expansion rather than transcription. Toolchain message drift is what these shapes are most exposed to, and it is not something the unit suite can catch. 27 tests collected.
  • ruff check and ruff format clean on all touched files; check_compliance.py (Gitlint, Ruff, YAMLLint, KeepSorted, Identity) clean over the whole range.

One caveat: the unit suite runs everywhere, but the integration suite was validated against a hand-built edt.pickle using host toolchains. The real twister/ctest run against the Zephyr SDK on qemu_cortex_m3 still needs CI.

@kartben kartben changed the title dtdoctor: reverse-engineer three more classes of Devicetree build error dtdoctor: reverse-engineer four more classes of Devicetree build error Aug 25, 2026
@kartben
kartben force-pushed the claude/error-macro-reverse-engineer-t29qdc branch from e291676 to cd198de Compare August 25, 2026 05:34
@kartben
kartben force-pushed the dtdoctor-tests branch 5 times, most recently from 753245f to dc21102 Compare August 25, 2026 16:08
@kartben
kartben force-pushed the claude/error-macro-reverse-engineer-t29qdc branch from cd198de to 96ce7da Compare August 25, 2026 16:20
@kartben kartben changed the title dtdoctor: reverse-engineer four more classes of Devicetree build error dtdoctor: reverse-engineer four more error classes, report disabled ancestors Aug 25, 2026
@kartben
kartben force-pushed the claude/error-macro-reverse-engineer-t29qdc branch from 96ce7da to 23c302d Compare August 25, 2026 17:00
@kartben
kartben force-pushed the claude/error-macro-reverse-engineer-t29qdc branch from 23c302d to 5670a60 Compare August 25, 2026 17:04
DT Doctor could only explain one kind of build error:
__device_dts_ord_<N>, i.e. DEVICE_DT_GET() on a node identifier that
resolved but has no driver.

The errors users hit more often are the ones where the node identifier
itself never resolves, so the unexpanded macro leaks into the compiler
output:

    error: '__device_dts_ord_DT_N_NODELABEL_my_serial_ORD' undeclared
    error: 'DT_N_S_soc_S_uart_40002000_P_currentspeed' undeclared
    error: '__device_dts_ord_DT_N_INST_0_vnd_foo_ORD' undeclared

Everything needed to explain those is already in edt.pickle. Map every
node identifier gen_defines.py generates back to its node, then match
a failing macro against that map: the longest part that does name a
node tells us what exists, and the leftover tells us what does not.

That covers three new families of error:

- a node identifier naming no node (DT_NODELABEL(), DT_ALIAS(),
  DT_CHOSEN() and DT_PATH()), reported with fuzzy suggestions drawn
  from the devicetree and the DTS-versus-C spelling rules
- a property a node does not have, distinguishing a typo, a property
  the binding declares but the node leaves unset, and an index or cell
  the property does not define
- DT_INST()/DT_DRV_INST() past the end of a compatible, or on a
  compatible nothing declares

A node identifier that does resolve now falls through to the existing
enabled/disabled diagnosis, so DEVICE_DT_GET(DT_NODELABEL(x)) gets the
same answer the ordinal form already got.

Recovering a name from a macro that has no generated counterpart needs
care. Dropping trailing '_' components until something fuzzy-matches
over-trims, and would report DT_N_ALIAS_no_such_alias_ORD as an alias
named 'no_such': a wrong name next to a suggestion, which is worse
than no suggestion. There is no need to guess. What <devicetree.h>
appends always starts with an upper case component ('_ORD', '_P_',
'_REG_IDX_0'), and gen_defines.py lower-cases every name it builds an
identifier from, so the first upper case component is exactly where
the name ends.

The name mangling itself is taken from gen_defines.py rather than
reimplemented, since the two drifting apart would silently resolve
macros to the wrong node.

Assisted-by: Claude:opus-5
Signed-off-by: Benjamin Cabé <benjamin@zephyrproject.org>
The wrapper only captured symbols matching __device_dts_ord_<digits>,
so none of the errors where the node identifier never expanded were
even handed to the analyzer.

Widen the captured symbol to any device symbol or bare devicetree
macro, keeping the five toolchain message patterns as they are. The
GNU ld pattern becomes lazy so it captures the whole device symbol
rather than settling for the DT_N_ macro pasted inside it.

Verified against real gcc, clang and GNU ld output.

Assisted-by: Claude:opus-5
Signed-off-by: Benjamin Cabé <benjamin@zephyrproject.org>
Add a fixture devicetree carrying everything the new diagnoses need
something real to compare against: node labels, an alias, a /chosen
entry, two instances of one compatible, and properties that are set,
declared but unset, and of a type with no plain value macro. Then a
test module per family.

Pin the name recovery rule in particular: an appended '_ORD',
'_EXISTS' or '_P_<prop>' must not be read as part of a node label,
and a label containing underscores must survive intact.

Extend the wrapper's toolchain table with the new symbol shapes across
the gcc, GNU ld, clang and lld message formats, taken from real
toolchain output, and check that unrelated undeclared symbols are
still ignored.

Assisted-by: Claude:opus-5
Signed-off-by: Benjamin Cabé <benjamin@zephyrproject.org>
Give the fixture devicetree a property that is set and one the
binding only declares, and note that its two nodes are also the
compatible's two DT_INST() instances.

Exercise each new diagnosis family against the build's real
edt.pickle, and run the unexpanded macro shapes through the wrapper
with the toolchain the application was built with, both at compile
time and, for one of them, at link time. Toolchain message drift is
what these shapes are most exposed to, and it is not something the
unit suite can catch.

Assisted-by: Claude:opus-5
Signed-off-by: Benjamin Cabé <benjamin@zephyrproject.org>
Cells were the weakest thing DT Doctor printed, and they reached two
different generic fallbacks depending on where they came from.
DT_GPIO_PIN() landed on the property message, "usually an index past
the end of the property, or a name or cell it does not define", which
mentions cells but names none. DT_IRQ() did not even get that far,
falling to "the devicetree API asked for 'IRQ_IDX_0_VAL_priority' on
this node".

Both are high traffic: DT_GPIO_PIN(), DT_GPIO_FLAGS(),
DT_PWMS_CHANNEL(), DT_IRQ() and DT_PHA_BY_NAME() all bottom out in
cell macros. And the answer the user needs, which cells exist, lives
in the *controller's* binding, not in the node the macro was written
on. That indirection is precisely what cannot be chased from the
error text, and edt.pickle already holds it.

phandle-array, 'interrupts' and 'reg' all reduce to a list of entries
picked by index or by name, each defining a set of cells, so one
parser and one handler explain all three. Nothing keys off 'gpios' in
particular: any phandle-array property gets this, and Zephyr's
in-tree bindings declare several hundred distinct ones. Only
'interrupts' and 'reg' are a fixed pair, since those hang off the
node rather than off a property.

The parser leans on the same upper/lower case split the node
identifier code already relies on, which is what keeps '_VAL_' from
being read as part of a name.

A bad cell now names the controller, lists the cells its binding
defines with the values this node gives them, and suggests the
closest. A bad index or name lists the entries that do exist. Entry
names are looked up by specifier space rather than by property name,
so 'cs-gpios' entries are correctly named through 'gpio-names' and
not 'cs-gpio-names'.

Anything the parser does not recognise keeps the existing message:
this narrows the generic fallback, it does not replace it.

Assisted-by: Claude:opus-5
Signed-off-by: Benjamin Cabé <benjamin@zephyrproject.org>
Add a fixture devicetree with all three specifier spaces on one node:
a phandle-array whose entries are named and whose cells come from a
controller's binding, an interrupt whose entries are deliberately not
named, and a register.

A second phandle-array, 'pwms', uses a different specifier space and
cell count so the diagnoses stay pinned as working on any
phandle-array rather than just on 'gpios'.

Cover a bad cell, a bad index and a bad name in each space, plus the
two cases that have to keep the old message: a valid entry with no
cell named, and a non-specifier suffix. Pin every accessor shape
against the parser directly, since that is the part most exposed to
<devicetree.h> growing new accessors.

Assisted-by: Claude:opus-5
Signed-off-by: Benjamin Cabé <benjamin@zephyrproject.org>
Give the fixture devicetree a GPIO controller carrying real
'pin'/'flags' cells and a consumer that uses them, then check a bad
cell, index and name against the build's real edt.pickle, and run the
bad-cell shape through the wrapper with the toolchain the application
was built with.

The consumer's property is 'dtdoctor-gpios' while its names come from
'gpio-names', so the fixture also pins the specifier-space naming
rule rather than just asserting it in a unit test.

Assisted-by: Claude:opus-5
Signed-off-by: Benjamin Cabé <benjamin@zephyrproject.org>
edtlib and gen_defines.py only look at a node's own 'status', so a
node below a disabled parent still counts as okay: its driver builds
and its instance macros exist, and DT Doctor's diagnoses never
mentioned the parent. Whether the node was reported as disabled or as
lacking a driver, following the advice just moved the failure to the
parent device on the next build.

Walk the ancestor chain in both the enabled-node and disabled-node
diagnoses and name every disabled ancestor with the file and line
where its status is set, so the whole chain gets fixed in one go.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Benjamin Cabé <benjamin@zephyrproject.org>
Unit-test the ancestor walk in both diagnoses: an enabled node under
a disabled parent (with the Kconfig analysis still running), a
disabled node under a disabled parent, a disabled grandparent behind
an okay parent reported as an "ancestor" without dragging the okay
parent in, and no note at all when the whole chain is okay.

End to end, give the fixture devicetree an enabled device below a
disabled parent, under its own compatible so the DT_INST() instance
counts the other tests rely on stay untouched.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Benjamin Cabé <benjamin@zephyrproject.org>
@kartben
kartben force-pushed the claude/error-macro-reverse-engineer-t29qdc branch from 5670a60 to 52b23fd Compare August 25, 2026 19:48
kartben pushed a commit that referenced this pull request Aug 25, 2026
Merges dtdoctor-tests, PR #501 (four new error-macro families +
disabled-ancestor reporting), and sca-launcher-ccache, then documents
a real build of tests/drivers/coredump/coredump_api for
qemu_riscv32/qemu_virt_riscv32/aia-direct (and its /smp variant) —
matching the DT_N_S_coredump_device0_P_memory_regions_IDX_0 failure
from the weekly-CI twister report — showing dtdoctor's raw diagnosis
of the missing node label and missing property, and the underlying
devicetree overlay-selection bug it points to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbyFdGk3NBCRUsEhcQWxFN
kartben pushed a commit that referenced this pull request Aug 25, 2026
Extends the single-case demo with a full sweep of the remaining
devicetree-related weekly-CI build failures: 19 additional
(board, test) pairs built for real against the Zephyr SDK v1.0.1 and
real vendor HALs (Infineon, TI, Renesas, Espressif, Nordic, ST,
Raspberry Pi, Atmel, Sifli, NXP, WCH), each first reproducing the
exact leaked-macro/undefined-reference signature from the CI report,
then rebuilding with -DZEPHYR_SCA_VARIANT=dtdoctor and capturing the
raw diagnosis.

18/19 matched (dtdoctor correctly diagnosed the real failure); 1
(heltec_t114_v2) was confirmed to be a plain C bug unrelated to
devicetree, not a dtdoctor case. 0 blocked. Report also assesses
which of PR #501's diagnosis families got exercised by real CI
failures and which didn't.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbyFdGk3NBCRUsEhcQWxFN
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant