Skip to content

Device Tree and Hardware Description

Overview

A Device Tree is a data structure that describes the hardware topology of a computer system to the operating system at boot time. Rather than encoding hardware knowledge directly into the kernel source code, the Device Tree allows a single kernel binary to run on many different boards by reading a board-specific description at boot. The bootloader passes a compiled Device Tree Blob (DTB) to the kernel, which then uses it to discover what hardware is present, where it is mapped in the address space, which interrupts it uses, and how it should be initialized.

This mechanism solved one of the most serious organizational crises in embedded Linux history: by 2011, the ARM Linux architecture directory had accumulated hundreds of board-specific C files containing hardcoded register addresses, interrupt numbers, and hardware initialization sequences. Each new board required a new kernel patch. Linus Torvalds threatened to stop accepting ARM patches entirely if the community did not adopt a cleaner hardware description mechanism. Device Tree, already proven on PowerPC and SPARC architectures, was adopted as the solution.


Prerequisites

  • Basic understanding of how a bootloader (U-Boot, GRUB) transfers control to the kernel
  • Familiarity with memory-mapped I/O and how peripheral registers are addressed
  • Understanding of interrupt controllers and IRQ numbers
  • Basic understanding of Linux device driver model (platform devices, drivers)
  • Comfort reading C-like syntax

Historical Context

Open Firmware and PowerPC

Device Tree descends from Open Firmware, a standard developed at Sun Microsystems and Apple in the late 1980s. Open Firmware defined a device tree representation as part of its system firmware interface for SPARC and PowerPC workstations. When IBM PowerPC servers needed to boot a generic kernel across diverse hardware, the Linux PowerPC port adopted the Open Firmware device tree format.

The dtc (Device Tree Compiler) tool was developed at IBM around 2006 to handle the human-readable DTS (Device Tree Source) to binary DTB (Device Tree Blob) compilation step outside of the firmware.

The ARM Crisis

By 2010, the arch/arm/ directory in the Linux kernel contained over 1,000 board-support files. Each ARM board had a file like arch/arm/mach-omap2/board-beagle.c that hardcoded hardware addresses and called platform device registration functions. When a new chip arrived with slightly different peripheral addresses, a new file appeared. Nothing was reusable.

In March 2011, Linus Torvalds wrote in a public message:

"This is just insane. I've seen the same problem before with x86 and I thought ARM would learn from it. It's really quite sad. I'm not going to take any more arm patches unless there is clear proof that the whole ARCH thing has been improved."

The ARM Linux community committed to a "DT-only" approach for new SoCs: no more board files, all hardware description in Device Tree. This was substantially complete by Linux 3.14 (2014).


Device Tree Structure

Conceptual Model

A Device Tree is a tree of nodes. Each node represents a hardware component (a CPU, a memory controller, a UART, a GPIO controller, an I2C bus, a device hanging off that bus). Nodes have properties: key-value pairs that describe the component's identity, location, and configuration.

Device Tree Conceptual Hierarchy
==================================

/  (root node)
├── #address-cells = <1>
├── #size-cells = <1>
├── compatible = "vendor,board-name"
│
├── cpus/
│   ├── #address-cells = <1>
│   ├── #size-cells = <0>
│   ├── cpu@0
│   │   ├── compatible = "arm,cortex-a53"
│   │   ├── device_type = "cpu"
│   │   └── reg = <0>
│   └── cpu@1
│       ├── compatible = "arm,cortex-a53"
│       └── reg = <1>
│
├── memory@80000000
│   ├── device_type = "memory"
│   └── reg = <0x80000000 0x40000000>  /* 1GB at 0x80000000 */
│
├── soc/
│   ├── #address-cells = <1>
│   ├── #size-cells = <1>
│   ├── ranges                         /* 1:1 address mapping */
│   │
│   ├── uart0: serial@10000000
│   │   ├── compatible = "ns16550a"
│   │   ├── reg = <0x10000000 0x100>
│   │   ├── interrupts = <4>
│   │   └── clock-frequency = <24000000>
│   │
│   ├── intc: interrupt-controller@c000000
│   │   ├── compatible = "riscv,plic0"
│   │   ├── #interrupt-cells = <1>
│   │   ├── interrupt-controller
│   │   └── reg = <0xc000000 0x4000000>
│   │
│   └── i2c@20000000
│       ├── compatible = "vendor,i2c-v1"
│       ├── reg = <0x20000000 0x100>
│       ├── #address-cells = <1>
│       ├── #size-cells = <0>
│       └── eeprom@50
│           ├── compatible = "atmel,24c256"
│           └── reg = <0x50>
│
└── chosen
    ├── bootargs = "console=ttyS0,115200n8 root=/dev/mmcblk0p2"
    └── stdout-path = &uart0

