HomeAbout UsContact Us

Fixing Zephyr Devicetree Overlays That Silently Fail

By Jithin Tom
Published in Embedded Concepts
August 25, 2026
6 min read
Fixing Zephyr Devicetree Overlays That Silently Fail

Table Of Contents

01
Symptom: The Overlay Built But Changed Nothing
02
How Zephyr Merges Overlays at Build Time
03
Root Cause 1: The Overlay Never Enters the Merge Queue
04
Root Cause 2: Another Input Applies After Your Overlay
05
Root Cause 3: The Orphan Node Trap
06
Verifying Overlays: Inspect the Merged Tree
07
Confirming the Effect at Runtime From Application Code
08
Prevention Checklist
09
Summary
10
Related Reading
11
References
12
Frequently Asked Questions

When a Zephyr devicetree overlay does not take effect, the build almost never warns you. The merge succeeds, the firmware flashes, and the peripheral keeps behaving exactly the way the board port defined it. You re-check pinouts, scope the lines, swap boards, while the merged tree never changed in the first place. A routine miss like this costs one to two hours per incident on a typical project, and it repeats because the failure modes are invisible until someone shows you where to look.

This article dissects the three patterns behind nearly every silent overlay miss: the overlay never enters the merge queue (naming and location problems), the overlay loses a precedence fight (another input applies after it), and the overlay writes to a node that is not the node you think (the orphan node trap). Each pattern ends with a concrete detection method built around one habit: inspect build/zephyr/zephyr.dts, the final merged tree, instead of reasoning about source fragments.

Symptom: The Overlay Built But Changed Nothing

The classic report looks like this. You are working with an nRF52840 DK and need UART1 running at 1000000 baud for an external radio link. You add an overlay to the application directory, wire the pins in code, build, flash, and the pin stays dead. The console shows the usual CMake output, no red anywhere. You clean-build twice. Nothing.

The useful question is not whether Zephyr parsed your file. It did, or the build would have stopped on a syntax error. The useful questions are: was the file part of the merge queue at all, did anything override it after it merged, and did its node references actually match existing nodes? Each failure mode answers “no” to one of those, and each answers it quietly.

How Zephyr Merges Overlays at Build Time

Everything about overlay debugging follows from one fact: the devicetree you compile against is a stack of inputs merged in a fixed order, and later inputs win.

+-----------------------------------------------------------------------------+
| DEVICETREE MERGE PIPELINE: EACH STEP OVERRIDES THE ONE ABOVE |
+-----------------------------------------------------------------------------+
| |
| [1] SoC dtsi soc/nrf52840.dtsi |
| | peripheral defaults, mostly disabled |
| v |
| [2] Board dts boards/nrf52840dk/nrf52840.dts |
| | board bring-up, connector mapping |
| v |
| [3] Shield + snippet dts applied when --shield or SNIPPET is set |
| | |
| v |
| [4] Application overlays app.overlay, then EXTRA_DTC_OVERLAY_FILE |
| | applied LAST = highest precedence |
| v |
| FINAL TREE build/zephyr/zephyr.dts |
| inspect THIS, not your source fragments |
| |
+-----------------------------------------------------------------------------+

Two consequences matter. First, if any two inputs set the same property on the same node, the last one applied wins. There is no error and no warning; the earlier value simply disappears. Second, the input list is configurable, which means the queue itself can be wrong before merge semantics even come into play.

The two CMake variables control the tail of that pipeline. When the build system assembles overlays automatically, it picks up app.overlay from the application source directory, then anything listed in EXTRA_DTC_OVERLAY_FILE after it. Setting DTC_OVERLAY_FILE directly replaces the whole default list, which silently drops the automatic app.overlay pickup. That asymmetry is the source of a large share of overlay bugs on its own.

Root Cause 1: The Overlay Never Enters the Merge Queue

The One-File Discovery Contract

The default contract is narrow: the build recognizes exactly one overlay by convention, app.overlay, and only when it sits in the application source directory, next to the top-level CMakeLists.txt. Anything else needs explicit wiring.

