R0KernelPath
module 090 / 262

Platform Devices & the Device Tree

Master the Device Tree and platform driver — the machinery that lets a single Linux kernel binary boot on thousands of different ARM boards without knowing their hardware at compile time.

~18h focused study12 concepts

Overview

Every piece of hardware that runs Linux eventually needs a driver — a kernel module that speaks the hardware's language so user programs can ignore it. But not all hardware announces itself. Plug a USB flash drive into a laptop and the kernel discovers it automatically through the USB protocol's enumeration mechanism. Plug a UART controller directly onto an ARM SoC's internal bus, however, and there is no such announcement. The kernel can't probe it. Someone has to tell the kernel: "there is a serial port here, at this memory address, using this interrupt line." That "someone" is the Device Tree.

This module digs into the machinery that makes embedded Linux possible: platform devices, the kernel abstraction for hardware that cannot enumerate itself, and the Device Tree Source (DTS) format — a data-driven hardware description language that ARM and RISC-V embedded boards use to wire that hardware to its drivers at boot time. Understanding this is not just academic. Nearly every embedded Linux system — Raspberry Pi, BeagleBone, any Android phone's SoC — uses these mechanisms, and kernel driver submissions for such hardware must handle them correctly.

We will build from first principles: what "discoverable" vs "non-discoverable" really means, how the old platform_data approach worked and why it broke down at scale, what a Device Tree actually is and how the bootloader turns it into a binary blob, how the kernel's of_* API lets a driver read resources from that blob, and how platform_driver ties the whole stack together. By the end you'll be able to write a minimal-but-real platform driver backed by a Device Tree node and understand every line of it.

A word on scope: we use ARM (specifically Cortex-A, as used in the Raspberry Pi and BeagleBone) as our reference architecture throughout, because that is where the Device Tree story matured and where nearly all the real-world examples live. The same mechanisms apply verbatim to RISC-V and MIPS embedded systems.

why this matters for the kernel

If you want to contribute to the Linux kernel in the embedded or driver space — which is, by commit count, the single largest segment of kernel development — you will encounter platform drivers and Device Trees on your first day. The drivers/ tree is enormous, and most of the interesting new work lands in drivers/soc/, drivers/clk/, drivers/gpio/, drivers/mfd/, drivers/regulator/, and dozens of other subsystems that are almost entirely platform-driver-based. A patch that adds support for a new SoC peripheral must include a Device Tree binding document, pass the dt-schema checker, and implement of_match_table correctly or it will be rejected by maintainers outright.

Beyond contribution mechanics, understanding this machinery gives you a mental model for how the kernel separates hardware description from driver logic — a design discipline you will use every time you write a driver. The Device Tree is, in a sense, a contract between hardware designers and software developers, and reading it fluently lets you reverse-engineer almost any embedded board just from its DTS files without a schematic. That is a superpower that opens doors to debugging, porting, and security research on real hardware.

Platform Driver Probe Lifecycle via Device Tree

rendering diagram…

From bootloader handoff to driver probe: the Device Tree blob is parsed into an in-memory node tree, platform_device objects are synthesized for enabled nodes, and the driver core matches each device to a registered platform_driver via the of_match_table. The -EPROBE_DEFER loop allows drivers to retry when dependencies (clocks, regulators, GPIOs) become available later.

The concepts

01Discoverable vs Non-Discoverable Buses

A discoverable bus is one that has a built-in enumeration protocol: the bus controller can ask 'who is here?' and each device on the bus answers with its identity (vendor ID, device ID, class). PCI/PCIe, USB, and SCSI all work this way. The kernel probes the bus at boot, reads the IDs, and looks up the right driver in a table — fully automatic.

A non-discoverable bus has no such protocol. I2C, SPI, UART, and the memory-mapped register spaces of SoC peripherals (timers, GPIO controllers, DMA engines, etc.) are all non-discoverable. There is no way for the kernel to ask 'what GPIO controller is at address 0x3F200000?' — it either already knows or it doesn't.

On x86 PCs, the ACPI firmware table plays the role of telling the kernel about such devices. On ARM/RISC-V embedded systems — where ACPI is rare — the Device Tree fills this gap. The conceptual split is important: the bus protocol determines discoverability, not whether the hardware is 'modern' or 'old'. An I2C temperature sensor on a 2024 SoC is just as non-discoverable as one from 1990.

The platform bus (struct bus_type platform_bus_type) is the kernel's catch-all for non-discoverable devices. It provides the lifecycle management (probe/remove) without any enumeration — instead, devices on the platform bus are registered explicitly, either from board files (old way) or from Device Tree (new way).

/* x86: PCI device ID table — the bus gives the kernel these IDs automatically */
static const struct pci_device_id my_pci_ids[] = {
    { PCI_DEVICE(0x8086, 0x1234) },  /* vendor=Intel, device=0x1234 */
    { 0 }                             /* sentinel */
};

/* ARM: platform device — NO bus-level discovery; kernel is told explicitly */
/* The platform bus matches by NAME or by Device Tree 'compatible' string */
static const struct of_device_id my_dt_ids[] = {
    { .compatible = "vendor,my-uart" },
    { /* sentinel */ }
};
/* The kernel can't probe for these: it reads them from the Device Tree */

