Skip to content

Part 3 · Firmware Systems46 min read

Bootloaders and Firmware Update

Every product that ships with firmware will one day need different firmware. A bootloader is the small, rarely changed program that runs first after reset, decides whether the application in flash is trustworthy, and either starts it or receives a replacement. In this lesson you will design the flash layout, write the image header and its validation, perform the jump into the application correctly (it is six lines, and every one of them matters), and build a complete minimal UART bootloader plus the Python script that feeds it. Then you will look at what real products add: dual slots, trial boots with rollback, and power-fail safety at every step.

A device on your desk can be reflashed over SWD. A device bolted inside a machine, sealed in a wall, or sold to ten thousand customers cannot. The bootloader exists for three reasons:

  • Field updates. New features and bug fixes arrive over whatever link the product has: UART, USB, CAN, BLE, Wi-Fi, cellular.
  • Recovery. If an update is interrupted or the new application crashes on boot, something must still run that can accept another image. That something cannot be the application, because the application is the thing that is broken.
  • Trust. The bootloader is the natural place to check that the application is intact (CRC) and, in the next lesson, that it is authentic (signature).

The rule that shapes everything else: the bootloader must never be in a state where it cannot boot itself and accept a new image. You can lose the application. You must never lose the bootloader.

The STM32F407 flash is not uniform. Sectors 0 to 3 are 16 KB, sector 4 is 64 KB, sectors 5 to 11 are 128 KB. Erase works on whole sectors, so the layout must follow sector boundaries.

0x08000000 +---------------------------+
| sector 0 16 KB | bootloader (32 KB)
0x08004000 | sector 1 16 KB | vector table at 0x08000000
0x08008000 +---------------------------+
| image header (512 bytes) | written LAST during an update
0x08008200 | application vector table | SCB->VTOR points here
| application .text/.rodata |
| sectors 2..11 |
| (992 KB - 512 B max) |
0x08100000 +---------------------------+
0x20000000 +---------------------------+
| handoff block (32 bytes) | same address in boot and app, never zeroed
0x20000020 | RAM for whoever is running|
0x20020000 +---------------------------+ initial MSP (top of SRAM)

The bootloader gets sectors 0 and 1, 32 KB. That is generous for a UART bootloader (ours is about 2 KB) but leaves room to add a signature check later, which brings in a crypto library. Putting the bootloader in the small sectors at the bottom is not a choice: the core boots from 0x08000000, so whatever lives there runs first.

The application slot starts at sector 2. Its first 512 bytes hold the image header, and the application’s vector table starts right after, at 0x08008200. Why 512 bytes and not 32? Because of a rule about VTOR.

On Cortex-M3/M4 the vector table must be aligned to its own size rounded up to the next power of two, with a minimum of 128 bytes. The STM32F407 table has 16 system entries plus 82 device IRQs, 98 words, 392 bytes. The next power of two is 512, so the application’s vector table must start on a 512-byte boundary. Header size 512 keeps the table at 0x08008200, which is aligned. A 32-byte header would put the table at 0x08008020, and the core would fetch some vectors from the wrong addresses. MCUboot, the most widely used open-source bootloader, is commonly configured with a 0x200 header for exactly this reason.

The bootloader and the application are two separate programs, each with its own ELF, startup code, and linker script. Only the MEMORY block differs from the linker script you built in Linker scripts:

boot.ld (MEMORY only)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 32K
HANDOFF (rw) : ORIGIN = 0x20000000, LENGTH = 32
RAM (rwx) : ORIGIN = 0x20000020, LENGTH = 128K - 32
}
app.ld (MEMORY only)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08008200, LENGTH = 1024K - 32K - 512
HANDOFF (rw) : ORIGIN = 0x20000000, LENGTH = 32
RAM (rwx) : ORIGIN = 0x20000020, LENGTH = 128K - 32
}

Both scripts also get one extra output section:

.handoff (NOLOAD) :
{
KEEP(*(.handoff))
} > HANDOFF

NOLOAD means the section occupies RAM addresses but has no contents in the file, so objcopy emits nothing for it and the startup code neither copies nor zeroes it. That is what lets the bootloader and the application pass a few words to each other across a reset. You saw the same idea for crash counters in Linker scripts as .noinit; here the region is pinned to a fixed address so two independently linked programs agree on where it is.

Both programs set _estack to 0x20020000. The bootloader’s RAM contents are irrelevant once the application starts, because the application’s startup code re-copies .data and zeroes .bss over the same memory.

The bootloader needs to answer “is there a complete, intact application in the slot?” without guessing. The application binary alone cannot tell you: an erased slot is all 0xFF, a half-written slot looks like code followed by 0xFF, and a corrupted one looks like code. So every image carries a header that the bootloader can check.

boot_shared.h
#ifndef BOOT_SHARED_H
#define BOOT_SHARED_H
#include <stdint.h>
#define SLOT_HDR_ADDR 0x08008000u /* image header, start of sector 2 */
#define APP_VTOR 0x08008200u /* app vector table, 512-byte aligned */
#define FLASH_END_ADDR 0x08100000u /* end of the 1 MB flash */
#define APP_MAX_SIZE (FLASH_END_ADDR - APP_VTOR)
#define IMAGE_MAGIC 0xB007C0DEu
#define HANDOFF_MAGIC 0x48414E44u /* "HAND" */
#define BOOT_REQ_UPDATE 0x55504454u /* "UPDT" */
typedef struct {
uint32_t magic; /* IMAGE_MAGIC */
uint32_t hdr_version; /* layout version of this struct, currently 1 */
uint32_t image_size; /* bytes of image after the header, mult. of 4 */
uint32_t image_crc; /* CRC-32 (zlib flavour) of those bytes */
uint32_t fw_version; /* major << 24 | minor << 16 | patch */
uint32_t load_addr; /* must equal APP_VTOR */
uint32_t reserved; /* zero; room to grow without a new version */
uint32_t hdr_crc; /* CRC-32 of the 28 bytes above */
} image_header_t;
_Static_assert(sizeof(image_header_t) == 32, "header layout is a wire format");
typedef struct {
uint32_t magic; /* HANDOFF_MAGIC when the request is meaningful */
uint32_t request; /* BOOT_REQ_UPDATE, or anything else for none */
} boot_handoff_t;
#endif