DTS Syntax

The Device Tree Source format is a text file compiled by dtc into a binary blob:

/dts-v1/;

/ {
    #address-cells = <1>;
    #size-cells = <1>;
    compatible = "starfive,jh7110-evb", "starfive,jh7110";
    model = "StarFive JH7110 Evaluation Board";

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

        cpu@0 {
            compatible = "sifive,u74-mc", "riscv";
            device_type = "cpu";
            reg = <0>;
            riscv,isa = "rv64imafdc";
            mmu-type = "riscv,sv39";

            cpu0_intc: interrupt-controller {
                #interrupt-cells = <1>;
                compatible = "riscv,cpu-intc";
                interrupt-controller;
            };
        };
    };

    memory@40000000 {
        device_type = "memory";
        reg = <0x40000000 0x40000000>;
    };

    clocks {
        osc: oscillator {
            compatible = "fixed-clock";
            #clock-cells = <0>;
            clock-frequency = <24000000>;
            clock-output-names = "osc";
        };
    };

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

        uart0: serial@10000000 {
            compatible = "snps,dw-apb-uart";
            reg = <0x10000000 0x10000>;
            clocks = <&osc>;
            interrupts = <32>;
            interrupt-parent = <&plic>;
            reg-shift = <2>;
            reg-io-width = <4>;
            status = "okay";
        };
    };

    chosen {
        stdout-path = "serial0:115200n8";
    };
};

Key Properties and Their Semantics

Node Naming and Addressing

Node names follow the pattern name@unit-address. The unit-address is the first value of the reg property for the node, expressed in hex. This ensures uniqueness among sibling nodes at the same level. Nodes without a physical address (like cpus, memory, chosen) omit the @unit-address.

compatible Property

The compatible property is the most important matching property. It is a list of strings from most-specific to least-specific:

compatible = "vendor,exact-chip-uart", "ns16550a";

The Linux kernel's of_match_table mechanism walks this list and binds the first matching driver. The convention "vendor,model" prevents collisions between vendors using similar peripheral IP.

reg Property and Address Cells

The reg property encodes (address, size) pairs. The encoding width is determined by #address-cells and #size-cells of the parent node:

Parent: #address-cells = <2>, #size-cells = <2>
Child:  reg = <0x0 0x80000000 0x0 0x40000000>
                 ^^^^^^^^^^^^^^^^  ^^^^^^^^^^^^^^^^
                 64-bit address    64-bit size

This 64-bit addressing is used for systems with physical addresses exceeding 4GB, common on ARM64 and RISC-V server SoCs.

Interrupt Specification

Interrupt properties form a phandle reference chain:

Device tree interrupt encoding:
================================

interrupt-controller node:
  #interrupt-cells = <2>   /* how many cells per interrupt specifier */
  interrupt-controller;    /* declares this is an interrupt controller */

Device using interrupts:
  interrupt-parent = <&gic>;   /* phandle reference to controller */
  interrupts = <GIC_SPI 32 IRQ_TYPE_LEVEL_HIGH>;
               ^^^^^^^^  ^^  ^^^^^^^^^^^^^^^^^^^
               domain    SPI  trigger type
               type      #    (cell 2)
               (cell 0)  (cell 1)

Clock Specification

Clock relationships form a tree mirroring the hardware clock distribution network:

/* Clock provider */
clk_apb: clk-apb {
    compatible = "fixed-factor-clock";
    clocks = <&clk_sys>;       /* parent clock phandle */
    #clock-cells = <0>;
    clock-div = <4>;
    clock-mult = <1>;
};

/* Clock consumer */
i2c0: i2c@20000000 {
    clocks = <&clk_apb>;       /* phandle to clock provider */
    clock-names = "apb_pclk";  /* name used by driver to look up clock */
};

Drivers call devm_clk_get(dev, "apb_pclk") to get a reference, then clk_enable() / clk_set_rate(). The clock framework handles the tree of dependencies.


DTS to DTB Build Pipeline

Build Pipeline
===============

board.dts  ──┐
              ├──► dtc ──► board.dtb  ──► bootloader ──► DRAM ──► kernel
include/   ──┘
dt-bindings/


Detailed steps:
1. C preprocessor: #include, #define macros are resolved
   dtc is typically invoked via: cpp -Iinclude board.dts | dtc -O dtb -o board.dtb

2. dtc compiles DTS to DTB (binary blob, big-endian format):
   - FDT magic: 0xd00dfeed
   - Header: total size, offsets to structure, strings, memory reservation blocks
   - Structure block: flattened tree as tokens (FDT_BEGIN_NODE, FDT_PROP, FDT_END_NODE)
   - Strings block: interned property name strings

3. DTB is either:
   a) Concatenated with kernel image (Image + DTB appended)
   b) Loaded to separate memory address by bootloader
   c) Embedded in initramfs

4. Bootloader (U-Boot / OpenSBI / UEFI) places DTB physical address in:
   - ARM64: x0 register at kernel entry
   - RISC-V: a1 register at kernel entry
   - x86: ACPI or EFI config tables (DT less common on x86)

5. Kernel reads FDT at boot:
   - early_init_dt_scan() parses memory and bootargs
   - of_platform_populate() creates platform_device objects
   - Drivers match via of_match_table and probe()

Pinctrl: Pin Multiplexing

Modern SoCs have physical pins that can serve multiple functions (GPIO, UART TX, SPI CLK, I2C SDA, etc.). The pinctrl subsystem configures which function a pin serves:

/* Pin controller node (SoC-level) */
pinctrl: pinctrl@11400000 {
    compatible = "starfive,jh7110-sys-pinctrl";
    reg = <0x11400000 0x10000>;

    /* Named pin configuration states */
    uart0_pins: uart0-pins {
        tx-pins {
            pinmux = <GPIOMUX(5, GPOUT_SYS_UART0_TX, GPOEN_ENABLE, GPI_NONE)>;
            bias-disable;
            drive-strength = <12>;
            input-disable;
        };
        rx-pins {
            pinmux = <GPIOMUX(6, GPOUT_LOW, GPOEN_DISABLE, GPI_SYS_UART0_RX)>;
            bias-pull-up;
            input-enable;
        };
    };
};

/* Device using pin configuration */
uart0: serial@10000000 {
    pinctrl-names = "default", "sleep";
    pinctrl-0 = <&uart0_pins>;   /* active state */
    pinctrl-1 = <&uart0_sleep_pins>; /* low-power state */
};

DT Bindings and Validation

DT bindings are YAML schema files in Documentation/devicetree/bindings/ that formally define what properties are valid for a given compatible string. The kernel build runs dt-schema validation against DTS files during make dtbs:

# Documentation/devicetree/bindings/serial/ns16550.yaml
%YAML 1.2
---
$id: http://devicetree.org/schemas/serial/ns16550.yaml#
$schema: http://devicetree.org/meta-schemas/core.yaml#

title: NS16550-compatible UART

properties:
  compatible:
    enum:
      - ns16550
      - ns16550a
      - snps,dw-apb-uart

  reg:
    maxItems: 1

  interrupts:
    maxItems: 1

  clock-frequency:
    description: Input clock rate in Hz

required:
  - compatible
  - reg

This prevents typos and undefined properties from silently failing. Adding a new binding requires a YAML schema file and documentation.


Device Tree Overlays

