Zephyr Devicetree Explained: How Hardware Description Actually Works

You’ve copied an overlay snippet from a Zephyr sample, pasted it into your project, and it worked. Then you tried to add your own sensor on a different I2C bus and got a wall of cryptic build errors. You changed status to "okay" and nothing happened. You grepped the codebase for DT_NODELABEL and found macros referencing a generated header you’ve never opened.
Here’s the problem: most Zephyr devicetree tutorials show you a working overlay, but never explain the machinery underneath it. The moment your hardware deviates from the example, you’re stuck. You can’t debug what you can’t see.
This article makes every stage visible. By the end, you’ll understand the full pipeline from .dts source files through to the C macros in your application code. You’ll know where every intermediate artifact lives in your build directory, and when something breaks, you’ll know exactly which stage failed and why.
One note before we start: Zephyr’s devicetree system is not the Linux kernel’s devicetree system. We’re treating it as its own thing from the ground up.
What Devicetree Actually Is in Zephyr
In Zephyr, devicetree is a build-time hardware description language that compiles down to C macros. That sentence is the entire mental model. There is no runtime parser. There is no binary blob loaded at boot. Your .dts files are consumed during west build, turned into C #define statements, and then they’re gone.
This means devicetree errors are build errors, devicetree values are compile-time constants, and if your node is wrong, your firmware won’t compile. That’s actually a feature.
Five terms you need before we go further:
- Node: A block describing one piece of hardware (a peripheral, a bus, a GPIO controller).
- Property: A key-value pair inside a node (
reg = <0x48>;). - Label: A human-friendly name you attach to a node for easy reference (
my_sensor: ...). - Compatible: The string that identifies which driver and binding applies (
compatible = "ti,tmp116";). - Status:
"okay"means the node is active;"disabled"means drivers ignore it.
Here’s what these look like on a real node:
/* A single I2C sensor node — annotated */
my_sensor: tmp116@48 {
compatible = "ti,tmp116"; /* Matches a driver + binding YAML */
reg = <0x48>; /* I2C address */
status = "okay"; /* Driver will initialize this node */
};That node lives inside a parent I2C bus node, which lives inside a SoC-level tree. Everything nests.
The Full Pipeline, Step by Step
This is the centerpiece. Every time you run west build, the Zephyr devicetree pipeline executes these stages in order:
┌──────────────┐ ┌──────────────┐ ┌────────────────┐
│ SoC .dtsi │ │ Board .dts │ │ App .overlay │
└──────┬───────┘ └──────┬───────┘ └───────┬────────┘
│ #include │ │
└────────┬─────────┘ │
│ overlay merge │
└──────────────┬───────────────┘
▼
┌──────────────────────────┐
│ C Preprocessor pass │
└────────────┬─────────────┘
▼
┌──────────────────────────┐
│ gen_defines.py │
│ + Bindings (.yaml) │
└────────────┬─────────────┘
▼
┌──────────────────────────┐
│ devicetree_generated.h │
└────────────┬─────────────┘
▼
┌──────────────────────────┐
│ Your C code via │
│ DT_NODELABEL / DT_PROP │
└──────────────────────────┘Stage 1: Source assembly. The board’s .dts file #includes one or more SoC-level .dtsi files. These .dtsi files define every peripheral the SoC has, usually with status = "disabled". The board .dts then enables and configures the peripherals that are actually wired up.
Stage 2: Overlay merge. Your application-level .overlay file is merged on top of the assembled tree. Overlays can override properties, add new nodes, or enable disabled peripherals. This is the primary customization mechanism for Zephyr hardware config.
Stage 3: C Preprocessor. The merged DTS goes through the standard C preprocessor. This resolves #include, #define, and #if directives. Yes, you can use C macros inside .dts files, and Zephyr does this heavily for GPIO and pin definitions.
Stage 4: gen_defines.py + bindings. Zephyr’s Python script gen_defines.py parses the preprocessed DTS and validates every node against its matching binding (a .yaml file keyed on the compatible string). If a node has no matching binding, no macros are generated for it. The node effectively vanishes.
Stage 5: devicetree_generated.h. The script outputs a single generated C header file containing #define macros for every validated node and property.
Stage 6: Your code. You call Zephyr’s devicetree.h API macros, like DT_NODELABEL() and DT_PROP(), which expand to the generated #define values.
The two files you need for debugging live in your build directory:
- Merged DTS:
build/zephyr/zephyr.dts, the complete, flattened tree after all overlays. - Generated header:
build/zephyr/include/generated/devicetree_generated.h, the actual macros your code sees.
If you can find these two files, you can debug almost any Zephyr devicetree issue.
DTS Overlays: The Part Everyone Gets Wrong
An overlay is not a replacement for the board’s .dts. It’s a patch. It merges into the existing tree using specific rules:
Action in Overlay │ Result
───────────────────────────┼────────────────────────────
Set property on existing │ Overwrites base value
node │
Add new child node │ Appended to parent
Add node at wrong path │ Creates NEW node (bug!)
Omit a property │ Base value preserved
Set status = "okay" │ Enables node for driversAuto-discovery: If you place a file named <board>.overlay (e.g., nrf52840dk_nrf52840.overlay) in your application directory, the Zephyr build system picks it up automatically. You can also pass it explicitly with -DDTC_OVERLAY_FILE=my_overlay.overlay. With multiple overlays, later files win: they overwrite properties set by earlier ones.
Here’s a concrete example. Say your board’s base DTS has i2c1 disabled, and you need to enable it and attach a TMP116 temperature sensor at address 0x48:
/* app.overlay — enable I2C1 and attach a sensor */
&i2c1 {
status = "okay"; /* Enable the bus */
clock-frequency = <100000>; /* 100 kHz standard mode */
my_sensor: tmp116@48 {
compatible = "ti,tmp116";
reg = <0x48>;
status = "okay";
};
};The &i2c1 syntax references an existing node by its label. It doesn’t create a new one. This is how you “patch” the base tree.
Common mistake #1: Typing a path or label that doesn’t exist in the base DTS. The overlay silently creates an entirely new node at a new path instead of modifying the one you intended. Your sensor ends up orphaned, not attached to any bus. Check build/zephyr/zephyr.dts to verify the node ended up where you expect.
Common mistake #2: Adding the sensor node but forgetting status = "okay" on either the sensor or its parent bus. The node exists in the merged tree, but the driver’s initialization macro skips it. You’ll see no errors, just silence.
Bindings: The Silent Gatekeeper
Bindings are .yaml files that act as schemas. When gen_defines.py encounters a node with compatible = "ti,tmp116", it searches for a binding file that declares compatible: "ti,tmp116". If it finds one, it validates the node’s properties against the binding’s schema and generates macros. If it doesn’t find one, the node is silently skipped: no macros are generated.
This is the single most common source of “my node exists but nothing happens” bugs. It’s not a devicetree problem. It’s a binding problem.
Here’s a minimal binding file:
# ti,tmp116.yaml — binding for TI TMP116 sensor
description: TI TMP116 digital temperature sensor
compatible: "ti,tmp116"
include: [sensor-device.yaml, i2c-device.yaml] # Inherit common I2C properties
properties:
alert-gpios:
type: phandle-array
required: false
description: GPIO connected to the ALERT pinThe include lines pull in standard property definitions (like reg for the I2C address). The properties block defines any additional, device-specific properties.
Where bindings live: Zephyr’s built-in bindings are in zephyr/dts/bindings/. For custom hardware, you can add bindings in your application by setting DTS_ROOT to include your project, or by placing them in a dts/bindings/ directory within your application tree and adding the path to dts_root in your CMakeLists.txt.
Debugging tip: During the build, Zephyr logs warnings when a node’s compatible string has no matching binding. These warnings scroll by fast. Search your build output for "has unknown vendor prefix" or "No binding found". They point directly to the problem.
Using Zephyr Devicetree in C Code
Once the pipeline has produced devicetree_generated.h, you access hardware configuration through Zephyr’s DT_ macro API. These macros resolve entirely at compile time. There is no runtime lookup, no hashtable, no string parsing.
Here’s how you’d use the sensor node from the overlay above in application code:
#include <zephyr/devicetree.h>
#include <zephyr/drivers/sensor.h>
/* Get a node identifier by its label */
#define SENSOR_NODE DT_NODELABEL(my_sensor)
/* Get the I2C bus device */
const struct device *i2c_dev = DEVICE_DT_GET(DT_BUS(SENSOR_NODE));
/* Read properties at compile time */
uint32_t addr = DT_REG_ADDR(SENSOR_NODE); /* 0x48 */
/* Or just get the sensor device directly */
const struct device *sensor = DEVICE_DT_GET(SENSOR_NODE);Key macros to know:
DT_NODELABEL(label)returns a node identifier from a DTS label.DT_PROP(node_id, prop)reads a property value.DT_REG_ADDR(node_id)reads theregaddress.DT_BUS(node_id)gets the parent bus node.DT_NODE_HAS_STATUS(node_id, okay)checks if a node is enabled.
If you reference a node that doesn’t exist (because the binding was missing or the overlay was wrong), you’ll get a build error. This is intentional. Zephyr’s devicetree macros fail at compile time rather than silently returning garbage at runtime.
DT_INST vs. DT_NODELABEL: In driver code (the implementation inside drivers/), you’ll see DT_INST_* macros that reference nodes by instance index for a given compatible. In application code, stick to DT_NODELABEL. It’s explicit and readable.
Debugging the Pipeline: A Practical Checklist
When something goes wrong with your Zephyr hardware config, work through this checklist in order. Each step corresponds to a stage in the pipeline:
Check the merged DTS. Open
build/zephyr/zephyr.dts. Search for your node. Is it present? Are the properties correct? Is it under the right parent? If not, your overlay has a path or label mismatch.Check binding resolution. Search the build log for warnings about your node’s
compatiblestring. Ifgen_defines.pycan’t find a matching.yamlbinding, it tells you, but only in the log output, not as a hard error.Check the generated header. Open
build/zephyr/include/generated/devicetree_generated.hand grep for your node label (converted to uppercase with underscores). If macros are present, the binding matched. If not, go back to step 2.Check
status. In the merged DTS, confirm your node and its parent bus both havestatus = "okay". A disabled parent disables all children.Check the driver. Verify that a driver exists for your
compatiblestring and that it usesDT_INST_FOREACH_STATUS_OKAYto register instances. A matching binding without a matching driver means macros exist but nothing initializes.Do a pristine build. Run
west build --pristine. The Zephyr build system caches DTS processing aggressively. If you’ve moved files, renamed overlays, or changed CMake variables, stale artifacts can mask your changes.
The One Mental Model Worth Memorizing
The Zephyr devicetree pipeline is a straight line:
DTS source → overlay merge → C preprocessor → gen_defines.py + bindings → devicetree_generated.h → your C macros
Every stage is inspectable. The merged tree is a file you can open. The generated header is a file you can grep. The bindings are YAML you can read.
The next time you hit a devicetree build error, don’t guess. Open build/zephyr/zephyr.dts in your current project, right now, before you forget, and look at what’s actually in there. Then open devicetree_generated.h and search for a node label you recognize. Once you see the connection between the two files, the black box disappears and it’s just a build pipeline with predictable, debuggable steps.
Hubble Network enables Bluetooth-connected devices to transmit data directly to satellites—no gateways, no extra infrastructure. See how it works →