Every field is a uint32_t, so there is no padding and the layout is identical in C and in Python’s struct.pack("<8I", ...). The _Static_assert turns an accidental layout change into a compile error rather than a field failure.

Field by field:

  • magic distinguishes “a header” from erased flash (0xFFFFFFFF) and from random data.
  • hdr_version lets a future bootloader understand old headers and lets an old bootloader reject headers it does not understand.
  • image_size bounds every later check. Without it, the bootloader does not know how much to CRC.
  • image_crc catches corruption: a bit flip in transit, an interrupted write, a sector that wore out. A CRC-32 detects all burst errors up to 32 bits and all but roughly 1 in 4 billion random corruptions. It does not detect deliberate modification, because anyone can recompute a CRC. Authenticity needs a signature, which is the subject of the next lesson. Products that want a stronger integrity check without signing use SHA-256 here.
  • fw_version feeds anti-rollback and lets the application report what it is.
  • load_addr catches an image linked for the wrong slot, which would otherwise pass the CRC and then crash.
  • hdr_crc protects the header itself, and, as you will see, it doubles as the commit marker.

A CRC over 1 MB takes time, and a valid CRC does not prove the image will run on this board. So the bootloader also checks the first two words of the application’s vector table:

  • Initial SP (vt[0]) must point into RAM where a stack can live: above 0x20000000 and at most 0x20020000, or inside CCM (0x10000000 to 0x10010000), and word aligned. A value like 0xFFFFFFFF means erased flash; a value like 0x08001234 means the file is not a vector table at all.
  • Reset vector (vt[1]) must have bit 0 set (Thumb, see Startup code and the vector table) and, with bit 0 cleared, must point inside the image.

These two checks catch the most common field mistake: sending the wrong file, for example the bootloader’s own binary or an image linked at 0x08000000.

Starting the application is not “call its main”. The application is a whole program that expects to come out of reset: its own stack pointer, its own vector table, and peripherals in their reset state. The bootloader has to recreate that state as closely as it can, then branch to the application’s Reset_Handler.

Here is the sequence with CMSIS names, from the complete bootloader below:

__attribute__((noreturn)) static void jump_to_app(uint32_t vtor)
{
const uint32_t *vt = (const uint32_t *)vtor;
uint32_t sp = vt[0];
app_entry_t entry = (app_entry_t)(uintptr_t)vt[1]; /* Thumb bit already set */
__disable_irq();
SysTick->CTRL = 0u;
SysTick->VAL = 0u;
SCB->ICSR = SCB_ICSR_PENDSTCLR_Msk;
for (uint32_t i = 0; i < 8u; i++) {
NVIC->ICER[i] = 0xFFFFFFFFu;
NVIC->ICPR[i] = 0xFFFFFFFFu;
}
/* ... reset the peripherals the bootloader used ... */
SCB->VTOR = vtor;
__DSB();
__ISB();
__set_MSP(sp);
__enable_irq();
entry();
for (;;) {
}
}

Step by step, and what breaks if you skip it:

  1. Read SP and PC first. Both values are loaded into locals before anything changes. app_entry_t is void (*)(void), and the cast from the integer in vt[1] is the classic trick that turns an address into something you can call. Do not clear bit 0: blx to an address with bit 0 set stays in Thumb state, which is the only state a Cortex-M has. Clearing it causes an INVSTATE UsageFault.
  2. __disable_irq(). Sets PRIMASK so nothing can interrupt the hand-over. An interrupt arriving after VTOR changes but before the application initialised its handler’s data would run application code against uninitialised RAM.
  3. Stop SysTick and clear its pending bit. SysTick is a core exception, not an NVIC IRQ, so the NVIC loop does not touch it. Leave it running and the application takes a SysTick exception before its startup code finishes, often before .bss is zeroed. This is the single most common bootloader bug.
  4. Disable and un-pend every NVIC interrupt. NVIC->ICER and NVIC->ICPR are write-1-to-act registers; writing all ones clears every enable and every pending bit. The F407 uses three of the eight registers; writing all eight is harmless.
  5. Return peripherals to reset state. The application’s drivers assume reset values. A USART left enabled with RXNEIE set, a DMA stream left running into a RAM buffer the application now uses for its stack, a GPIO left driving a pin: each is a bug that only shows up when booting through the bootloader, never when you flash the application directly with the debugger. The RCC reset registers (APB1RSTR, AHB1RSTR) put a peripheral back to its reset state in two writes.
  6. Clocks. If the bootloader switched to the PLL, switch back to HSI and turn the PLL off. Vendor clock setup code typically assumes it starts from HSI; trying to reprogram PLLCFGR while the PLL is the system clock does nothing, and the application ends up at the wrong frequency with every baud rate wrong. Our bootloader never leaves the 16 MHz HSI, which removes the problem.
  7. SCB->VTOR = vtor, then __DSB() and __ISB(). From now on, exceptions fetch their handler addresses from the application’s table. The barriers guarantee the write has completed and the pipeline is refetched before anything can take an exception, as covered in The Cortex-M architecture.
  8. __set_MSP(sp). Loads the main stack pointer with the application’s initial value, exactly as the hardware would at reset.
  9. __enable_irq(). After reset PRIMASK is 0. Leaving it set would mean the application’s interrupts silently never fire until it happens to call __enable_irq() itself. It is safe here because every interrupt source is disabled.
  10. entry(). Branches to the application’s Reset_Handler, which never returns.