02The Old Way: Board Files and platform_data

Before Device Tree arrived on ARM, every board had a board file — a C source file like arch/arm/mach-omap2/board-n800.c that lived inside the kernel source tree and called functions like platform_device_register() to tell the kernel what hardware was present.

This worked, but it became a maintenance disaster. The Linux kernel ended up with hundreds of board files, each hardcoding memory addresses, IRQ numbers, and configuration data in C structs. Adding support for a new board revision meant adding a new C file to the kernel, recompiling, and shipping a new kernel binary. You couldn't ship a single kernel binary that booted on multiple boards.

By around 2011, Linus Torvalds famously described the ARM architecture's proliferation of board files as 'a fucking pain in the ass'. The arch/arm/ directory was the single most-committed-to directory in the kernel, mostly with board-specific boilerplate. The community adopted the Device Tree approach (borrowed from PowerPC/SPARC) as the solution.

The platform_data field in struct platform_device still exists for cases where you genuinely need to pass structured data from a board-support layer to a driver, but for all new code, Device Tree properties replace it.

/* OLD way (board file, ~2010 era) — lives inside kernel source tree */
static struct resource my_uart_resources[] = {
    {
        .start = 0x101F1000,  /* physical base address — hardcoded! */
        .end   = 0x101F1FFF,
        .flags = IORESOURCE_MEM,
    },
    {
        .start = 12,           /* IRQ number — hardcoded! */
        .end   = 12,
        .flags = IORESOURCE_IRQ,
    },
};

static struct platform_device my_uart_device = {
    .name = "my-uart",
    .id   = 0,
    .num_resources = ARRAY_SIZE(my_uart_resources),
    .resource      = my_uart_resources,
};

/* called from board init */
platform_device_register(&my_uart_device);
/* Driver matched by .name string — fragile, no versioning */

03What the Device Tree IS — DTS, DTSI, and DTB

The Device Tree is a data structure that describes hardware. Think of it as a JSON-like tree where each node represents a hardware component (a CPU, a memory bank, a peripheral) and each node's properties describe that component's configuration (its register base address, its interrupt line, its clock source, etc.).

The Device Tree Source (.dts file) is the human-readable text format. It uses a C-like syntax. DTSI files (.dtsi) are include files — common SoC-level definitions that many boards share. The cpp preprocessor processes #include directives between them. The Device Tree Compiler (dtc) compiles a .dts into a Device Tree Blob (.dtb), a compact binary format that the bootloader (U-Boot, GRUB, etc.) loads into RAM and passes to the kernel via a register at boot time.

The kernel's Open Firmware layer (drivers/of/) parses this blob at boot and creates an in-memory tree of struct device_node objects, one per DTS node. Everything the of_* API does is walk and query this tree.

Key DTS concepts:

  • Node name: uart@101f1000 — human label with optional unit address.
  • compatible: a list of strings (most-specific first) that the kernel uses to match a driver. Must follow the format "vendor,device". This is the most important property.
  • reg: describes MMIO register ranges as <base_address size> pairs, interpreted according to the #address-cells and #size-cells properties of the parent node.
  • interrupts: interrupt specifier, interpreted by the parent interrupt controller node.
  • status: "okay" enables the device; "disabled" suppresses it.
/* arch/arm/boot/dts/example-board.dts — simplified */
/dts-v1/;
#include "example-soc.dtsi"    /* SoC-level defines (CPU, buses) */

/ {                              /* root node */
    model = "Example Board Rev 1";
    compatible = "vendor,example-board", "vendor,example-soc";

    /* memory node: 512 MiB at physical address 0 */
    memory@0 {
        device_type = "memory";
        reg = <0x0 0x20000000>;
    };

    /* SoC bus node (defined in .dtsi); we add a UART child here */
    soc {
        #address-cells = <1>;   /* one 32-bit cell for addresses */
        #size-cells    = <1>;   /* one 32-bit cell for sizes     */

        uart0: serial@101f1000 {
            compatible = "arm,pl011", "arm,primecell";
            reg = <0x101F1000 0x1000>;  /* base=0x101F1000, size=0x1000 */
            interrupts = <12>;          /* IRQ 12 */
            clocks = <&apb_clk>;
            clock-names = "apbclk";
            status = "okay";
        };
    };
};
/* Compile: dtc -I dts -O dtb -o example-board.dtb example-board.dts */

04How the Bootloader Hands the DTB to the Kernel

When the system powers on, the CPU begins executing from a reset vector. On ARM Cortex-A, control typically passes to U-Boot (or a similar first-stage bootloader). U-Boot loads two things into RAM:

  1. The kernel image (zImage / Image).
  2. The Device Tree Blob (.dtb).

Then it jumps to the kernel entry point, following the ARM boot protocol: register r0 = 0, register r1 = machine type (legacy, often ignored now), and critically r2 = physical address of the DTB in RAM. On AArch64 (64-bit ARM), it is x0 = physical address of DTB.

