Bootloaders & Firmware Updates¶
Module 2-05 covered ESP32 OTA at the API level — esp_ota_begin/write/end,
handled almost entirely by ESP-IDF. This module builds the mechanism a
bootloader like that relies on from the ground up: how a chip decides which
firmware image to run, how a bootloader safely writes a new image without
bricking the device if power is lost mid-write, and the vector-table
relocation trick that lets application code run somewhere other than
address 0.
The two-stage boot model¶
[reset vector @ 0x08000000] -> Bootloader (small, rarely updated)
|
+-- validates application image
+-- jumps to Application @ 0x08008000
The bootloader occupies the low, fixed flash address the CPU always starts executing from (module 3-01's reset mechanism). It never changes in normal operation — only the application region gets overwritten during an update, which means even a firmware update gone wrong can't corrupt the code responsible for recovering from it, as long as the bootloader itself is correct and small enough to be worth the extra scrutiny of rarely touching it.
Jumping from bootloader to application¶
typedef void (*app_entry_t)(void);
#define APP_START_ADDR 0x08008000UL
void jump_to_application(void) {
uint32_t app_stack = *(volatile uint32_t *)(APP_START_ADDR + 0);
uint32_t app_reset = *(volatile uint32_t *)(APP_START_ADDR + 4);
/* relocate the vector table: from this point on, interrupts/exceptions
must use the APPLICATION's vector table, not the bootloader's */
#define SCB_VTOR (*(volatile uint32_t *)0xE000ED08)
SCB_VTOR = APP_START_ADDR;
__asm volatile ("msr msp, %0" :: "r" (app_stack)); /* set the app's stack pointer */
app_entry_t app_entry = (app_entry_t)app_reset;
app_entry(); /* never returns */
}
This mirrors module 3-01's reset mechanism exactly — the CPU normally reads
SP and PC from address 0x0; a bootloader does the same read manually from
wherever it decided the application lives, then sets VTOR so any
subsequent interrupt looks up its handler in the application's table
instead of the bootloader's leftover one. Forgetting to set VTOR is
one of the most common bootloader bugs: the application appears to boot
(main() runs), then the first interrupt it takes jumps into the
bootloader's vector table instead of its own, executing whatever handler
happened to be at that offset — a crash that looks unrelated to the actual
cause.
Safe firmware update: never erase what you can't yet replace¶
The single rule that keeps a failed update from bricking a device: never erase the region an old, known-good image occupies until the new image is fully written and verified. A naive updater that erases the application region, then starts writing the new image, guarantees a bricked device on any power loss during that write.
typedef struct {
uint32_t magic; /* sentinel: is this slot valid? */
uint32_t size;
uint32_t crc32;
uint32_t version;
} image_header_t;
#define IMAGE_MAGIC 0x46495254u /* "FIRT" */
/* dual-bank scheme: write the new image to the INACTIVE bank while the
active bank keeps running; only flip which bank boots after the new
image is verified in place. */
int apply_update(const uint8_t *new_image, uint32_t size, uint32_t inactive_bank_addr) {
image_header_t hdr;
memcpy(&hdr, new_image, sizeof(hdr));
if (hdr.magic != IMAGE_MAGIC || hdr.size != size) return -1;
flash_erase(inactive_bank_addr, size); /* erasing the INACTIVE bank is safe */
flash_write(inactive_bank_addr, new_image, size);
uint32_t computed_crc = crc32(new_image + sizeof(hdr), size - sizeof(hdr));
if (computed_crc != hdr.crc32) return -2; /* corrupted write — old bank still bootable */
set_active_bank(inactive_bank_addr); /* only now: flip which bank boots */
return 0;
}
A power loss at any point before set_active_bank() leaves the currently
running (old) image completely untouched — the device just reboots into the
firmware it already had. This dual-bank approach costs twice the flash of a
single-image scheme, which is exactly the tradeoff it's making: flash space
for update safety.
Verifying the CRC/header validation logic¶
The validation logic — not real flash erase/write, which needs actual
hardware — is pure computation and was compiled and run with gcc:
#include <stdio.h>
#include <assert.h>
#include <string.h>
#include <stdint.h>
typedef struct { uint32_t magic, size, crc32, version; } image_header_t;
#define IMAGE_MAGIC 0x46495254u
/* simple additive checksum standing in for a real CRC32 for this test */
static uint32_t simple_checksum(const uint8_t *data, uint32_t len) {
uint32_t sum = 0;
for (uint32_t i = 0; i < len; i++) sum = (sum * 31) + data[i];
return sum;
}
int validate_image(const uint8_t *image, uint32_t total_size) {
image_header_t hdr;
memcpy(&hdr, image, sizeof(hdr));
if (hdr.magic != IMAGE_MAGIC) return -1;
if (hdr.size != total_size) return -2;
uint32_t actual = simple_checksum(image + sizeof(hdr), total_size - sizeof(hdr));
if (actual != hdr.crc32) return -3;
return 0;
}
int main(void) {
uint8_t payload[16] = "hello firmware!";
uint8_t image[sizeof(image_header_t) + sizeof(payload)];
image_header_t hdr = { IMAGE_MAGIC, sizeof(image), 0, 1 };
hdr.crc32 = simple_checksum(payload, sizeof(payload));
memcpy(image, &hdr, sizeof(hdr));
memcpy(image + sizeof(hdr), payload, sizeof(payload));
assert(validate_image(image, sizeof(image)) == 0);
image[sizeof(hdr) + 2] ^= 0xFF; /* corrupt one payload byte */
assert(validate_image(image, sizeof(image)) == -3); /* CRC catches it */
printf("image validation model OK\n");
return 0;
}
Traps in bootloader design¶
- Forgetting
VTORrelocation, as above — application boots, then dies on the first interrupt. - Erasing before verifying, as above — the single most bricking-prone mistake in update design.
- No rollback on repeated boot failure: a device that boots the new image, which then crashes before marking itself "known good," and reboots into the same broken image forever needs a boot counter and an automatic fallback to the previous bank after N failed boots — without it, one bad update permanently bricks every device that received it.
- Weak or missing signature verification: CRC catches corruption, not malicious images — module 4-02 covers cryptographic signing for update authenticity, a materially different and stronger guarantee.
How It Actually Works¶
Why VTOR exists as a separate, settable register at all: on the
simplest Cortex-M implementations, the vector table is architecturally fixed
at address 0x0 with no way to relocate it — but the moment you need
position-independent code regions (a bootloader plus a separately-linked
application, exactly this module's scenario), the CPU needs some way to know
where the currently running code's exception handlers live, since
HardFault_Handler, SysTick_Handler, and every peripheral IRQ handler in
the application are linked at different flash addresses than the
bootloader's own copies of those same handler names. SCB_VTOR is a
memory-mapped register the exception-entry hardware itself reads on every
single exception dispatch — not just at startup — to compute "vector table
base + (exception number × 4)" as the address to fetch the handler pointer
from. This is exactly why forgetting to set it is silent until the first
interrupt: main() executing is just straight-line instruction fetch from
wherever PC was set, entirely independent of VTOR — only the moment an
exception actually fires does the hardware consult VTOR, and if it still
holds the bootloader's base address, it fetches a handler pointer from the
bootloader's table, jumping into code that has no idea it's running in the
application's context (wrong stack contents, wrong global variable
addresses relative to what that handler expects).
Why dual-bank writing genuinely can't brick a device, mechanically: the
CPU's boot-time read of SP/PC (module 3-01) happens from one fixed,
unconditional address — for this scheme, that means the bootloader's own
fixed location, which this update logic never touches. flash_erase and
flash_write in apply_update only ever target inactive_bank_addr — a
completely separate range of flash sectors from whatever the CPU would jump
into on the next reset before set_active_bank() runs. Because a flash
sector's contents at one address have no physical relationship to a sector's
contents at a different address (they're independent blocks of floating-gate
cells, module 2-07), corrupting or partially writing the inactive bank
literally cannot alter a single bit of the active bank sitting elsewhere in
the same flash chip — the "safety" here isn't a clever software guarantee,
it's the simple physical fact that erase/program operations are scoped to
the specific sector addresses given to them.
Why CRC catches corruption but not tampering: a CRC (or the simplified multiplicative checksum used in the portable test above) is designed purely to detect random bit errors — the kind introduced by a dropped byte during a UART transfer or a power glitch mid-flash-write — because for any single random bit flip, the probability that the CRC recomputed over the corrupted data happens to still match the stored value is vanishingly small by construction of the polynomial/algorithm. But a CRC has no secret component at all: anyone who can write an arbitrary payload can trivially recompute the matching CRC for their own modified payload and store it alongside — the check would pass, because nothing about a CRC depends on knowledge only the legitimate firmware author has. A cryptographic signature (module 4-02) instead is verified using a public key whose matching private key only the legitimate signer holds, so recomputing a valid signature for tampered data is (assuming sound cryptography) computationally infeasible — a fundamentally different guarantee (authenticity) from a CRC's guarantee (accidental corruption detection), not merely a "stronger CRC."
Cheat sheet¶
| Concept | Detail |
|---|---|
| Two-stage boot | Fixed bootloader at reset address, jumps to application elsewhere in flash |
SCB_VTOR |
Must be set to the application's vector table address after the jump |
| Dual-bank update | Write+verify the new image in the inactive bank; flip active bank only after |
| Never erase-before-verify | The rule that prevents power-loss bricking |
| Boot counter / rollback | Falls back to the previous bank after N failed boots of a new image |
| CRC vs signature | CRC catches corruption; only cryptographic signing catches tampering (module 4-02) |
| Verification here | Header/CRC validation logic compiled/run with gcc; real flash erase/write needs actual hardware |
Exercise¶
Extend validate_image into a small state machine usable by a real
bootloader: BOOT_TRY_NEW, BOOT_CONFIRMED, BOOT_ROLLED_BACK, tracked via
a boot-attempt counter persisted in a header field. Write a portable C
simulation where a "new" image fails to call a mark_boot_successful()
function for 3 simulated boots in a row, and assert that your state machine
then reports BOOT_ROLLED_BACK and would select the previous bank on the
next real boot. Compile and run it with gcc, and in a comment explain
where the boot counter itself must be stored so a hard reset doesn't reset
it back to zero along with everything else.