Why RAM state matters, and the reset alternative

Section titled “Why RAM state matters, and the reset alternative”

The application’s startup code re-initialises .data and .bss, so ordinary globals are fine. What it does not re-initialise is everything outside the C runtime: the core’s exception state, peripheral registers, clock tree, DMA streams, the MPU, the FPU’s CPACR. If the bootloader ran from inside an interrupt handler (for example, it decided to jump from a UART ISR), the core is still in Handler mode and the application starts at the wrong privilege and priority. Always jump from Thread mode, from main’s context.

A popular way to sidestep all of this is to never jump from a dirty state at all. The bootloader decides to boot the application, writes a flag to the handoff block, and calls NVIC_SystemReset(). On the next pass through reset the hardware is pristine, the bootloader sees the flag at the very start of main before touching any peripheral, and jumps immediately. Our bootloader uses exactly this path after an update: it resets, re-validates, and jumps from a clean state.

Two things change in the application. First, it is linked at 0x08008200 with app.ld. Second, it must not overwrite VTOR with the wrong value. Some vendor SystemInit implementations write SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET, which points back to the bootloader’s table. The first interrupt then runs a bootloader handler. Check your SystemInit; in recent ST code this write only happens if USER_VECT_TAB_ADDRESS is defined. A defensive application sets VTOR itself from the linker symbol of its own table, so it works no matter who launched it.

To ask the bootloader for an update, the application writes the handoff block and resets:

app_update.c
#include "stm32f4xx.h"
#include "boot_shared.h"
__attribute__((section(".handoff"))) static volatile boot_handoff_t handoff;
void app_request_update(void)
{
handoff.request = BOOT_REQ_UPDATE;
handoff.magic = HANDOFF_MAGIC;
NVIC_SystemReset(); /* CMSIS: DSB, set AIRCR.SYSRESETREQ, wait */
}

SRAM keeps its contents through a system reset (not through a power cycle), so the bootloader finds both words. It checks the magic first, because after power-on the block contains random bits, and it clears the magic immediately so the request is one-shot: if the update session fails and the device resets, it does not get stuck requesting updates forever.

The bootloader needs some way to receive bytes:

  • UART is the simplest and the most common for development and factory use. Standard protocols exist: XMODEM (128-byte blocks, checksum or CRC-16, ACK/NAK per block) and YMODEM (adds a header block with file name and size, 1 KB blocks). Every terminal program speaks them. Many teams write a small custom framed protocol instead, because it can carry an image header and offsets, which XMODEM cannot.
  • USB usually means the DFU class (Device Firmware Upgrade), which host tools like dfu-util speak, or a CDC virtual COM port running the same UART protocol.
  • OTA (over the air: BLE, Wi-Fi, cellular, LoRa) almost never lives in the bootloader, because radio stacks are large. Instead, the application receives the image chunk by chunk, writes it to an inactive slot or external flash, and the bootloader only has to validate and install it. That is the dual-slot design below.

Whatever the transport, the same design rules apply: frame every chunk, protect each frame with a CRC, acknowledge every frame, carry an explicit offset so a retransmitted frame is harmless, and use timeouts on every wait.

The bootloader in this lesson speaks a stop-and-wait framed protocol at 115200 baud on USART2 (PA2/PA3):

frame: 0x7E | type (1) | len (2, LE) | payload (len) | CRC-32 (4, LE) over type+len+payload
reply: 0x06 ACK or 0x15 NAK (one byte, after the frame is processed)
type 0x01 START payload = 32-byte image_header_t -> validate header, erase slot
type 0x02 DATA payload = offset (4, LE) + 4..256 bytes -> program and verify
type 0x03 END payload = empty -> CRC whole image, write header, reset

The host sends a frame and waits for the reply before sending the next one. Erasing 992 KB of F407 flash can take many seconds (128 KB sectors take on the order of one to two seconds each), during which the CPU stalls on every flash access and bytes arriving on the UART would be lost. Stop-and-wait makes that a non-issue: the host simply waits up to 30 seconds for the START reply.

The order of operations is what makes an update survivable:

  1. Erase every sector the new image will occupy, including the header sector.
  2. Program the image data, chunk by chunk, and verify each word by reading it back.
  3. Check the CRC over the whole image in flash, not over the bytes as they arrived. This catches a bad flash cell, an addressing bug, or a lost frame.
  4. Commit: write the header last.

Step 4 is the trick. Erased flash reads 0xFF, which fails the magic and the header CRC, so a slot without a header is invalid by construction. The header goes in only after everything else is verified. If power fails at any moment before the last word of the header is programmed, the header CRC does not match and the bootloader treats the slot as empty. This pattern, writing a small commit record last over something that reads as invalid when erased, is the core of every power-fail-safe flash scheme, and you met it for config records in Flash, EEPROM, and non-volatile storage.

Walk through every point where power can disappear:

Power lost duringState at next bootOutcome
Waiting for STARTOld image intact, header validBoots old image
EraseSlot partially erased, header goneBootloader waits for an update
Programming dataHeader still erasedBootloader waits for an update
Final CRC checkHeader still erasedBootloader waits for an update
Programming the headerHeader CRC mismatchBootloader waits for an update
After header writtenValid new imageBoots new image

No row bricks the device, because the bootloader itself is never erased. But notice rows 2 to 5: in a single-slot design the old application is gone as soon as the erase starts, and the device is useless until someone completes an update. For a device on a bench with a laptop attached, that is fine. For a device in the field updating over a flaky radio, it is not, and that is what dual-slot designs fix.

Single slot: one application area. The bootloader itself receives the image and writes it over the old one. Simple, uses the least flash, and the bootloader must contain the transport (UART, USB). The device is offline from the first erase until the update completes.

Dual slot (A/B): two application areas. The running application receives the new image into the inactive slot while continuing to do its job. Only when the new image is completely written and verified does anything change about what boots. An interrupted download costs nothing: the old image still runs. This is how OTA updates work.

