Skip to main content
emobility.academy
B.Tech / BEEmbedded software architectureAdvanced About 6 weeksKit only

Custom UART Bootloader for STM32

Students write their own bootloader for the STM32F103: it sits at the start of flash, accepts a new application image from a Python tool over UART, erases and programs internal flash, checks a CRC-32 and jumps to the verified application.

Uses: UART

The problem

EV control units are updated in service through a bootloader, not with a debugger. Writing one teaches flash memory layout, vector tables, image verification and recovery from bad updates, the basics behind every production update system.

Objectives

  • Partition internal flash into bootloader and application regions with matching linker scripts.
  • Implement a framed, acknowledged UART protocol for image transfer.
  • Erase and program internal flash with HAL and verify each page.
  • Verify the image with CRC-32 and jump to it safely.
  • Keep the device recoverable if an update is interrupted.

What you need

From the EV Embedded Development Kit

  • STM32F103C8T6 main controller
  • Serial UART interface
  • Reset, boot-mode and programming headers
  • RGB LEDs
  • 4×3 matrix keypad

Software

  • STM32CubeIDE with HAL drivers
  • Embedded C and GNU linker scripts
  • Python 3 with pyserial
  • STM32CubeProgrammer for recovery

Extra parts

None. Everything this project needs is on the kit.

Key concepts

Vector table and VTOR
The table of the initial stack pointer and interrupt handler addresses. An application placed above the bootloader must point SCB->VTOR at its own table.
Flash pages on the STM32F103C8
Internal flash is erased in 1 KB pages and written in 16-bit half-words; writing requires unlocking with HAL_FLASH_Unlock.
Jump to application
The bootloader loads the application's stack pointer from its first word, then calls the address in its second word (the reset handler) after shutting down its own peripherals and interrupts.
Valid-image flag
A marker written only after the image is completely programmed and verified, so the bootloader never jumps into a half-written application.
ROM system bootloader
The STM32's built-in bootloader, entered with the BOOT0 pin, is a recovery route if your own bootloader is damaged.

