Building Custom Device Tree Overlays for Nerves: A BeagleBone Cape Guide

If you’ve ever built a Nerves firmware image for a BeagleBone-family board, you’ve probably run into device tree overlays — the mechanism the kernel uses to describe hardware that isn’t already baked into the base board support. Overlays are how you tell Linux “there’s a device on this SPI bus,” “this pin is now a GPIO instead of a UART,” or “there’s a sensor on this I2C address.” They’re small, they’re declarative, and when they work, you never think about them again.

The trouble starts when the *same* overlay behaves differently across two boards that are supposed to be close cousins — a cape that boots clean on one variant and comes up with wrong pin states or a missing device node on another. This is a general hazard of the BeagleBone family, and of layered device trees generally: every variant shares a base tree but customizes it just enough to bite you. This post covers the general shape of that problem, how to structure a custom overlay in a Nerves project, and a systematic workflow for debugging one when it doesn’t behave the way you expect — regardless of the peripheral type.

Why Overlays Diverge Across “Compatible” Boards

Every BeagleBone-family board shares a base device tree, but each variant layers its own `.dtsi` on top that remaps pins for whatever it integrates onboard — Wi-Fi, Bluetooth, an extra UART, an eMMC controller, a different LED arrangement. A pin that’s free for your cape to claim on one board may already be spoken for on another, regardless of what your cape actually does with that pin — SPI, I2C, PWM, or plain GPIO.

 

This is the root of almost every “works on board A, breaks on board B” overlay bug in this family, for any peripheral:

 

  • The pin was reassigned at the board level: A variant’s base `.dtsi` mux’d your pin to something else before your overlay ever loads — commonly a UART, but just as often an MMC line, an LED, or a power-management signal.
  • The `compatible` string is too narrow: U-Boot’s cape manager checks the overlay’s `compatible` property against the board it’s booting on. Miss a variant, and the overlay silently doesn’t apply — no crash, no error, just absent hardware.
  • A conflicting driver claims the pin at runtime, not just at boot: Disabling a pinctrl node isn’t enough if the device node behind it is still enabled; the driver can re-probe and re-claim the pins later, whether that driver is a UART, an MMC controller, or anything else.

 

None of this is specific to any one peripheral type — it’s the general shape of the problem any time you’re layering board-specific hardware on top of a shared base tree in a Nerves project.

Where Overlays Live in a Nerves Project

Regardless of which board or peripheral you’re dealing with — a display, a sensor, a relay board, a custom breakout — the pieces are always the same three:

 

  1. The overlay source — a `.dts` fragment file, typically kept under something like `package/extra-dts/` in your system repo. Buildroot compiles this into a `.dtbo` and copies it to `/lib/firmware/` on the target.
  2. The U-Boot environment setting that tells the bootloader which `.dtbo` to load — usually set via `uboot_setenv` in a provisioning config, pointing at a variable like `uboot_overlay_addr4`.
  3. Application-level config that consumes the resulting hardware once Linux has instantiated it — driver options, tuning data, whatever your userspace code needs to talk to the device correctly.

 

This three-file shape — overlay source, U-Boot provisioning, application config — is worth treating as a template for any new cape you add to a Nerves system, whatever it does.
 

Anatomy of an Overlay: Get the `compatible` String Right First

Every overlay starts with a header identifying which boards it’s allowed to apply to:
				
					/dts-v1/;
/plugin/;