The kernel's early assembly startup reads this register before the MMU is enabled and stores the DTB pointer. Later, in setup_arch(), the kernel calls unflatten_device_tree() which parses the raw DTB binary (which follows the Flattened Device Tree / FDT format, defined in the ePAPR/DT spec) and builds the in-kernel tree of struct device_node and struct property objects.

From that point on, the DTS data is live in kernel memory as a structured tree, and the of_* API functions query it. The DTB itself is a well-defined binary format: it starts with a magic number 0xD00DFEED, has a header with offsets to the structure block and strings block, and encodes the tree in depth-first order.

On QEMU you can observe this directly: qemu-system-arm -machine virt -kernel zImage -dtb virt.dtb ... — QEMU places the DTB at a known address and sets r2 exactly as real hardware would.

# Dump the live device tree from a running Linux system
# (requires CONFIG_PROC_DEVICETREE or CONFIG_OF_PROC_FS)
ls /proc/device-tree/

# Better: the full tree as DTS
cat /sys/firmware/devicetree/base/model     # board model string

# dtc can decompile a running kernel's blob back to readable DTS:
dtc -I fs -O dts /sys/firmware/devicetree/base 2>/dev/null | head -60

# Or decompile a .dtb file directly:
dtc -I dtb -O dts -o decoded.dts /boot/dtbs/5.15.0/broadcom/bcm2711-rpi-4-b.dtb

05platform_driver: The Driver Side of the Contract

A platform driver is a kernel driver that registers itself against the platform bus. Its structure is struct platform_driver, which wraps a struct device_driver with platform-specific additions. The key fields are:

  • .probe: called when the kernel finds a device that matches this driver. This is where you claim resources (MMIO, IRQ), initialize hardware, and register with a higher-level subsystem (e.g., tty_register_driver, misc_register). If probe fails (returns non-zero), the device is not bound.
  • .remove: called when the device is unbound (module unload, device hotplug). Free resources here in the reverse order you claimed them.
  • .driver.name: used for matching when DT is unavailable.
  • .driver.of_match_table: pointer to an array of struct of_device_id entries; this is how DT-based matching works.

The registration macro module_platform_driver(my_driver) expands to a module_init that calls platform_driver_register() and a module_exit that calls platform_driver_unregister(). It is idiomatic; use it.

The matching algorithm: when the kernel processes a DT node with status = "okay", it creates a struct platform_device for it. The driver core then walks all registered platform drivers and checks if any of_device_id.compatible string matches the node's compatible property. If yes, the driver's .probe() is called with a pointer to the platform_device.

#include <linux/module.h>
#include <linux/platform_device.h>
#include <linux/of.h>

static int my_uart_probe(struct platform_device *pdev)
{
    dev_info(&pdev->dev, "probe called for %s\n",
             pdev->dev.of_node->full_name);
    /* TODO: claim resources, init hardware */
    return 0;  /* 0 = success; negative errno = failure */
}

static int my_uart_remove(struct platform_device *pdev)
{
    dev_info(&pdev->dev, "remove called\n");
    /* free resources in reverse probe order */
    return 0;
}

/* Device Tree match table */
static const struct of_device_id my_uart_of_match[] = {
    { .compatible = "vendor,my-uart" },
    { /* sentinel — MUST be last */ }
};
MODULE_DEVICE_TABLE(of, my_uart_of_match);

static struct platform_driver my_uart_driver = {
    .probe  = my_uart_probe,
    .remove = my_uart_remove,
    .driver = {
        .name           = "my-uart",   /* fallback name match */
        .of_match_table = my_uart_of_match,
        .owner          = THIS_MODULE,
    },
};

module_platform_driver(my_uart_driver);

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Minimal platform driver example");
MODULE_AUTHOR("Your Name");

06Claiming Resources: MMIO, IRQ, and Managed Resources (devm_*)

Hardware peripherals expose their configuration registers as memory-mapped I/O (MMIO) — physical address ranges where reads/writes go to the hardware, not to RAM. Before a driver can touch these registers, it must:

  1. Claim the region with platform_get_resource() + request_mem_region() — this marks the range as owned so no other driver accidentally touches it.
  2. Map it with ioremap() — the CPU's MMU translates a physical address to a virtual address the kernel can use with readl()/writel().

This two-step claim-then-map is so common that the kernel provides devm_ioremap_resource(), which does both in one call and automatically undoes them when the device is removed. The devm_* (device-managed) family of functions is the modern, correct way to allocate resources in a probe function. They are tied to the device's lifetime: when the device is unbound, all devm_* allocations are freed in reverse order automatically. This nearly eliminates resource-leak bugs.

Similarly, platform_get_irq() extracts the interrupt number from the interrupts DT property, and devm_request_irq() registers an interrupt handler that is automatically freed on remove.

For reading properties beyond reg and interrupts, the of_property_read_u32(), of_property_read_string(), and related functions parse DT properties from the pdev->dev.of_node pointer.

#include <linux/platform_device.h>
#include <linux/of.h>
#include <linux/io.h>        /* ioremap, readl, writel */
#include <linux/interrupt.h>

