DEV Community

Darshan Rathod
Darshan Rathod

Posted on

Explaining the Linux Device Tree for Embedded Engineers From DTS Source to a Running Kernel

If you work with ARM or embedded Linux, you’ve almost certainly touched a .dts file, toggled a status = "okay" somewhere, rebuilt, and moved on—without fully internalizing what’s really happening. For many embedded engineers, the device tree feels like a magical incantation between “the board” and “the kernel.”

This post walks through the full story, end‑to‑end: what the device tree is, how DTS becomes DTB, what the bootloader and kernel do with it, and a concrete I2C sensor example you can map to your own board.

1. Why the device tree exists
On PCs, hardware discovery is dynamic: ACPI, PCI, and the BIOS/UEFI tell the OS what’s present. On microcontrollers and many SoCs, the opposite is true: peripherals live at fixed addresses with hard‑wired interrupts, and the board designer decides which ones are actually used.

Hardcoding all of that into kernel C files does not scale when:

  • You have multiple boards built around the same SoC.
  • You spin board revisions and move peripherals to different pins.
  • You want to reuse a vanilla kernel image across variants.

The device tree is Linux’s answer on many embedded platforms: a data‑driven hardware description that says “what exists” and “where it lives,” separated from the kernel code.

High‑level idea:

  • One kernel image, many DTBs.
  • Bootloader passes the appropriate DTB for the actual board.
  • Kernel parses it and instantiates devices + drivers dynamically.

2. Mental model: it’s just a tree of nodes and properties
A device tree is literally a tree:

  • Each node represents a device or bus (CPUs, memory, SoC, UART, I2C, GPIO controller, etc.).
  • Each node has properties—simple key/value pairs that encode addresses, interrupts, strings, or small arrays.

At the source level, you work with DTS/DTI files:

/dts-v1/;

 / {
     model = "My Tiny ARM Board";
     compatible = "myvendor,myboard", "myvendor,my-soc";
 };

 cpus {
     #address-cells = <1>;
     #size-cells = <0>;

     cpu0: cpu@0 {
         compatible = "arm,cortex-a7";
         reg = <0>;
     };
 };

 memory@80000000 {
     device_type = "memory";
     reg = <0x80000000 0x10000000>; /* 256 MB */
 };

 soc {
     #address-cells = <1>;
     #size-cells = <1>;
     compatible = "simple-bus";
     ranges;

     uart0: serial@4000 {
         compatible = "vendor,soc-uart";
         reg = <0x00004000 0x1000>;
         interrupts = <5>;
         status = "okay";
     };

     i2c1: i2c@5000 {
         compatible = "vendor,soc-i2c";
         reg = <0x00005000 0x1000>;
         interrupts = <6>;
         status = "disabled";
     };
 };
Enter fullscreen mode Exit fullscreen mode

A few must‑understand properties:

  • compatible: the string the kernel uses to match this node to a driver. Drivers declare a table of strings they support.
  • reg: base address and size of the device’s register region, in parent bus address space.
  • interrupts: which interrupt the device uses; encoding depends on the interrupt controller binding.

status: "okay" means “use this device”; "disabled" means “ignore it.”

Once you see a DTS as “a typed, structured config file for hardware,” it stops feeling mystical and becomes a tool you control.

3. File structure: .dtsi vs .dts
Real BSPs rarely have a single giant .dts. Instead, they layer the description:

  • SoC .dtsi—shared between boards using the same SoC. It describes the common peripherals (timers, UARTs, SPI/I2C controllers, pinctrl, etc.), often with status = "disabled".
  • Board .dts — includes the SoC .dtsi and turns on only what your board actually wires out, plus external devices (sensors, PMICs, connectors).

Conceptually:

/* my-soc.dtsi – used by many boards */
soc {
    i2c1: i2c@5000 {
        compatible = "vendor,soc-i2c";
        reg = <0x00005000 0x1000>;
        interrupts = <6>;
        #address-cells = <1>;
        #size-cells = <0>;
        status = "disabled";
    };
};
Enter fullscreen mode Exit fullscreen mode
/* my-board.dts */
#include "my-soc.dtsi"

 / {
     model = "My Tiny ARM Board RevA";
     compatible = "myvendor,myboard-reva", "myvendor,my-soc";
 };

 &i2c1 {
     status = "okay";
     /* external devices will be added here */
 };
Enter fullscreen mode Exit fullscreen mode

The board file doesn’t duplicate the SoC details—it only overrides and extends them. That’s exactly why you can have multiple board DTs pointing at the same SoC .dtsi.

4. From DTS to DTB: compiling the description
DTS is not what the kernel reads directly; it reads a compact binary form called a DTB (Device Tree Blob).

The conversion is handled by the Device Tree Compiler (dtc):

  • Install the tool on your dev machine:
sudo apt-get install device-tree-compiler
Enter fullscreen mode Exit fullscreen mode
  • Compile a single DTS manually:
dtc -I dts -O dtb -o my-board.dtb my-board.dts
Enter fullscreen mode Exit fullscreen mode

Inside the kernel tree, you usually run:

make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- dtbs
Enter fullscreen mode Exit fullscreen mode