/ {
    compatible = "ti,beaglebone", "ti,beaglebone-black",
                 "ti,beaglebone-green", "ti,beaglebone-green-wireless";

    part-number = "YOUR-CAPE-PART-NUMBER";
    version = "00A0";
				
			
This is the single most common silent failure point, for any cape. If `compatible` doesn’t list the exact machine-compatible string for the board you’re booting, U-Boot’s cape manager will decline to apply the overlay — with no error visible unless you go looking in `dmesg`. Before debugging anything deeper about pins, drivers, or readings, confirm this list actually includes every board you’re targeting. This single check eliminates a large fraction of “the overlay isn’t doing anything” reports before you have to look at pin muxing at all.
 
 

Diagnosing a Pin Conflict: The General Pattern

When an overlay applies (you’ve confirmed `compatible` is correct) but the peripheral still misbehaves on one board variant and not another, the next suspect is always a pin conflict introduced by that variant’s base `.dtsi`. The general diagnostic sequence:
  1. Identify the physical pins your cape uses, from its schematic or the overlay’s own pinmux fragment.
  2. Grep the target board’s base `.dtsi` for those same pins. Every BeagleBone variant’s board support files are structured as layered pin group definitions (`*_pins` nodes) — search for the pin names (e.g. `SPI0_SCLK`, `I2C1_SDA`) across the base tree for the board you’re seeing problems on.
  3. Check what that board claims the pin for by default. Wi-Fi/Bluetooth modules, an onboard UART, MMC, or an LED are the usual culprits, but it varies by variant.
  4. Disable the conflicting node in your overlay — both the pinctrl group and the device node behind it (see below for why both matter).
This pattern applies whether the conflicting peripheral is a UART, an MMC controller, or anything else the board vendor decided to wire onto a pin your cape also wants.
Why You Have to Disable Both the Device Node and the Pinctrl
It’s not enough to disable a pinctrl node — the driver behind it can still probe and re-claim the pins at runtime if the device node itself is left enabled. You generally need two fragments per conflicting peripheral:
				
					/* Fragment: Disable the conflicting device */
fragment@1 {
    target = <&conflicting_device>;
    __overlay__ {
        status = "disabled";
    };
};

/* Fragment: Disable its pinctrl group */
fragment@2 {
    target = <&conflicting_device_pins>;
    __overlay__ {
        status = "disabled";
    };
};
				
			
One thing worth checking early on any Nerves base DTB: not every node label is resolvable from an overlay fragment. If a label like `&uart2` (or whatever your conflicting device is) isn’t resolvable directly, you may need to nest the disable inside a broader `target = <&ocp>` fragment instead. This kind of label-availability quirk shows up whenever a “standard” fix copied from a reference DTS doesn’t apply cleanly — the label surface depends on how that particular base tree was built, so it’s worth confirming rather than assuming.
 

A General Debugging Workflow for Overlay Problems

These checks apply to essentially any overlay-related hardware bug on a Nerves target — a misbehaving display, an unresponsive sensor, a GPIO that won’t toggle. Run them in order; each rules out one layer of the stack before you move to the next.
 

Check dmesg for Overlay and Driver Messages

Look for confirmation that your driver actually bound to the device. The exact message format depends on the driver, but you’re looking for a probe or bind line naming your device, its bus address, and (for interrupt-driven devices) an IRQ number.
				
					# In IEx on the Nerves target:
log = to_string(System.cmd("dmesg", []))
IO.puts(log)
				
			
Bus errors (SPI, I2C, or otherwise) here instead of a bind message usually mean the overlay didn’t apply at all — go back and check the `compatible` string before looking any deeper.
 

Verify a Conflicting Peripheral Is Actually Disabled

If your diagnosis points to a pin conflict (section 4), confirm the conflicting peripheral is truly off — not just declared disabled in the overlay, but absent at runtime:
				
					# Check the live device-tree node status
path = "/sys/firmware/devicetree/base/ocp/<path-to-conflicting-node>/status"
case File.read(path) do
  {:ok, status} -> IO.puts("Status: #{String.trim(status)}")
  {:error, _}  -> IO.puts("Node not found")
end
				
			
The idea generalizes to any conflicting peripheral — a UART, an MMC controller, an I2C bus. Look for its device node under `/dev` or its status under `/sys/firmware/devicetree/base`, and confirm it reads `disabled` rather than `okay`.

Confirm Your Device Enumerates the Way You Expect

Check that your new hardware shows up under the class you’d expect for its type — `/sys/class/input` for an input device, `/sys/class/spi_master` or `/sys/bus/spi/devices` for a SPI device, `/sys/bus/i2c/devices` for I2C, `/sys/class/gpio` for a GPIO line — with the address, chip-select, or pin number you configured in the overlay.
				
					for f <- Path.wildcard("/sys/bus/i2c/devices/*/name") do
  IO.puts("#{Path.dirname(f) |> Path.basename()}: #{File.read!(f) |> String.trim()}")
end
				
			
(Swap the wildcard path for whatever bus/class your peripheral lives on.)
 

Check the Live Device Tree for the Node You Expect

				
					 Look for the device node in the live DT
path = "/sys/firmware/devicetree/base/ocp/<bus>@<address>/<device-name>@<addr>"
IO.puts("Node exists: #{File.exists?(path)}")

# Check any custom properties your overlay set
IO.puts("your-custom-property: #{File.read!("#{path}/your-custom-property")}")
				
			
This is a general technique — walk `/sys/firmware/devicetree/base` to confirm the node your overlay added is actually present and carrying the properties you set, regardless of what kind of device it is.

 

Compare Reported Values Against the Hardware’s Real Range or Behavior

Many device tree properties declare a range, scale, or limit — an ADC’s max reading, a sensor’s full-scale range, a PWM’s frequency bounds. Whatever your peripheral reports at runtime, compare it against what the DTS declares:
				
					for f <- Path.wildcard("/sys/class/<your-class>/<your-device>/*") do
  IO.puts("#{Path.basename(f)}: #{File.read!(f) |> String.trim()}")
end
				
			
A mismatch between the declared range and the hardware’s actual behavior is a DTS bug, not something you can fix downstream with tuning or calibration — the DTS values have to match reality first.
 

Exercise the Device Across Its Full Expected Range

On the target’s IEx console, watch raw events or readings as you exercise the device — full travel for an encoder, min/max readings for a sensor, the full range of an input surface. Compare what you observe against what the DTS declares. If real values exceed the declared range, the DTS doesn’t match reality and no amount of downstream calibration fixes it.
 

Quick Checklist for a New Cape

  • `compatible` includes the exact machine-compatible string for every board variant you’re targeting
  • No existing peripheral on any target board claims the same pins — check each variant’s base `.dtsi` (our project stores them under `pp_custom_bbb/package/extra-dts/`), not just the one you developed on
  • Any device-tree properties describing range, scale, or addressing match the hardware’s actual behavior, not just a copied reference value
  • After rebuild and flash, confirm any disabled conflicting peripheral is actually gone at runtime, not just marked disabled in the overlay
  • Application-level readings match the DTS on every target board variant, not just the first one you tested
 
 
 
This post covers the general mechanics of writing and debugging device tree overlays for Nerves on BeagleBone-family boards. For a worked example of applying this to a specific display-and-touch cape — including the exact pin conflict, the calibration workflow, and the Scenic driver configuration involved — see the follow-up post on enabling an LCD and resistive touchscreen.
Facebook
Twitter
LinkedIn
Email

Recent Posts

4 Responses

Leave a Reply

Your email address will not be published. Required fields are marked *

Table Header Table Header Table Header Table Header
Content
Content
Content
Content