Zephyr devicetree: overlays, bindings and reading the build errors
Zephyr's devicetree looks like Linux's and behaves differently on the point that matters most: under Linux it is loaded at runtime by the kernel; under Zephyr it is fully resolved at build time. Nothing of the devicetree survives into the binary, only constants. That difference explains almost every surprise people hit early on.
The resolution chain
When you run west build, the sequence is as follows.
The board's <board>.dts is read, along with the .dtsi files it includes describing the SoC. Overlays are then applied on top, in a specific order. The whole thing is flattened into a single tree, validated against bindings, then turned into devicetree_generated.h, a file of C macros.
That file is worth opening at least once, at build/zephyr/include/generated/zephyr/devicetree_generated.h. It shows exactly what the compiler will see, and many questions are settled by reading it rather than guessing.
The overlay discovery rule is the most classic source of error, and it is not cumulative. The build system looks in this order:
socs/<SOC>_<qualifiers>.overlayboards/<board>.overlayboards/<board>_<revision>.overlay<board>.overlayapp.overlay
As soon as one or more files are found at a step, the search stops. This is the point everyone gets wrong: your app.overlay is simply ignored if a boards/<board>.overlay exists. It is not merged, not overridden, never read, and nothing warns you.
CMake variables escape that rule. DTC_OVERLAY_FILE replaces the discovered list, and EXTRA_DTC_OVERLAY_FILE is appended after it, therefore with higher precedence.
When in doubt, build/zephyr/zephyr.dts holds the final merged tree. It is the only source of truth.
Bindings, the part Linux does not enforce the same way
A binding is a YAML file describing which properties a compatible accepts, their types, and which are mandatory. With no matching binding, a node is silently ignored: it generates no macro, and your DEVICE_DT_GET fails at compile time with a message that never mentions the devicetree.
That is the first reflex to build: if a node seems not to exist although it is in the .dts, look for the binding before anything else.
A minimal binding looks like this:
description: In-house temperature sensor
compatible: "acme,temp-sensor"
include: [sensor-device.yaml, i2c-device.yaml]
properties:
sample-rate-hz:
type: int
required: true
enable-gpios:
type: phandle-array
Bindings live in dts/bindings/ in your application or in a module. The filename is irrelevant; the compatible value makes the link.
The macros, and the status trap
Every enabled node produces a set of macros. The most used ones:
#define MY_SENSOR DT_NODELABEL(temp0)
static const struct device *dev = DEVICE_DT_GET(MY_SENSOR);
static const uint32_t rate = DT_PROP(MY_SENSOR, sample_rate_hz);
static const struct gpio_dt_spec en = GPIO_DT_SPEC_GET(MY_SENSOR, enable_gpios);
Note the property name transformation: sample-rate-hz in devicetree becomes sample_rate_hz in C. Hyphens become underscores, everything is lowercased.
The most common trap involves status. A node whose status is not "okay" exists in the tree but produces no usable macro. Check it like this:
#if !DT_NODE_HAS_STATUS(DT_NODELABEL(temp0), okay)
#error "temp0 is missing or disabled in the devicetree"
#endif
That explicit #error is far better than the default compiler message, which complains about an unknown identifier without ever naming the devicetree.
Writing an overlay that works
To add a peripheral on an existing bus, target the bus node by its label and add a child:
&i2c1 {
status = "okay";
clock-frequency = <I2C_BITRATE_FAST>;
temp0: temp-sensor@48 {
compatible = "acme,temp-sensor";
reg = <0x48>;
sample-rate-hz = <10>;
enable-gpios = <&gpioa 5 GPIO_ACTIVE_HIGH>;
status = "okay";
};
};
Three things trip up beginners. The address after the at sign must match the first reg value exactly, or validation fails. The parent bus must also be status = "okay": enabling only the child is not enough. And macros such as GPIO_ACTIVE_HIGH require the matching headers at the top of the overlay:
#include <zephyr/dt-bindings/gpio/gpio.h>
Reading the errors
| Message | Actual cause |
|---|---|
'DT_N_S_...' undeclared | The node does not exist, or its status is not okay |
no bindings found for ... | No YAML file declares that compatible |
'reg' is marked as required | The binding requires reg, missing from the node |
'unit-address' does not match 'reg' | The address after the at sign differs from reg |
dtc: Warning: unit_address_vs_reg | Same cause, warning only |
The first row covers most cases, and it is misleading: the compiler reports an unknown C identifier, never a devicetree problem. The correct reflex is to open build/zephyr/zephyr.dts and check that the node is there with the right status.
Tools that save time
west build -t boards lists available targets. More useful day to day:
west build -t initlevels # device initialisation order
And above all, reading the final tree directly. Facing unexpected behaviour, build/zephyr/zephyr.dts answers the question "what did the system actually understand from my configuration", which neither the board .dts nor the overlay can answer on its own.
References
- Zephyr Project, Devicetree HOWTOs, overlay discovery stop rule and
DTC_OVERLAY_FILEvariables - Zephyr Project, Devicetree bindings
- Zephyr Project, Devicetree API,
DT_macros - devicetree.org, Specification
Going further
These mechanisms become natural once you have written a binding, added a peripheral on a real board, and debugged a node that refused to appear. That is what our Zephyr RTOS programming course and our West, SDK and Kconfig course cover.