Series A custom Nerves gateway on the STM32MP1 Part 3 of 4 · all parts

Device trees demystified: reading the hardware map

On a desktop PC, when you plug in a USB device or install a PCI graphics card, the operating system discovers it automatically. PCI has enumeration.

What Is a Device Tree and Why Should I Care?

On a desktop PC, when you plug in a USB device or install a PCI graphics card, the operating system discovers it automatically. PCI has enumeration. USB has descriptors. The hardware announces itself.

Embedded systems don't work that way. The peripherals on an STM32MP1 are hardwired onto the chip. They don't announce themselves. The Ethernet controller doesn't say "hey, I'm here at address 0x5800a000, I need these clocks and these pins." It just sits there, silent, waiting for someone to configure it correctly.

That someone is the device tree.

A device tree is a data structure — literally a tree of nodes — that describes the hardware to the Linux kernel. It tells the kernel: here are the peripherals, here are their memory addresses, here are the interrupts they use, here are the clocks they need, here are the pins they're wired to. Without it, the kernel has no idea what hardware exists.

If you've used Nerves on a Raspberry Pi, you've benefited from device trees without knowing it. The official Nerves system ships with a pre-built .dtb (device tree blob) that matches the Pi's hardware. You never had to think about it. But when you're building a custom system for a board that nobody has packaged yet, you need to understand these files.

DTS, DTSI, DTB — The Three File Types

  • .dts (Device Tree Source) — The top-level source file for a specific board. This is what gets compiled. Think of it as main.c.
  • .dtsi (Device Tree Source Include) — A reusable include file, like a .h header. Contains hardware descriptions shared across multiple boards or SoC variants.
  • .dtb (Device Tree Blob) — The compiled binary. This is what the bootloader passes to the kernel at runtime. You never edit this directly.

The compilation flow:

.dtsi files ──(#include)──→ .dts file ──(dtc compiler)──→ .dtb blob
U-Boot loads this
into DDR and passes
it to the kernel

The Include Hierarchy — How Files Compose

This is where it gets interesting. The device tree for the DK2 isn't one big file. It's a stack of includes, each adding a layer:

stm32mp157c-dk2.dts ← Our board (top-level)
├── stm32mp157.dtsi ← SoC: adds GPU + DSI display controller
│ └── stm32mp153.dtsi ← SoC: adds 2nd A7 core + CAN-FD
│ └── stm32mp151.dtsi ← SoC: base (1x A7, all peripheral blocks)
├── stm32mp15xc.dtsi ← Variant: adds CRYP1 crypto accelerator
├── stm32mp15-pinctrl.dtsi ← Pin muxing definitions (~2800 lines!)
├── stm32mp15xxac-pinctrl.dtsi ← Package-specific GPIO availability
└── stm32mp15xx-dkx.dtsi ← DK board family (shared between DK1/DK2)

Each layer adds to or overrides what came before. Read from bottom to top:

  1. stm32mp151.dtsi defines every peripheral block in the SoC — all the UARTs, I2C buses, SPI controllers, DMA engines, the Ethernet MAC, USB, the M4 coprocessor. Everything starts with status = "disabled".
  2. stm32mp153.dtsi includes stm32mp151 and adds the second A7 core and CAN-FD controllers.
  3. stm32mp157.dtsi includes stm32mp153 and adds the GPU and DSI display controller.
  4. stm32mp15xc.dtsi adds the CRYP1 hardware crypto block.
  5. stm32mp15-pinctrl.dtsi defines all the possible pin muxing combinations.
  6. stm32mp15xxac-pinctrl.dtsi defines which GPIO banks exist on the AC package variant.
  7. stm32mp15xx-dkx.dtsi is where the board-level configuration happens — it enables specific peripherals, sets pin assignments, configures the PMIC, and defines the memory layout.
  8. stm32mp157c-dk2.dts adds DK2-specific features: the DSI display panel, touchscreen, and WiFi/BLE module.

This layering is elegant. The SoC files describe what the silicon can do. The board files describe what this particular board actually uses. A different board with the same SoC would include the same DTSI files but enable different peripherals and pin assignments.

The "F" Variant Surprise

Here's something I didn't expect. My board is the STM32MP157F-DK2 — the "F" variant. I assumed there would be a stm32mp157f-dk2.dts file in the kernel. There isn't. Not in kernel 6.6. Not in 6.10. Not in 6.12. Not even in 6.14.

It doesn't exist in mainline Linux. At all.

The STM32MP157F is a newer silicon revision of the STM32MP157C. ST updated the DK2 board (revision MB1272 Var4.0 vs the original Var2.0) to use the F chip. But at the device tree level, the F and C are software-compatible. The F variant runs at the same frequencies and has the same peripheral set — the main difference is additional crypto capabilities and a higher temperature rating.

The community consensus, confirmed on ST's forums and DigiKey's developer community: the STM32MP157F-DK2 boots perfectly fine using the stm32mp157c-dk2 device tree.

This taught me something important about device trees: the silicon part number on the chip doesn't always have a matching DTS file in the kernel. What matters is electrical and functional compatibility. The kernel doesn't check your chip's part number against the device tree's compatible string in any strict way — it uses the compatible string to find the right driver, and the C-variant drivers work identically for the F variant.

In our Buildroot configuration, we'll use:

BR2_LINUX_KERNEL_INTREE_DTS_NAME="st/stm32mp157c-dk2"

Not stm32mp157f-dk2. Because that file literally doesn't exist in the kernel tree. I spent a while confused by this before figuring it out.

Reading a Real Node — The Ethernet Controller

Theory is nice, but let's read actual device tree source code. Here's the Ethernet controller from stm32mp151.dtsi (the base SoC definition):

ethernet0: ethernet@5800a000 {
compatible = "st,stm32mp1-dwmac", "snps,dwmac-4.20a";
reg = <0x5800a000 0x2000>;
reg-names = "stmmaceth";
interrupts-extended = <&intc GIC_SPI 61 IRQ_TYPE_LEVEL_HIGH>;
interrupt-names = "macirq";
clock-names = "stmmaceth", "mac-clk-tx", "mac-clk-rx",
"eth-ck", "ptp_ref", "ethstp";
clocks = <&rcc ETHMAC>, <&rcc ETHTX>, <&rcc ETHRX>,
<&rcc ETHCK_K>, <&rcc ETHPTP_K>, <&rcc ETHSTP>;
st,syscon = <&syscfg 0x4>;
snps,mixed-burst;
snps,pbl = <2>;
snps,axi-config = <&stmmac_axi_config_0>;
snps,tso;
status = "disabled";
};

Let's decode this line by line:

ethernet0: ethernet@5800a000 — The node name. ethernet0 is a label (used by other nodes to reference this one). ethernet@5800a000 is the node name with the register base address.

compatible = "st,stm32mp1-dwmac", "snps,dwmac-4.20a" — This is how the kernel finds the right driver. It tries to match drivers in order: first st,stm32mp1-dwmac (ST's specific driver), then snps,dwmac-4.20a (the generic Synopsys DesignWare MAC driver). The format is always vendor,device. This is not a suggestion — it's the exact string the driver registers.

reg = <0x5800a000 0x2000> — The peripheral's memory-mapped register block starts at address 0x5800a000 and is 0x2000 bytes (8KB) long. This comes directly from the STM32MP1 reference manual's memory map. The kernel uses this to ioremap() the registers for the driver.

interrupts-extended = <&intc GIC_SPI 61 IRQ_TYPE_LEVEL_HIGH> — This peripheral uses interrupt line 61 on the GIC (Generic Interrupt Controller). When a packet arrives or a transfer completes, the hardware asserts this interrupt, the GIC routes it to the CPU, and the kernel calls the driver's interrupt handler.

clocks = <&rcc ETHMAC>, <&rcc ETHTX>, ... — The Ethernet controller needs six different clocks, all provided by the RCC (Reset and Clock Controller). The driver will call clk_prepare_enable() on each of these during initialization. If any clock isn't properly configured, the peripheral silently doesn't work.

status = "disabled" — This is the key pattern. At the SoC level, everything starts disabled. The board-level DTS enables what it actually uses.

Now look at how the board file (stm32mp15xx-dkx.dtsi) enables and configures it:

&ethernet0 {
status = "okay";
pinctrl-0 = <&ethernet0_rgmii_pins_a>;
pinctrl-1 = <&ethernet0_rgmii_sleep_pins_a>;
pinctrl-names = "default", "sleep";
phy-mode = "rgmii-id";
max-speed = <1000>;
phy-handle = <&phy0>;
mdio0 {
#address-cells = <1>;
#size-cells = <0>;
compatible = "snps,dwmac-mdio";
phy0: ethernet-phy@0 {
reg = <0>;
};
};
};

This is where the hardware description meets the board design:

status = "okay" — Enables the peripheral. Without this, the kernel ignores the entire node.

pinctrl-0 = <&ethernet0_rgmii_pins_a> — Points to a pin muxing definition in stm32mp15-pinctrl.dtsi. This tells the pin controller which physical pins to assign to the Ethernet controller and in which mode (RGMII). Each STM32 pin can serve multiple functions — a single pin might be UART TX, SPI MOSI, or GPIO depending on its mux setting. The pin control system ensures each pin is assigned to exactly one function.

phy-mode = "rgmii-id" — The interface between the MAC and the external PHY chip uses RGMII with internal delay. This is a physical layer detail — the timing of data signals relative to the clock. Get this wrong and you get no link, or a link with massive packet loss.

phy-handle = <&phy0> — Points to the PHY definition below. The PHY (Realtek RTL8211F on the DK2) is discovered via the MDIO bus, but the device tree tells the kernel where to look.

And this is exactly what we saw in the kernel boot log:

stm32-dwmac 5800a000.ethernet eth0: PHY [stmmac-0:00] driver [RTL8211F Gigabit Ethernet] (irq=POLL)

Every piece of information in that log line traces back to the device tree. The driver name (stm32-dwmac) matches the compatible string. The address (5800a000) matches the reg property. The PHY (RTL8211F) was discovered on the MDIO bus at address 0 (reg = <0> in the mdio node). The link mode (rgmii-id) matches the phy-mode property.

Pin Control — The Most Confusing Part

Pin muxing is where most device tree confusion lives. The STM32MP1 has hundreds of pins, and each pin can serve 16 different alternate functions. The stm32mp15-pinctrl.dtsi file is nearly 3000 lines long, defining every possible pin configuration.

Here's what the RGMII Ethernet pin definition looks like:

ethernet0_rgmii_pins_a: ethernet0-rgmii-0 {
pins1 {
pinmux = <STM32_PINMUX('G', 5, AF11)>, /* ETH_RGMII_CLK125 */
<STM32_PINMUX('G', 4, AF11)>, /* ETH_RGMII_GTX_CLK */
<STM32_PINMUX('G', 13, AF11)>, /* ETH_RGMII_TXD0 */
<STM32_PINMUX('G', 14, AF11)>, /* ETH_RGMII_TXD1 */
<STM32_PINMUX('C', 2, AF11)>, /* ETH_RGMII_TXD2 */
<STM32_PINMUX('E', 2, AF11)>, /* ETH_RGMII_TXD3 */
<STM32_PINMUX('B', 11, AF11)>, /* ETH_RGMII_TX_CTL */
<STM32_PINMUX('A', 2, AF11)>, /* ETH_MDIO */
<STM32_PINMUX('C', 1, AF11)>; /* ETH_MDC */
bias-disable;
drive-push-pull;
slew-rate = <2>;
};
pins2 {
pinmux = <STM32_PINMUX('C', 4, AF11)>, /* ETH_RGMII_RXD0 */
<STM32_PINMUX('C', 5, AF11)>, /* ETH_RGMII_RXD1 */
<STM32_PINMUX('B', 0, AF11)>, /* ETH_RGMII_RXD2 */
<STM32_PINMUX('H', 7, AF11)>, /* ETH_RGMII_RXD3 */
<STM32_PINMUX('A', 1, AF11)>, /* ETH_RGMII_RX_CLK */
<STM32_PINMUX('A', 7, AF11)>; /* ETH_RGMII_RX_CTL */
bias-disable;
};
};

The STM32_PINMUX('G', 5, AF11) macro says: take pin G5 (GPIO port G, pin 5) and set it to alternate function 11 (which happens to be Ethernet). These aren't arbitrary — they come from the STM32MP1 datasheet's alternate function table. Each pin has exactly 16 possible functions (AF0 through AF15), and the hardware designers at ST chose which function maps to which AF on which pin.

The TX pins are configured with drive-push-pull and slew-rate = <2> (fast) because they're actively driving signals. The RX pins only have bias-disable because they're inputs.

Why separate pins1 and pins2 groups? Because TX and RX pins need different electrical characteristics. The pin controller applies the settings per group.

The M4 Coprocessor Node

Since we'll need this for Phase 2, let's look at how the device tree describes the Cortex-M4. From stm32mp151.dtsi:

m4_rproc: m4@10000000 {
compatible = "st,stm32mp1-m4";
reg = <0x10000000 0x40000>,
<0x30000000 0x40000>,
<0x38000000 0x10000>;
resets = <&rcc MCU_R>;
reset-names = "mcu_rst";
st,syscfg-holdboot = <&rcc 0x10C 0x1>;
st,syscfg-pdds = <&pwr_mcu 0x0 0x1>;
st,syscfg-rsc-tbl = <&tamp 0x144 0xFFFFFFFF>;
st,syscfg-m4-state = <&tamp 0x148 0xFFFFFFFF>;
status = "disabled";
};

The three reg entries are the M4's memory regions:

  • 0x10000000, 0x40000 (256KB) — MCU SRAM, used for M4 code and data
  • 0x30000000, 0x40000 (256KB) — Another MCU SRAM bank
  • 0x38000000, 0x10000 (64KB) — Retention RAM (survives standby)

The compatible = "st,stm32mp1-m4" string matches the stm32_rproc driver in the kernel, which implements the remoteproc interface. When we enable this node and load firmware, the driver will:

  1. Parse the firmware ELF for the resource table
  2. Set up shared memory and virtio vrings
  3. Hold the M4 in reset, load firmware, release reset
  4. Create /dev/rpmsg* devices for communication

The st,syscfg-holdboot property controls the M4's boot hold register — this is how Linux keeps the M4 halted until firmware is loaded.

For Phase 2, the board-level DTS (or an overlay) will need to:

  • Set status = "okay" on this node
  • Add reserved-memory nodes for the shared RPMsg buffers
  • Add mailbox references for the IPCC (Inter-Processor Communication Controller)

The DK Board File — Where It All Comes Together

The stm32mp15xx-dkx.dtsi file (~740 lines) is where the abstract SoC description meets the physical DK board. Some highlights:

Memory:

memory@c0000000 {
device_type = "memory";
reg = <0xc0000000 0x20000000>; /* 512MB DDR at 0xC0000000 */
};

PMIC (STPMIC1) on I2C4:

&i2c4 {
pmic: stpmic@33 {
compatible = "st,stpmic1";
reg = <0x33>;
// ... defines all voltage regulators: vddcore, vdd_ddr, vdd,
// v3v3, v1v8_audio, vdd_usb, vdd_sd, v2v1
};
};

UART4 — Our Serial Console:

&uart4 {
pinctrl-names = "default", "sleep", "idle";
pinctrl-0 = <&uart4_pins_a>;
pinctrl-1 = <&uart4_sleep_pins_a>;
pinctrl-2 = <&uart4_idle_pins_a>;
/delete-property/ dmas;
/delete-property/ dma-names;
status = "okay";
};

This enables UART4 with the uart4_pins_a pin configuration (which maps it to the pins connected to the ST-LINK virtual COM port). The /delete-property/ directives remove DMA configuration — the serial console works fine with interrupt-driven I/O and doesn't need DMA.

Remember console=ttySTM0,115200 from the boot chain discussion? UART4 is the first enabled UART, so the kernel names it ttySTM0. If we enabled UART2 before UART4, the numbering would shift. The ttySTM index follows the order of device tree enumeration, not the UART peripheral number.

The Reserved Memory for M4 (from the DK board file):

reserved-memory {
#address-cells = <1>;
#size-cells = <1>;
ranges;
mcuram2: mcuram2@10000000 {
compatible = "shared-dma-pool";
reg = <0x10000000 0x40000>;
no-map;
};
vdev0vring0: vdev0vring0@10040000 {
compatible = "shared-dma-pool";
reg = <0x10040000 0x1000>;
no-map;
};
vdev0vring1: vdev0vring1@10041000 {
compatible = "shared-dma-pool";
reg = <0x10041000 0x1000>;
no-map;
};
vdev0buffer: vdev0buffer@10042000 {
compatible = "shared-dma-pool";
reg = <0x10042000 0x4000>;
no-map;
};
mcuram: mcuram@30000000 {
compatible = "shared-dma-pool";
reg = <0x30000000 0x40000>;
no-map;
};
retram: retram@38000000 {
compatible = "shared-dma-pool";
reg = <0x38000000 0x10000>;
no-map;
};
};

This is the memory layout for the M4 coprocessor and RPMsg communication. The no-map property tells Linux: this memory exists, but don't map it into the kernel's address space. It belongs to the M4. The vdev0vring0 and vdev0vring1 regions are the virtio ring buffers — this is the shared memory where RPMsg messages actually live.

We'll revisit this in detail during Phase 2 when we set up RPMsg.

The DK2-Specific Additions

Finally, the top-level stm32mp157c-dk2.dts adds what makes the DK2 different from the DK1:

&dsi {
status = "okay";
panel@0 {
compatible = "orisetech,otm8009a";
reg = <0>;
reset-gpios = <&gpioe 4 GPIO_ACTIVE_LOW>;
power-supply = <&v3v3>;
status = "okay";
};
};
&i2c1 {
touchscreen@38 {
compatible = "focaltech,ft6236";
reg = <0x38>;
interrupts = <2 2>;
interrupt-parent = <&gpiof>;
touchscreen-size-x = <480>;
touchscreen-size-y = <800>;
status = "okay";
};
};

The DK2's 4-inch display uses an OTM8009A panel controller connected via MIPI DSI, with a FocalTech FT6236 capacitive touch controller on I2C1 at address 0x38. The touch interrupt comes from GPIO port F, pin 2.

The DK1 doesn't have this display, so these nodes only appear in the DK2 DTS.

Key Takeaways

After reading through all these files, a few patterns emerge:

1. Everything starts disabled. The SoC-level DTSI files define every possible peripheral with status = "disabled". Board files selectively enable what they use. This is safe by default — an unconfigured peripheral doesn't accidentally interfere with anything.

2. Pin control is everything. Most device tree bugs come from pin muxing mistakes. Two peripherals claiming the same pin, wrong alternate function number, missing bias configuration. If a peripheral doesn't work, check pins first.

3. Clocks are invisible but critical. Every peripheral has clock dependencies. The RCC (Reset and Clock Controller) node defines all the clock sources. If a peripheral's clock isn't enabled, the peripheral's registers read as zero and writes are silently ignored. No error, no warning — just nothing works.

4. The compatible string is the bridge between hardware and software. It's how the kernel finds the right driver. The device tree says compatible = "st,stm32mp1-dwmac", and somewhere in the kernel source there's a driver that registers itself for that exact string. Change the compatible string and the kernel can't find the driver. The hardware is still there, but the software doesn't know how to talk to it.

5. Board variants share more than they differ. The DK1 and DK2 share a 740-line common file and differ by about 50 lines. The 151, 153, and 157 SoC variants differ by a few nodes each. Good device tree design maximizes reuse through the include hierarchy.

How This Connects to Buildroot — Finding the Right Config Keys

When you're building a custom Nerves system, the device tree name ends up in your Buildroot defconfig. But how do you know which config key to use? Where does BR2_LINUX_KERNEL_INTREE_DTS_NAME come from?

Buildroot's configuration system uses Kconfig — the same system the Linux kernel uses for make menuconfig. Every BR2_* variable corresponds to a menu entry. You can discover them in several ways:

1. Run make menuconfig in a Buildroot build directory. Navigate through the menus. The kernel DTS name lives under Kernel → Device tree source → In-tree device tree source. When you hover over an option, it shows the variable name.

2. Read existing defconfigs. Buildroot ships with defconfigs for supported boards in its configs/ directory. The stm32mp157c_dk2_defconfig is our starting point — it contains BR2_LINUX_KERNEL_INTREE_DTS_NAME="st/stm32mp157c-dk2". I found this by searching for stm32mp157 in Buildroot's configs directory.

3. Search the Buildroot source. The Kconfig files in Buildroot define every variable. For kernel-related options, look in linux/Config.in. For U-Boot, boot/uboot/Config.in. For TF-A, boot/arm-trusted-firmware/Config.in.

4. Look at existing Nerves systems. The nerves_system_bbb (BeagleBone), nerves_system_rpi4, and the community nerves_system_stm32mp157c_odyssey all have nerves_defconfig files you can study. Each one is a Buildroot defconfig adapted for Nerves.

Here's the trail I followed for our key config values:

Where to find it What it told me
────────────────────────────────────────── ────────────────────────────
buildroot/configs/stm32mp157c_dk2_defconfig → BR2_LINUX_KERNEL_INTREE_DTS_NAME="st/stm32mp157c-dk2"
→ BR2_TARGET_ARM_TRUSTED_FIRMWARE_PLATFORM="stm32mp1"
→ BR2_TARGET_UBOOT_BOARD_DEFCONFIG="stm32mp15_trusted"
nerves_system_bbb/nerves_defconfig → How Nerves wraps Buildroot (skeleton, overlay, busybox paths)
→ BR2_NERVES_SYSTEM_NAME, BR2_NERVES_ADDITIONAL_IMAGE_FILES
nerves_system_br/board/nerves-common/ → The shared skeleton, post-build scripts, BusyBox config

The DTS name value "st/stm32mp157c-dk2" maps to the file path arch/arm/boot/dts/st/stm32mp157c-dk2.dts in the kernel source. The st/ prefix is the subdirectory, and the name (without .dts) is the device tree that gets compiled into stm32mp157c-dk2.dtb.

The same pattern applies to TF-A and U-Boot — each build system has its own device tree references that need to match. TF-A uses DTB_FILE_NAME=stm32mp157c-dk2.dtb and U-Boot uses DEVICE_TREE=stm32mp157c-dk2. If any of these point to a nonexistent file, the build fails with a cryptic error about missing DTS.

The F→C Discovery — Why Your Chip's Name Isn't Always Your DTS Name

This deserves emphasis because it cost me real debugging time. My board says "STM32MP157F" on the chip. The product page says "STM32MP157F-DK2." Every instinct says the device tree should be stm32mp157f-dk2.dts.

It doesn't exist. Not in kernel 6.6. Not in 6.8. Not in 6.10. Not in 6.12. Not even in 6.14.

I searched the Linux kernel git history, the ST community forums, the Buildroot repository, everywhere. The file stm32mp157f-dk2.dts has never been submitted to mainline Linux.

Here's why: ST's part numbering encodes information that doesn't affect the device tree:

STM32MP157 F AC
│ │ │
│ │ └── Package: TFBGA361 (same physical pins as C variant)
│ └──── Speed/crypto grade:
│ C = crypto, 650MHz
│ F = crypto, 800MHz, higher temp rating
└─────────────── SoC family

The F variant runs at 800MHz instead of 650MHz and has a wider temperature range. But from a device tree perspective, this doesn't change the peripheral set, the pin muxing, the memory map, or the register addresses. The 800MHz capability is handled by the operating point (OPP) tables in the device tree, and the C variant's OPP table already includes the 800MHz operating point — it just depends on whether the silicon can sustain it (which the F variant can).

The stm32mp15xc.dtsi file that the C-DK2 DTS includes adds the CRYP1 crypto accelerator node. Both C and F variants have this hardware. So the C variant's device tree is a complete and correct description of the F variant's hardware.

The lesson: the device tree describes the hardware's functional interface, not the marketing part number. If two chips have the same peripheral set, same pin muxing, and same memory map, they share a device tree — regardless of speed grade, temperature rating, or packaging differences.

This is actually a feature of the device tree system. It keeps the number of DTS files manageable. Without this approach, ST would need separate DTS files for every combination of SoC variant, package, speed grade, and board — hundreds of files instead of dozens.

What We'll Do With This Knowledge

For our custom Nerves system, we need to:

  1. Use st/stm32mp157c-dk2 as our device tree (not F — it doesn't exist in mainline)
  2. In Phase 2, enable the m4_rproc node and configure reserved memory for RPMsg
  3. In Phase 3, add a device tree overlay to assign a UART to the M4 for Micro:bit communication
  4. In Phase 4, ensure the DSI display and touch nodes are properly configured

The device tree is the map. Now it's time to build the system that uses it.