Three variations of this failure show up constantly:

  1. The file has a descriptive name like high_speed.overlay but nothing references it. The file parses fine when you finally test it manually, which reinforces the illusion that it is active.
  2. Someone set DTC_OVERLAY_FILE to a custom file to “fix” an unrelated problem months ago. Your app.overlay stopped being discovered that day, and every overlay added since then silently did nothing.
  3. Devicetree syntax ended up in a .conf file, or Kconfig lines ended up in the overlay. Each domain parses its own format happily, so the build stays green while the intent crosses domains into nowhere.

Wiring Overlays Explicitly

The robust wiring keeps the default discovery intact and appends through EXTRA_DTC_OVERLAY_FILE, set before find_package(Zephyr) so the build system sees it during configuration:

# Top-level CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0)
list(APPEND EXTRA_DTC_OVERLAY_FILE
${CMAKE_CURRENT_SOURCE_DIR}/boards/nrf52840dk_high_speed.overlay
)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(my_app)

Or from the command line, without touching the build files:

west build -b nrf52840dk/nrf52840 -- -DEXTRA_DTC_OVERLAY_FILE=high_speed.overlay

Detection: Read the Configure Output

Detection is one line. Read the CMake configure output once: Zephyr prints every devicetree overlay it found, including the path. If your file is not in that list, it never entered the merge queue, and no amount of overlay content editing will help.

Root Cause 2: Another Input Applies After Your Overlay

What Applies After Your Overlay

Because the pipeline is ordered, an overlay can merge perfectly and still lose. The regular suspects:

  • Board revision overlays. With hardware model v2, board revisions such as nrf52840dk/nrf52840@1.0.1 pull in their own overlay fragments late in the queue.
  • Shields. Passing —shield=adafruit_2in_tftgz applies shield overlays after the board tree, and shields routinely rewire pins and enable or disable buses.
  • Snippets. A snippet added with -DSNIPPET can contribute its own devicetree overlay, and snippets tend to accumulate silently in build scripts and CI files.
  • Your own overlay list order. Files in EXTRA_DTC_OVERLAY_FILE apply left to right, so the rightmost file wins conflicts. Teams that list “least specific to most specific” in the opposite direction get deterministic but surprising results.

The failure signature differs from Root Cause 1: here the build log lists your overlay, so the file entered the queue, yet the merged tree disagrees with it. That combination means something later overrode the properties.

Auditing the Tail of the Pipeline

The fix is an audit of the tail of the pipeline. Enumerate what applies after your file: board revision, shields, snippets, extra overlays. Remove or reorder until the intended source is last. This takes minutes once you know the pipeline order, and it is why the pipeline diagram belongs in your team wiki rather than in one engineer’s head.

Root Cause 3: The Orphan Node Trap

The most deceptive pattern is not about the queue at all. It is about what a node reference means. Consider the overlay everyone writes at least once:

/* WRONG: missing ampersand */
/ {
uart1 {
status = "okay";
current-speed = <1000000>;
};
};

Without the ampersand, this is not a reference to the node labeled uart1. It is the definition of a brand-new child node, literally named uart1, at the root of the tree. The build succeeds. The schema validation has nothing to complain about; a root child with a couple of properties is legal devicetree. And the real UART1 node, wherever the SoC dtsi placed it, remains exactly as the board left it.

+-----------------------------------------------------------------------------+
| THE ORPHAN NODE TRAP: MISSING AMPERSAND |
+-----------------------------------------------------------------------------+
| |
| SoC dtsi defines: Your overlay writes: |
| uart1: uart@40022000 / { |
| status = "disabled"; uart1 { |
| status = "okay"; |
| }; |
| }; |
| |
| Missing ampersand: this does not match the existing node. |
| It CREATES a brand-new root child named uart1. |
| |
| Merged tree afterwards: |
| |
| /soc/uart@40022000 status = "disabled" <- unchanged, still off |
| /uart1 status = "okay" <- orphan, nobody reads it |
| |
| Build result: SUCCESS. No error. No warning. Nothing changed. |
| |
+-----------------------------------------------------------------------------+

With the ampersand, the same content modifies the existing node:

/* CORRECT: &label resolves against nodes from earlier inputs */
&uart1 {
status = "okay";
current-speed = <1000000>;
pinctrl-0 = <&uart1_default_alt>;
pinctrl-names = "default";
};

