STM32 Dual-Firmware Bootloader
A minimal dual-slot bootloader for the STM32U575ZI, written in Rust. Two firmware slots, one word of SRAM to pick between them — no swap partition, no state machine, and no flash wear.
Flash Layout
┌─────────────────────────────────┐ 0x0800_0000 │ bootloader (64 KiB) │ ├─────────────────────────────────┤ 0x0801_0000 │ Firmware A (64 KiB) │ ├─────────────────────────────────┤ 0x0802_0000 │ Firmware B (64 KiB) │ └─────────────────────────────────┘
Each slot is a complete, independently linked firmware image with its own vector table. The three memory.x files differ only in FLASH ORIGIN; all of them map RAM at 0x2000_0000 with 768 KiB.
On every reset the bootloader reads a single word at 0x200A_0000 — mid-SRAM, clear of .data/.bss at the bottom and the stack at the top — and dispatches on it.
SRAM contents survive a system reset but are lost on a power cycle. That makes the selection purely a runtime choice: nothing is written to flash, so there is no state sector to wear out. It also means a power cycle always returns to Firmware A.
Tech Stack
Language: Rust (no_std)
MCU: STM32U575ZI (Cortex-M33)
Target: thumbv8m.main-none-eabihf
Runtime: Embassy
Tooling: probe-rs ≥ 0.24
Links
Write-up
Boot Flag
| Flag value at 0x200A_0000 | Action |
|---|---|
| 1 | Jump to Firmware B (0x0802_0000) |
| anything else | Jump to Firmware A (0x0801_0000) |
Defaulting everything that is not 1 to slot A means an uninitialised or corrupted word boots the known-good image rather than jumping somewhere undefined.
Update Flow
1. running firmware receives a new image (USB / UART / CAN / …)
2. writes it into the *inactive* slot's flash range
3. sets the boot flag in SRAM
4. triggers a system reset
│
└─▶ bootloader reads the flag and dispatches to the new slotBecause the flag lives in SRAM, this is a try-once activation: the new image runs until the next power cycle, then the device falls back to slot A. That is the behaviour you want while bringing firmware up on the bench. A production OTA would need the selection persisted to flash plus a “confirm update” step once the new image proves it can boot.
Two Bootloader Implementations
| Binary | File | How it jumps |
|---|---|---|
| bootloader | src/bin/bootloader.rs | embassy-boot-stm32 BootLoader::load() — invalidates I-cache, sets VTOR, then bootloads |
| raw_bootloader | src/bin/raw_bootloader.rs | Hand-rolled: disables interrupts, reads SP/PC from the vector table, sets VTOR, then inline asm |
Both are functionally equivalent; bootloader is the default. The raw version exists to show that dispatching a Cortex-M image needs only three things — set VTOR, set the stack pointer, branch to the reset handler:
unsafe fn jump_to(app_start: u32) -> ! {
cortex_m::interrupt::disable();
let sp = read_volatile(app_start as *const u32); // vector table +0x00
let pc = read_volatile((app_start + 4) as *const u32); // vector table +0x04
write_volatile(0xE000_ED08 as *mut u32, app_start); // SCB.VTOR
asm!(
"cpsie i",
"msr msp, {sp}",
"bx {pc}",
sp = in(reg) sp,
pc = in(reg) pc,
options(noreturn)
);
}Setting VTOR is the step most walkthroughs skip. Without it the CPU keeps consulting the bootloader's vector table, so the first interrupt after the jump lands in a stale handler.
Quick Start
Build and flash all three binaries
Bootloader plus both firmware slots. Each ELF carries its own load address, so probe-rs puts it in the right place.
make flash
Boot a slot and stream RTT
The target writes the boot flag over SWD, resets, and lets the bootloader dispatch. Firmware A prints "Hello from Firmware A!" once a second.
make run-a # boot Firmware A (flag = 0) make run-b # boot Firmware B (flag = 1)
Try the hand-rolled bootloader
Same behaviour, but the jump is done with inline assembly instead of going through embassy-boot-stm32.
BIN=raw_bootloader make flash BIN=raw_bootloader make run-b
Verifying the Slots
make verify reads the first two words back out of each slot. Distinct reset-handler addresses confirm three genuinely different binaries are in flash:
=== Flash slots: [initial-MSP] [Reset-Handler] === bootloader @ 0x08000000 -> 200c0000 08000239 app_a @ 0x08010000 -> 200c0000 08010239 app_b @ 0x08020000 -> 200c0000 08020239