/* Layout of our fictional UART's registers */
#define UART_DR   0x00   /* data register */
#define UART_CR   0x30   /* control register */

struct my_uart_dev {
    void __iomem *base;  /* virtual address of MMIO base */
    int           irq;
};

static irqreturn_t my_uart_isr(int irq, void *dev_id)
{
    struct my_uart_dev *uart = dev_id;
    u32 data = readl(uart->base + UART_DR); /* read hardware register */
    (void)data; /* process received byte */
    return IRQ_HANDLED;
}

static int my_uart_probe(struct platform_device *pdev)
{
    struct my_uart_dev *uart;
    struct resource *res;
    int ret;
    u32 fifo_size;

    /* Allocate driver-private data, auto-freed on remove */
    uart = devm_kzalloc(&pdev->dev, sizeof(*uart), GFP_KERNEL);
    if (!uart)
        return -ENOMEM;

    /* Get MMIO resource from DT 'reg' property (index 0) */
    res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    /* Map it — claims AND maps; auto-unmapped on remove */
    uart->base = devm_ioremap_resource(&pdev->dev, res);
    if (IS_ERR(uart->base))
        return PTR_ERR(uart->base);

    /* Get IRQ from DT 'interrupts' property */
    uart->irq = platform_get_irq(pdev, 0);
    if (uart->irq < 0)
        return uart->irq;  /* already logged by platform layer */

    /* Register interrupt handler — auto-freed on remove */
    ret = devm_request_irq(&pdev->dev, uart->irq, my_uart_isr,
                           0, dev_name(&pdev->dev), uart);
    if (ret)
        return ret;

    /* Read an optional DT property */
    if (of_property_read_u32(pdev->dev.of_node, "fifo-size", &fifo_size))
        fifo_size = 16;  /* default if absent */
    dev_info(&pdev->dev, "UART at %pa, IRQ %d, FIFO %u\n",
             &res->start, uart->irq, fifo_size);

    /* Now write hardware registers */
    writel(0x301, uart->base + UART_CR); /* enable TX+RX */

    platform_set_drvdata(pdev, uart);   /* store for remove/ioctl */
    return 0;
}

07Device Tree Bindings and dt-schema

A Device Tree binding is the specification that defines what a device's DT node must and may contain — which properties are required, what their types are, what valid values look like. Without a binding doc, a DT node is undocumented and potentially incompatible across kernel versions.

In the kernel source, bindings live in Documentation/devicetree/bindings/. Historically they were .txt files. Modern bindings are YAML schemas validated by the dt-schema tool (installable with pip install dtschema). The make dtbs_check kernel build target validates all in-tree DTS files against their bindings.

A YAML binding specifies:

  • compatible: the exact strings this binding applies to.
  • properties: each named property with its type (uint32, phandle, string-array, etc.) and constraints.
  • required: list of mandatory properties.
  • examples: at least one valid DTS snippet.

The compatible string format "vendor,device" is enforced: vendor must be a registered vendor prefix (see Documentation/devicetree/bindings/vendor-prefixes.yaml). For out-of-tree or example code, the prefix "simple-bus" or your own domain name is used. Never use a made-up vendor prefix in an upstream patch.

When submitting a new driver upstream, the binding YAML is required and reviewed separately from the driver code — often by different maintainers. The devicetree@vger.kernel.org mailing list handles bindings; linux-arm-kernel@lists.infradead.org handles the driver.

# Documentation/devicetree/bindings/serial/vendor,my-uart.yaml
# (simplified example of a dt-schema binding)
---
$id: http://devicetree.org/schemas/serial/vendor,my-uart.yaml#
$schema: http://devicetree.org/meta-schemas/core.yaml#

title: Vendor My-UART Controller

maintainers:
  - Your Name <you@example.com>

properties:
  compatible:
    const: vendor,my-uart

  reg:
    maxItems: 1         # exactly one MMIO range

  interrupts:
    maxItems: 1

  fifo-size:
    $ref: /schemas/types.yaml#/definitions/uint32
    description: Hardware FIFO depth in bytes
    default: 16

required:
  - compatible
  - reg
  - interrupts

examples:
  - |
    serial@101f1000 {
        compatible = "vendor,my-uart";
        reg = <0x101f1000 0x1000>;
        interrupts = <12>;
        fifo-size = <32>;
    };

# Validate: make dtbs_check DT_SCHEMA_FILES=Documentation/devicetree/bindings/serial/vendor,my-uart.yaml

08The of_match_table and Driver Data

The of_match_table does more than just match a compatible string — each entry can carry a .data pointer, an arbitrary const void * that lets a single driver support multiple hardware variants with different configurations.

This pattern appears constantly in real drivers. For example, the PL011 UART driver supports both the original PL011 and the PL011r1p5 variant; they share 95% of code but differ in a few register offsets. Rather than duplicating the driver, the .data field points to a per-variant struct that the probe function reads with of_device_get_match_data().

The idiom:

  1. Define a per-variant config struct.
  2. Create one of_device_id entry per variant, each with its compatible string and a pointer to its config.
  3. In probe, call of_device_get_match_data(&pdev->dev) to get the right config.

