03 · Device Tree Overlays¶
Level 2 treated the device tree as a fixed description compiled once into the boot image. In production that's rarely true: a carrier board vendor adds a daughter card, a factory line needs to disable a sensor on units missing that BOM option, or the same base image must support three board revisions with different peripheral wiring. Overlays patch a base device tree at boot or load time without recompiling or forking it.
What an overlay actually is¶
A .dtbo is a compiled device tree fragment plus metadata describing
where it attaches — a target node in the base tree, referenced either
by full path or by phandle label (&label). The bootloader or kernel
applies the overlay by splicing the fragment's properties and child nodes
into the target.
// base.dts (excerpt)
/ {
soc {
i2c1: i2c@30a20000 {
#address-cells = <1>;
#size-cells = <0>;
status = "okay";
};
};
};
// imu-overlay.dts
/dts-v1/;
/plugin/;
/ {
compatible = "acme,mainboard";
};
&i2c1 {
#address-cells = <1>;
#size-cells = <0>;
imu@68 {
compatible = "bosch,bmi270";
reg = <0x68>;
interrupt-parent = <&gpio1>;
interrupts = <14 IRQ_TYPE_EDGE_RISING>;
status = "okay";
};
};
/plugin/; is what marks this as an overlay rather than a full tree —
without it, dtc compiles it as a standalone (and broken) device tree
instead of a fragment. &i2c1 is the target: the overlay's imu@68 node
becomes a child of the base tree's real i2c1 node after application.
Compiling and inspecting¶
$ dtc -@ -I dts -O dtb -o imu-overlay.dtbo imu-overlay.dts
$ fdtdump imu-overlay.dtbo | head -20
// magic: 0xd00dfeed
// totalsize: 0x2a4 (676)
...
/ {
compatible = "acme,mainboard";
fragment@0 {
target = <0xffffffff>;
__overlay__ {
imu@68 { ... };
};
};
__symbols__ {
i2c1 = "/soc/i2c@30a20000";
};
};
The -@ flag is what generates the __symbols__ table the target
resolver needs — omit it and label-based targeting (&i2c1) silently
fails to resolve at apply time, because the compiler had no symbol table
to record where i2c1 actually points.
Applying at U-Boot time (build-time-adjacent, most common in production)¶
=> load mmc 0:1 ${loadaddr} base.dtb
=> load mmc 0:1 ${fdtoverlay_addr} imu-overlay.dtbo
=> fdt addr ${loadaddr}
=> fdt resize 8192
=> fdt apply ${fdtoverlay_addr}
=> bootz ${kerneladdr} - ${loadaddr}
fdt resize before fdt apply is not optional — the loaded base DTB has
exactly enough room for itself, and applying an overlay without growing
the buffer first corrupts memory past the end of the blob. This is one of
the most common "board hangs before console output" bugs reported against
overlay-based boot flows, and it looks nothing like a device tree problem
from the symptom alone.
Applying via kernel configfs (runtime, for hot-pluggable expansion)¶
$ mount -t configfs none /sys/kernel/config
$ mkdir /sys/kernel/config/device-tree/overlays/imu
$ cat imu-overlay.dtbo > /sys/kernel/config/device-tree/overlays/imu/dtbo
$ dmesg | tail -3
[ 45.221009] OF: overlay: Overlay ID 0 applied
[ 45.223441] bmi270 1-0068: chip id 0x24
$ rmdir /sys/kernel/config/device-tree/overlays/imu # removes it
Removal only works cleanly if every driver bound to the overlay's nodes
properly implements remove() — a driver that leaks a devm_* resource
or holds a raw pointer past teardown will crash or leave the platform bus
in an inconsistent state on overlay removal. This is why Module 2's
emphasis on clean probe/remove symmetry matters even more once
overlays are in play: overlay removal is essentially forced unbind.
Conflicts: two overlays targeting the same node¶
$ echo overlay-a.dtbo > .../overlays/a/dtbo
$ echo overlay-b.dtbo > .../overlays/b/dtbo # also touches i2c1@68
mkdir: cannot create directory '.../overlays/b': File exists
or, more insidiously, both apply successfully but the second overlay's properties silently win for any property both define — there's no "merge conflict" error for property collisions the way there is for two overlays claiming the exact same unit-address. Debug by dumping the live tree, not by re-reading the source overlays:
Trap: a factory-configurable overlay set (e.g. one overlay per BOM
option) that isn't tested pairwise will occasionally produce a board that
boots fine with any overlay alone but hangs or misconfigures a peripheral
when two specific overlays are combined — because both quietly touch a
shared pinmux node. Treat the applied result (/proc/device-tree or
fdtdump on the merged blob), not the individual .dts sources, as the
thing you test.
status = "disabled" vs deleting a node¶
To turn a peripheral off for a board variant, prefer overriding status:
over /delete-node/, unless you specifically need the node gone from
/proc/device-tree entirely (e.g. it would otherwise claim a GPIO another
overlay needs). status = "disabled" keeps the node's phandle valid for
anything still referencing it elsewhere, which /delete-node/ does not —
a stray reference to a deleted node's phandle produces an obscure
fdt_node_offset_by_phandle failure at apply time rather than a clear
error pointing at the real cause.
Traps¶
- Missing
#address-cells/#size-cellsre-declaration in the overlay fragment when adding children under a bus node — DT does not inherit these across fragment boundaries the way it does within one static tree, and the omission produces childregvalues interpreted with the wrong cell count silently. - Interrupt parent mismatches: an overlay's
interrupt-parentmust resolve to a phandle that exists in the base tree at apply time; if the base tree's GPIO controller node label differs from what the overlay assumes (common across board revisions), the overlay applies without error but the interrupt line never fires. - Forgetting
fdt resizein U-Boot beforefdt apply— see above; the failure mode (silent hang, corrupted memory) gives almost no signal about the actual cause.
Cheat sheet¶
| Command | Purpose |
|---|---|
dtc -@ -I dts -O dtb -o x.dtbo x.dts |
Compile overlay with symbol table |
fdtdump x.dtbo |
Inspect a compiled overlay's fragments |
fdt resize <n>; fdt apply <addr> |
Apply overlay in U-Boot (resize first!) |
mount -t configfs none /sys/kernel/config |
Enable runtime overlay application |
.../overlays/<name>/dtbo |
Write compiled .dtbo here to apply at runtime |
cat /proc/device-tree/.../compatible |
Inspect the merged, live tree |
status = "disabled" |
Preferred way to turn off a node per board variant |
On verification
Overlay fragment syntax, /plugin/;, and the configfs application
interface were checked against the documented DT overlay ABI and
dtc behavior; the U-Boot and configfs command sequences were reviewed
for correctness but not executed on real hardware or QEMU here.
How It Actually Works¶
An overlay is a second, smaller FDT blob applied by tree-merge
semantics, not textual patching. A compiled .dtbo is itself a valid
FDT with /fragment@N nodes, each carrying a target/target-path
property and an __overlay__ subnode. fdtoverlay (or the kernel's
in-tree of_overlay_apply for runtime application) walks each fragment,
resolves the target against the base tree's phandles/paths, and does a
node-level merge: new properties are added, same-named properties are
replaced wholesale, and new child nodes are grafted in — there is no
line-based diffing anywhere in this pipeline, which is why two overlays
touching the same target node but different properties compose cleanly,
while two overlays setting the same property produce a last-applied-
wins outcome with no conflict warning.
Phandle resolution is why overlays need a symbols metadata pass at
all. The base DTB's phandles are just small integers assigned at
compile time; an overlay compiled independently has no way to know what
integer the base tree assigned to, say, &i2c1. dtc's -@ flag emits
a __symbols__ node listing every labeled node's path, and the overlay
compiler emits __fixups__ describing which of its own properties (like
an interrupt-parent reference) need which named symbol patched in at
apply time — fdtoverlay/of_overlay_apply reads both tables and
rewrites the overlay's raw phandle integers to match the actual
values found in the live base tree before merging. Skip -@ on either
side and the reference silently resolves to phandle 0, which is the
single most common "overlay applied with no error, device didn't show
up" bug in this whole area.
Exercise¶
(1) Write a base tree with an unpopulated spi1 node and an overlay that
adds a spi-nor flash child at chip-select 0, compile it with dtc -@,
and use fdtdump to confirm the __symbols__ table resolves spi1. (2)
Reproduce the "forgot fdt resize" failure by applying an overlay in
U-Boot without resizing first (or by reasoning through the U-Boot source
if you don't have hardware to hang), and document the exact symptom. (3)
Design two overlays that both touch a shared pinmux node with conflicting
settings, apply them in each order via configfs, and explain from
/proc/device-tree which one won and why order — not overlay "priority"
— decided it.