A related quiet failure: label typos with the ampersand present are loud, not silent. Writing &urg1 against a tree that defines no such label fails the build with a reference error, which is the system working as intended. The silent variants are the ones that produce a valid tree: the missing ampersand above, a status value that is close but not exact, and property names that are legal but meaningless.

Status Values That Are Almost Right

Status strings deserve their own warning. The devicetree specification allows any string, and the binding check only emits a warning when the value is not okay or disabled:

&uart1 {
status = "ok"; /* warning only; drivers test for "okay" */
};

Every driver in-tree compares the node status against the exact string okay. “ok”, “OK”, “enabled”, and “on” all behave as disabled, with one warning line scrolling past during configuration. Treat devicetree binding warnings as defects, not noise; they are the only signal this class of bug produces.

Property Name Typos Are Silent Too

Property name typos follow the same logic. Writing baudrate instead of current-speed adds an inert property to the node; the driver reads its own binding keys and never notices yours. The merged tree will faithfully contain both properties, which makes the diagnosis easy once you are reading the right artifact.

Verifying Overlays: Inspect the Merged Tree

Every failure above reduces to one verification step: read build/zephyr/zephyr.dts and confirm your change survived the merge. Make it a reflex after every overlay edit rather than a forensic tool after a day of debugging.

+-----------------------------------------------------------------------------+
| OVERLAY VERIFICATION LOOP |
+-----------------------------------------------------------------------------+
| |
| edit overlay (.overlay file) |
| | |
| v |
| west build -b BOARD |
| | |
| v |
| grep the target node in build/zephyr/zephyr.dts |
| | |
| +--> properties absent or wrong value? |
| | | |
| | v |
| | fix filename / precedence / ampersand, rebuild |
| | | |
| | +--> back to build step |
| | |
| v |
| status = "okay" and values present? |
| | |
| v |
| flash, then confirm at runtime with DT_NODELABEL probes |
| |
+-----------------------------------------------------------------------------+

For the running example:

west build -b nrf52840dk/nrf52840
grep -n -A8 "uart@40022000" build/zephyr/zephyr.dts

Expected output when everything worked:

uart@40022000 {
compatible = "nordic,nrf-uarte";
status = "okay";
current-speed = <1000000>;
pinctrl-0 = <&uart1_default_alt>;
pinctrl-names = "default";
...
};

If the node still shows status = “disabled”, or shows your speed but not your pin mapping, the merged tree is telling you exactly which layer swallowed the change. That single grep collapses the one-to-two-hour mystery loop into a ten-second check.

For regression protection, encode the expectation in CI so an overlay regression breaks the build instead of the bench:

#!/usr/bin/env python3
# Fail CI when the expected overlay effect regresses.
import pathlib
import sys
tree = pathlib.Path("build/zephyr/zephyr.dts").read_text()
block = tree.split("uart@40022000", 1)[1].split("};", 1)[0]
checks = {
"uart1 enabled": 'status = "okay"' in block,
"speed applied": "current-speed = <1000000>" in block,
}
for name, ok in checks.items():
print(f"{name}: {'OK' if ok else 'FAIL'}")
sys.exit(0 if all(checks.values()) else 1)

Confirming the Effect at Runtime From Application Code

The same idea pushed one level deeper: turn the overlay’s effect into compile-time facts. The devicetree macros evaluate during preprocessing, so they detect a missed overlay before the firmware exists.

#include <zephyr/device.h>
#include <zephyr/devicetree.h>
/* These fire at build time if the overlay did not land */
BUILD_ASSERT(DT_NODE_HAS_STATUS(DT_NODELABEL(uart1), okay),
"uart1 not enabled: overlay did not apply");
BUILD_ASSERT(DT_PROP(DT_NODELABEL(uart1), current_speed) == 1000000,
"uart1 speed not overridden: check overlay precedence");
static const struct device *const console_uart =
DEVICE_DT_GET(DT_NODELABEL(uart1));