This is a beautiful example of the kernel using data-driven polymorphism instead of if-else chains — a driver design philosophy worth internalizing. The same pattern appears in I2C drivers (i2c_device_id.driver_data), SPI drivers, and USB drivers.

/* Different hardware variants, one driver */
struct my_uart_variant {
    unsigned int fifo_depth;
    bool         has_dma;
    u32          dr_offset;    /* data-register byte offset */
};

static const struct my_uart_variant uart_v1 = {
    .fifo_depth = 16, .has_dma = false, .dr_offset = 0x00,
};
static const struct my_uart_variant uart_v2 = {
    .fifo_depth = 64, .has_dma = true,  .dr_offset = 0x00,
};

static const struct of_device_id my_uart_of_match[] = {
    { .compatible = "vendor,my-uart-v1", .data = &uart_v1 },
    { .compatible = "vendor,my-uart-v2", .data = &uart_v2 },
    { /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, my_uart_of_match);

static int my_uart_probe(struct platform_device *pdev)
{
    const struct my_uart_variant *variant;

    /* Retrieve the matched variant's data pointer */
    variant = of_device_get_match_data(&pdev->dev);
    if (!variant)
        return -ENODEV;

    dev_info(&pdev->dev, "FIFO depth: %u, DMA: %s\n",
             variant->fifo_depth, variant->has_dma ? "yes" : "no");
    /* use variant->dr_offset when mapping registers */
    return 0;
}

09Interrupts and Interrupt Controllers in the DT

Interrupts in embedded systems are more complex than a single integer. A hardware interrupt line runs from the peripheral to an interrupt controller (e.g., ARM's GIC — Generic Interrupt Controller), which in turn signals the CPU. The DT models this hierarchy.

Every interrupt controller node declares #interrupt-cells, which says how many 32-bit cells are needed to specify one interrupt. For a GIC, it takes three cells: <type number flags>. For a simple VIC (Vectored Interrupt Controller), it may be just <number>.

A peripheral node's interrupts property is a sequence of interrupt specifiers, each of #interrupt-cells length. The interrupt-parent property (or its inherited value from a parent node) names the interrupt controller that interprets those specifiers.

The driver does not deal with any of this directly: platform_get_irq(pdev, 0) calls irq_of_parse_and_map() internally, which consults the interrupt controller driver (via the irq_domain framework) to translate the DT specifier into a Linux virtual IRQ number — a simple integer the driver passes to request_irq(). The abstraction is clean: the driver is fully portable across boards with different interrupt controllers.

For drivers that need multiple interrupts (e.g., separate TX and RX interrupt lines), platform_get_irq(pdev, 0) and platform_get_irq(pdev, 1) retrieve them by index matching the order in the DT interrupts list.

/* DTS: GIC interrupt controller (ARM Cortex-A) */
gic: interrupt-controller@1c81000 {
    compatible = "arm,cortex-a7-gic";
    #interrupt-cells = <3>;      /* type, number, flags */
    interrupt-controller;
    reg = <0x01c81000 0x1000>,
          <0x01c82000 0x2000>;
};

/* Peripheral using GIC: timer with two interrupts */
timer@1c20c00 {
    compatible = "allwinner,sun4i-a10-timer";
    reg = <0x01c20c00 0x90>;
    interrupts = <GIC_SPI 22 IRQ_TYPE_LEVEL_HIGH>,   /* index 0 */
                 <GIC_SPI 23 IRQ_TYPE_LEVEL_HIGH>;   /* index 1 */
    interrupt-parent = <&gic>;
    /* GIC_SPI=0, number, IRQ_TYPE_LEVEL_HIGH=4 */
};

/* In the driver: */
int irq0 = platform_get_irq(pdev, 0); /* maps to Linux virq for SPI 22 */
int irq1 = platform_get_irq(pdev, 1); /* maps to Linux virq for SPI 23 */

10Clocks, Resets, and the DT Provider/Consumer Model

Real SoC peripherals don't just need MMIO and IRQs — they need a clock signal to run and often a reset signal to be initialized. The Device Tree models these as phandle references: a consumer node lists the clock or reset it needs, and a phandle points to the provider node (the clock controller or reset controller).

This is the provider/consumer pattern that appears throughout DT for clocks, resets, power domains, GPIO, pinmux, DMA channels, and more. The DT node that provides a resource declares #clock-cells (or #reset-cells, etc.) and sets clock-controller (or equivalent). Consumer nodes reference it with a phandle plus selector cells.

In the driver, the clk_get() / devm_clk_get() API retrieves a clock handle by the name declared in clock-names. Then clk_prepare_enable() starts it and clk_disable_unprepare() stops it. The underlying clock framework figures out which hardware clock is meant by looking at the DT phandle chain — the driver is completely decoupled from the clock hardware's identity.

This matters because the same peripheral IP block (e.g., an ARM PL011 UART) may be clocked differently on every SoC it's integrated into. DT externalizes that dependency so the driver stays generic.

A related pattern: devm_reset_control_get() and reset_control_deassert() for peripherals that need an explicit reset release before they will respond.

/* DTS: clock provider (CCU) and consumer (UART) */
ccu: clock-controller@1c20000 {
    compatible = "allwinner,sun8i-h3-ccu";
    reg = <0x01c20000 0x400>;
    #clock-cells = <1>;  /* one cell = clock ID */
    #reset-cells = <1>;  /* one cell = reset ID */
};

uart0: serial@1c28000 {
    compatible = "snps,dw-apb-uart";
    reg = <0x01c28000 0x400>;
    interrupts = <GIC_SPI 0 IRQ_TYPE_LEVEL_HIGH>;
    /* Two clocks: bus clock + baud-rate clock */
    clocks = <&ccu CLK_BUS_UART0>, <&ccu CLK_UART0>;
    clock-names = "bus", "baud";
    resets = <&ccu RST_BUS_UART0>;
    reset-names = "uart";
    status = "okay";
};

/* In the driver probe: */
struct clk *bclk = devm_clk_get(&pdev->dev, "baud");
if (IS_ERR(bclk))
    return PTR_ERR(bclk);

struct reset_control *rst =
    devm_reset_control_get(&pdev->dev, "uart");
if (IS_ERR(rst))
    return PTR_ERR(rst);

reset_control_deassert(rst);       /* release peripheral from reset */
clk_prepare_enable(bclk);          /* start baud clock */
/* hardware is now alive */

11Testing Without Real Hardware: QEMU and Overlay DTS

One of the most powerful things about the Device Tree approach is testability: you can add a fake peripheral to QEMU's virtual machine by writing a DTS node, and your driver will probe and run without any real hardware.

QEMU's virt machine for ARM/AArch64 dynamically generates a DTB describing its virtual devices. You can append to this DTB using a Device Tree Overlay (.dtbo) — a fragment that modifies or extends the base DTB without replacing it. Overlays use a special __overlay__ syntax and are applied with dtc + fdtoverlay or the kernel's configfs overlay mechanism.

For development, the simplest approach is to compile your .dts to .dtb and pass it directly to QEMU with -dtb. This lets you iterate on driver and DTS together without a real board.

Another invaluable tool is dtc -I fs -O dts /sys/firmware/devicetree/base on a running system — it decompiles the live kernel's DTB back to readable DTS, showing you exactly what the kernel sees, including any overlays that were applied at boot (common on Raspberry Pi, where config.txt applies overlays).

The kernel's of_unittest.c (enabled by CONFIG_OF_UNITTEST) runs a suite of tests against the live Device Tree in a running kernel — useful for verifying your understanding of the of_* API behavior.

# Run an AArch64 QEMU VM with a custom DTB, no real hardware needed
# 1. Install: apt install qemu-system-arm device-tree-compiler gcc-aarch64-linux-gnu

# 2. Get QEMU's generated DTB so we can extend it
qemu-system-aarch64 \
    -machine virt,dumpdtb=virt-base.dtb \
    -cpu cortex-a57 -m 512M \
    -nographic -kernel /dev/null

# 3. Decompile to see what's there
dtc -I dtb -O dts -o virt-base.dts virt-base.dtb

# 4. Write an overlay that adds our fake UART node:
cat > my-uart-overlay.dts << 'EOF'
/dts-v1/;
/ {
    fragment@0 {
        target-path = "/";
        __overlay__ {
            my_test_uart: serial@9000000 {
                compatible = "vendor,my-uart";
                reg = <0x0 0x9000000 0x0 0x1000>;
                interrupts = <0x0 8 0x4>;
                status = "okay";
            };
        };
    };
};
EOF
dtc -I dts -O dtb -o my-uart-overlay.dtbo my-uart-overlay.dts

# 5. Merge overlay onto base DTB
fdtoverlay -i virt-base.dtb -o virt-final.dtb my-uart-overlay.dtbo

# 6. Boot QEMU with merged DTB — your driver will probe!
qemu-system-aarch64 \
    -machine virt -cpu cortex-a57 -m 512M \
    -kernel Image -dtb virt-final.dtb \
    -append "console=ttyAMA0 root=/dev/ram" \
    -initrd rootfs.cpio.gz -nographic

12The probe Lifecycle and Common Pitfalls

The probe() function is the driver's constructor. Getting its lifecycle right is crucial because the kernel may call it from multiple contexts and may retry it. The return value contract:

  • Return 0: device bound successfully, driver owns the device.
  • Return a negative errno: binding fails; the device is NOT bound. Log the reason first with dev_err().
  • Return -EPROBE_DEFER: a special sentinel meaning 'I need a dependency (a clock, a GPIO, a regulator) that isn't registered yet; try me again later'. The kernel will re-call probe when any new driver registers.

-EPROBE_DEFER is how the kernel handles initialization ordering without requiring every driver to know about every other driver. If your UART probe calls devm_clk_get() and the clock provider hasn't probed yet, it returns ERR_PTR(-EPROBE_DEFER). Your probe should detect this (often via if (IS_ERR(clk)) return PTR_ERR(clk);) and return it to the kernel.

Common probe mistakes:

  1. Resource leak on error: not using devm_*, then bailing out early without freeing. devm_* functions make this nearly impossible.
  2. NULL dereference on optional properties: of_property_read_u32() returns non-zero if absent; always check.
  3. Not deferring on unmet dependencies: if devm_clk_get returns -EPROBE_DEFER, you must propagate it, not print an error and soldier on.
  4. Hardware access before clock enable: writing to MMIO before clk_prepare_enable() hangs or corrupts the system.
  5. Forgetting MODULE_DEVICE_TABLE: without this macro, the module doesn't get the right alias in its ELF metadata, so modprobe and udev cannot auto-load it when the DT node is found.
static int careful_probe(struct platform_device *pdev)
{
    struct my_dev *d;
    struct clk *clk;
    struct resource *res;
    int ret;

    d = devm_kzalloc(&pdev->dev, sizeof(*d), GFP_KERNEL);
    if (!d)
        return -ENOMEM;  /* OOM — don't need to free anything */

    res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    d->base = devm_ioremap_resource(&pdev->dev, res);
    if (IS_ERR(d->base))
        return PTR_ERR(d->base);  /* includes -EPROBE_DEFER cases */

    /* Clock may not be ready yet — propagate EPROBE_DEFER */
    clk = devm_clk_get(&pdev->dev, "apb");
    if (IS_ERR(clk)) {
        /* This might be -EPROBE_DEFER OR a real error */
        if (PTR_ERR(clk) != -EPROBE_DEFER)
            dev_err(&pdev->dev, "clock not found: %ld\n", PTR_ERR(clk));
        return PTR_ERR(clk);  /* let the kernel decide when to retry */
    }

    ret = clk_prepare_enable(clk);
    if (ret) {
        dev_err(&pdev->dev, "clock enable failed: %d\n", ret);
        return ret;
        /* devm_clk_get auto-puts clk on device release */
    }

    /* NOW safe to touch registers */
    writel(0x1, d->base + CTRL_REG);

    platform_set_drvdata(pdev, d);
    dev_info(&pdev->dev, "probed OK\n");
    return 0;
    /* On any error, all devm_ resources freed automatically */
}

Key insights

  • ✦The Device Tree is fundamentally a separation-of-concerns tool: hardware description lives in DTS, hardware behavior lives in the driver. This lets one driver binary run on hundreds of boards.
  • ✦The 'compatible' string is the contract between hardware designer and driver author. Its 'vendor,device' format is not aesthetic — it is a namespace that prevents naming collisions across thousands of SoC vendors.
  • ✦devm_* functions are not just convenience — they enforce correct resource management by making the device lifetime the allocation lifetime. Always prefer them; resist the urge to use raw ioremap/request_irq.
  • ✦-EPROBE_DEFER is a first-class initialization mechanism, not an error. The kernel's deferred probe queue makes topological ordering of driver initialization implicit and automatic.
  • ✦The provider/consumer model for clocks, resets, and GPIOs means drivers are decoupled from the specific clock hardware. The same UART driver runs on boards with completely different clock trees without modification.
  • ✦MODULE_DEVICE_TABLE is not optional metadata — without it, the module loader cannot automatically bind the module to a DT node, breaking udev autoloading on embedded systems.
  • ✦QEMU + DTS overlays give you a full hardware simulation environment for driver development. You do not need physical hardware to write, test, and iterate on a platform driver.
  • ✦The history matters: the 'ARM mess' of board files was a real crisis that shaped modern kernel architecture. Understanding why the Device Tree exists helps you understand when and how to use it correctly.

Common pitfalls

  • ⚠Forgetting MODULE_DEVICE_TABLE(of, ...) — the driver compiles and loads manually but udev never auto-loads it; dmesg shows no probe on boot.
  • ⚠Not propagating -EPROBE_DEFER — printing 'clock not found' as a fatal error and returning -ENODEV instead of the deferred error, causing the driver to permanently fail on boards where the clock driver probes late.
  • ⚠Writing to MMIO before enabling the peripheral's clock — causes a bus hang (CPU spin-waits for a response that never comes) or a silent write to /dev/null, both very hard to debug.
  • ⚠Mixing raw resource allocation (ioremap, request_irq) with early-return error paths — creates resource leaks that only show up on driver rebind or module reload, not on the happy path.
  • ⚠Using the wrong #address-cells / #size-cells context when writing DTS — if the parent bus has #address-cells = <2> (common on 64-bit buses), a reg property with one cell will silently be parsed wrong.
  • ⚠Writing a 'compatible' string without a real registered vendor prefix (e.g., 'mycompany,widget') in code destined for upstream — will be rejected; check Documentation/devicetree/bindings/vendor-prefixes.yaml.
  • ⚠Accessing pdev->dev.of_node without checking for NULL — on boards that use the old board-file path instead of DT, of_node is NULL; always guard with if (pdev->dev.of_node).
  • ⚠Comparing the of_match_table compatible string by address instead of content — the kernel does string comparison internally; do not try to compare the matched entry pointer directly.

Practice

  1. 01
    easy

    Write a complete, compilable minimal platform driver for a fictional 'vendor,led-ctrl' device. It should: (1) read its MMIO base from the DT 'reg' property using devm_ioremap_resource, (2) read an optional 'default-brightness' u32 property (default 128 if absent), (3) write that value to offset 0x04 of the MMIO region in probe, (4) zero the register in remove. Include the DTS node, the Makefile, and all necessary MODULE_ macros. Build it for your host machine using CONFIG_OF stubbed out (or cross-compile for ARM with QEMU).

  2. 02
    easy

    Take the Raspberry Pi 4's DTB (bcm2711-rpi-4-b.dtb, found in /boot/firmware/ on Raspberry Pi OS or in the kernel source at arch/arm/boot/dts/), decompile it with dtc, and answer: (1) What is the compatible string of the first I2C controller? (2) How many cells does the GIC use for interrupt specifiers? (3) Find a peripheral that is 'disabled' — what is its full compatible string and address? Write up your findings.

  3. 03
    medium

    Write a platform driver that supports two hardware variants ('vendor,timer-v1' and 'vendor,timer-v2') using the .data field of of_device_id. Variant v1 has a 16-bit counter register at offset 0x00; variant v2 has a 32-bit counter at offset 0x08. In probe, use of_device_get_match_data to select the right config, then use readw/readl respectively to read the counter and print it. Write the DTS for both variants. Test in QEMU by mapping a RAM region as fake MMIO.

  4. 04
    medium

    Write a complete Device Tree binding YAML file for the 'vendor,led-ctrl' device from exercise 1. It should validate with 'pip install dtschema && python -m dtschema.dtc Documentation/devicetree/bindings/leds/vendor,led-ctrl.yaml'. Include: compatible (const), reg (maxItems: 1), default-brightness (uint8, 0-255), and a working example. Then run make dtbs_check on your DTS using the schema file.

  5. 05
    hard

    Implement -EPROBE_DEFER handling correctly: write a platform driver that depends on a clock named 'core-clk'. If devm_clk_get returns EPROBE_DEFER, the driver should NOT log an error (only a debug message). If it returns any other error, log dev_err. Then simulate deferred probe in QEMU: register a dummy clock provider that sleeps 2 seconds before making the clock available, and verify (via dmesg timestamps) that your driver's probe is retried after the clock appears.

Resources

📄Kernel documentation: Open Firmware and Device Tree
docsfree

The canonical kernel.org doc on the Linux DT usage model — required reading before writing any DT-based driver.

✎Device Tree for Dummies (Free Electrons / Bootlin slides)
articlefree

Thomas Petazzoni's famous 94-slide deck that demystified DT for an entire generation of Linux developers. Visual, dense, accurate.

📕Linux Device Drivers, 3rd Edition (LDD3)
bookfree

Pre-DT but foundational for platform_device/platform_driver lifecycle. Chapter 14 covers the Linux device model that DT sits on top of.

🎓Bootlin Linux Kernel and Device Driver Training Materials
coursefree

Full lab exercises with QEMU-based boards (STM32MP1, BeagleBone). The closest thing to a structured hands-on curriculum for this exact topic.

✎LWN: The platform device API
articlefree

Grant Likely and Josh Triplett's definitive LWN article on the platform device API — explains the history and design rationale.

📄ePAPR / Devicetree Specification
docsfree

The official DTS/DTB format specification. Required reading to understand #address-cells, #size-cells, and the FDT binary format.

✎LWN: ARM, the device tree, and the future
articlefree

Historical context: explains the 'ARM mess' crisis that forced the DT transition. Understanding the problem sharpens understanding of the solution.

📕Linux Kernel Development, 3rd Edition — Robert Love
bookpaidAddison-Wesley, by Robert Love — Chapter on the device model provides essential background on kobjects, sysfs, and the bus/driver/device triad.

Best single-volume kernel internals book for developers; excellent chapter on the device model that platform drivers sit on.

✎Kernel newbies: Writing a Simple Platform Driver
articlefree

Kernelnewbies wiki covers environment setup and first driver steps — good companion for the exercises in this module.

📄QEMU ARM virt machine documentation
docsfree

Reference for the QEMU ARM virt machine used in the QEMU exercise — covers how it generates its DTB and what memory ranges are available.

Checkpoints — you can now…

  • ✓You can explain in one sentence why an I2C temperature sensor cannot be auto-discovered, but a USB mouse can.
  • ✓You can write a minimal .dts node with correct reg, interrupts, compatible, and status properties for a memory-mapped peripheral, and compile it with dtc.
  • ✓You can write a platform_driver with probe/remove, an of_match_table, and MODULE_DEVICE_TABLE, and explain what each piece does.
  • ✓You can use devm_ioremap_resource and platform_get_irq in a probe function, and explain why devm_* prevents resource leaks.
  • ✓You can correctly handle -EPROBE_DEFER in a probe function and explain what the kernel does with it.
  • ✓You can decompile a real DTB (e.g., from a Raspberry Pi) and locate a specific peripheral's compatible string, register base, and interrupt specifier.
  • ✓You can write a dt-schema YAML binding for a simple device and validate a DTS file against it.
  • ✓You can add a DTS node to QEMU's virt machine DTB and verify that your platform driver probes it inside QEMU.