How to Add a Custom Devicetree Overlay in Zephyr for Your Board

You got blinky running on your dev board. LEDs are toggling, the toolchain works, west build completes without errors. Then you try to use a different GPIO pin, or wire up an I2C sensor, and the build falls apart. You poke around in zephyr/boards/ and find a .dts file that describes your board’s hardware. Your instinct is to edit it directly. Don’t. That file belongs to upstream Zephyr, and modifying it means you’ll fight merge conflicts on every SDK update.
The right tool is a devicetree overlay: a small file you drop into your application directory that patches the board’s hardware description at build time. By the end of this article, you’ll be able to write one from scratch, confirm it compiled correctly, and use the result in your C code.
What a Devicetree Overlay Actually Does
Zephyr describes every piece of hardware (pins, peripherals, buses, clocks) in devicetree source files. These are plain text files with a .dts extension. Your board has one. The SoC it’s built on has one or more .dtsi files that the board file includes.
An overlay merges into the existing tree, changing only the nodes and properties you specify. Everything else stays untouched.
Here’s how the files layer together:
┌─────────────────────────────────┐
│ Your .overlay file │ ← You write this
│ (app directory) │
└───────────────┬─────────────────┘
│ merges into
┌───────────────▼─────────────────┐
│ Board .dts file │ ← zephyr/boards/<vendor>/<board>/
│ (upstream, don't edit) │
└───────────────┬─────────────────┘
│ includes
┌───────────────▼─────────────────┐
│ SoC .dtsi file(s) │ ← zephyr/dts/arm/ (or riscv, etc.)
│ (peripheral definitions) │
└─────────────────────────────────┘
Final merged output:
build/zephyr/zephyr.dtsThe build system takes all three layers, squashes them together, and produces one final zephyr.dts. That merged file is what actually drives code generation. Your overlay just describes the delta.
The Minimum Devicetree Syntax You Need
You don’t need to understand the full devicetree specification to be productive. Here’s the subset that covers 90% of overlay work.
Nodes are the building blocks. Each one represents a piece of hardware (a peripheral, a pin, a bus). They live in a tree structure, addressed by path (like /soc/i2c@40003000).
Properties are key-value pairs inside a node. The ones you’ll touch most: status, compatible, reg, and label.
Phandle references let you refer to an existing node by its label using the & prefix (like &i2c0). This is how overlays target nodes that already exist in the base tree.
status is the on/off switch. Set it to "okay" to enable a peripheral, "disabled" to turn it off.
Here’s a tiny annotated example:
/* Reference the existing i2c0 node by its label */
&i2c0 {
status = "okay"; /* Enable this peripheral */
clock-frequency = <400000>; /* Set I2C bus speed to 400 kHz */
};That’s 4 lines and it does something real. The &i2c0 syntax means “find the node labeled i2c0 in the base tree and merge these properties into it.” You’re not creating a new node; you’re patching an existing one.
Where to Put Your Overlay File
Zephyr’s build system looks for overlays in specific places.
Option A (preferred): Name the file <board>.overlay and place it in your application root directory. The build system picks it up automatically. For an nRF52840 DK, that’s nrf52840dk_nrf52840.overlay.
my_app/
├── CMakeLists.txt
├── prj.conf
├── nrf52840dk_nrf52840.overlay ← auto-detected by build system
└── src/
└── main.cOption B: Pass it explicitly on the command line:
west build -b nrf52840dk/nrf52840 -- -DDTC_OVERLAY_FILE=myoverlay.overlayThis is useful when you have multiple overlays or non-standard naming.
Option C: Name it app.overlay in the application root. Zephyr will pick this up regardless of which board you’re building for. Good for simple, single-board projects.
Option A is the cleanest for most cases. Stick with it until you have a reason not to.
Step by Step: Writing Your First Overlay
Let’s walk through a concrete scenario. You want to add an LED connected to a GPIO pin that isn’t in your board’s default devicetree.
Step 1: Find the base devicetree
Your board’s .dts file lives in the Zephyr tree. For the nRF52840 DK:
# Zephyr v3.x+ directory structure
zephyr/boards/nordic/nrf52840dk/nrf52840dk_nrf52840.dtsOpen it and you’ll see nodes for LEDs, buttons, and peripheral aliases. SoC-level definitions like GPIO controllers and I2C blocks come from the .dtsi files included at the top. Scan these files to find the node names and labels you’ll reference.
Step 2: Identify what you want to add
Say your board defines LED0 through LED3, but you’ve wired an extra LED to GPIO pin P0.31. You need a new child node under the leds node.
Step 3: Write the overlay
Create nrf52840dk_nrf52840.overlay in your app root:
/* Add a new LED to the existing leds node */
/ {
leds {
compatible = "gpio-leds";
myled: my_custom_led {
gpios = <&gpio0 31 GPIO_ACTIVE_LOW>; /* P0.31, active low */
label = "Custom LED";
};
};
};
/* Create an alias so we can reference it easily in C */
/ {
aliases {
my-led = &myled;
};
};A few things to notice. The / { leds { ... }; }; path merges into the existing root-level leds node. It doesn’t replace the other LEDs; it just adds my_custom_led as a new child. The myled: before the node name creates a label (a phandle) we can reference elsewhere, like in the alias.
Step 4: Build and confirm
west build -b nrf52840dk/nrf52840 -pIf you get zero devicetree errors, the overlay was parsed and merged. Any typos in node references or property names will show up here as build failures.
Step 5: Inspect the merged output
This is the most useful debugging habit you can build. After a successful build:
cat build/zephyr/zephyr.dts | grep -A 5 "my_custom_led"Your node should appear inside the leds block, right alongside the board’s default LEDs.
A second quick example: enabling a disabled peripheral
Many SoC-level .dtsi files define peripherals with status = "disabled" by default. The board .dts enables the ones it uses. If you need one that’s left disabled (say, a second I2C bus), your overlay can be as short as:
&i2c1 {
status = "okay";
pinctrl-0 = <&i2c1_default>; /* Pin config, board-specific */
pinctrl-names = "default";
};Two lines of actual change. The merge behavior handles the rest.
Using Overlay-Defined Nodes in C Code
You defined hardware in the overlay. Here’s how you reach it from C.
Zephyr resolves the entire devicetree at compile time. There’s no runtime parsing. Instead, you use macros that expand to constants. The most common ones for GPIO work:
#include <zephyr/drivers/gpio.h>
/* Get a gpio_dt_spec struct from the node labeled "myled" */
static const struct gpio_dt_spec my_led =
GPIO_DT_SPEC_GET(DT_ALIAS(my_led), gpios);
void main(void) {
if (!gpio_is_ready_dt(&my_led)) {
return; /* GPIO controller not ready */
}
gpio_pin_configure_dt(&my_led, GPIO_OUTPUT_ACTIVE);
gpio_pin_toggle_dt(&my_led);
}DT_ALIAS(my_led) maps to the alias you created in the overlay. GPIO_DT_SPEC_GET pulls the pin number, controller reference, and flags directly from the devicetree node. No magic numbers in your C code.
If you’re building Bluetooth-enabled IoT devices on Zephyr, the same overlay principles apply when configuring radio peripherals. Hubble’s Zephyr RTOS reference application shows a working project structure with overlays for BLE-connected devices.
Common Mistakes and How to Fix Them
| Symptom | Likely Cause | Fix |
|---|---|---|
| Overlay seems ignored | Wrong filename or directory | Filename must exactly match the board identifier, placed in app root |
| Build error: “node not found” | Typo in &node_reference | Check spelling against the base .dts file |
| Property not taking effect | Overriding parent, not child node | Verify the full node path in zephyr.dts output |
| Duplicate node error | Re-declaring a node instead of merging | Use &reference { } syntax, don’t copy the full node path from the base file |
The single best debugging tool: always check build/zephyr/zephyr.dts. It’s the final truth. If your change isn’t reflected there, the overlay wasn’t applied. Also peek at build/zephyr/devicetree_generated.h to see the exact C macros generated from your nodes.
When You Outgrow Overlays
If you’re designing a custom PCB (not just using a dev board), overlays are a great place to start. Pick a supported board with the same SoC, use overlays to remap pins and peripherals to match your schematic, and get your firmware running. Once it’s stable, graduate to a full board definition by creating your own directory under boards/ with a dedicated .dts file.
That’s a bigger topic for another day. If you’re building custom hardware that sends data over BLE to satellite or terrestrial networks, Hubble’s device integration guide covers full board bring-up and provisioning.
Build Your Next Overlay
Overlays are the right way to customize hardware descriptions without forking upstream Zephyr. They’re small and additive, and they keep your project cleanly separated from the SDK.
Start with something simple: an LED, a button, enabling a disabled UART. Build, inspect zephyr.dts, confirm it looks right. Then ramp up. Try adding an I2C sensor node with a compatible string that matches a Zephyr driver, or remapping your SPI pins. Each overlay you write will take less time than the last.
The devicetree can feel like an obstacle when you’re new to Zephyr. After you’ve written 3 or 4 overlays, it clicks. It’s just a way to describe hardware that keeps your C code clean and portable.
Hubble Network connects your custom hardware to satellite infrastructure directly from a standard BLE chip—no extra radios or complex RF design. See how it works →