Table of Contents
KEY TAKEAWAYS
- A bootloader is firmware that updates firmware — it receives new application code via UART and writes it to flash memory.
- Memory is split into bootloader region (fixed, never updated) and application region (updated by the bootloader).
- The jump-to-application sequence requires setting the stack pointer and jumping to the app’s reset handler address.
- CRC32 verification before committing the flash write prevents bricking the device with a corrupted firmware image.
- A “golden image” or dual-bank approach provides rollback capability if a firmware update fails mid-write.
What Is a Bootloader?
A bootloader is a small program that lives in the first section of flash memory and has one critical job: it can replace the main application firmware without needing a debug probe. When the device powers up, the bootloader runs first. If it detects a firmware update request (button held, magic UART command, flag in EEPROM), it receives the new firmware via UART, writes it to flash, and reboots into the new code.
Without a bootloader, updating firmware in the field requires physical access to the debug port — impractical for deployed devices. Bootloaders enable over-the-air (OTA) updates, factory programming via serial, and field recovery from bad firmware.
Memory Layout
The bootloader and application firmware must coexist in the microcontroller’s flash memory without interfering with each other. The standard approach is to divide the flash into two regions: the bootloader occupies the first few kilobytes starting at the reset vector address (0x08000000 on STM32), and the application firmware starts at a fixed offset. The bootloader’s vector table lives at the base of flash, so it runs first on every reset. It then decides whether to stay in bootloader mode (to receive a firmware update) or jump to the application. For related background, see Memory Mapping in Embedded Systems.
/* Flash Memory Layout for Bootloader + Application
*
* STM32F103C8T6: 64KB flash (0x08000000 - 0x0800FFFF)
*
* ┌──────────────────────────┐ 0x08010000
* │ │
* │ Application Firmware │ ← Updated by bootloader
* │ (up to 56KB) │
* │ │
* ├──────────────────────────┤ 0x08002000 (app start)
* │ │
* │ Bootloader (8KB) │ ← Never updated, always present
* │ │
* └──────────────────────────┘ 0x08000000 (flash start)
*
* The bootloader occupies the first 8KB (0x08000000 - 0x08001FFF).
* The application starts at 0x08002000.
*
* On reset, the CPU:
* 1. Reads initial stack pointer from 0x08000000
* 2. Reads reset handler address from 0x08000004
* 3. Jumps to the bootloader's reset handler
* 4. Bootloader decides: update firmware or jump to app?
*/
#define BOOTLOADER_START 0x08000000
#define BOOTLOADER_SIZE 0x2000 /* 8KB */
#define APP_START (BOOTLOADER_START + BOOTLOADER_SIZE) /* 0x08002000 */
#define APP_MAX_SIZE (0x10000 - BOOTLOADER_SIZE) /* 56KB */
/* Application vector table structure (first 8 bytes) */
#define APP_STACK_PTR (*(volatile uint32_t *)(APP_START))
#define APP_RESET_HANDLER (*(volatile uint32_t *)(APP_START + 4))Bootloader Entry Decision
When the bootloader starts, it must decide whether to enter firmware update mode or jump directly to the application. This decision can be based on several triggers: a hardware button held during reset, a magic value written to RAM before a software reset, a corrupt or missing application image, or a command received over UART within a timeout window. The most robust designs check multiple conditions, with a timeout fallback to ensure the device always boots even if the update process is interrupted.
/* bootloader.c — Main bootloader logic */
#include
#include
/* Check if we should enter bootloader mode or jump to app */
typedef enum {
BOOT_REASON_NONE, /* No special reason — jump to app */
BOOT_REASON_BUTTON, /* Boot button held during reset */
BOOT_REASON_MAGIC, /* Magic word in backup register */
BOOT_REASON_NO_APP, /* No valid application in flash */
BOOT_REASON_CORRUPT /* Application CRC failed */
} boot_reason_t;
/* Magic value in backup register — set by app to request update */
#define BOOT_MAGIC_REQUEST 0xB00710AD
#define BKP_DR1 (*(volatile uint32_t *)0x40006C04) /* Backup register 1 */
/* Check if button (PA0, active low) is held */
static uint8_t is_boot_button_pressed(void) {
/* Enable GPIOA clock */
*(volatile uint32_t *)0x40021018 |= (1 << 2); /* RCC_APB2ENR IOPAEN */
/* Configure PA0 as input with pull-up */
volatile uint32_t *GPIOA_CRL = (volatile uint32_t *)0x40010800;
*GPIOA_CRL = (*GPIOA_CRL & ~0x0F) | 0x08; /* Input with pull-up/down */
*(volatile uint32_t *)0x4001080C |= 1; /* ODR bit 0 = pull-UP */
/* Small delay for GPIO to settle */
for (volatile int i = 0; i < 10000; i++);
return !(*(volatile uint32_t *)0x40010808 & 1); /* Active low */
}
/* Check if application looks valid */
static uint8_t is_app_valid(void) {
uint32_t app_sp = APP_STACK_PTR;
/* Stack pointer should be in RAM (0x20000000 - 0x20005000 for STM32F103) */
if (app_sp 0x20005000) {
return 0; /* Invalid — no app or corrupted */
}
/* Reset handler should be in flash (app region) */
uint32_t app_reset = APP_RESET_HANDLER;
if (app_reset (APP_START + APP_MAX_SIZE)) {
return 0;
}
return 1;
}
boot_reason_t check_boot_reason(void) {
/* Check magic word first (app requested update) */
if (BKP_DR1 == BOOT_MAGIC_REQUEST) {
BKP_DR1 = 0; /* Clear the request */
return BOOT_REASON_MAGIC;
}
/* Check boot button */
if (is_boot_button_pressed()) {
return BOOT_REASON_BUTTON;
}
/* Check if app is valid */
if (!is_app_valid()) {
return BOOT_REASON_NO_APP;
}
return BOOT_REASON_NONE; /* Normal boot — jump to app */
}Jumping to the Application
Jumping from the bootloader to the application firmware is not a simple function call — it requires reconfiguring the processor state. You must set the main stack pointer (MSP) to the value stored at the beginning of the application’s vector table, then jump to the reset handler address stored in the second word. Before jumping, disable all interrupts and reset any peripherals the bootloader initialized, otherwise the application will inherit unexpected state. On ARM Cortex-M, you also need to relocate the vector table using the VTOR register so that interrupts route to the application’s handlers, not the bootloader’s.
/* Jump from bootloader to application firmware
*
* This is one of the most critical pieces of code in any bootloader.
* Get it wrong and the MCU locks up or behaves unpredictably.
*/
typedef void (*app_entry_t)(void);
void jump_to_application(void) {
/* 1. Check that the application looks valid */
if (!is_app_valid()) {
return; /* Stay in bootloader */
}
/* 2. Disable all interrupts */
__asm volatile ("cpsid i");
/* 3. Disable SysTick (if we used it in bootloader) */
*(volatile uint32_t *)0xE000E010 = 0; /* SysTick CTRL = 0 */
/* 4. Disable all NVIC interrupts and clear pending */
for (int i = 0; i VTOR */
/* 6. Set the main stack pointer to the app's initial SP */
__asm volatile ("MSR MSP, %0" :: "r" (APP_STACK_PTR));
/* 7. Jump to the application's reset handler */
app_entry_t app_entry = (app_entry_t)APP_RESET_HANDLER;
app_entry();
/* Should never reach here */
while (1);
}UART Firmware Reception Protocol
The firmware update protocol defines how the host PC sends the new firmware image to the bootloader over UART. A simple but effective protocol sends the firmware in fixed-size packets, each containing a sequence number, the data payload, and a CRC checksum. The bootloader acknowledges each packet before the host sends the next one, ensuring reliable delivery even over noisy serial connections. For UART driver implementation details, see Writing a UART Driver and UART Protocol Deep Dive.
/* Simple UART firmware update protocol
*
* Protocol:
* 1. Host sends: SYNC byte (0x7F)
* 2. Bootloader responds: ACK (0x06)
* 3. Host sends: total firmware size (4 bytes, little-endian)
* 4. Bootloader responds: ACK
* 5. Host sends firmware in 128-byte chunks:
* [chunk_data (128 bytes)] [CRC8 (1 byte)]
* 6. Bootloader writes chunk to flash, responds: ACK or NACK (0x15)
* 7. After all chunks: host sends DONE command (0x04)
* 8. Bootloader verifies full firmware CRC32, responds: ACK/NACK
* 9. If ACK: reboot into new firmware
*/
#define SYNC_BYTE 0x7F
#define ACK_BYTE 0x06
#define NACK_BYTE 0x15
#define DONE_BYTE 0x04
#define CHUNK_SIZE 128
/* CRC8 for per-chunk integrity */
static uint8_t crc8(const uint8_t *data, uint16_t len) {
uint8_t crc = 0;
while (len--) {
crc ^= *data++;
for (uint8_t i = 0; i < 8; i++) {
if (crc & 0x80)
crc = (crc << 1) ^ 0x07;
else
crc <<= 1;
}
}
return crc;
}
/* CRC32 for full firmware verification */
static uint32_t crc32(const uint8_t *data, uint32_t len) {
uint32_t crc = 0xFFFFFFFF;
while (len--) {
crc ^= *data++;
for (uint8_t i = 0; i > 1) ^ 0xEDB88320;
else
crc >>= 1;
}
}
return ~crc;
}
/* Receive firmware via UART and program flash */
int receive_firmware(void) {
uint8_t byte;
uint32_t fw_size;
uint32_t addr = APP_START;
uint32_t received = 0;
/* Wait for SYNC */
uart_receive(&byte, 1, 5000);
if (byte != SYNC_BYTE) return -1;
uart_send_byte(ACK_BYTE);
/* Receive firmware size */
uart_receive((uint8_t *)&fw_size, 4, 1000);
if (fw_size > APP_MAX_SIZE || fw_size == 0) {
uart_send_byte(NACK_BYTE);
return -2;
}
uart_send_byte(ACK_BYTE);
/* Erase application flash pages */
flash_unlock();
uint32_t pages = (fw_size + 1023) / 1024; /* 1KB pages on STM32F1 */
for (uint32_t p = 0; p < pages; p++) {
flash_erase_page(APP_START + p * 1024);
}
/* Receive and program chunks */
while (received fw_size) {
chunk_len = fw_size - received;
}
/* Receive chunk data + CRC */
if (uart_receive(chunk, chunk_len, 2000) != 0) {
uart_send_byte(NACK_BYTE);
flash_lock();
return -3;
}
uart_receive(&chunk_crc, 1, 1000);
/* Verify chunk CRC */
if (crc8(chunk, chunk_len) != chunk_crc) {
uart_send_byte(NACK_BYTE);
continue; /* Request retransmission */
}
/* Write to flash (halfword at a time on STM32F1) */
for (uint16_t i = 0; i < chunk_len; i += 2) {
uint16_t halfword = chunk[i] | (chunk[i + 1] << 8);
flash_program_halfword(addr + received + i, halfword);
}
received += chunk_len;
uart_send_byte(ACK_BYTE);
}
flash_lock();
/* Wait for DONE command */
uart_receive(&byte, 1, 2000);
if (byte != DONE_BYTE) return -4;
/* Verify full firmware CRC32 */
uint32_t expected_crc;
uart_receive((uint8_t *)&expected_crc, 4, 1000);
uint32_t actual_crc = crc32((uint8_t *)APP_START, fw_size);
if (actual_crc != expected_crc) {
uart_send_byte(NACK_BYTE);
return -5; /* CRC mismatch — firmware corrupted! */
}
uart_send_byte(ACK_BYTE);
return 0; /* Success! */
}Complete Bootloader Main
The main function ties everything together: it initializes the hardware, checks the entry conditions, and either enters firmware update mode or jumps to the application. The bootloader must be small and reliable — it is the one piece of code that cannot be updated remotely (unless you implement a two-stage bootloader), so bugs here can permanently brick the device. Keep the bootloader code minimal and thoroughly tested.
/* Bootloader main function */
int main(void) {
/* Minimal hardware init — clock, UART for communication */
system_clock_init(); /* Configure HSE, PLL */
uart_init(115200);
boot_reason_t reason = check_boot_reason();
if (reason == BOOT_REASON_NONE) {
/* Normal boot — jump to app immediately */
jump_to_application();
/* If we return here, app was invalid */
reason = BOOT_REASON_NO_APP;
}
/* We're in bootloader mode */
uart_send_string("rn=== BOOTLOADER v1.0 ===rn");
switch (reason) {
case BOOT_REASON_BUTTON:
uart_send_string("Mode: Button requestrn");
break;
case BOOT_REASON_MAGIC:
uart_send_string("Mode: App update requestrn");
break;
case BOOT_REASON_NO_APP:
uart_send_string("Mode: No valid applicationrn");
break;
default:
uart_send_string("Mode: Unknownrn");
break;
}
uart_send_string("Waiting for firmware (send SYNC 0x7F)...rn");
/* Wait for firmware update */
while (1) {
int result = receive_firmware();
if (result == 0) {
uart_send_string("Update successful! Rebooting...rn");
/* Small delay for UART to finish transmitting */
for (volatile int i = 0; i APB1ENR |= RCC_APB1ENR_PWREN | RCC_APB1ENR_BKPEN;
* PWR->CR |= PWR_CR_DBP;
*
* // Write magic word to backup register
* BKP->DR1 = 0xB00710AD;
*
* // Reset into bootloader
* NVIC_SystemReset();
* }
*
* ─── Application linker script changes ───
*
* The application must be compiled to start at 0x08002000, not 0x08000000.
* In your linker script (.ld):
*
* MEMORY {
* FLASH (rx) : ORIGIN = 0x08002000, LENGTH = 56K
* RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
* }
*
* And in application startup code, set VTOR:
* SCB->VTOR = 0x08002000;
*/Safety Considerations
A bootloader that can brick your device is worse than no bootloader at all. These safety measures ensure that a failed firmware update leaves the device in a recoverable state, not a paperweight. Every item in this list addresses a real failure mode that has bricked devices in production.
- Never erase the bootloader: The bootloader must be protected from accidental erasure. Use flash write protection on the bootloader pages.
- Verify before committing: Calculate CRC32 of the received firmware before erasing the old one (if you have enough RAM to buffer it).
- Power loss protection: If power is lost during flash write, the firmware is corrupted. Solutions: dual-bank flash (write to bank B while running from bank A), or keep the bootloader always able to recover.
- Timeout: If the bootloader waits forever for UART data, a noisy SYNC detection could lock out the application. Add a timeout (e.g., 30 seconds) to fall back to the app.
- Version tracking: Store firmware version in a known flash location so the bootloader can report it and prevent downgrades.
Related Articles
- UART Protocol Deep Dive
- Writing a UART Driver in Embedded C
- Reset Systems in Microcontrollers
- How to Read a Microcontroller Datasheet
- Bitwise Operations and Bit Fields in C
- Logging and Trace in Embedded C
- Memory Mapping in Embedded Systems
- Registers in Microcontrollers
- Secure Boot and Firmware Updates
- Firmware Architecture Patterns
- Encryption and Authentication for Embedded Systems
📖 Related: Writing an SPI Driver in Embedded C: Complete Implementation • Unions in C: Memory Layout, Use Cases, and Struct Comparison

Vivek Bhageria — Lead Firmware R&D Engineer, 12+ years. Ex-Bosch (automotive powertrain), MusicTribe (real-time audio), medical devices. M.Tech BITS Pilani. I write at NerdyElectronics — practical, register-level embedded systems for engineers who want to understand what’s actually happening under the hood.