A plausible A/B layout on the F407:

sectors 0-1 0x08000000 32 KB bootloader
sectors 2-3 0x08008000 32 KB metadata / config (A/B records with sequence numbers)
sector 4 0x08010000 64 KB spare
sectors 5-7 0x08020000 384 KB slot A
sectors 8-10 0x08080000 384 KB slot B
sector 11 0x080E0000 128 KB scratch (only for swap)

How does a new image in slot B get to run? Two strategies:

  • Swap. The application is always linked to run from slot A. The bootloader swaps the contents of A and B, sector by sector, using a scratch sector and recording progress in flash after every step so a power cut mid-swap resumes where it left off. Afterwards the old image sits in B, ready for rollback. The cost: swapping 384 KB through 128 KB sectors means many erase cycles and seconds of boot time, and the scratch sector must be as large as the largest sector, which on the F407 is 128 KB. MCUboot implements this as “swap using scratch” and a newer “swap using move” that avoids the scratch area.
  • Direct XIP (execute in place). Each slot runs from where it is. You build the application twice, once linked for slot A and once for slot B (or once as position-independent code, which is rare on Cortex-M because of its costs). The bootloader picks the valid slot with the highest version and jumps to it. No copying, fast boot, but every release ships two binaries and the updater must send the one matching the inactive slot.

There is also overwrite-only: the new image is downloaded into B and then copied over A with no swap. It still protects against an interrupted download, but there is no old image to roll back to afterwards.

A valid CRC means the image arrived intact. It does not mean the image works. A new release might hang during initialisation on one hardware revision, or fail to bring up the radio, which would cut the device off from any further update. Rollback handles this with a trial boot:

  1. After installing a new image, the bootloader marks it as “trial” (pending confirmation) and boots it, with the independent watchdog running.
  2. The new application runs its self-tests: peripherals come up, it can reach the update server, the main loop runs for a while.
  3. If satisfied, the application writes a “confirmed” flag to flash. From now on this image is the good one.
  4. If the application hangs, the watchdog resets the device. The bootloader sees an image that is still in trial after a reset (or counts boot attempts and gives up after N), and reverts to the previous image.

The watchdog is essential: without it, a hang is permanent. The confirm flag must live in flash or in the metadata sector, not in .noinit RAM, because it must survive power cycles. The boot attempt counter, on the other hand, can live in the handoff block, because a power cycle during a trial is a reasonable moment to start counting again.

Rollback to the previous image is a feature. Rollback to a version from three years ago with a known security hole is an attack: if an attacker can feed the device an old, correctly signed image, they get the old vulnerability back. Anti-rollback keeps a monotonic “minimum acceptable version” in storage the application cannot decrease (OTP bits or a protected flash counter) and refuses any image below it. The counter is raised only when a release fixes a security problem, not on every release, so normal rollback to the previous image still works. The next lesson shows how to store such a counter in the F407’s one-time-programmable area.

You do not always write your own.

ST’s ROM bootloader. Every STM32 contains a factory bootloader in system memory, at 0x1FFF0000 on the F4. Pull BOOT0 high (with BOOT1/PB2 low) and reset, and the chip runs it instead of your flash. On the F407 it can load firmware over several interfaces including USART, CAN and USB DFU; ST’s application note AN2606 lists the exact pins per part. It is perfect for factory programming and for recovering a board whose own bootloader is broken. It cannot validate your image format or do A/B, and once readout protection is enabled its usefulness drops sharply. Applications can also jump into it from software (same jump sequence, with 0x1FFF0000 as the vector table address).

MCUboot. An open-source secure bootloader used by Zephyr, Mbed, and many vendor SDKs. Its image format is a header (with its own magic value and a configurable header size, commonly 0x200, for the alignment reason you saw above), the image, and a trailer of TLV records holding a SHA-256 hash and a signature. You sign images with its imgtool script, giving it the key, header size, slot size, and version. It supports single slot, swap with scratch, swap using move, overwrite-only, direct XIP, encrypted images, and rollback protection. If your product needs signed A/B updates, start here rather than writing your own.

Versioning the bootloader and the application

Section titled “Versioning the bootloader and the application”

The bootloader and the application are released on different schedules, so their interface must be explicit and stable:

  • The image header format carries hdr_version. A new bootloader accepts old versions; an old bootloader rejects newer versions it does not understand rather than misinterpreting them.
  • The handoff block has a magic and a fixed address. Never move it. Add fields only at the end.
  • The flash layout (slot addresses, metadata sector) is frozen once devices ship. Changing it requires a bootloader update.

Never update the bootloader unless you must. Updating it means erasing sector 0, and if power fails between the erase and the end of the write, the chip has nothing to run at reset and needs a debugger or the ROM bootloader to recover. When you must, the standard answer is a two-stage design: a tiny, immutable first stage in sector 0 that does nothing except pick and validate one of two second-stage bootloaders (in their own A/B slots) and jump. The second stage holds the transport, the crypto, and the update logic, and can be updated with the same power-fail-safe A/B machinery as the application. The first stage is small enough to get right once and never touch again.

Here is the whole bootloader: about 230 lines of C plus boot_shared.h above. It uses the CMSIS device header stm32f4xx.h (compile with -DSTM32F407xx), the startup code from Startup code and the vector table, and boot.ld. It runs entirely on the 16 MHz HSI with interrupts disabled, which keeps it simple and keeps the jump clean.

