A custom bootloader for the STM32F411RE that receives a new application image over UART, verifies it with a CRC-32, and hands control to the application, together with a Python host tool that drives the update.
This project implements a bare-metal bootloader that lives in its own region of flash and can reflash a separate application region over a serial cable, with no debugger required. It demonstrates the core skills a field-update path exercises at once: flash programming on the STM32F411, linker scripts and flash partitioning, Cortex-M vector-table relocation, and the design of a small framed UART protocol with CRC verification. The repository contains three cooperating pieces: the bootloader firmware, a demo application built to run from a separate flash region, and a Python host uploader.
Status: the full update path is implemented in source and the protocol, CRC-32, and flash-bounds logic are unit-tested against the Python host in CI. End-to-end flashing has not yet been validated on physical hardware, so the current release is tagged as a release candidate.
The STM32F411RE has 512 KB of flash in one continuous address space starting at 0x08000000. It is split into two independently linked programs:
| Region | Address range | Size | Sectors | Contents |
|---|---|---|---|---|
| Bootloader | 0x08000000–0x08007FFF |
32 KB | 0–1 | this bootloader; runs first at reset |
| Application | 0x08008000–0x0807FFFF |
480 KB | 2–7 | the user application |
Two linker scripts (bootloader/linker/bootloader.ld and app/linker/app.ld) set each program's flash origin so the bootloader keeps the reset vector and the application is built to run from 0x08008000.
At reset the CPU always starts in the bootloader. On startup it briefly polls the UART. If the host is already sending data, the bootloader enters update mode and serves commands. Otherwise, if a valid application is present, it jumps to it. If neither is true, it stays in the update loop waiting for a host.
The jump reads the application's initial stack pointer from 0x08008000 and its reset handler address from 0x08008004. Before transferring control, the bootloader relocates the vector table by writing the application base to SCB->VTOR (0xE000ED08), so interrupts vector into the application rather than the bootloader. It validates the application first: the stack-pointer word must point into SRAM (0x20000000–0x20020000), which rejects an erased flash region that reads back as 0xFFFFFFFF.
The host and bootloader speak a simple framed request/response protocol over USART2 at 115200 baud, 8N1. The full specification is in docs/protocol.md.
Each frame is:
SOF (0xA5) | CMD (1 byte) | LEN (uint16, little-endian) | PAYLOAD (LEN bytes) | CRC32 (4 bytes)
The CRC-32 covers CMD + LEN + PAYLOAD and is bit-compatible with Python's zlib.crc32, which is how the host and target agree that a frame arrived intact. Commands from host to bootloader:
| CMD | Name | Payload | Meaning |
|---|---|---|---|
0x01 |
HELLO | none | probe; bootloader replies ACK plus one version byte |
0x02 |
ERASE | none | erase the application region (sectors 2–7) |
0x03 |
WRITE | offset:u32 + data |
program data at app_base + offset |
0x04 |
VERIFY | crc32:u32 |
verify the CRC-32 of the whole written image |
0x05 |
JUMP | none | relocate the vector table and boot the application |
The bootloader answers every frame with a single byte, ACK (0x79) or NACK (0x1F), and the host waits for that byte before sending the next frame. WRITE offsets must be word-aligned and are range-checked (overflow-safe) so a write can never land outside the application region, and the bootloader never erases its own sectors 0–1. VERIFY re-reads the highest offset written since the last erase and compares its CRC-32 to the value the host supplies.
- Board: STMicroelectronics NUCLEO-F411RE
- MCU: STM32F411RE (ARM Cortex-M4F, 512 KB flash, 128 KB SRAM)
- Serial link: USART2 on PA2/PA3, which is routed to the on-board ST-LINK USB virtual COM port, so plugging in the USB cable provides the update link without extra wiring
- Demo application blinks the on-board user LED (LD2 on PA5) to prove the handover
The firmware cross-compiles with arm-none-eabi-gcc and CMake. The toolchain file cmake/arm-none-eabi-gcc.cmake targets Cortex-M4 with hardware floating point.
sudo apt-get install -y gcc-arm-none-eabi cmake
# build the bootloader and the demo application (two separate CMake projects)
for t in bootloader app; do
cmake -S $t -B build/$t \
-DCMAKE_TOOLCHAIN_FILE="$PWD/cmake/arm-none-eabi-gcc.cmake" \
-DCMAKE_BUILD_TYPE=Release
cmake --build build/$t
doneThe build produces build/bootloader/bootloader.bin and build/app/app.bin (each project also emits an .elf and a .map).
Flash the bootloader once with an SWD programmer (for example st-flash from the open-source st-link tools):
st-flash write build/bootloader/bootloader.bin 0x08000000After the bootloader is in place, application updates go over UART with no debugger. The host tool is host/uploader.py and depends on pyserial (pip install -r host/requirements.txt):
python3 host/uploader.py --port /dev/ttyACM0 --image build/app/app.binArguments: --port (required, the serial device, for example /dev/ttyACM0), --image (required, the application .bin to flash), and --baud (optional, defaults to 115200). The uploader pads the image to a word boundary, then runs the full sequence: HELLO to read the bootloader version, ERASE, WRITE in 256-byte chunks with a progress indicator, VERIFY against the whole-image CRC-32, and JUMP to start the new application.
tests/host/ contains a host interop test that compiles the exact bl_proto.c and bl_crc32.c sources used in the firmware with the host compiler and checks three things:
- CRC-32 matches the canonical check value
0xCBF43926, confirming compatibility with Python'szlib.crc32 - the flash-region bounds check accepts valid ranges and rejects end-straddling and unsigned-overflow cases
- frames generated by
tests/host/gen_frames.py(byte-identical to the host uploader's framing) parse correctly, and a deliberately corrupted frame is rejected on CRC
Run it locally:
python3 tests/host/gen_frames.py frames.bin
gcc -Ibootloader/src tests/host/test_proto.c \
bootloader/src/bl_proto.c bootloader/src/bl_crc32.c -o test_proto
./test_proto frames.binGitHub Actions (.github/workflows/ci.yml) runs four jobs on every push and pull request:
- firmware build: cross-compiles both images, asserts the bootloader links at
0x08000000and the application at0x08008000by inspecting the.isr_vectorsection, and uploads the built binaries as artifacts - protocol interop: builds and runs the host interop test described above
- host tool: byte-compiles
host/uploader.pyand lints it with flake8 - static analysis: runs cppcheck over the firmware sources (advisory)
bootloader/ bootloader firmware: UART driver, frame parser, CRC-32, flash driver, jump logic
app/ demo application linked at 0x08008000 (LED blink + VTOR relocation)
host/ uploader.py, the Python host tool that sends an image over serial
docs/ the UART protocol specification
tests/ host interop test for the protocol, CRC, and flash-bounds logic
cmake/ arm-none-eabi-gcc cross-compile toolchain file
MIT. See LICENSE.