The puzzle
A thousand devices are installed in buildings you will never visit, and a bug fix is ready. The fix cannot be installed by the program it replaces, at least not safely: erase the flash you are executing from and the processor has nothing left to run. What has to be on the device from the day it ships so that new firmware can be installed, and so that a failed installation can still be recovered?
STEP 1
Why updates need a bootloader
After reset the processor starts at a fixed place: the boot ROM, then (on the RP2040) boot2, then the vector table of whatever is in flash (unit 5, lesson 2). An update needs a small program that also runs at every reset, is never replaced during an update, and decides what to start: a bootloader. It sits at the start of flash, where the reset path lands, and the application moves to a region of its own.
Bootloaders divide the work. MCUboot, the bootloader Zephyr and Trusted Firmware-M use, only verifies images at startup and switches to a new one; downloading it is the application’s job. The running application receives the new image over whatever link the product has and writes it to a second flash region, the secondary slot. At the next reset the bootloader checks it and installs it (lessons 2 and 3). Other designs put the download in the bootloader too, or rely on a boot ROM that can load firmware over USB or a UART, like the RP2040’s USB mode.
STEP 2
The image in its slot
A slot is a flash region that holds one image. MCUboot’s usual flash map has a bootloader area, a primary slot the application runs from and a secondary slot for the incoming image, each erasable without affecting the others. An MCUboot image is the application binary wrapped by the signing tool, imgtool:
- a header at the start of the slot: a 32-bit magic number 0x96F3B83D, the header size, the image size, flags and a version such as 1.2.3+4, 32 bytes of fields in all;
- the payload, which begins with the application’s vector table;
- records (type-length-value entries) after the payload: a SHA-256 hash, the hash of the signing key and the signature, plus protected entries such as a security counter;
- a trailer at the very end of the slot, written mostly by the bootloader and the update code (imgtool’s
--padcan pre-fill it): swap progress, “copy done” and “image OK” flags and a 16-byte magic.
↑ This step uses the figure at the top of the page.
The payload starts right after the header, so the vector table address is
and it must be one that VTOR can hold. On a Cortex-M0+ VTOR has no bits 7:0 (CMSIS puts its offset field at bit 8), so the table must be 256-byte aligned; on the Cortex-M4 the field starts at bit 7, and larger tables need more alignment (unit 5, lesson 3). That is why imgtool pads the header (-H 0x200, say) instead of using the bare 32 bytes. The records and trailer take space at the end, so
(for swap using scratch and overwrite; swap using move also needs one spare sector, so the trailer is rounded up to whole sectors and one sector less is available).
and imgtool’s --slot-size option refuses an image that would overflow into the trailer.
STEP 3
Linking for the slot
The application is an ordinary program, but it no longer starts at the beginning of flash. MCUboot requires images “built to run from a fixed location”, not position-independent code, so the linker script’s flash origin must be the slot address plus the header (unit 4, lesson 4): every absolute address in the binary, starting with the vector table entries, depends on it. Zephyr does this when the application is configured with CONFIG_BOOTLOADER_MCUBOOT and its code partition is set to slot0_partition; its build also prepends the zeroed header that imgtool fills in. A binary linked for the start of flash and copied into a slot has vector entries that point into the bootloader, and crashes on its first exception or its first call.
STEP 4
The hand-off
Starting the application is not a function call. MCUboot’s Cortex-M port reads the first two words after the header, the application’s initial stack pointer and reset vector, and then:
- quiets what the bootloader used: the system timer and USB are stopped, NVIC enables and pending bits cleared, the MPU and stack-limit registers reset;
- points VTOR at the application’s table, when so configured (otherwise the application’s startup code sets VTOR itself);
- loads MSP with the application’s stack pointer and branches to its reset handler.
A bootloader is an ordinary program that ends by starting another one. MCUboot’s Zephyr port first checks for a recovery request, then lets boot_go() finish or start any swap and validate the image. Before jumping it quiets the hardware it used, points VTOR at the application’s vector table (a configuration option in MCUboot; otherwise the application’s startup code does it), loads the application’s initial stack pointer and branches to its reset handler: the same two loads a reset performs, without resetting anything else.
The last two steps are exactly what boot2 does on the RP2040 in a handful of instructions, and what the hardware does at reset: load SP and PC from a vector table. What a jump does not do is reset the peripherals, so anything the bootloader turned on is still on when the application starts, unless the bootloader turns it off first.
STEP 5
A path back
A bootloader that is never replaced must never need replacing, so it is kept small, well tested and able to recover a device whose application is broken. MCUboot can enter serial recovery (an image upload over a UART) when a pin is held at reset or when no valid image is found. The RP2040 boot ROM falls back to USB boot when the boot2 CRC fails (unit 5, lesson 2). Whichever you use, the recovery path must not depend on the application working.
STEP 6
Worked example: placing an application behind a 32 KiB bootloader
An RP2040-style map: flash is executed in place from 0x1000_0000, and the first 32 KiB (boot2 plus a bootloader) are reserved. The primary slot is 256 KiB and starts at
With a 0x200-byte header the vector table is at 0x1000_8200. Its offset 0x8200 = 33 280 = 130 × 256, so it is 256-byte aligned and the Cortex-M0+ VTOR can hold it. With the bare 32-byte header the table would be at 0x1000_8020, 32 bytes past a 256-byte boundary: unusable.
The largest application, taking the records as about 152 bytes (a SHA-256, a key hash and an ECDSA P-256 signature, each with a 4-byte record header, plus a 4-byte area header) and the trailer as about 1584 bytes (the 1536-byte swap status plus flags and magic):
A 120 KiB application fits with room to grow; the application must be linked at 0x1000_8200.
MYTHS AND FACTS
Common misconceptions
The bootloader is part of the application
It is a separate program with its own vector table, linker script and build, and it runs first at every reset.
Any binary can be copied into the slot
It must be linked for the slot’s address and carry the header and records the bootloader expects.
Starting the application is just a jump
The stack pointer, VTOR and the state of every peripheral the bootloader touched must be set up first.
If an update fails, the device goes back to the factory
Only if nobody designed a recovery path: a second slot, serial recovery or a ROM loader.
Check yourself
Answer in your head, then open the card.
The bootloader region is 64 KiB at 0x1000_0000 and imgtool adds a 0x100-byte header. Where is the vector table, and can a Cortex-M0+ use it?
At 0x1001_0000 + 0x100 = 0x1001_0100. The offset is a multiple of 256, so VTOR can hold it.
Why does MCUboot read two words right after the image header before jumping?
They are the first two entries of the application’s vector table: the initial stack pointer, loaded into MSP, and the reset vector, the address it branches to.
An application built with the default linker script (flash origin 0x1000_0000) is signed and written to a slot at 0x1000_8000. What happens when the bootloader starts it?
Every address in the image was computed for an image at 0x1000_0000, so the reset vector and handler addresses point into the bootloader region (with the default pico-sdk script, the words right after the header are not even a vector table but boot2’s code). The processor runs the wrong code and crashes; the image must be linked for the slot.
A bootloader uses a timer interrupt for an LED blink and jumps to the application with that timer still running. What can happen?
The timer keeps raising its request. When the application enables interrupts, the NVIC takes it through the application’s vector table, whose handler may be a default fault loop or may touch a driver that is not initialised yet.
Sources (6)
- MCUboot v2.1.0, docs/design.md (“Image format”, “Image slots”, “Image trailer”, “Limitations”) — IMAGE_MAGIC 0x96f3b83d, IMAGE_HEADER_SIZE 32; struct image_header (ih_magic, ih_load_addr, ih_hdr_size, ih_protect_tlv_size, ih_img_size, ih_flags, ih_ver); TLVs “placed after the end of the image”; images must be “Built to run from a fixed location (i.e., not position-independent)”; the trailer holds swap status, swap info, copy done, image OK and a 16-byte magic, and the swap status “for 128 slot sectors with a 4-byte alignment … would become 1536 B”
- MCUboot v2.1.0, docs/imgtool.md (“Signing images”) — imgtool sign “adds a header and trailer that the bootloader is expecting”; -H/--header-size and --pad-header add a zeroed header; “The --slot-size argument is required and used to check that the firmware does not overflow into the swap status area”
- MCUboot v2.1.0, boot/zephyr/main.c (do_boot and main) — “The beginning of the image is the ARM vector table, containing the initial stack pointer address and the reset vector”; reads two words at ih_hdr_size; sys_clock_disable(), usb_disable(), cleanup_arm_nvic(), MPU config cleared, PSPLIM/MSPLIM zeroed (or only irq_lock() without CONFIG_MCUBOOT_CLEANUP_ARM_CORE); SCB->VTOR set under CONFIG_BOOT_INTR_VEC_RELOC; __set_MSP(vt->msp); jump to vt->reset; serial recovery on a GPIO or when no bootable image is found
- Zephyr v3.7.0, doc/services/device_mgmt/dfu.rst (“MCUboot”) — define the flash partitions, set chosen zephyr,code-partition = &slot0_partition, enable CONFIG_BOOTLOADER_MCUBOOT so the application is built in an MCUboot-compatible manner, and flash it “at the correct offset (right after the bootloader)”
- Raspberry Pi Ltd, pico-sdk 1.5.1, boot_stage2/asminclude/boot2_helpers/exit_from_boot2.S — the smallest hand-off: VTOR ← XIP_BASE + 0x100, then ldmia loads the stack pointer and reset vector from that table, msr msp, bx to the reset handler
- Arm, CMSIS 6 v6.1.0, CMSIS/Core/Include/core_cm0plus.h and core_cm4.h — SCB_VTOR_TBLOFF_Pos is 8 on the Cortex-M0+ (VTOR optional, __VTOR_PRESENT) and 7 in core_cm4.h: the low bits of the table address do not exist