boot.c (1 of 4): definitions and CRC-32
/* boot.c - minimal UART bootloader for the STM32F407, lives in sectors 0-1 */
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include "stm32f4xx.h" /* CMSIS device header; compile with -DSTM32F407xx */
#include "boot_shared.h"
#define FRAME_SYNC 0x7Eu
#define FRAME_ACK 0x06u
#define FRAME_NAK 0x15u
#define T_START 0x01u
#define T_DATA 0x02u
#define T_END 0x03u
#define MAX_PAYLOAD (4u + 256u) /* offset + one chunk */
#define FLASH_KEY1 0x45670123u
#define FLASH_KEY2 0xCDEF89ABu
#define FLASH_ERRORS (FLASH_SR_PGSERR | FLASH_SR_PGPERR | FLASH_SR_PGAERR | FLASH_SR_WRPERR)
typedef void (*app_entry_t)(void);
typedef enum { RX_OK, RX_TIMEOUT, RX_BAD } rx_t;
__attribute__((section(".handoff"))) static volatile boot_handoff_t handoff;
static image_header_t pending; /* header from the START frame */
static bool started; /* START accepted, slot erased */
/* CRC-32, reflected, polynomial 0xEDB88320: identical to Python's zlib.crc32 */
static uint32_t crc32_update(uint32_t crc, const uint8_t *p, size_t n)
{
while (n--) {
crc ^= *p++;
for (int k = 0; k < 8; k++) {
crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u)));
}
}
return crc;
}
static uint32_t crc32(const void *data, size_t n)
{
return crc32_update(0xFFFFFFFFu, data, n) ^ 0xFFFFFFFFu;
}
static uint32_t get_u32(const uint8_t *p)
{
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) |
((uint32_t)p[3] << 24);
}

The CRC is the bitwise, branch-free form: 0u - (crc & 1u) is either 0 or 0xFFFFFFFF, so the polynomial is XORed in without a branch. It is small (about 40 bytes of code) and slow, roughly 60 cycles per byte; a 64 KB image takes about a quarter of a second at 16 MHz, acceptable for a bootloader. Performance and code size optimization makes it more than 30 times faster. The initial value 0xFFFFFFFF and final XOR match the CRC-32 used by zlib, Ethernet, and PNG, so the host can use Python’s built-in zlib.crc32 and the two sides always agree. A quick test: the CRC-32 of the ASCII string 123456789 must be 0xCBF43926.

get_u32 assembles a little-endian value from bytes with shifts, never with a pointer cast, so it works at any alignment (see Bit manipulation).

boot.c (2 of 4): validation and flash
static bool header_ok(const image_header_t *h)
{
return h->magic == IMAGE_MAGIC && h->hdr_version == 1u &&
h->hdr_crc == crc32(h, offsetof(image_header_t, hdr_crc)) &&
h->load_addr == APP_VTOR && h->image_size >= 8u &&
h->image_size <= APP_MAX_SIZE && (h->image_size % 4u) == 0u;
}
static bool image_is_valid(const image_header_t *h)
{
if (!header_ok(h)) {
return false;
}
const uint32_t *vt = (const uint32_t *)APP_VTOR;
uint32_t sp = vt[0];
uint32_t pc = vt[1] & ~1u;
bool sp_ok = (sp & 3u) == 0u &&
((sp > 0x20000000u && sp <= 0x20020000u) ||
(sp > 0x10000000u && sp <= 0x10010000u));
bool pc_ok = (vt[1] & 1u) != 0u && pc >= APP_VTOR && pc < APP_VTOR + h->image_size;
return sp_ok && pc_ok && crc32(vt, h->image_size) == h->image_crc;
}
static uint32_t sector_start(uint32_t s)
{
if (s < 4u) {
return 0x08000000u + s * 0x4000u; /* sectors 0-3: 16 KB */
}
if (s == 4u) {
return 0x08010000u; /* sector 4: 64 KB */
}
return 0x08020000u + (s - 5u) * 0x20000u; /* sectors 5-11: 128 KB */
}
static bool flash_wait(void)
{
while (FLASH->SR & FLASH_SR_BSY) {
}
uint32_t err = FLASH->SR & FLASH_ERRORS;
FLASH->SR = err; /* error flags: write 1 to clear */
return err == 0u;
}
static bool flash_erase_sector(uint32_t s)
{
FLASH->CR = FLASH_CR_PSIZE_1 | FLASH_CR_SER | (s << FLASH_CR_SNB_Pos);
FLASH->CR |= FLASH_CR_STRT;
bool ok = flash_wait();
FLASH->CR = 0u;
return ok;
}
static bool flash_program_word(uint32_t addr, uint32_t word)
{
volatile uint32_t *dst = (volatile uint32_t *)addr;
if (*dst == word) {
return true; /* retried frame: already there */
}
if (*dst != 0xFFFFFFFFu) {
return false; /* not erased: refuse */
}
FLASH->CR = FLASH_CR_PSIZE_1 | FLASH_CR_PG;
*dst = word;
bool ok = flash_wait();
FLASH->CR = 0u;
return ok && *dst == word;
}

header_ok checks the cheap things in order and short-circuits, so garbage is rejected before any CRC. The header CRC covers exactly the bytes before hdr_crc, computed with offsetof rather than a hard-coded 28.

image_is_valid runs the SP and PC checks before the image CRC, so a wrong file is rejected in microseconds.

The flash functions are a compressed version of the driver from Flash, EEPROM, and non-volatile storage. FLASH_CR_PSIZE_1 selects 32-bit parallelism, which needs a 2.7 V to 3.6 V supply (the Discovery runs at 3 V). The sector number goes in SNB at bit 3. While a sector erases, any CPU access to flash stalls, including instruction fetches, so the bootloader simply freezes inside flash_wait until the erase completes. That is fine here because nothing else needs to run, and it is why the protocol is stop-and-wait.

flash_program_word refuses to program a word that is not erased, and silently succeeds if the word already holds the target value. That second rule makes a retransmitted DATA frame harmless: if the host never saw our ACK and sends the same chunk again, every word already matches.

The ART accelerator’s caches are off after reset and this bootloader never turns them on, so reads after erase and program see the new flash contents. If you enable the data cache in a bootloader (or do in-application programming with caches on), reset the caches after erasing, as the reference manual requires.

