A 30-Line STM32 Bootloader
June 1, 2026
Full working code: github.com/zephyr-atomi/stm32-dual-boot
What I Wanted
A bootloader that, on reset, decides which of two firmware images to run and jumps to it. Nothing else. No swap partitions, no state machine, no flash driver.
This is a demo, not a production OTA scheme. The point is to make one thing concrete: a bootloader is fundamentally "pick an address and start executing there." Everything past that is engineering trade-offs.
The Code
#![no_std]
#![no_main]
use cortex_m_rt::entry;
use panic_halt as _;
const APP_A: u32 = 0x0801_0000;
const APP_B: u32 = 0x0802_0000;
/// Boot flag address — mid-SRAM, safe from .data/.bss (low) and stack (high).
/// Write with probe-rs before reset:
/// probe-rs write <chip> b32 0x200A0000 0 → boot Firmware A
/// probe-rs write <chip> b32 0x200A0000 1 → boot Firmware B
const BOOT_FLAG: *const u32 = 0x200A_0000 as *const u32;
#[entry]
fn main() -> ! {
// embassy-stm32 is a Cargo dep (enables cortex-m-rt's "device" feature),
// so stm32-metapac's interrupt vector table must be linked in even here.
let _ = unsafe { embassy_stm32::Peripherals::steal() };
unsafe {
let flag = core::ptr::read_volatile(BOOT_FLAG);
let target = if flag == 1 { APP_B } else { APP_A };
jump_to(target)
}
}
unsafe fn jump_to(app_start: u32) -> ! {
cortex_m::interrupt::disable();
let sp = core::ptr::read_volatile(app_start as *const u32);
let pc = core::ptr::read_volatile((app_start + 4) as *const u32);
core::ptr::write_volatile(0xE000_ED08 as *mut u32, app_start);
core::arch::asm!(
"cpsie i",
"msr msp, {sp}",
"bx {pc}",
sp = in(reg) sp,
pc = in(reg) pc,
options(noreturn)
);
}That is the whole file: 45 lines as written, 30 with blank lines and comments removed. No state machine, no swap logic, no flash driver.
Anything that is not 1 boots slot A. An uninitialised or corrupted word therefore lands on the known-good image instead of branching somewhere undefined.
Flash Layout
┌─────────────────────────────────┐ 0x0800_0000
│ bootloader (64 KiB) │
├─────────────────────────────────┤ 0x0801_0000
│ Firmware A (64 KiB) │
├─────────────────────────────────┤ 0x0802_0000
│ Firmware B (64 KiB) │
└─────────────────────────────────┘
Three independently linked images, each with its own vector table. The three memory.x files differ in exactly one value — FLASH ORIGIN — and all map RAM at 0x2000_0000 with 768 KiB.
Where the Selection Lives: SRAM or Flash
The flag sits at 0x200A_0000, 640 KiB into SRAM. That is far above the app's .data/.bss at the bottom and comfortably below the stack growing down from the top. The exact boundary is set by the app's memory.x, so if you change the RAM layout, re-check this address.
SRAM versus flash is a real choice, and it depends on what you are doing:
| SRAM | Flash | |
|---|---|---|
| Write cost | Single word, instant | Erase required — slow, wears the sector |
| After power loss | Cleared → back to the default (slot A) | Retained → selection sticks |
| Fits | Debugging, quick bring-up | Production OTA (selection must persist) |
This matters more than it first looks. SRAM survives a system reset but not a power cycle. So the update flow works like this:
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 slot
…and then the user power-cycles the device and it comes back on slot A.
That makes slot B a try-once image, not an activated one. On the bench that is exactly the behaviour you want — a bad image cannot brick anything, since unplugging the board reverts it. As an OTA mechanism it is incomplete: a real one needs the selection persisted to flash plus a confirm-update step, where the new firmware writes its own slot as the active one only after it has proven it can boot. Without that second step you get the opposite failure — a broken image that permanently boots itself.
The Three Things jump_to Actually Does
1. Read the vector table. The first two words of any Cortex-M image are the initial stack pointer and the reset handler address. jump_to is doing by hand what the hardware does automatically on reset.
2. Set VTOR. 0xE000_ED08 is the Vector Table Offset Register. This is the step most walkthroughs skip, and skipping it does not fail immediately — it fails at the first interrupt after the jump, when the CPU consults the bootloader's vector table, finds a stale handler address, and branches into it. Unpredictable crash, far from the cause.
3. cpsie i before msr msp. Enable interrupts first, then install the new stack pointer. If an interrupt arrives at that moment, the stack pointer is already the app's, so the exception frame is pushed where it belongs. Reversed — set SP, then enable — an interrupt taken immediately after cpsie i would push its frame onto the new stack top while the PC is still in the bootloader, leaving a stray exception frame of unclear origin on the app's stack.
Two things the demo leaves out that production code should consider:
I-cache invalidation. The Cortex-M33 in the STM32U575 has an instruction cache that may hold lines fetched from the bootloader's address range. Writing 0 to ICIALLU at 0xE000_EF50 invalidates it. The raw bootloader here omits this and works fine in practice; the embassy-boot-stm32 variant in the same repo does it as part of BootLoader::load().
Forcing MSP. If your bootloader ran an RTOS beforehand it may be using the process stack pointer, in which case writing MSP updates a register that is not currently in use. Clear CONTROL.SPSEL before the jump:
"mrs {tmp}, CONTROL",
"bics {tmp}, {spsel}",
"msr CONTROL, {tmp}",
"isb",Why Peripherals::steal() Is in There
The one line that looks like dead code:
let _ = unsafe { embassy_stm32::Peripherals::steal() };embassy-stm32 is a Cargo dependency here because it enables cortex-m-rt's device feature. That feature expects the full interrupt vector table from stm32-metapac to be linked in. Without a reference reaching into the crate, the linker garbage-collects it and the bootloader ends up with an incomplete vector table. The steal() call exists purely to keep that reference alive.
Framework or Roll Your Own
embassy-boot-stm32 wants five flash partitions and a state machine, and writes flash at each step. For the range of scenarios it targets — swap-based updates, rollback, interrupted-update recovery — that is a reasonable design.
But booting a Cortex-M image needs three things: set VTOR, set SP, branch to the reset handler. Swap partitions, a state partition, and a state machine are all optional on top. If what you need is "read a flag, jump," the framework's complexity is pure overhead.
The repo carries both versions — bootloader.rs using embassy-boot-stm32, and raw_bootloader.rs shown above — so you can flash either and compare. Understanding the bottom layer is not an argument against frameworks; it is what lets you tell when you need one.
Aside: DFU Is Not This
A common conflation is treating DFU — the bootloader burned into STM32 ROM — as the same category as a custom bootloader. They cover different layers:
| Situation | Mechanism |
|---|---|
| First programming of a blank chip | DFU or SWD |
| Routine in-field updates | Custom bootloader |
| Bootloader itself is corrupt / bricked | DFU recovery (needs the BOOT0 pin) |
DFU solves the chicken-and-egg problem: how does a blank chip receive its first program. Day-to-day updates go through your own bootloader. The two do not compete.
Gotchas on the App Side
Interrupt state. cortex-m-rt's reset handler does not touch PRIMASK. Since the bootloader already ran cpsie i, the app's main() starts with interrupts enabled. If an Embassy executor's __wfi() never wakes, check that interrupts are genuinely on — this is the combination that produces a silent hang with no panic and no output.
Peripheral state. embassy_stm32::init() reconfigures RCC and the peripherals, so most leftover bootloader state gets overwritten. DMA channels and enabled interrupt sources are the exception; shut those down before jumping rather than relying on the app to clean up after you.
Project page with the flash map, both bootloader variants, and the make workflow: STM32 Dual-Firmware Bootloader.