ac6-training, un département d'Ac6 SAS
EN
EnglishFrench
go-up

ac6 ac6-training

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:

  1. socs/<SOC>_<qualifiers>.overlay
  2. boards/<board>.overlay
  3. boards/<board>_<revision>.overlay
  4. <board>.overlay
  5. app.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

MessageActual cause
'DT_N_S_...' undeclaredThe node does not exist, or its status is not okay
no bindings found for ...No YAML file declares that compatible
'reg' is marked as requiredThe 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_regSame 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

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.