boot.c (3 of 4): UART and the update protocol
static void hw_init(void)
{
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
RCC->APB1ENR |= RCC_APB1ENR_USART2EN;
(void)RCC->APB1ENR; /* let enables land */
GPIOA->MODER = (GPIOA->MODER & ~(0xFu << 4)) | (0xAu << 4); /* PA2, PA3: AF */
GPIOA->AFR[0] = (GPIOA->AFR[0] & ~(0xFFu << 8)) | (0x77u << 8); /* AF7: USART2 */
USART2->BRR = 139u; /* 16e6/115200 */
USART2->CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE;
SysTick->LOAD = 16000u - 1u; /* wraps each ms */
SysTick->VAL = 0u;
SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_ENABLE_Msk;
}
static void uart_putc(uint8_t c)
{
while (!(USART2->SR & USART_SR_TXE)) {
}
USART2->DR = c;
}
static bool uart_getc(uint8_t *c, uint32_t timeout_ms)
{
for (;;) {
if (USART2->SR & USART_SR_RXNE) {
*c = (uint8_t)USART2->DR;
return true;
}
if (SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk) { /* reading clears it */
if (timeout_ms-- == 0u) {
return false;
}
}
}
}
static rx_t recv_frame(uint8_t *type, uint8_t *payload, uint16_t *len, uint32_t timeout_ms)
{
uint8_t c, hdr[3], tail[4];
do {
if (!uart_getc(&c, timeout_ms)) {
return RX_TIMEOUT;
}
} while (c != FRAME_SYNC);
for (size_t i = 0; i < sizeof hdr; i++) {
if (!uart_getc(&hdr[i], 100u)) return RX_BAD;
}
*type = hdr[0];
*len = (uint16_t)(hdr[1] | (hdr[2] << 8));
if (*len > MAX_PAYLOAD) {
return RX_BAD;
}
for (size_t i = 0; i < *len; i++) {
if (!uart_getc(&payload[i], 100u)) return RX_BAD;
}
for (size_t i = 0; i < sizeof tail; i++) {
if (!uart_getc(&tail[i], 100u)) return RX_BAD;
}
uint32_t crc = crc32_update(crc32_update(0xFFFFFFFFu, hdr, 3), payload, *len) ^ 0xFFFFFFFFu;
return crc == get_u32(tail) ? RX_OK : RX_BAD;
}
static bool handle_start(const uint8_t *p, uint16_t len)
{
started = false;
if (len != sizeof pending) {
return false;
}
memcpy(&pending, p, sizeof pending);
if (!header_ok(&pending)) {
return false;
}
if (FLASH->CR & FLASH_CR_LOCK) {
FLASH->KEYR = FLASH_KEY1;
FLASH->KEYR = FLASH_KEY2;
}
FLASH->SR = FLASH_ERRORS; /* clear stale error flags */
uint32_t end = APP_VTOR + pending.image_size;
for (uint32_t s = 2u; s < 12u && sector_start(s) < end; s++) {
if (!flash_erase_sector(s)) {
return false;
}
}
started = true;
return true;
}
static bool handle_data(const uint8_t *p, uint16_t len)
{
if (!started || len <= 4u || (len - 4u) % 4u != 0u) {
return false;
}
uint32_t off = get_u32(p);
uint32_t n = len - 4u;
if (off % 4u != 0u || off > pending.image_size || n > pending.image_size - off) {
return false;
}
for (uint32_t i = 0; i < n; i += 4u) {
uint32_t w;
memcpy(&w, p + 4u + i, sizeof w);
if (!flash_program_word(APP_VTOR + off + i, w)) {
return false;
}
}
return true;
}
static bool handle_end(void)
{
if (!started || crc32((const void *)APP_VTOR, pending.image_size) != pending.image_crc) {
return false;
}
uint32_t words[sizeof pending / 4u];
memcpy(words, &pending, sizeof words);
for (uint32_t i = 0; i < sizeof pending / 4u; i++) { /* commit: header last */
if (!flash_program_word(SLOT_HDR_ADDR + 4u * i, words[i])) {
return false;
}
}
return image_is_valid((const image_header_t *)SLOT_HDR_ADDR);
}

hw_init configures USART2 exactly as in UART: serial communication, but from the 16 MHz HSI: 16000000 / 115200 = 138.9, so BRR = 139 gives 115108 baud, 0.08% off. SysTick runs without its interrupt; its COUNTFLAG bit is set every time the counter wraps (every 1 ms) and clears when read, which gives uart_getc a millisecond timeout with no ISR. No interrupts at all means nothing to clean up before the jump.

recv_frame hunts for the sync byte (skipping noise), then reads the rest with a 100 ms inter-byte timeout so a truncated frame cannot hang the bootloader. It rejects an oversized length before reading the payload, which is what keeps payload from overflowing: the length field comes from the wire and is untrusted. The frame CRC is computed incrementally over the three header bytes and the payload, which equals zlib.crc32 of type + len + payload on the host.

handle_data checks the offset with off > size || n > size - off instead of the tempting off + n > size. The tempting version overflows when an attacker or a bug sends off = 0xFFFFFF00, wraps to a small number, passes, and programs flash outside the slot. The subtraction form cannot wrap because off <= size is checked first. You will see this pattern again in Firmware security.

handle_end re-reads the image from flash for the CRC, then programs the header. Because the header carries its own CRC, the order in which its eight words are programmed does not matter: until the last word lands, hdr_crc does not match. Finally it runs the full image_is_valid, so the SP and PC checks apply to the new image before we ACK.

