How to Write a Bootloader for STM32 in C: From Flash Map to Firmware Update
1.
Introduction
A bootloader is a small piece of firmware that runs first on every reset. Its job is to decide which application to run, verify that the application is valid, and transfer execution to it. On STM32 devices, building a custom bootloader means splitting Flash into two regions, one for the bootloader itself and one for the application, and implementing the logic to manage, verify, and jump between them.
In this article we build a complete bootloader in C for STM32 that covers dual bank Flash layout, CRC verification of the application image, a UART-based firmware update mechanism, and a fallback strategy when the application is invalid.
2.
Memory Map Design:
The first step in building a bootloader is deciding how to split Flash between the bootloader and the application. On a typical STM32F4 with 512KB of Flash, a reasonable split looks like this:
Bootloader region: 0x08000000 to 0x0800FFFF (64KB)
Application region: 0x08010000 to 0x0807FFFF (448KB)
The bootloader occupies the first 64KB of Flash starting at 0x08000000. This is where the vector table lives and where the CPU starts executing on every reset. The application is placed starting at 0x08010000 and uses the remaining Flash.
Fig 1. Flash memory map split between bootloader and application regions.
To place the bootloader in its own region, its linker script defines Flash starting at 0x08000000 with a length of 64K:
/* bootloader.ld */ MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K } |
The application uses a separate linker script that starts Flash at 0x08010000:
/* application.ld */ MEMORY { FLASH (rx) : ORIGIN = 0x08010000, LENGTH = 448K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K } |
This separation ensures the bootloader and application never overlap and that each can be compiled and linked independently.
3.
Jumping to the Application
Once the bootloader decides the application is valid, it must transfer execution to it. This is not a simple function call. The bootloader must set the stack pointer to the application's initial SP value, then jump to the application's reset handler.
On Cortex-M, the application's vector table starts at 0x08010000. The first word at that address is the initial SP value and the second word is the reset handler address.
/* application base address in Flash */ #define APP_BASE_ADDRESS 0x08010000UL
void bootloader_jump_to_app(void) { /* read initial SP from the first word of the app vector table */ uint32_t app_sp = *(volatile uint32_t *)(APP_BASE_ADDRESS);
/* read reset handler address from the second word */ uint32_t app_reset = *(volatile uint32_t *)(APP_BASE_ADDRESS + 4U);
app_entry_t app_entry = (app_entry_t)app_reset;
/* disable all interrupts before jumping */ __disable_irq();
/* relocate the vector table to the application region */ SCB->VTOR = APP_BASE_ADDRESS;
/* set the stack pointer to the application's initial SP */ __set_MSP(app_sp);
/* jump to the application reset handler */ app_entry(); } |
Two critical steps are required before the jump. First, the vector table offset register (SCB->VTOR) must be updated to point to the application's vector table so that interrupts and exceptions are routed correctly after the jump. Second, the main stack pointer must be set to the application's initial SP value so the application starts with a clean stack.
Fig 2. Jump to application sequence: SP and VTOR are updated before execution transfers.
4.
CRC Check on the Application Image
Before jumping to the application, the bootloader must verify that the image in Flash is valid and has not been corrupted. The simplest and most common approach on STM32 is a CRC32 check.
The application image is built with a known CRC32 value appended at a fixed location, typically at the very end of the image or in a small header placed at the start of the application Flash region. Here we use a header approach where the first 16 bytes of the application region contain metadata:
typedef struct { uint32_t magic; /* fixed magic number to identify a valid image */ uint32_t size; /* size of the application in bytes */ uint32_t crc32; /* CRC32 of the application code */ uint32_t reserved; } app_header_t; #define APP_HEADER_MAGIC 0xDEADBEEFUL #define APP_HEADER_ADDR 0x08010000UL #define APP_CODE_ADDR 0x08010010UL |
The bootloader reads the header, checks the magic number, then computes the CRC32 over the application code and compares it to the stored value:
uint32_t crc32_compute(const uint8_t *data, uint32_t length) { uint32_t crc = 0xFFFFFFFFUL; for (uint32_t i = 0; i < length; i++) { crc ^= (uint32_t)data[i]; for (uint8_t bit = 0; bit < 8; bit++) { if (crc & 1UL) crc = (crc >> 1) ^ 0xEDB88320UL; else crc >>= 1; } } return crc ^ 0xFFFFFFFFUL; }
bool bootloader_verify_app(void) { const app_header_t *header = (const app_header_t *)APP_HEADER_ADDR;
/* check magic number */ if (header->magic != APP_HEADER_MAGIC) return false;
/* check application size is within bounds */ if (header->size == 0 || header->size > (448U * 1024U)) return false;
/* compute CRC32 over application code */ const uint8_t *code = (const uint8_t *)APP_CODE_ADDR; uint32_t computed = crc32_compute(code, header->size);
return (computed == header->crc32); } |
If bootloader_verify_app() returns false, the bootloader knows the application is invalid and activates the fallback mechanism instead of jumping.
5.
Dual Bank Layout
A dual bank bootloader maintains two application slots in Flash: a primary bank and a backup bank. This allows the device to always have a known-good firmware image available even when a new update fails or is corrupted.
On our STM32F4 with 512KB Flash, a dual bank layout looks like this:
Bootloader: 0x08000000 to 0x0800FFFF (64KB)
Primary application: 0x08010000 to 0x0803FFFF (192KB)
Backup application: 0x08040000 to 0x0806FFFF (192KB)
Metadata / flags: 0x08070000 to 0x0807FFFF (64KB)
Fig 3. Dual bank Flash layout with bootloader, primary, backup, and metadata regions.
A small metadata region stores a boot flag that tells the bootloader which bank to boot from and whether the last update was confirmed:
typedef struct { uint32_t boot_bank; /* 0 = primary, 1 = backup */ uint32_t update_pending; /* 1 = new image received, not yet confirmed */ uint32_t confirmed; /* 1 = application confirmed itself as valid */ } boot_flags_t;
#define METADATA_ADDR 0x08070000UL
const boot_flags_t *boot_flags = (const boot_flags_t *)METADATA_ADDR; |
The bootloader decision logic becomes:
void bootloader_run(void) { if (boot_flags->boot_bank == 0) { if (bootloader_verify_app_at(APP_PRIMARY_ADDR)) bootloader_jump_to(APP_PRIMARY_ADDR); else if (bootloader_verify_app_at(APP_BACKUP_ADDR)) bootloader_jump_to(APP_BACKUP_ADDR); else bootloader_enter_update_mode(); } else { if (bootloader_verify_app_at(APP_BACKUP_ADDR)) bootloader_jump_to(APP_BACKUP_ADDR); else if (bootloader_verify_app_at(APP_PRIMARY_ADDR)) bootloader_jump_to(APP_PRIMARY_ADDR); else bootloader_enter_update_mode(); } } |
6.
UART Firmware Update:
When the bootloader cannot find a valid application or receives an update request, it enters UART update mode. Instead of a simple chunk loop, the update is structured as a state machine with a minimal framed protocol over UART.
Each frame exchanged between the host and the bootloader follows this format
/* ------------------------------------------------------- * Frame format (host -> bootloader): * * +-------+-------+--------+----------+-------+ * | SOF | CMD | LEN | PAYLOAD | CRC | * | 1byte | 1byte | 2bytes | N bytes | 2bytes| * +-------+-------+--------+----------+-------+ * * SOF : 0xA5 start of frame marker * CMD : command (START, DATA, END, ABORT) * LEN : payload length, little-endian uint16 * PAYLOAD : up to 256 bytes * CRC : CRC-16/CCITT over CMD + LEN + PAYLOAD * * Responses (bootloader -> host): * ACK 0x06 accepted * NAK 0x15 bad frame or invalid state * ERR 0xFF flash operation failed * ------------------------------------------------------- */ |
The bootloader state machine and the four commands are defined as:
typedef enum { CMD_START = 0x01, /* begin session, payload = app size (4 bytes) */ CMD_DATA = 0x02, /* firmware chunk, payload = up to 256 bytes */ CMD_END = 0x03, /* end transfer, payload = expected CRC32 */ CMD_ABORT = 0x04, /* cancel update, payload = empty */ } cmd_t;
typedef enum { STATE_IDLE, /* waiting for CMD_START */ STATE_RECEIVING, /* receiving CMD_DATA chunks */ STATE_DONE, /* image verified, jump */ STATE_ERROR, /* abort, fallback */ } updater_state_t; |
The main loop receives one frame at a time and dispatches it to the correct handler based on the current state:
void bootloader_enter_update_mode(void) { updater_ctx_t ctx = {0}; ctx.state = STATE_IDLE; frame_t f = {0};
while (ctx.state != STATE_DONE && ctx.state != STATE_ERROR) { if (!receive_frame(&f)) { send_response(NAK_BYTE); continue; }
switch (ctx.state) { case STATE_IDLE: ctx.state = handle_idle(&ctx, &f); break;
case STATE_RECEIVING: if (f.cmd == CMD_END) ctx.state = handle_end(&ctx, &f); else ctx.state = handle_receiving(&ctx, &f); break;
default: ctx.state = STATE_ERROR; break; } }
if (ctx.state == STATE_DONE) bootloader_jump_to(APP_PRIMARY_ADDR); else if (bootloader_verify_app_at(APP_BACKUP_ADDR)) bootloader_jump_to(APP_BACKUP_ADDR); else while (1); /* Notify error , hang, wait for watchdog reset */ } |
Fig 4. UART firmware update state machine flow.
7.
Fallback Mechanism
The fallback mechanism ensures the device never gets permanently bricked by a bad update. The logic is straightforward: if the primary bank fails CRC verification, the bootloader attempts to boot from the backup bank. If both banks fail, the bootloader enters UART update mode and waits indefinitely for a valid image from the host.
flash_copy() copies the contents of one Flash bank to another word by word, allowing the bootloader to restore the backup image into the primary slot before jumping
void bootloader_run(void) { /* try primary bank first */ if (bootloader_verify_app_at(APP_PRIMARY_ADDR)) { bootloader_jump_to(APP_PRIMARY_ADDR); }
/* primary failed, try backup bank */ if (bootloader_verify_app_at(APP_BACKUP_ADDR)) { /* restore backup to primary for next boot */ flash_copy(APP_BACKUP_ADDR, APP_PRIMARY_ADDR, 192U * 1024U); bootloader_jump_to(APP_PRIMARY_ADDR); }
/* both banks invalid, enter recovery mode */ bootloader_enter_update_mode(); } |
The backup bank is only overwritten when a new update is received and confirmed. This means even after multiple failed update attempts, the device can always recover by falling back to the last known-good image.
8.
Conclusion
A production-grade STM32 bootloader is built from four key components working together: a carefully designed Flash memory map that separates the bootloader from the application, a correct jump sequence that sets the stack pointer and relocates the vector table, a CRC32 verification step that catches corrupted images before they run, and a dual bank layout with a fallback mechanism that ensures the device always has a valid firmware to boot.
The UART update mechanism ties everything together by providing a reliable path to deliver new firmware to the device in the field without external programmers or debug adapters.
