The STM32MP1 boot chain, from power-on to Linux
I've been using Nerves for years, and for most of that time the boot process was a black box. Plug in power, wait a few seconds, get an IEx prompt. Magic.
The Black Box Problem
I've been using Nerves for years, and for most of that time the boot process was a black box. Plug in power, wait a few seconds, get an IEx prompt. Magic.
But it's not magic. Between the moment electrons start flowing and the moment you see iex(1)>, four completely different pieces of software execute in sequence, each one setting up the conditions for the next. Understanding this chain is the difference between "it boots" and "I know why it boots" — and more importantly, "I know why it doesn't boot."
I plugged in my STM32MP157F-DK2, opened a serial console, and watched.
Connecting to the Board
The DK2 has an on-board ST-LINK/V2.1 debugger that exposes a virtual serial port over USB. When you plug in the micro-USB cable, your host machine sees it as a USB-ACM device:
$ lsusb | grep ST
Bus 003 Device 005: ID 0483:3752 STMicroelectronics ST-LINK/V2.1
And a serial device appears at /dev/ttyACM0. To talk to it:
picocom -b 115200 /dev/ttyACM0 --logfile serial.log
The --logfile flag saves everything to a file, which turns out to be invaluable. Boot output scrolls by fast, and you'll want to go back and study it line by line.
With the serial console connected, I power-cycled the board. What followed was a rapid sequence of messages from four different bootloaders, each handing off to the next.
Stage 0: The ROM Code
The very first thing that executes is code you cannot see, cannot change, and cannot update. It's burned into the silicon of the STM32MP1 chip at the ST factory.
When power reaches the CPU, it begins executing from an internal ROM. This code is deliberately minimal — it does three things and nothing else:
1. It reads the boot pins. Three physical pins on the chip (BOOT0, BOOT1, BOOT2) are wired to specific voltages on the PCB. Their combination tells the ROM code where to look for the bootloader. On the DK2, they're hardwired for microSD boot. Other boards might boot from eMMC, NAND flash, or even over USB or serial (useful for factory programming).
2. It scans the GPT partition table. The ROM code reads the microSD's GUID Partition Table and searches for a partition whose name starts with fsbl. Not a magic byte sequence, not a fixed sector offset — just the partition name in the GPT entry. This is surprisingly modern for a boot ROM. It means you can reorganize your partition layout as long as you keep the naming convention.
3. It loads that partition into SYSRAM and jumps to it. SYSRAM is a tiny (256KB) internal RAM that doesn't need any initialization. The ROM code copies the first-stage bootloader binary there and begins executing it.
If any of this fails — wrong boot pins, no microSD, no partition named fsbl, corrupted binary — the ROM code falls through to the next boot source or just sits there. The board appears completely dead. No output on the serial console. Nothing. This is the most common "my board won't boot" scenario, and it's the hardest to debug because the ROM code doesn't print anything to the UART.
One subtle detail: the ROM code expects the binary to have an STM32 header — a specific 256-byte prefix that contains a magic number, image length, and checksum. This isn't a standard ARM vector table; it's ST-specific. The TF-A build process adds this header automatically, but if you try to use a raw binary, the ROM code will reject it silently.
Stage 1: TF-A (Trusted Firmware-A)
The first output you see on the serial console comes from here. On my board:
NOTICE: CPU: STM32MP157FAC Rev.Z
NOTICE: Model: STMicroelectronics STM32MP157F-DK2 Discovery Board
TF-A is an open-source project maintained by ARM (not ST, though ST contributes to it). It's the reference implementation of ARM's secure boot architecture. The version running here is called BL2 — Boot Loader stage 2 (stage 1 being the ROM code).
TF-A has the hardest job of any stage in the boot chain. It runs from SYSRAM — just 256KB, a workspace so small that the entire program, its stack, and its data must fit together. And from that cramped starting point, it has to bring up the three most critical subsystems on the board:
DDR Initialization
Until TF-A runs, the 4-gigabit DDR3L memory is unpowered and unconfigured. The CPU literally cannot use it. If you think about that for a moment, it's a bit mind-bending — the first-stage bootloader cannot use the main RAM. It has to run entirely from a tiny internal SRAM while it programs the DDR controller.
DDR initialization is notoriously finicky. The timing parameters — CAS latency, write recovery time, refresh interval — must precisely match the specific DDR chips on your board. Get them wrong and you get silent data corruption, random crashes, or the DDR controller simply refusing to initialize. TF-A contains these parameters in its device tree (yes, even the bootloader has a device tree).
On the DK2, the DDR is a 16-bit wide DDR3L running at 533 MHz. The total capacity is 512 MB (4 gigabits / 8 = 512 megabytes). After TF-A configures it, the entire 512 MB becomes available, and every subsequent stage runs from it.
PMIC Setup
The DK2 uses ST's STPMIC1 — a dedicated power management IC that controls the voltage rails for the CPU, DDR, and peripherals. TF-A talks to it over I2C to set the correct voltages. Without this step, the CPU might be running at the wrong voltage (causing instability) or the DDR might not have power at all.
Clock Configuration
The STM32MP1 starts at a low clock frequency. TF-A programs the PLL (Phase-Locked Loop) circuits to bring the Cortex-A7 cores up to their rated 800 MHz (on the F variant). It also configures the clock trees for various peripheral buses — AHB, APB, and the DDR controller clock.
The Handoff
After DDR, PMIC, and clocks are initialized, TF-A loads the next stage — the FIP binary — from the microSD into DDR memory and jumps to it. At this point, we've gone from 256KB of internal SRAM to 512MB of DDR. The constraint is lifted.
The NOTICE: log prefix is TF-A's signature. If you see these lines, it means the ROM code successfully found and loaded TF-A, DDR is initialized, and things are going well.
What Is FIP? (Firmware Image Package)
Before we get to U-Boot, there's an important concept: the FIP binary.
FIP stands for Firmware Image Package. It's a container format defined by the TF-A project that bundles multiple boot components into a single file. On the STM32MP1, the FIP typically contains:
- BL32 (sp_min) — A minimal secure monitor that handles ARM TrustZone transitions
- BL33 (U-Boot) — The main bootloader, stripped of its own device tree
- HW_CONFIG — U-Boot's device tree blob
The reason for packaging them together is the boot chain's security model. TF-A can authenticate the entire FIP as a unit — verify its signature, check its integrity — before executing any component inside it. Even if you're not using secure boot (and we're not, for development), the packaging format is still used because that's how the ecosystem works.
On the microSD, FIP lives in its own GPT partition named fip, right after the two FSBL partitions.
Stage 2: SP_MIN (The Invisible Secure Monitor)
Inside the FIP, the first thing that runs is BL32 — in our case, sp_min.
SP_MIN is ARM's minimal Secure Monitor. It's the "secure world" counterpart to the "normal world" where Linux runs. ARM TrustZone divides the processor into two worlds:
- Secure world — runs trusted firmware, has access to everything
- Normal world — runs the OS (Linux), restricted from secure resources
SP_MIN's job is small but important: it provides PSCI (Power State Coordination Interface) services. When Linux wants to suspend a CPU core, do a warm reset, or power down the system, it makes a "secure monitor call" that traps into sp_min, which performs the actual hardware operation.
You'll never see sp_min output on the serial console. It installs itself in a protected memory region and stays resident for the lifetime of the system, quietly handling secure world calls. It's the bouncer standing at the door — you don't notice it until you need it.
The alternative to sp_min is OP-TEE (Open Portable Trusted Execution Environment), a full TEE with its own kernel, drivers, and trusted applications. Overkill for our gateway, but something you'd want for handling DRM, key storage, or secure enclaves.
Stage 3: U-Boot (The Interactive Bootloader)
Now we're in familiar territory. U-Boot is the workhorse bootloader of the embedded Linux world. It runs from DDR with full access to all peripherals.
U-Boot 2022.10-stm32mp-r2 (Jun 21 2023 - 01:09:47 +0000)
CPU: STM32MP157FAC Rev.Z
Model: STMicroelectronics STM32MP157F-DK2 Discovery Board
Board: stm32mp1 in trusted mode (st,stm32mp157f-dk2)
DRAM: 512 MiB
...
Hit any key to stop autoboot: 0
There's a lot happening in those few lines:
trusted mode— U-Boot knows it was loaded by TF-A (as opposed tobasic modewhere U-Boot SPL loads U-Boot directly, without TF-A).DRAM: 512 MiB— DDR is confirmed working. If TF-A had failed to initialize DDR, we'd never get this far.Hit any key to stop autoboot— A countdown timer. If you press a key during this window, you drop into the U-Boot command line. This is your emergency escape hatch.
U-Boot's job is conceptually simple:
- Find the Linux kernel image (
zImage) on the microSD - Find the device tree blob (
stm32mp157f-dk2.dtb) - Set up the kernel command line (which console to use, which partition is root)
- Place kernel and DTB at specific addresses in DDR
- Call
bootzto jump to the kernel
The kernel command line typically includes something like:
console=ttySTM0,115200 root=/dev/mmcblk0p6 rootfstype=ext4 rootwait
This tells Linux: use ttySTM0 at 115200 baud for the console (that's our serial connection), and mount partition 6 of the microSD as the root filesystem.
The U-Boot Shell
If you hit a key during the autoboot countdown, you get a powerful command-line environment:
STM32MP> help
STM32MP> printenv # show all environment variables
STM32MP> mmc info # show microSD card info
STM32MP> part list mmc 0 # show partition table
STM32MP> fdt addr $fdtaddr # load device tree
STM32MP> fdt print / # print device tree contents
This is incredibly useful for debugging. You can manually load a kernel, change the root partition, inspect the device tree, test network connectivity — all without a working Linux system. When your Nerves image doesn't boot, the U-Boot shell is often where you figure out why.
Stage 4: Linux Kernel
U-Boot has loaded the kernel and device tree into DDR at known addresses. It calls bootz and the kernel takes over.
Starting kernel ...
[ 0.000000] Booting Linux on physical CPU 0x0
[ 0.000000] Linux version 5.15.118 ...
[ 0.000000] CPU: ARMv7 Processor [410fc075] revision 5 (ARMv7)
[ 0.000000] Machine model: STMicroelectronics STM32MP157F-DK2 ...
[ 0.000000] Memory: 497152K/524288K available
The kernel's first act is parsing the device tree blob that U-Boot passed to it. This is the hardware description — every peripheral, every pin assignment, every clock relationship. The kernel uses it to decide which drivers to load and how to configure them.
Then it proceeds through driver initialization. Each line you see like:
[ 15.593049] stm32-dwmac 5800a000.ethernet eth0: PHY [stmmac-0:00] driver [RTL8211F Gigabit Ethernet] (irq=POLL)
...is a driver matching a device tree node and initializing hardware. Let's decode this one:
stm32-dwmac— The driver name (STM32's Synopsys DesignWare MAC)5800a000— The peripheral's base address in the memory map (matches the device treeregproperty).ethernet— The device tree node nameeth0— The Linux network interface name assignedRTL8211F— The Ethernet PHY chip identified on the MDIO busrgmii-id— The interface mode (RGMII with internal delay), configured in the device tree
Every single piece of information in that line traces back to either the device tree or the driver source code. Nothing is auto-detected. This is fundamentally different from a PC where the BIOS and PCI bus handle hardware discovery. In embedded Linux, if it's not in the device tree, it doesn't exist.
What Was Running on My Board
The output I captured shows ST's OpenSTLinux distribution:
ST OpenSTLinux - Weston - (A Yocto Project Based Distro) 4.0.1-openstlinux-5.15-yocto-kirkstone-mp1-v22.06.15.1
This is a Yocto-based Linux distribution with:
- Kernel 5.15 (an older LTS kernel, we'll use 6.6)
- systemd as init (we'll use erlinit)
- Weston compositor on the display (we'll use Scenic or Surface)
- Full root login via serial (we'll have IEx)
All of this gets replaced. Our custom Nerves image will use a newer kernel, a minimal SquashFS rootfs, no systemd, and the BEAM VM as the primary runtime. But seeing ST's working image is valuable — it confirms that all the hardware works, and it gives us a reference for driver and device tree configuration.
What We're Building vs. What's There
Here's the mapping between what ST ships and what we'll create:
| Component | ST OpenSTLinux | Our Nerves System |
|---|---|---|
| FSBL | TF-A v2.6 | TF-A v2.10 |
| SSBL | U-Boot 2022.10 | U-Boot 2024.01 |
| Kernel | Linux 5.15 | Linux 6.6 |
| Init | systemd | erlinit |
| Rootfs | ext4, read-write, ~800MB | SquashFS, read-only, ~30MB |
| Runtime | C/C++ userspace | BEAM VM + OTP |
| Display | Weston (Wayland) | Scenic or Surface |
| Updates | apt / manual flash | fwup A/B partitions + NervesHub |
| Shell | bash | IEx |
The boot chain structure stays the same — ROM → TF-A → FIP → U-Boot → Linux. What changes is everything above the kernel. And that's the whole point of building a custom Nerves system: you keep the hardware initialization that ST got right, and replace the userspace with something purpose-built.
The Partition Layout
One more piece that's essential to understand before we start building. The microSD card has a GPT partition table with a specific layout that the boot chain expects:
Partition Name Size Content
───────── ──── ──── ───────
1 fsbl1 256 KiB TF-A binary (with STM32 header)
2 fsbl2 256 KiB TF-A binary (redundant copy)
3 fip 4 MiB FIP: sp_min + U-Boot + DTB
4 rootfs-a 256 MiB SquashFS root filesystem (active slot)
5 rootfs-b 256 MiB SquashFS root filesystem (update slot)
6 app remaining Persistent application data (F2FS)
The first three partitions are the boot chain. The ROM code finds fsbl1 by name, TF-A finds fip by name. Partitions 4 and 5 are the Nerves A/B scheme — one slot runs while the other receives updates. If an update fails, you revert to the previous slot.
The two fsbl partitions contain identical content. It's redundancy — if fsbl1 gets corrupted during a power failure while updating the bootloader, fsbl2 is still intact. The ROM code tries both.
The "app" Partition — Nerves' Persistent Storage
Partition 6 deserves special attention. In Nerves, it's typically called the "app" partition, but it's mounted at /root (or sometimes referred to as the "home" partition — not /home, but /root, the root user's home directory). This is the only writable, persistent partition on the entire system. The rootfs partitions are read-only SquashFS.
Everything that needs to survive a reboot or firmware update lives here:
Nerves.Runtime.KVkey-value pairs- Application data and configuration
- Logs (if configured for persistent logging)
- Any files your application writes to
/rootor/data
This partition is formatted with F2FS (Flash-Friendly File System), which is designed for flash storage like microSD cards. It handles wear leveling better than ext4 on raw flash.
Flashing with fwup — The -t complete Lesson
The tool that writes firmware to the microSD is fwup. It reads the fwup.conf we defined and knows about the partition layout. But it has different tasks for different situations, and picking the wrong one can ruin your day.
The Tasks
-t complete — Factory flash. Writes everything: GPT table, bootloaders, and rootfs. This is what you use on a blank microSD card or when setting up a board for the first time.
sudo fwup firmware.fw -a -i /dev/sdX -t complete
-t upgrade.a / -t upgrade.b — OTA-style upgrade. Only writes the rootfs to the inactive slot, then switches the active slot marker in U-Boot's environment. Does NOT touch the bootloader partitions. Does NOT touch the app partition.
# From the host, to update slot A:
sudo fwup firmware.fw -a -i /dev/sdX -t upgrade.a
The Mistake
Here's something I learned the hard way: -t complete wipes the app partition. It writes a fresh GPT table and reformats everything. All your Nerves.Runtime.KV values, all your application data, all your configuration — gone.
I was iterating on my Nerves system, flashing new builds to test changes. Out of habit I kept using -t complete every time. Each flash wiped my KV store, my WiFi credentials, my device identity — everything I'd configured through Nerves.Runtime.KV.put/2. I didn't even realize it at first because the system booted fine each time. But all my persisted state was silently disappearing.
The fix is simple: once your board has been flashed once with -t complete, always use -t upgrade.a or -t upgrade.b for subsequent updates. These tasks only write the rootfs to the inactive slot and leave everything else untouched — bootloaders, app partition, KV store all preserved.
# First time (blank microSD):
sudo fwup firmware.fw -a -i /dev/sdX -t complete
# Every time after that:
sudo fwup firmware.fw -a -i /dev/sdX -t upgrade.a
# or
sudo fwup firmware.fw -a -i /dev/sdX -t upgrade.b
The upgrade tasks also check which slot is currently active (via the nerves_fw_active U-Boot environment variable) and write to the other slot. So upgrade.a will only work if slot B is currently active, and vice versa. If you're not sure which slot is active, fwup will tell you — it simply won't match the wrong task.
When you're doing OTA updates from within Nerves itself (via NervesHub or ssh nerves.local "fwup /tmp/firmware.fw"), this is all handled automatically. The -t complete vs -t upgrade distinction mainly matters when you're flashing from the host machine during development.
Rule of thumb: Use -t complete once. Then never again unless you truly want to start fresh.
Up Next
The board boots. The boot chain makes sense. Next we need to understand the device tree — the file that describes all this hardware to the kernel. That's where the real customization begins, and it's the part that took me the longest to wrap my head around.
We'll read the actual DTS files for the DK2 and trace how a peripheral goes from "silicon on the chip" to "working Ethernet interface in Linux." Every pin assignment, every clock source, every driver match — it's all in there. And we'll discover something surprising about our board's "F" variant along the way.