boot.c (4 of 4): the jump and main
__attribute__((noreturn)) static void jump_to_app(uint32_t vtor)
{
const uint32_t *vt = (const uint32_t *)vtor;
uint32_t sp = vt[0];
app_entry_t entry = (app_entry_t)(uintptr_t)vt[1]; /* Thumb bit already set */
__disable_irq();
SysTick->CTRL = 0u; /* stop SysTick ... */
SysTick->VAL = 0u;
SCB->ICSR = SCB_ICSR_PENDSTCLR_Msk; /* ... drop a pending tick */
for (uint32_t i = 0; i < 8u; i++) {
NVIC->ICER[i] = 0xFFFFFFFFu; /* disable every IRQ */
NVIC->ICPR[i] = 0xFFFFFFFFu; /* clear every pending IRQ */
}
RCC->APB1RSTR |= RCC_APB1RSTR_USART2RST; /* back to reset state */
RCC->APB1RSTR &= ~RCC_APB1RSTR_USART2RST;
RCC->AHB1RSTR |= RCC_AHB1RSTR_GPIOARST;
RCC->AHB1RSTR &= ~RCC_AHB1RSTR_GPIOARST;
RCC->APB1ENR &= ~RCC_APB1ENR_USART2EN;
RCC->AHB1ENR &= ~RCC_AHB1ENR_GPIOAEN;
FLASH->CR |= FLASH_CR_LOCK;
SCB->VTOR = vtor; /* app's vector table */
__DSB();
__ISB();
__set_MSP(sp); /* app's initial stack */
__enable_irq(); /* PRIMASK = 0, as at reset */
entry(); /* app's Reset_Handler */
for (;;) {
}
}
int main(void)
{
bool requested = handoff.magic == HANDOFF_MAGIC && handoff.request == BOOT_REQ_UPDATE;
handoff.magic = 0u; /* one-shot request */
const image_header_t *hdr = (const image_header_t *)SLOT_HDR_ADDR;
if (!requested && image_is_valid(hdr)) {
jump_to_app(APP_VTOR);
}
hw_init();
static uint8_t buf[MAX_PAYLOAD];
for (;;) {
uint8_t type = 0u;
uint16_t len = 0u;
rx_t r = recv_frame(&type, buf, &len, 30000u);
if (r == RX_TIMEOUT) {
if (!started && image_is_valid(hdr)) {
NVIC_SystemReset(); /* nobody came: boot old image */
}
continue;
}
bool ok = false;
if (r == RX_OK) {
switch (type) {
case T_START: ok = handle_start(buf, len); break;
case T_DATA: ok = handle_data(buf, len); break;
case T_END: ok = handle_end(); break;
default: break;
}
}
uart_putc(ok ? FRAME_ACK : FRAME_NAK);
if (ok && type == T_END) {
while (!(USART2->SR & USART_SR_TC)) {
}
NVIC_SystemReset(); /* reboot into new image */
}
}
}

main has three paths. The fast path, taken on almost every boot, validates the image and jumps before any peripheral is touched, which makes the jump trivially clean. If the application requested an update, or the slot is invalid, the bootloader enters the update loop. If an update was requested but no host shows up within 30 seconds and the slot was never erased, it resets; the request has been consumed, so the next pass boots the old application. After a successful END it waits for TC (transmission complete, so the ACK has actually left the shift register, not just the data register) and resets, so the new image starts from a pristine reset rather than from the bootloader’s state.

jump_to_app resets GPIOA even on the fast path where it was never enabled. That is harmless, and GPIOA’s reset value keeps PA13/PA14 in their SWD alternate function, so the debugger stays connected.

Build it with the lesson-standard flags plus -DSTM32F407xx, the CMSIS include paths, and -T boot.ld. Expect roughly 2 KB of .text, well inside the 32 KB budget.

The host side builds the header, pads the image, and drives the protocol. It needs pyserial (pip install pyserial).

send_image.py
#!/usr/bin/env python3
"""send_image.py - frame a raw .bin and send it to the Firmware Path bootloader.
usage: python3 send_image.py PORT firmware.bin 1.4.2
"""
import struct
import sys
import zlib
import serial # pip install pyserial
IMAGE_MAGIC = 0xB007C0DE
APP_VTOR = 0x08008200
SYNC, ACK, NAK = 0x7E, 0x06, 0x15
T_START, T_DATA, T_END = 0x01, 0x02, 0x03
CHUNK = 256
def make_header(image: bytes, version: int) -> bytes:
body = struct.pack("<7I", IMAGE_MAGIC, 1, len(image), zlib.crc32(image),
version, APP_VTOR, 0)
return body + struct.pack("<I", zlib.crc32(body))
def send_frame(port, ftype: int, payload: bytes, timeout: float, retries: int = 3) -> None:
body = struct.pack("<BH", ftype, len(payload)) + payload
frame = bytes([SYNC]) + body + struct.pack("<I", zlib.crc32(body))
for attempt in range(1, retries + 1):
port.reset_input_buffer()
port.write(frame)
port.timeout = timeout
reply = port.read(1)
if reply == bytes([ACK]):
return
why = "timeout" if not reply else f"reply 0x{reply[0]:02x}"
print(f"\n frame type {ftype}: {why}, attempt {attempt}/{retries}")
raise RuntimeError(f"device did not accept frame type {ftype}")
def main() -> int:
if len(sys.argv) != 4:
print(__doc__)
return 2
port_name, path, version_text = sys.argv[1:]
major, minor, patch = (int(x) for x in version_text.split("."))
version = (major << 24) | (minor << 16) | patch
with open(path, "rb") as f:
image = f.read()
image += b"\xff" * (-len(image) % 4) # pad to whole words
header = make_header(image, version)
with serial.Serial(port_name, 115200) as port:
print(f"START: {len(image)} bytes, erasing (can take several seconds)")
send_frame(port, T_START, header, timeout=30.0)
for off in range(0, len(image), CHUNK):
chunk = image[off:off + CHUNK]
send_frame(port, T_DATA, struct.pack("<I", off) + chunk, timeout=2.0)
print(f"\r {off + len(chunk)}/{len(image)} bytes", end="", flush=True)
print("\nEND: device checks the CRC and commits the header")
send_frame(port, T_END, b"", timeout=10.0)
print("done, device is rebooting into the new image")
return 0
if __name__ == "__main__":
sys.exit(main())