Device Tree Overlays (DTBO) allow extending a base DTB at runtime without recompiling it. The Raspberry Pi popularized this mechanism for enabling optional hardware:

/* Base DTB has UART disabled */
uart0: serial@7e201000 {
    status = "disabled";
};

/* Overlay enables it */
/dts-v1/;
/plugin/;

&uart0 {
    status = "okay";
    pinctrl-names = "default";
    pinctrl-0 = <&uart0_pins>;
};

On Raspberry Pi:

# /boot/config.txt
dtoverlay=uart0
dtoverlay=i2c-rtc,pcf8523
dtoverlay=dwc2,dr_mode=host

The configfs interface allows loading overlays at runtime:

mkdir /sys/kernel/config/device-tree/overlays/uart0
cat uart0.dtbo > /sys/kernel/config/device-tree/overlays/uart0/dtbo
# Overlay is now applied; /proc/device-tree reflects the new nodes

Debugging

fdtdump and dtc Decompile

# Dump raw DTB structure
fdtdump /boot/dtb/board.dtb

# Decompile DTB back to DTS for inspection
dtc -I dtb -O dts -o board-decoded.dts /boot/dtb/board.dtb

# Validate DTS against bindings
make dtbs_check DT_SCHEMA_FILES=Documentation/devicetree/bindings/serial/ns16550.yaml

# Check for dtc warnings (often indicates real errors)
dtc -W all -I dts -O dtb board.dts

/proc/device-tree Sysfs

The kernel exposes the live device tree through /proc/device-tree and /sys/firmware/devicetree/base:

# List all nodes
find /proc/device-tree -type d | head -30

# Read a property (binary, often needs xxd)
cat /proc/device-tree/cpus/cpu@0/compatible | tr '\0' '\n'

# Check which driver matched a device
ls /sys/bus/platform/drivers/
cat /sys/bus/platform/devices/10000000.serial/uevent

# Check device probe status
dmesg | grep "of_platform_populate\|probe"

# List all DT-registered platform devices
ls /sys/bus/platform/devices/

Common Errors and Their Meaning

Error: "of_driver_probe": No driver found for device
  → The compatible string in DTS doesn't match any driver's of_match_table
  → Check: cat /sys/bus/platform/devices/<device>/modalias

Error: "could not get clocks" in driver
  → clocks property phandle is wrong, or clock provider driver not loaded
  → Check: cat /sys/kernel/debug/clk/clk_summary

Error: "irq: type mismatch, failed to map hwirq-N for /soc/device"
  → #interrupt-cells mismatch, or wrong interrupt number
  → Check: cat /proc/interrupts; dmesg | grep irq

Kernel panic: "Unable to find a multifunction device"
  → root compatible string not matched by any board support driver
  → Likely wrong DTB loaded for this kernel

Warning: "interrupt-parent not found, using default"
  → interrupt-parent phandle missing, device falling back to root interrupt controller

Security Implications

  • DTB tampering: If an attacker controls the bootloader, they can modify the DTB before passing it to the kernel. This could remap peripheral addresses, disable security peripherals, or inject fake devices. Secure Boot chains should extend to DTB measurement/verification.
  • IOMMU specification: The DTB specifies iommus properties for DMA-capable devices. Missing or incorrect IOMMU assignment in DT allows a device to DMA into arbitrary physical memory, creating privilege escalation via DMA.
  • Initrd address: The chosen/linux,initrd-start and linux,initrd-end properties specify the initrd location. Manipulation allows substituting a malicious initramfs.
  • Memory reservation: memory-reserve nodes mark regions the OS should not use. These are trusted; a compromised DT could hide firmware-resident malware by incorrectly marking memory as reserved.

Performance Implications

  • Device Tree parsing occurs at early boot and contributes 5-50ms to boot time on embedded systems depending on DT complexity.
  • The of_platform_populate() call synchronously probes all platform devices; deferred probing is used when dependencies aren't yet initialized.
  • DT property lookups (of_property_read_u32, etc.) use linear search within nodes — acceptable since they occur only at probe time, not in hot paths.
  • Overlays applied via configfs have measurable latency (milliseconds) due to tree modification locking.

