ukasol

Blog Zephyr Series · 05/07

What Device Tree is, from a bare-metal developer's point of view

Zephyr moved what Linux does at runtime to compile time. What a devicetree is, how an overlay adds a sensor, and why the result costs nothing at runtime.

Device Tree is a hard concept to learn if you do not come from Linux; I was one of the people who struggled with it.

The simplest way to think of it is as a description of your hardware. You create a .dts file for each PCB, and that file describes every electronic component connected to the processor, in a hierarchy.

That sentence is true, but it is hard to act on. So this post follows one real example all the way through: adding a BME280 sensor to an STM32 Nucleo board, from the overlay you write to the machine code it ends up as. Everything below was built with Zephyr 4.4.2.

From board.h to a devicetree#

In bare metal, the description of the board lives in a header:

/* board.h */
#define LED_PORT     GPIOA
#define LED_PIN      5
#define SENSOR_I2C   I2C1
#define SENSOR_ADDR  0x77

It works, and it has three quiet problems. Nothing checks it: a typo in a pin number compiles fine. Nothing except your code can read it, so the build cannot tell which drivers the board needs. And it is tangled with code: the moment one #ifdef BOARD_REV_B appears, the description of the hardware and the logic of the firmware are the same file.

A devicetree holds the same facts as data, in a format that the build can read, check and turn into code.

Three layers: SoC, board, overlay#

A Zephyr devicetree is built from layers, each one owned by someone different:

  • The SoC .dtsi describes the chip: every peripheral with its address, interrupts and clocks. Most of them are marked status = "disabled". The silicon vendor and the Zephyr maintainers own this file.
  • The board .dts includes the SoC and describes the PCB: which peripherals are wired up and how, which pins they use, where the LEDs and buttons are, which UART is the console. Whoever designed the board owns it.
  • Overlays are your project’s changes on top: the sensor you added, the peripheral you need enabled, the pin you moved. You own those.

Here is the overlay that ships with Zephyr’s BME280 sample:

&arduino_i2c {
    status = "okay";

    bme280@77 {
        compatible = "bosch,bme280";
        reg = <0x77>;
    };
};

Notice what it does not say: it never names an STM32 I2C peripheral. It refers to arduino_i2c, a label for the I2C bus on the Arduino header. The Nucleo board’s connector file maps that label with one line:

arduino_i2c: &i2c1 {};

and the nRF52840 DK maps it to its own i2c0. So this overlay is not written for the Nucleo at all. It is written for the header, and it builds unchanged for both boards: on the Nucleo the build enables the STM32 I2C driver, on the DK the Nordic one.

After the build, the merged result is in build/zephyr/zephyr.dts, and this is the most useful file in the whole build directory. Every property carries a comment saying which file and which line it came from. Here is the I2C node on the Nucleo, trimmed and with the paths shortened:

i2c1: arduino_i2c: i2c@40005400 {
    compatible = "st,stm32-i2c-v1";              /* in zephyr/dts/arm/st/f4/stm32f4.dtsi:316 */
    reg = < 0x40005400 0x400 >;                  /* in zephyr/dts/arm/st/f4/stm32f4.dtsi:320 */
    interrupts = < 0x1f 0x0 >,
                 < 0x20 0x0 >;                   /* in zephyr/dts/arm/st/f4/stm32f4.dtsi:322 */
    pinctrl-0 = < &i2c1_scl_pb8 &i2c1_sda_pb9 >; /* in zephyr/boards/st/nucleo_f401re/nucleo_f401re.dts:107 */
    clock-frequency = < 0x61a80 >;               /* in zephyr/boards/st/nucleo_f401re/nucleo_f401re.dts:110 */
    status = "okay";                             /* in arduino_i2c.overlay:14 */

    /* node '/soc/i2c@40005400/bme280@77' defined in arduino_i2c.overlay:16 */
    bme280@77 {
        compatible = "bosch,bme280"; /* in arduino_i2c.overlay:17 */
        reg = < 0x77 >;              /* in arduino_i2c.overlay:18 */
    };
};

All three layers are visible in one node. The address, the interrupts and the compatible come from the SoC file. The pins (PB8 and PB9) and the 400 kHz bus speed come from the board. The status and the sensor itself come from the overlay. When a build does something you did not expect, this file tells you who said what.

Bindings: what makes a compatible mean something#

compatible = "bosch,bme280" is not a free-form comment. The build matches it against a binding: a YAML file in dts/bindings that says which properties a node of that kind may have, of which type, and which are required. The BME280’s binding is short, because it inherits almost everything:

description: BME280 integrated environmental sensor

compatible: "bosch,bme280"

include: [sensor-device.yaml, i2c-device.yaml]

Bindings are what turn the devicetree from a description into a checked description. The gpio-leds binding, for example, marks the gpios property of each LED as required. Add an LED to an overlay without it, and the build stops before a single C file is compiled:

devicetree error: 'gpios' is marked as required in 'properties:' in 'zephyr/dts/bindings/led/gpio-leds.yaml', but does not appear in <Node /leds/led_9 in bad-led.overlay:3>

It names the binding, the rule, and the exact line of your overlay. Compare that with a typo in board.h.

You can write bindings for your own hardware too, and it is less work than it sounds. Here is one for a status LED with a configurable blink period:

description: |
  A status LED that blinks at a fixed period.

compatible: "fwnotes,status-led"

properties:
  gpios:
    type: phandle-array
    required: true
    description: The GPIO that drives the LED.

  blink-period-ms:
    type: int
    default: 500
    description: Full on-off period, in milliseconds.

A board overlay then describes the actual hardware, in this case LD2 on the Nucleo:

/ {
    status_led: status-led {
        compatible = "fwnotes,status-led";
        gpios = <&gpioa 5 GPIO_ACTIVE_HIGH>;
        blink-period-ms = <1000>;
    };
};

and the code reads it through the DT_ macros:

#define STATUS_LED DT_NODELABEL(status_led)
#define PERIOD_MS  DT_PROP(STATUS_LED, blink_period_ms)

static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(STATUS_LED, gpios);

Two practical details. A binding lives in a dts/bindings directory of your application or module, and the build finds it there on its own. And an out-of-tree vendor prefix like fwnotes gets a warning (has unknown vendor prefix) until you list it in a vendor-prefixes.txt next to your bindings. The complete example, with an overlay for native_sim and one for the Nucleo, is in device-tree in firmware-notes, and CI runs it on every push.

So how does it work?#

During the build, CMake (which west build drives) collects the board’s .dts and your overlays and runs them through the C preprocessor. That is why a devicetree can use #include and macros like GPIO_ACTIVE_HIGH. The result then goes to Zephyr’s devicetree scripts. gen_edt.py parses it and checks every node against its binding. A Python script called gen_defines.py writes a header file called devicetree_generated.h. The DT_ macros in that header give the C code the hardware’s details at compile time.

010203 Sourcesboard .dts, .dtsiand your overlays Preprocessor#include, macroszephyr.dts.pre gen_edt.pychecks each nodeagainst bindings C headerdevicetree_generated.hDT_ macros KconfigDT_HAS_..._ENABLEDdrivers switch on
The whole pipeline runs at configure time, before any C is compiled.

For the sensor in the overlay, the header ends up with 86 macros. A few of them:

#define DT_N_S_soc_S_i2c_40005400_S_bme280_77_PATH "/soc/i2c@40005400/bme280@77"
#define DT_N_S_soc_S_i2c_40005400_S_bme280_77_P_compatible {"bosch,bme280"}
#define DT_N_S_soc_S_i2c_40005400_S_bme280_77_P_reg {119 /* 0x77 */}

You never write these names yourself; DT_NODELABEL(), DT_PROP() and their relatives build them for you. But when a DT_ macro fails to compile, grepping this header for your node is the fastest way to see what the build actually generated.

The devicetree also feeds Kconfig: the compatible of every enabled node produces a DT_HAS_<compat>_ENABLED symbol, and that is how the right drivers get switched on and compiled. The BME280 driver’s Kconfig shows how far this goes:

menuconfig BME280
    bool "BME280/BMP280 sensor"
    default y
    depends on DT_HAS_BOSCH_BME280_ENABLED
    select I2C if $(dt_compat_on_bus,$(DT_COMPAT_BOSCH_BME280),i2c)
    select SPI if $(dt_compat_on_bus,$(DT_COMPAT_BOSCH_BME280),spi)

The driver turns itself on when a BME280 exists in the devicetree, and it turns on the I2C subsystem only if that BME280 sits on an I2C bus. The sample’s prj.conf contains two lines, CONFIG_SENSOR=y and CONFIG_SENSOR_ASYNC_API=y. It never mentions the BME280 or I2C, and both end up enabled, because the devicetree says there is a BME280 on an I2C bus.

All of it at compile time#

All of this happens at compile time. There is no runtime cost at all.

That is easy to say, so here is what it looks like. This is the main() of the status LED example above, built for the Nucleo, disassembled and trimmed:

 8000492:   mov.w   r3, #1000   @ 0x3e8
 8000496:   movs    r2, #5
 8000498:   bl  8002b7c <printk>
 ...
 800049e:   movs    r1, #32
 ...
 80004ae:   movw    r0, #5000   @ 0x1388
 80004b2:   movs    r1, #0
 80004b4:   bl  80034ee <z_impl_k_sleep>

#1000 is the blink period from the overlay. #5 is the pin, PA5, and #32 is its bit mask, 1 << 5. #5000 is PERIOD_MS / 2, half a second, already converted to kernel ticks at this board’s 10,000 ticks per second. The devicetree did not survive into the binary as data. It became immediate operands in the instructions, exactly as if you had typed the numbers by hand, except that the build checked them first.

On Linux the same mechanism works differently: the .dts is compiled into a binary blob and the kernel reads it at runtime. Zephyr deliberately moved this to compile time, because the microcontrollers it targets have neither the memory nor the processing power to spare for it at runtime.

Why it is worth the effort#

Coming from bare metal, this looks like unnecessary complexity at first. But you can build the same application code for a different PCB just by changing an .overlay file. The application code never knows the hardware; it only sees the abstraction that comes out of the Device Tree.

That pays off in places a demo never shows. A board revision that moves the sensor to another I2C bus is a one-line change in an overlay. A second product variant with a different sensor is a second overlay, not an #ifdef. And because connectors like the Arduino header have their own labels, an add-on board can be described once and used on every base board that has the header.

Three habits that save time#

  1. Read build/zephyr/zephyr.dts after every surprising build. It is the final devicetree, and every property says where it came from.
  2. Grep devicetree_generated.h when a DT_ macro does not compile. The error almost always means the node or the property does not exist the way you think, and the header shows what does.
  3. Pick the right way to refer to a node. Use node labels (DT_NODELABEL) for your own hardware, aliases (DT_ALIAS, like led0) for things every board is expected to provide, and the chosen node (DT_CHOSEN) for system roles such as the console.

Devicetree error messages deserve a post of their own, and they will get one later in this series.

I recommend looking at the blinky sample in the official Zephyr repository. For the whole topic, the Device Tree documentation is a good place to start. The next post in this series covers the other half of the configuration: Kconfig.

Next in · Zephyr fundamentals How project configuration with Kconfig works