struct.pack("<7I", ...) produces the first 28 bytes of image_header_t in little-endian order, the same layout the C struct has in memory on the Cortex-M. The header CRC is computed over those 28 bytes and appended. The image is padded with 0xFF (the erased value) to a multiple of four, and the CRC covers the padded image, which is what the bootloader will read back from flash.

send_frame resends a frame up to three times on a timeout or NAK. Thanks to the explicit offset and the “already programmed” rule in the bootloader, a resent DATA frame is idempotent. START is idempotent too (it just erases again). The timeouts reflect what the device is doing: 30 s for an erase, 2 s for a 256-byte program, 10 s for the final CRC over up to 1 MB at 16 MHz (about 4 s in the worst case).

A run looks like this:

$ python3 send_image.py /dev/ttyUSB0 build/app.bin 1.4.2
START: 23816 bytes, erasing (can take several seconds)
23816/23816 bytes
END: device checks the CRC and commits the header
done, device is rebooting into the new image

At 115200 baud a 256-byte chunk takes about 23 ms on the wire plus the turnaround, so this protocol moves roughly 5 to 10 KB/s, depending on how much latency your USB-to-serial adapter adds to each reply. Larger chunks or a faster baud rate help; for megabyte images, USB is the better transport.

  • SysTick left running when jumping. The application takes a tick before its .bss is zeroed and crashes in a handler. Stop SysTick and clear PENDSTSET before the jump.
  • Application overwrites VTOR with 0x08000000. Its first interrupt runs a bootloader handler. Remove the write from SystemInit or set VTOR to the application’s own table.
  • App linked at 0x08000000. Passes the CRC, fails the PC range check (or crashes if you skipped it). Link with app.ld and keep the load_addr check in the header.
  • Vector table misaligned after the header. A 32-byte or 256-byte header breaks the 512-byte alignment rule on the F407. Use a 512-byte header area.
  • A RAM section without NOLOAD. If .handoff or .noinit is a loadable section, objcopy -O binary pads from 0x08008200 up to 0x20000000 and produces a 400 MB .bin. Mark every RAM-only section NOLOAD.
  • Handoff block in .bss. The startup code zeroes it and the request vanishes. Put it in its own NOLOAD section at a fixed address in both programs.
  • CRC flavours that do not match. The STM32 CRC peripheral computes a non-reflected CRC-32 over 32-bit words with no final XOR, which is not zlib.crc32. Pick one definition, document it in the header format, and test both sides with 123456789.
  • Debugging the application with load and wondering why it does not start. The debugger writes the image but not the header, so the bootloader stays in update mode. Flash a headered image, or have a debug build of the bootloader skip validation.
  1. On your host, compile the crc32 function from the bootloader with gcc -std=c11 -Wall -Wextra and print the CRC of "123456789". Then run python3 -c "import zlib; print(hex(zlib.crc32(b'123456789')))". Both must print 0xcbf43926.
  2. Take your blinky application, link it with app.ld, and inspect it with arm-none-eabi-objdump -h app.elf and arm-none-eabi-nm app.elf | grep -i vector. Confirm the vector table section starts at 0x08008200. Then dump the first 8 bytes of app.bin with xxd -l 8 app.bin and check that word 0 is 0x20020000 and word 1 is an odd address above 0x08008200.
  3. Add a button override: if PA0 (the Discovery user button) is held at reset, the bootloader enters update mode even with a valid image. Remember to enable the GPIOA clock before reading IDR, and to leave PA0 in its reset state before jumping. Verify that a normal reset still boots the application.
  4. The END frame is not retry-safe: if its ACK is lost, the device has already reset and the host reports failure even though the update worked. Add a STATUS frame (type 0x04) that replies ACK if the slot holds a valid image whose header matches a 32-byte header sent in the payload, and make the host use it after an END timeout.
  5. Hard: turn this into an A/B bootloader with trial boot. Add a state word to a metadata record in sector 2 (new, trial, confirmed), enable the IWDG in the bootloader when booting a trial image, and have the application confirm after 10 seconds of normal running. Build an application that deliberately hangs in its initialisation and show that the device returns to the previous image after one watchdog reset.

Question 1:After the bootloader jumps, the application runs for a few microseconds and then HardFaults inside what looks like a random handler. The bootloader used SysTick for timeouts. What is the most likely missing step?

Question 2:The F407 vector table has 98 entries. Which image header size keeps the application's vector table legal when the slot starts at 0x08008000?

Question 3:In the single-slot bootloader, power fails after all image data has been programmed but before the header is written. What happens at the next boot?

Question 4:Why must the handoff block live in a NOLOAD section rather than in .bss?

Question 5:A new release hangs during initialisation on some boards. Which mechanism returns those boards to the previous image without human help?

  • The bootloader is the one piece of firmware that must never break: keep it small, put it at the reset address, and avoid updating it, or use an immutable first stage if you must.
  • Jumping to an application means recreating reset: interrupts off, SysTick stopped, NVIC cleared, peripherals and clocks back to reset state, VTOR set with DSB/ISB, MSP loaded, then branch to the reset vector.
  • Validate before you jump: header magic and CRC, size bounds, load address, a sane initial SP and an odd reset vector inside the image, then the image CRC.
  • Write the header (the commit record) last, so every interrupted update leaves an image that reads as invalid rather than one that half works.
  • A CRC proves integrity, not authenticity; signatures come next.
  • Dual-slot designs keep the old image running during the download, and a trial boot plus watchdog plus confirm flag gives you automatic rollback.

Next: Firmware security, where the CRC check grows into a signature check and the bootloader becomes the root of a chain of trust.