02 · U-Boot Deep Dive¶
Level 1's boot-flow module named U-Boot as the relay runner between the boot ROM and the kernel. That was the tourist view. On a real board U-Boot is where you spend your worst days: the kernel won't load, the device tree is stale, the board comes up in a boot loop at a customer site, and your only interface is a serial console and a two-second window to hit a key. This module makes that console familiar — the shell, the environment, boot scripts, and loading a kernel over TFTP and MMC.
Interrupting the boot¶
Power on with a serial terminal attached (115200 8N1 on most i.MX boards) and U-Boot prints a banner and counts down:
U-Boot 2024.04 (Apr 12 2024 - 09:15:33 +0000)
CPU: i.MX95 rev1.0 2000 MHz (running at 1000 MHz)
Model: NXP i.MX95 15X15 board
DRAM: 8 GiB
Core: 156 devices, 31 uclasses, devicetree: separate
MMC: FSL_SDHC: 0, FSL_SDHC: 1
Net: eth0: ethernet@428a0000
Hit any key to stop autoboot: 2
u-boot=>
That prompt is a full command interpreter with variables, conditionals and
scripting. help lists everything the board was built with; help <cmd>
explains one command.
The environment is the configuration¶
U-Boot's environment is a set of key/value strings stored in non-volatile memory (a raw eMMC offset, a SPI-NOR region, a UBI volume, or a file). It is the thing you edit to change how a board boots.
u-boot=> printenv bootargs
bootargs=console=ttyLP0,115200 root=/dev/mmcblk1p2 rootwait rw
u-boot=> printenv bootcmd
bootcmd=run distro_bootcmd
u-boot=> setenv bootargs "console=ttyLP0,115200 root=/dev/mmcblk1p2 rootwait ro"
u-boot=> saveenv
Saving Environment to MMC... Writing to MMC(0)... OK
u-boot=> printenv | wc -l
118
setenv changes RAM only; saveenv makes it persist. setenv foo
with no value deletes the variable. env default -a -f restores the
compiled-in defaults — your escape hatch when you have edited yourself
into a boot loop.
Variables can hold commands, and run executes them. That is the whole
scripting model:
Note 'single quotes' around anything containing ; — otherwise the
shell splits the command at the semicolon and you save only the first half.
Loading a kernel from MMC¶
The manual boot sequence every embedded engineer should be able to type from memory: load kernel, load device tree, hand over.
u-boot=> mmc list
FSL_SDHC: 0 (eMMC)
FSL_SDHC: 1 (SD)
u-boot=> mmc dev 1
switch to partitions #0, OK
mmc1 is current device
u-boot=> ls mmc 1:1
38797312 Image
53248 imx95-15x15-verdin.dtb
512 boot.scr
u-boot=> fatload mmc 1:1 ${kernel_addr_r} Image
38797312 bytes read in 812 ms (45.6 MiB/s)
u-boot=> fatload mmc 1:1 ${fdt_addr_r} imx95-15x15-verdin.dtb
53248 bytes read in 15 ms (3.4 MiB/s)
u-boot=> setenv bootargs "console=ttyLP0,115200 root=/dev/mmcblk1p2 rootwait rw"
u-boot=> booti ${kernel_addr_r} - ${fdt_addr_r}
## Flattened Device Tree blob at 83000000
Booting using the fdt blob at 0x83000000
Starting kernel ...
The - in the middle is the initramfs slot: "no initramfs here." Getting
that argument order wrong (booti <kernel> <initrd> <fdt>) is the source
of an enormous number of Bad Linux ARM64 Image magic! reports.
| Command | Image type |
|---|---|
booti |
Raw ARM64 Image |
bootz |
Raw ARM32 zImage |
bootm |
Legacy or FIT uImage (has a U-Boot header) |
Loading a kernel over the network (TFTP)¶
During bring-up nobody reflashes eMMC for every kernel rebuild. You serve the kernel over TFTP from your workstation and keep the rootfs on NFS — edit, rebuild, reset, in seconds.
On the host:
$ sudo apt install tftpd-hpa nfs-kernel-server
$ sudo cp arch/arm64/boot/Image /srv/tftp/
$ grep TFTP_DIRECTORY /etc/default/tftpd-hpa
TFTP_DIRECTORY="/srv/tftp"
On the board:
u-boot=> setenv ipaddr 192.168.1.50
u-boot=> setenv serverip 192.168.1.10
u-boot=> ping ${serverip}
Using ethernet@428a0000 device
host 192.168.1.10 is alive
u-boot=> tftp ${kernel_addr_r} Image
Using ethernet@428a0000 device
TFTP from server 192.168.1.10; our IP address is 192.168.1.50
Filename 'Image'.
Load address: 0x80400000
Loading: ################################################# 45.6 MiB/s
done
Bytes transferred = 38797312 (2500000 hex)
u-boot=> setenv bootargs "console=ttyLP0,115200 root=/dev/nfs rw \
nfsroot=192.168.1.10:/srv/nfs/rootfs,v3,tcp ip=dhcp"
u-boot=> booti ${kernel_addr_r} - ${fdt_addr_r}
Wrap the whole thing into one variable so it is a single command later:
u-boot=> setenv netboot 'tftp ${kernel_addr_r} Image; \
tftp ${fdt_addr_r} imx95-15x15-verdin.dtb; \
setenv bootargs console=ttyLP0,115200 root=/dev/nfs rw \
nfsroot=${serverip}:/srv/nfs/rootfs,v3,tcp ip=dhcp; \
booti ${kernel_addr_r} - ${fdt_addr_r}'
u-boot=> saveenv
u-boot=> run netboot
Boot scripts and the distro boot flow¶
A boot.scr is a compiled U-Boot script placed on the boot partition, so
the image — not the board's saved environment — decides how it boots.
That is what makes an SD card portable between identical boards.
$ cat boot.cmd
setenv bootargs console=ttyLP0,115200 root=/dev/mmcblk1p2 rootwait rw
fatload mmc 1:1 ${kernel_addr_r} Image
fatload mmc 1:1 ${fdt_addr_r} imx95-15x15-verdin.dtb
booti ${kernel_addr_r} - ${fdt_addr_r}
$ mkimage -A arm64 -O linux -T script -C none -n "boot script" \
-d boot.cmd boot.scr
Image Name: boot script
Image Type: AArch64 Linux Script (uncompressed)
Data Size: 241 Bytes = 0.24 kB
Modern U-Boot ships distro boot: bootcmd=run distro_bootcmd walks
boot_targets in order, and on each device looks for boot.scr, then an
extlinux/extlinux.conf, then falls back to EFI.
u-boot=> printenv boot_targets
boot_targets=mmc1 mmc0 usb0 pxe dhcp
u-boot=> setenv boot_targets "mmc0 mmc1" # eMMC first, then SD
Building U-Boot for a board¶
Same defconfig pattern as the kernel:
$ export CROSS_COMPILE=aarch64-linux-gnu-
$ export ARCH=arm
$ ls configs | grep imx95
imx95_19x19_evk_defconfig
$ make imx95_19x19_evk_defconfig
$ make menuconfig # e.g. Command line interface -> enable 'md5sum'
$ make -j$(nproc)
...
LD u-boot
OBJCOPY u-boot.bin
CAT u-boot-nodtb.bin
For QEMU's virt machine — which needs no vendor blobs — the loop is
short enough to actually practise:
$ make qemu_arm64_defconfig && make -j$(nproc)
$ qemu-system-aarch64 -M virt -cpu cortex-a57 -m 512M -nographic \
-bios u-boot.bin
U-Boot 2024.04 ...
=> version
=> printenv
Do that once and every command above stops being theory.
Traps¶
How boards get bricked
saveenvwrites to flash. Lose power mid-write and the environment region is corrupt; many boards then refuse to boot to a prompt. Boards with a redundant environment (CONFIG_SYS_REDUNDAND_ENVIRONMENT) survive this; boards without it need a recovery flash.- Overwriting SPL or U-Boot itself with a stray
mmc write ${loadaddr} 0x42 ...at the wrong offset removes the only thing that can boot the board. Recovery then means serial download mode (uuuon i.MX) or a JTAG probe. - Load addresses that overlap.
${kernel_addr_r}and${fdt_addr_r}too close together means the kernel image silently overwrites the DTB; the symptom is a hang right after "Starting kernel …". Check board defaults before inventing your own addresses. - Editing
bootargswithout a fallback. A typo inroot=gets youVFS: Unable to mount root fsand a reboot loop. Keep a known-good copy in a second variable (setenv bootargs_good "$bootargs"). - Unquoted
;insetenv. You save the first command only, then wonder whyrun mybootcmdhalf-works.
Cheat sheet¶
| Command | Purpose |
|---|---|
printenv / setenv / saveenv |
Show / set (RAM) / persist environment |
env default -a -f |
Restore compiled-in defaults — un-brick your env |
run <var> |
Execute a variable's contents as commands |
mmc list / mmc dev N / ls mmc 1:1 |
Enumerate, select, list an MMC device |
fatload mmc 1:1 ${addr} <file> |
Load a file from a FAT partition into RAM |
ext4load / load |
Same for ext4 / filesystem-agnostic |
tftp ${addr} <file> |
Load a file over the network |
ping ${serverip} |
Prove the board's Ethernet works before blaming TFTP |
booti / bootz / bootm |
Boot ARM64 Image / ARM32 zImage / uImage-FIT |
booti ${kernel} - ${fdt} |
The - means "no initramfs" |
md / mw / cmp |
Memory display / write / compare |
mkimage -T script -d boot.cmd boot.scr |
Compile a boot script |
boot_targets |
Distro-boot device search order |
bdinfo / version / help <cmd> |
Board info / build info / per-command help |
How It Actually Works¶
SPL is a separate, tinier U-Boot. On most modern SoCs, boot ROM
(fixed in silicon) can only read a few KB from the first flash/MMC
sector, so it loads the Secondary Program Loader — a stripped U-Boot
build (u-boot-spl.bin, often under 64 KB, no environment, no shell, no
network stack) whose only job is to bring up DRAM (via a hand-tuned DDR
timing blob) and load the real U-Boot proper (u-boot.bin /
u-boot.itb) from flash into that now-available DRAM. This is why a bad
DDR config bricks a board at a stage you can't even get serial output
for — SPL runs from SRAM before DRAM controller init succeeds, and a
timing mistake there hangs before U-Boot's console driver is even alive.
The environment is a CRC-checked block, not a text file. saveenv
serializes every name=value pair from U-Boot's in-memory hash table
into a fixed-size flash/MMC sector, prefixed with a CRC32 and (if
CONFIG_ENV_ADDR_REDUND is set) written to two alternating slots so a
power-loss mid-write leaves one valid copy. On boot, U-Boot reads that
sector, checks the CRC, and falls back to the compiled-in
CONFIG_EXTRA_ENV_SETTINGS default only if the check fails — which is
exactly why env default -a; saveenv is the fix for a corrupted
environment, and why editing bootcmd in RAM with setenv has zero
effect on the next cold boot unless you saveenv.
Distro boot is bootcmd running a variable-expansion program, not a
config file U-Boot "reads." distro_bootcmd iterates
BOOT_TARGETS, and for each target expands bootcmd_<target> — itself
built from a template like boot_scr_data — which runs the hush
shell's variable substitution recursively: ${devtype}, ${devnum},
${distro_bootpart} all get textually substituted before the resulting
string is executed as commands. extlinux.conf parsing
(sysboot/pxe boot) walks the FAT/ext partition looking for
/boot/extlinux/extlinux.conf, then issues the same load/fdt
addr/booti primitives you used by hand — distro boot is a convention
implemented entirely in boot-script text, not a separate boot mode
compiled into U-Boot.
FDT relocation happens twice. The kernel's Image/zImage is loaded
to one address and the DTB to another (fdt addr 0x83000000), but
booti/bootz first calls U-Boot's own fdt_check_header +
fdt_open_into to grow the tree in-place and patch in /chosen (kernel
command line, initrd location) and memory nodes from bdinfo — then the
kernel, once running, does its own unflatten_device_tree() pass to
turn that patched blob into the live kernel object tree. If fdt addr
points at the wrong blob (say, one for a different board revision) both
passes "succeed" against garbage, which is the classic silent-hang-after-
"Starting kernel..." symptom.
Exercise¶
(1) Build U-Boot with qemu_arm64_defconfig and run it under
qemu-system-aarch64 -M virt -bios u-boot.bin; at the prompt, capture the
output of version, bdinfo and printenv boot_targets. (2) Create a
variable hello that echoes two messages in sequence, save it, reset the
machine, and confirm it survived — then repeat without quoting the
semicolon and explain what was actually stored. (3) Write a boot.cmd
that loads a kernel and DTB from mmc 0:1, sets bootargs for an ext4
root on /dev/mmcblk0p2, and boots; compile it with mkimage and explain
in one sentence why shipping boot.scr on the image is safer than relying
on saveenv. (4) One paragraph: a field unit reboots in a loop because
bootargs names a root partition that no longer exists after an OTA
update — describe the U-Boot mechanism you would have designed in before
shipping so the board falls back to the previous known-good rootfs
(hint: bootcount, altbootcmd, and Level 4's OTA module).