This makes Kbuild walk directories like arch/arm/boot/dts/, resolve all #include chains, expand labels, and emit .dtb files.

5. Boot flow: how the DTB reaches the kernel
On a typical ARM board with U‑Boot, the journey looks like this:

  • Boot ROM loads and runs the first stage bootloader.
  • Bootloader initializes DRAM and basic clocks.
  • Bootloader loads Image/zImage (the kernel) and my-board.dtb into RAM.
  • Bootloader sets a CPU register or passes a pointer via the boot protocol to tell the kernel where the DTB lives, then jumps to the kernel entry.

From that point onward, it’s all kernel:

  • Early boot code parses the DTB into an internal “OF” tree (struct device_node hierarchy).
  • The kernel walks this tree, creating platform devices and bus devices based on node compatible strings and bus types.
  • Subsystems like I2C, SPI, GPIO, and pinctrl register their drivers and match nodes whose compatible entries appear in their of_match_table.

The important consequence: if your DTB is wrong, the kernel will politely ignore hardware or attach the wrong driver—even if your code is perfect.

6. Concrete example: I2C temperature sensor
Let’s map this to something real you might have on a board: a temperature sensor connected to an SoC’s I2C1 bus.

6.1. SoC .dtsi
As seen earlier, the SoC describes the controller but does not commit to any external devices:

i2c1: i2c@5000 {
    compatible = "vendor,soc-i2c";
    reg = <0x00005000 0x1000>;
    interrupts = <6>;
    #address-cells = <1>;
    #size-cells = <0>;
    status = "disabled";
};
Enter fullscreen mode Exit fullscreen mode

This tells the kernel:

  • There is an I2C controller at base address 0x5000.
  • It uses interrupt 6.
  • It can host children (devices with reg = I2C addresses).

6.2. Board .dts: enabling bus + sensor
Now your board .dts wires in the external sensor:

& i2c1 {
    status = "okay";

    temp@48 {
        compatible = "vendor,temp-sensor123";
        reg = <0x48>;          /* I2C address */
        interrupt-parent = <&gpio1>;
        interrupts = <12 0>;   /* GPIO1_12, active-low for example */
    };
};
Enter fullscreen mode Exit fullscreen mode

Boot‑time story:

  • Kernel sees /soc/i2c@5000 with compatible = "vendor,soc-i2c", so it instantiates an I2C controller and binds it to the corresponding driver.
  • The I2C subsystem scans child nodes under that controller and finds temp@48.
  • It then looks for a driver whose of_device_id table contains "vendor,temp-sensor123". That I2C sensor driver’s probe() is called with a handle to this DT node.
  • Inside probe(), the driver typically reads reg (address), interrupts, and any extra properties (e.g. vdd-supply, reset-gpios) to configure the device.

No hard‑coded addresses inside the driver. All board‑specific wiring lives in DTS.

7. Bindings: formal contract between DTS and drivers
If DTS is the configuration, bindings are the schema. They define what properties are valid and required for a given device type.

You’ll find them in the kernel tree under Documentation/devicetree/bindings/. Modern bindings are written in YAML and can be validated using dt-schema.

Before you create or modify a node:

  • Locate the binding file for your device, e.g.i2c/vendor,temp-sensor123.yaml.
  • Check: required properties, allowed values, phandle relationships (interrupt-parent, clocks, resets, gpios, etc.).

Treat bindings like a datasheet for your DTS. If your properties don’t satisfy the binding, the driver either won’t probe, or it will behave unpredictably.

8. Inspecting and debugging the live device tree
One of the nicest aspects of DT is that you can inspect what the kernel actually sees—no guesswork.

8.1. Browse the live tree
On DT‑based systems, the kernel exposes the live device tree under /sys/firmware/devicetree/base:

ls /sys/firmware/devicetree/base
Enter fullscreen mode Exit fullscreen mode

You can:

  • find nodes (find . -name 'i2c*').
  • cat properties (cat soc/i2c@5000/temp@48/compatible). ​ 8.2. Decompile the running DTB Sometimes the DTB in use is not the one you think you built. To verify:
# Many platforms expose the flattened DT as a single blob:
sudo dtc -I dtb -O dts -o running.dts /sys/firmware/fdt
Enter fullscreen mode Exit fullscreen mode

Or if your distribution uses DTBs under /boot/dtb/:

sudo dtc -I dtb -O dts -o board-from-boot.dts /boot/dtb/my-board.dtb
Enter fullscreen mode Exit fullscreen mode

This lets you:

  • Compare “expected DTS” vs “actual running DTS.”
  • See overlays applied by the bootloader or OS (common on Jetson, Raspberry Pi, Android, etc.).

A very practical debug loop:

  1. Inspect /sys/firmware/devicetree/base to see whether your node exists and is "okay".
  2. Confirm compatible and reg are what the driver expects (from bindings/docs).
  3. Check dmesgfor probe failures or “no compatible device found” messages.

9. Conclusion:
The device tree is not magic—it’s the simple, structured layer that tells Linux what your hardware really looks like. Once you’re comfortable moving from DTS to DTB and tracing a node all the way to a driver’s probe() board, bring‑up stops being guesswork and becomes a repeatable skill you can apply to every new design.

Top comments (0)