Step-by-step guide

  1. 1

    Plan the memory map and protocol

    Reserve 8 KB at 0x08000000 for the bootloader and place the application at 0x08002000, leaving the last 1 KB page for a valid-image record. Do not rely on undocumented extra flash in the chip. Sketch the protocol: the host sends HELLO, the bootloader replies with its version and the maximum image size; the host sends ERASE, then DATA frames, then a VERIFY with the CRC, and finally RUN.

    Checkpoint: A memory map with exact addresses and a protocol table with every command, reply and error code.

  2. 2

    Create the bootloader project

    Create a CubeIDE project for the bootloader, enable one USART at 115200 baud, a GPIO for an RGB LED and a keypad key or button to force bootloader mode. Edit the linker script so FLASH has ORIGIN 0x08000000 and LENGTH 8K, and keep HAL usage minimal to fit. Check the build size in the map file. Know how to recover with BOOT0 and STM32CubeProgrammer before you start writing to flash.

    Checkpoint: The bootloader builds under 8 KB and prints its version over UART after reset.

  3. 3

    Create the application project

    Create a separate application project (for example an LED blink and UART heartbeat). Set its linker script FLASH ORIGIN to 0x08002000 with the remaining length, and set the vector table offset to 0x2000 (VECT_TAB_OFFSET in system_stm32f1xx.c, or write SCB->VTOR early in main). Generate a raw .bin with a post-build objcopy step. Keep the application's .bin and .elf together for each version.

    Checkpoint: Flashing the application at 0x08002000 with STM32CubeProgrammer and the bootloader at 0x08000000 gives a running application once the jump is added.

  4. 4

    Write the jump routine

    In the bootloader, read the application's first word and check it is a plausible stack pointer inside RAM (0x20000000 to 0x20005000). Then de-initialise the UART and GPIO, call HAL_RCC_DeInit and HAL_DeInit, disable SysTick, disable interrupts and clear pending ones, set the MSP with __set_MSP and call the reset handler address from the second word. Refuse to jump if the reset handler address is outside the application flash region.

    Checkpoint: The bootloader starts the application and the application's interrupts (a timer or UART) work, proving VTOR is correct.

  5. 5

    Implement the frame layer

    Define frames: start byte, command, 16-bit length, payload up to 256 bytes and a CRC-16. Receive with HAL_UART_Receive and a timeout, or with an interrupt-driven ring buffer. Reply ACK or NAK with an error code. Discard frames with bad CRC or length and let the host retry. Use a receive timeout so a half-received frame is discarded, and include the frame offset in each DATA frame so a retried frame is written to the right place, never appended twice.

    Checkpoint: The Python tool can send 1,000 frames with injected corruption and every bad frame is NAKed and retried successfully.

  6. 6

    Erase and program flash

    On ERASE, clear the valid-image record first, then erase the application pages with HAL_FLASHEx_Erase (TYPEERASE_PAGES, 1 KB pages). On DATA, program half-words with HAL_FLASH_Program (FLASH_TYPEPROGRAM_HALFWORD) at the given offset, refuse any address outside the application region, then read back and compare. Lock flash again after each operation. Erasing a page takes milliseconds, so ACK the ERASE command only when it is finished.

    Checkpoint: Programming a test image and reading it back with STM32CubeProgrammer gives an identical file.

  7. 7

    Verify and mark the image

    On VERIFY, compute CRC-32 over the programmed region with the STM32 CRC unit (HAL_CRC_Calculate, which uses the CRC-32 polynomial on 32-bit words without bit reflection) and compare with the host's value; make the Python tool compute the same variant and check it with a test vector. Only on a match, write the valid-image record (magic, size, CRC). Pad the image to a multiple of four bytes for the CRC.

    Checkpoint: A deliberately altered image fails verification and is never marked valid.

  8. 8

    Define boot decisions

    At reset the bootloader stays in update mode if the force key is held, if no valid-image record exists, or if the application's CRC fails a quick check; otherwise it jumps after a short window (for example 500 ms) in which the host can send HELLO. Show the mode on an RGB LED. Keep the logic in one function and test each branch.

    Checkpoint: Interrupting an update halfway and resetting leaves the board in bootloader mode, ready for a retry.

  9. 9

    Write the Python update tool

    Write a command-line tool that reads the .bin, checks it fits, opens the port, sends HELLO, ERASE, DATA frames with retries and progress display, VERIFY and RUN, and reports timing. Handle timeouts and give clear error messages. Add a verify-only option that reads back the CRC without reprogramming, and a log file for each update with port, image name, size, CRC and duration. Make retry counts and timeouts configurable so the tool works on slower USB-serial adapters.

    Checkpoint: A full update completes from the command line and the new application runs.

  10. 10

    Test failure cases

    Test a wrong-size image, a corrupted file, a disconnected cable mid-transfer, power loss during erase and during programming, and an image built for the wrong address (its stack pointer check or CRC should catch it). Record what happens in each case. Repeat each case several times, and record the LED state and UART messages so the behaviour is visible without a debugger. Fix any case that leaves the board in neither state, then rerun the whole table to make sure the fix did not break another case.

    Checkpoint: A failure-test table where every case ends with either the new valid image running or the bootloader waiting for an update.

  11. 11

    Document the bootloader

    Write up the memory map, linker changes, protocol, flash routines, boot decision logic, test results and how to recover with the ROM bootloader. Measure and report update time for your image size. Add the linker script changes for both projects, the jump sequence with an explanation of each step, the boot decision flowchart and the update time measured for your image size. Describe what a production bootloader would add, such as image signatures, bootloader write protection and a rollback copy.

    Checkpoint: A report with the memory map, protocol specification and failure-test table.

Testing & validation

  • Round-trip compare of programmed flash against the source image.
  • CRC mismatch always prevents the valid-image flag.
  • Interrupted updates always leave the board in a recoverable state.
  • Application interrupts work after the jump.

Expected results

A working custom bootloader and Python update tool that safely update the STM32 application over UART, with verified images and recovery from interrupted updates.

Take it further

  • Move the transfer onto CAN so the update works like an in-vehicle ECU flash.
  • Add a signature check so only approved images are accepted.
  • Add write protection for the bootloader pages through option bytes, carefully and with a recovery plan.

Viva and review questions

Why must the application set VTOR?

Its vector table is at 0x08002000, not 0x08000000, so without VTOR its interrupts would use the bootloader's handlers.

Why clear the valid flag before erasing?

If power fails mid-update, the bootloader must not jump into a partly erased or partly written application.

Why de-initialise peripherals before jumping?

Interrupts and peripherals left running by the bootloader could fire into the application before it is ready.

How would you recover if the bootloader itself is broken?

Set BOOT0 to start the STM32's ROM bootloader, or use the SWD programming header with STM32CubeProgrammer.

Related EV projects

All 100 EV projects