The orphan node trap cannot survive this pattern. If the overlay created a useless root child, DT_NODELABEL(uart1) either fails to resolve or resolves with the wrong status, and BUILD_ASSERT stops the build with your message. The detection cost moves from hours of on-target guessing to seconds of build time, and it applies to every future developer who touches the repository, not only to whoever remembers the incident.

Prevention Checklist

  • Name conventional overlays app.overlay; wire everything else through EXTRA_DTC_OVERLAY_FILE, set before find_package(Zephyr).
  • Never set DTC_OVERLAY_FILE casually; remember it replaces the default discovery list.
  • Read the configure-time overlay list in the CMake output whenever a build behaves strangely.
  • Order EXTRA_DTC_OVERLAY_FILE from least to most specific, and audit shields, snippets, and board revisions that apply near the end of the queue.
  • Reference nodes with &label, never as bare path segments under the root node.
  • Spell status values as the exact string okay, and treat binding warnings as defects.
  • After every overlay edit, grep the target node in build/zephyr/zephyr.dts before flashing.
  • Guard critical nodes with BUILD_ASSERT on DT_NODE_HAS_STATUS and DT_PROP so misses fail at compile time.

Summary

Failure ModeSymptomDetectionFix
File not in merge queueConfigure log omits the overlayRead CMake configure outputRename to app.overlay or add via EXTRA_DTC_OVERLAY_FILE
Lost precedence fightOverlay listed, merged tree disagreesGrep zephyr.dts for final valuesReorder overlays; audit shields, snippets, revisions
Missing ampersandNew root child appears, real node unchangedGrep zephyr.dts for the orphanUse &label references
Wrong status stringBinding warning, node stays offRead configure warningsUse the exact string okay
Property name typoInert property in merged treeDiff zephyr.dts against intentMatch binding property names

The discipline that covers all five rows is identical: treat build/zephyr/zephyr.dts as the single source of truth, and convert expectations into BUILD_ASSERT probes so future regressions announce themselves at compile time.

References

  1. Zephyr Project, “Devicetree Introduction,” Zephyr Documentation. https://docs.zephyrproject.org/latest/build/dts/intro.html
  2. Zephyr Project, “Devicetree How-Tos,” Zephyr Documentation. https://docs.zephyrproject.org/latest/build/dts/howtos.html
  3. Zephyr Project, “Application Development,” Zephyr Documentation. https://docs.zephyrproject.org/latest/develop/application/index.html
  4. Devicetree Specification, v0.4, devicetree.org. https://www.devicetree.org/specifications/

Frequently Asked Questions

Why is my Zephyr devicetree overlay not being applied?

The three most common causes are a filename other than app.overlay that was never wired into the build, a later merge input overriding your properties, and a missing ampersand in the node reference that creates a new node instead of editing the existing one.

How do I view the final merged devicetree in Zephyr?

Inspect build/zephyr/zephyr.dts after building. It contains the exact merged tree the firmware was compiled against, including every overlay, shield, and snippet contribution in final form.

What is the difference between DTC_OVERLAY_FILE and EXTRA_DTC_OVERLAY_FILE?

Setting DTC_OVERLAY_FILE replaces the default overlay list, which disables automatic app.overlay discovery. EXTRA_DTC_OVERLAY_FILE appends overlays after the defaults, giving them the highest precedence.

Does a devicetree overlay replace the board device tree or merge with it?

It merges. Overlays are applied in order on top of the SoC and board trees, and later inputs override earlier ones property by property. Nothing is replaced wholesale.

Why did my overlay build cleanly but the peripheral stayed disabled?

Most often the status value was not the exact string okay, or the node reference lacked the ampersand so the changes landed in a new unused node. Check build/zephyr/zephyr.dts to see where the properties actually went. description:

Tags

zephyrdevicetreedtsbuild-systemdebugging

Share


Previous Article
Fixing STM32 DMA Double Buffering Data Corruption
Jithin Tom

Jithin Tom

A Closer Look at C/C++, RTOS, and Embedded Systems

Related Posts

Zephyr BLE Power Optimization for Coin Cell Devices
Zephyr BLE Power Optimization for Coin Cell Devices
August 11, 2026
2 min
© 2026, All Rights Reserved.
Powered By Netlyft

Quick Links

Advertise with usAbout UsContact Us

Social Media