Failure Modes

  1. Wrong DTB loaded: U-Boot loads the DTB for a similar but different board. Peripheral addresses are wrong. Symptom: early kernel crash or missing devices. Fix: ensure boot script selects DTB by board revision.
  2. Interrupt storm from mis-specced IRQ: Wrong trigger type (level vs edge) for an interrupt causes the interrupt handler to fire continuously. Symptom: 100% CPU usage in IRQ context, system unresponsive.
  3. Clock rate mismatch: UART clock frequency property set incorrectly. Symptom: garbled serial output (baud rate mismatch).
  4. Missing ranges in bus node: Without ranges, child node addresses are not translated to parent address space. Symptom: driver reads/writes wrong memory addresses; typically a silent failure until the peripheral does something unexpected.
  5. Pinctrl state not configured: Device works initially (reset default pin state) but fails after suspend/resume because the sleep pinctrl state reconfigures pins and default state is not restored.

Modern Usage (2025)

  • Every ARM, RISC-V, and MIPS Linux system uses Device Tree (x86 uses ACPI instead, though DT support exists)
  • Android kernel builds: every Android device ships a DTB or DTBO partition
  • Raspberry Pi: entire peripheral configuration managed through overlays in /boot/config.txt
  • OpenEmbedded/Yocto: automated DTB compilation and deployment is a standard build step
  • dtschema Python package: validates all in-tree DTS files at kernel build time since Linux 5.1
  • EBBR (Embedded Base Boot Requirements): standardizes DT + UEFI for ARM servers to reduce per-board kernel customization

Future Directions

  • ACPI on ARM servers: High-performance ARM servers (Ampere Altra, AWS Graviton) increasingly use ACPI rather than DT, converging with x86 server management conventions.
  • Automated DT generation: Tools to generate DTS fragments from SoC vendor register databases are maturing, reducing hand-authoring errors.
  • Runtime DT modification: Beyond overlays, the livepatch-style runtime DT modification for dynamic hardware (PCIe hotplug, modular devices) is an active area.
  • DT for RISC-V platforms: As RISC-V platform diversity grows, DT plays the same role it did for ARM — the RISC-V platform spec (RVA22 profile) mandates DT support.

Exercises

  1. Download a Raspberry Pi 4 DTB from a Linux kernel tree (arch/arm/boot/dts/bcm2711-rpi-4-b.dts). Decompile it with dtc and identify: (a) how many CPUs are described, (b) the UART base address, (c) the interrupt controller type.
  2. Write a minimal DTS for a hypothetical board with one RISC-V CPU, 256MB RAM at address 0x80000000, and one NS16550 UART at 0x10000000 with IRQ 10. Compile it with dtc and verify no warnings.
  3. On a Raspberry Pi or BeagleBone, list the files under /proc/device-tree/. Find the I2C bus node and identify the addresses of any attached devices. Correlate this with i2cdetect -l output.
  4. Create a Device Tree overlay that enables the SPI bus on a Raspberry Pi and adds an SPI device (e.g., a SPI flash). Apply it using configfs and verify the device appears in dmesg.
  5. Read the dt-bindings YAML file for snps,dw-apb-uart. Write a DTS snippet that would fail validation and explain which constraint it violates. Then fix it.

References

  • Zumsteg, G. (2013). Device Tree for Dummies. Embedded Linux Conference. https://elinux.org/images/f/f9/Petazzoni-device-tree-dummies_0.pdf
  • Linux kernel documentation: Documentation/devicetree/ (in-tree)
  • Devicetree Specification v0.4: https://www.devicetree.org/specifications/
  • Torvalds, L. (2011). [ARM] cleanup. LKML archive. https://lkml.org/lkml/2011/3/17/492
  • Grant Likely. (2013). Device Tree: The Disaster So Far. Linux Plumbers Conference.
  • dt-schema project: https://github.com/devicetree-org/dt-schema
  • U-Boot DT documentation: https://docs.u-boot.org/en/latest/develop/devicetree/
  • Petazzoni, T. Device Tree 101. Bootlin (formerly Free Electrons) training materials.