// resources for this phase
STM32 Reference Manual — your target MCU (free, st.com)Flash programming chapter is the primary source for everything in Weeks 1–3. Keep it open at all times.
AN2557 — STM32 in-application programming (IAP) using UART (free, st.com)The canonical ST reference for bootloader design. Read before Week 4.
AN3965 — STM32 bootloader customisation (free, st.com)Covers memory layout decisions, shared RAM, and jumping to application.
AN4767 — STM32 internal flash programming (free, st.com)ECC, dual-bank, read-while-write. Essential for STM32H7 and G4.
"Zero to main()" — François Baldassari (free, interrupt.memfault.com)The best free bootloader deep-dive available. Read the full series during Weeks 3–4.
Making Embedded Systems — Elicia White — bootloader chapterArchitecture decisions and trade-offs. Complements the ST app notes.
The Definitive Guide to ARM Cortex-M — Yiu — memory model and startup chaptersVector table, VTOR, startup sequence, fault handling during update.
// hardware for this phase
STM32G431RB or STM32G474RE Nucleo (recommended upgrade from Phase 1–2)Dual-bank flash, FDCAN peripheral (used in Phase 4), hardware RNG. ~€15–18.
USB-UART adapter (CP2102 or FTDI FT232RL) — already from Phase 1Used for UART-based firmware update in Weeks 7–8.
Oscilloscope — already from Phase 1Used to verify jump timing and fault behaviour during update.
// daily schedule template (mon–fri)
08:00 – 10:00  Reading — reference manual, app notes, or memfault seriesAlways read with the reference manual open alongside the app note.
10:00 – 13:00  Implementation — one concrete deliverable per morning sessionType every line by hand. Reading about flash programming is not the same as doing it.
14:00 – 16:30  Project integration — add the morning's work to the growing bootloaderEvery week adds one subsystem to the same codebase. Nothing is throwaway.
16:30 – 17:30  Debug + document — fix what broke, write up what you learnedFlash corruption and bad jumps happen constantly in Week 4–5. Budget time to debug them.
Saturday        Full integration test: power cycle, verify update sequence end-to-end, push to GitHub
// week-by-week

Week 1 — STM32 flash memory internals

Foundation
flash sectorserase / program cycles memory mapECC dual-bankread-while-write wear levelling
Do not skip this week
Every bootloader bug that is hard to debug traces back to a misunderstanding of how flash memory actually works. ECC double-fault from writing twice without erase, sector boundary confusion in the jump address, PCROP locking you out of your own flash — all of these happen if you don't understand the hardware first.
Reading
STM32 RM — Flash programming chapter: sector structure, latency, wait statesFind your MCU's flash organisation table. Draw it on paper. Know every sector address.
AN4767: internal flash programming — ECC, dual-bank, RWWFocus on the ECC section. It will surprise you.
Yiu Cortex-M guide — memory model chapterUnderstand the Cortex-M memory map before touching the linker script
Key concepts to understand deeply
Flash erase is by sector — you cannot erase individual bytesMinimum erase unit varies: 2kB on STM32G4, 128kB on STM32F4. Know yours.
ECC (Error Correction Code): STM32H7/G4 compute ECC on write. Writing to an already-programmed address without erase causes an ECC double-fault → HardFault.This is the most common beginner bootloader bug. Burn it into memory.
Dual-bank: two independent flash banks allow reads from Bank A while erasing/programming Bank B — read-while-write (RWW).Essential for a bootloader that doesn't stall the application during update.
Daily exercises
MonDraw your STM32's full memory map on paper: flash banks, sectors, addresses, sizes. Include SRAM, CCM, backup SRAM, peripheral base addresses.You will refer to this drawing constantly for the rest of the phase.
TueWrite a flash driver from scratch: Flash_Unlock(), Flash_LockSector(), Flash_EraseSector(n), Flash_Write(addr, buf, len), Flash_Verify(addr, buf, len). No HAL.Use only bare register access. Compare your result against HAL_FLASH functions — they should be functionally identical.
WedDeliberately trigger an ECC double-fault: write to a flash address, then write again without erasing. Observe the HardFault. Decode CFSR — identify PRECISERR and BFAR. Document.Now you will never forget why erase-before-write is mandatory.
ThuMeasure flash erase time with DWT_CYCCNT: how long does a sector erase take? How long does writing 1kB take? These numbers matter for UART timeout configuration later.Typical STM32G4: sector erase ~20ms, write 1kB ~5ms. Yours may differ.
FriConfigure dual-bank mode on your MCU (if supported). Verify RWW: run a loop reading from Bank A while Bank B is being erased. Confirm no stall in the read loop.On STM32G4: set DBANK bit in FLASH_OPTR option bytes. Requires option byte programming — be careful.
SatWrite a test harness: fill a sector with a known pattern, erase, verify all 0xFF, write new pattern, verify. Automate with a Python script over UART that reads back and confirms. Push to GitHub.

Week 2 — Linker scripts and startup code

Toolchain
linker scriptmemory regions output sectionsstartup assembly .data copy.bss zero-fill custom sectionsKEEP
Reading
Beningo Ch. 10–12: startup code, linker scripts, memory sectionsRead alongside the GNU LD manual section on MEMORY and SECTIONS
memfault "Zero to main()" Part 1: from reset to main()interrupt.memfault.com — free, the clearest explanation of startup sequence available
GNU LD manual — MEMORY command, SECTIONS command, AT keyword (LMA vs VMA)Free at sourceware.org/binutils/docs/ld/
The LMA vs VMA distinction
VMA (Virtual Memory Address): where the CPU accesses the data at runtime.data VMA = 0x20000000 (SRAM) — this is where your code reads/writes it
LMA (Load Memory Address): where the data is stored in flash.data LMA = after .text in flash — startup code copies from here to SRAM
The startup assembly does: copy [__data_start_lma .. __data_end_lma] → [__data_start_vma .. __data_end_vma], then zero-fill [__bss_start .. __bss_end]Read your startup_stm32xxx.s and find exactly these loops
Bootloader linker script skeleton
/* Bootloader linker script — STM32G431RB example
   Bootloader: 0x08000000 – 0x08007FFF (32 kB, sectors 0–15)
   Shared config region: 0x08008000 – 0x08009FFF (8 kB, sector 16)
   App Bank A: 0x0800A000 – 0x0803FFFF (216 kB)
   App Bank B: 0x08040000 – 0x08077FFF (224 kB)           */

MEMORY {
    FLASH_BL  (rx)  : ORIGIN = 0x08000000, LENGTH = 32K
    FLASH_CFG (r)   : ORIGIN = 0x08008000, LENGTH = 8K
    FLASH_APP (rx)  : ORIGIN = 0x0800A000, LENGTH = 216K
    RAM       (rwx) : ORIGIN = 0x20000000, LENGTH = 32K
    RAM_BKP   (rw)  : ORIGIN = 0x40024000, LENGTH = 4K   /* backup SRAM */
}

SECTIONS {
    .text : {
        KEEP(*(.isr_vector))      /* vector table must be first */
        *(.text*)
        *(.rodata*)
    } > FLASH_BL

    /* Bootloader metadata in a fixed location — app can read this */
    .bl_version : {
        KEEP(*(.bl_version))
    } > FLASH_BL

    .data : {
        __data_start_vma = .;
        *(.data*)
        __data_end_vma = .;
    } > RAM AT> FLASH_BL   /* VMA in RAM, LMA in flash */

    .bss : {
        __bss_start = .;
        *(.bss*)
        *(COMMON)
        __bss_end = .;
    } > RAM

    /* Boot flags in backup SRAM — survives warm reset */
    .boot_flags (NOLOAD) : {
        KEEP(*(.boot_flags))
    } > RAM_BKP
}
Daily exercises
Mon–TueWrite a linker script from scratch that places bootloader at 0x08000000 and reserves a dedicated region for application firmware starting at 0x0800A000. Verify section placement with arm-none-eabi-objdump -h.Do not use CubeMX-generated linker scripts. Write it yourself so you understand every directive.
WedAdd a custom section .bl_version placed at a fixed address. Store bootloader version, build date, and a magic word. Verify in GDB that the address matches the linker script.The application will read this at boot to know which bootloader version it's running on.
ThuAdd a .boot_flags section in backup SRAM. Define a struct with: update_requested flag, boot_count, last_fault_address. Verify it survives a warm reset (NVIC_SystemReset) but not a power cycle.Backup SRAM requires enabling the backup domain clock: RCC->APB1ENR1 |= RCC_APB1ENR1_PWREN; then PWR->CR1 |= PWR_CR1_DBP;
FriRead your startup assembly file in full. Add a comment above every instruction explaining what it does. Find the .data copy loop, the .bss zero-fill loop, and the call to SystemInit then main.
SatUse arm-none-eabi-nm --size-sort and arm-none-eabi-size to analyse your bootloader binary. Document: total flash usage, total RAM usage, biggest single symbol. Target: bootloader under 24 kB flash.

Week 3 — Jump mechanism and vector table relocation

Core mechanism
jump to applicationvector table SCB->VTORstack pointer peripheral de-initclock reset application header
Reading
memfault "Zero to main()" Part 2: vector tables and VTORinterrupt.memfault.com — essential reading before writing a single line of jump code
AN3965: bootloader customisation — jump to application sectionST's reference implementation. Compare your approach against it.
ARMv7-M Architecture Reference Manual — VTOR register descriptionFree from ARM. Understand the alignment requirement: VTOR must be 512-byte aligned.
What the jump actually does
Step 1: Read the application's initial stack pointer from APP_START + 0x00First word of the vector table is the initial MSP value
Step 2: Read the application's reset handler address from APP_START + 0x04Second word — the address to jump to. Bit 0 must be 1 (Thumb mode).
Step 3: Set SCB->VTOR = APP_START to relocate the vector tableWithout this, every interrupt the application takes will vector to the bootloader's handlers
Step 4: Set MSP to the application's initial SP, then branch to reset handlerMust be done in assembly or with very careful C — compiler must not use the stack between setting MSP and jumping
Jump implementation — the correct pattern
/* Application firmware header — at the very start of app flash region */
typedef struct {
    uint32_t stack_pointer;     /* initial MSP — loaded from APP_START+0 */
    uint32_t reset_handler;     /* reset vector  — loaded from APP_START+4 */
    uint32_t magic;             /* 0xDEADC0DE — validity marker            */
    uint32_t version;           /* firmware version: major<<24|minor<<16|patch */
    uint32_t length;            /* firmware length in bytes                */
    uint32_t crc32;             /* CRC32 over [APP_START+32 .. APP_START+length] */
    uint32_t reserved[2];       /* padding to 32 bytes                     */
} FirmwareHeader_t;

#define APP_START   0x0800A000UL
#define HDR_MAGIC   0xDEADC0DEUL

bool Bootloader_IsAppValid(void) {
    const FirmwareHeader_t *hdr = (const FirmwareHeader_t *)APP_START;
    if (hdr->magic != HDR_MAGIC)       return false;
    if (hdr->stack_pointer < 0x20000000UL) return false;  /* must point to RAM */
    if (hdr->reset_handler < APP_START)    return false;  /* must be in app flash */
    uint32_t crc = CRC32_Compute((uint8_t *)APP_START + 32, hdr->length - 32);
    return (crc == hdr->crc32);
}

__attribute__((noreturn)) void Bootloader_JumpToApp(void) {
    const FirmwareHeader_t *hdr = (const FirmwareHeader_t *)APP_START;

    /* 1. Disable all interrupts and peripheral clocks before jumping */
    __disable_irq();
    SysTick->CTRL = 0;         /* disable SysTick */
    /* De-init any peripherals your bootloader enabled */
    HAL_RCC_DeInit();
    HAL_DeInit();

    /* 2. Relocate vector table to application */
    SCB->VTOR = APP_START;
    __DSB();
    __ISB();

    /* 3. Set stack pointer and jump — must be in assembly to be safe */
    __asm volatile (
        "msr msp, %0       \n"  /* set main stack pointer */
        "bx  %1            \n"  /* branch to reset handler */
        :
        : "r" (hdr->stack_pointer),
          "r" (hdr->reset_handler)
        : "memory"
    );
    __builtin_unreachable();
}
Daily exercises
MonWrite a minimal application (blinks LED at 2Hz) at APP_START. Write the jump code above in the bootloader. Verify: bootloader starts, jumps to app, app blinks. Use GDB to observe VTOR change.If the app crashes immediately after jump: vector table not relocated, or stack pointer not set correctly. Debug with GDB — check PC and SP after jump.
TueDefine the FirmwareHeader_t struct in the application's linker script at APP_START. The application fills in all fields except CRC (that's the bootloader's job). Verify bootloader reads the header correctly.In the app's startup: __attribute__((section(".fw_header"))) const FirmwareHeader_t fw_header = {.magic = 0xDEADC0DE, ...};
WedImplement CRC32 verification: bootloader computes CRC32 over the application image, compares against hdr->crc32. If mismatch: log error, do not jump. Test by deliberately corrupting one byte of the app binary and verifying rejection.Use STM32 hardware CRC peripheral (HWCRC) for speed — computing in software over 200kB takes seconds.
ThuHandle all invalid-app cases: no magic word, invalid stack pointer (not in SRAM range), invalid reset handler (not in APP flash range), CRC mismatch. In each case: log the specific failure reason and enter update mode.Log over UART with the specific fault: "App CRC mismatch: expected 0x12345678 got 0xABCDEF01"
FriInterrupt safety: verify that after jump, an interrupt in the application correctly vectors to the app's handler, not the bootloader's. Configure SysTick in the app, verify tick fires correctly after jump.
SatPower-cycle test: power off, power on, verify app boots correctly every time. Measure time from reset to app running. Target: under 50ms from reset to app's first instruction.

Week 4 — Dual-bank A/B update scheme

Update logic
bank A / bank Bactive bank inactive bank writecommit atomic swapupdate state machine backup SRAM flags
The most important design decision in this phase
Always write to the inactive bank. Never overwrite the running application. If power is lost mid-update, the active bank is untouched and the system still boots. Commit only after the new image is fully written and CRC-verified. This is what separates a production bootloader from a toy one.
Bank selection logic
Active bank: the bank currently running. Stored in backup SRAM flag. Defaults to Bank A on first boot.Read at every boot. If flag is corrupt: default to Bank A and log a warning.
Inactive bank: erase this, write new firmware here, verify CRC.Only after successful verify: flip the active_bank flag in backup SRAM and reset.
Commit: a single atomic write to backup SRAM that changes which bank is considered active.If power is lost before commit: system boots the old bank. No brick possible.
Update state machine
IDLE → UPDATE_REQUESTED (app sets flag in backup SRAM, triggers reset)
UPDATE_REQUESTED → ERASING (bootloader erases inactive bank)
ERASING → WRITING (receive and write firmware chunks)
WRITING → VERIFYING (compute CRC over written image)
VERIFYING → COMMITTED (flip active bank flag) → REBOOTING
Any state → ABORTED (CRC fail, timeout, invalid data → stay on current bank)
Boot flags in backup SRAM
/* Placed in backup SRAM — survives warm reset, lost on power cycle  */
typedef struct __attribute__((packed)) {
    uint32_t magic;           /* 0xB007F1A6 — valid if this matches */
    uint8_t  active_bank;     /* 0 = Bank A, 1 = Bank B             */
    uint8_t  boot_count;      /* incremented each boot, reset on success */
    uint8_t  update_pending;  /* 1 = bootloader should enter update mode */
    uint8_t  reserved;
    uint32_t last_fault_pc;   /* PC at last HardFault, 0 if none    */
    uint32_t last_fault_lr;   /* LR at last HardFault                */
    uint32_t crc;             /* CRC32 of this struct (all above fields) */
} BootFlags_t;

#define BOOT_FLAGS_MAGIC  0xB007F1A6UL

__attribute__((section(".boot_flags")))
volatile BootFlags_t g_boot_flags;

void BootFlags_Init(void) {
    uint32_t crc = CRC32_Compute((uint8_t *)&g_boot_flags,
                                  sizeof(BootFlags_t) - 4);
    if (g_boot_flags.magic != BOOT_FLAGS_MAGIC || crc != g_boot_flags.crc) {
        /* Corrupt or first boot — initialise to defaults */
        g_boot_flags.magic         = BOOT_FLAGS_MAGIC;
        g_boot_flags.active_bank   = 0;   /* Bank A */
        g_boot_flags.boot_count    = 0;
        g_boot_flags.update_pending= 0;
        g_boot_flags.last_fault_pc = 0;
        g_boot_flags.last_fault_lr = 0;
        BootFlags_UpdateCRC();
    }
}

void BootFlags_CommitBank(uint8_t bank) {
    g_boot_flags.active_bank = bank;
    g_boot_flags.boot_count  = 0;    /* reset on successful update */
    BootFlags_UpdateCRC();
    __DSB();   /* ensure write completes before reset */
}
Daily exercises
MonImplement BootFlags: init, read, write, CRC update. Test: write flags, trigger warm reset (NVIC_SystemReset), verify flags survive. Trigger power cycle, verify flags reset to defaults.
TueImplement bank selection: at boot, read active_bank from BootFlags, compute APP_START for the active bank, validate header, jump. If invalid: try the other bank before giving up.
WedImplement the update state machine as a table-driven FSM (reuse your Phase 1 FSM framework). Each state has an entry function and a tick function. States persist across individual update packets.
ThuImplement the erase step: erase all sectors of the inactive bank. Log progress over UART (sector number, percentage). Verify after erase: all bytes are 0xFF.
FriImplement the write step: accept 256-byte chunks, write to inactive bank in sequence. After each chunk: verify by reading back and comparing. If any mismatch: abort and log the address.
SatFull test: manually trigger update_pending in GDB, reset, observe bootloader enter update mode, erase, and wait for data. Verify it waits indefinitely without corrupting the active bank.

Week 5 — Rollback and golden image

Safety
boot counterautomatic rollback golden imagewrite protection PCROPoption bytes watchdog integration
Rollback mechanism
Boot counter: incremented in BootFlags at every boot attempt. Reset to 0 when the app confirms it started successfully (sets a flag in backup SRAM).If counter reaches 3 without reset: new firmware is broken. Rollback.
Rollback: flip active_bank to the previous bank, reset boot_count, reboot. The old firmware runs again.Log: "Boot count exceeded 3. Rolling back to Bank A."
App confirmation: application calls BootFlags_ConfirmBoot() within 5 seconds of starting. If it doesn't call it before the watchdog fires, the bootloader handles the reset and increments the counter.
Golden image
What it is: a minimal, known-good firmware permanently stored in a write-protected flash region. Cannot be overwritten via the UART update interface.Not the same as Bank A. A dedicated small region that is always untouched.
When used: if both Bank A and Bank B are invalid (CRC fail) or if boot_count exceeds maximum for both banks.The golden image is the last resort that prevents a fully bricked device.
Write protection: PCROP (Proprietary Code Readout Protection) or WRP (Write Protection) on the golden image sector. Set via option bytes — cannot be cleared without a full chip erase.
Rollback logic in the bootloader main
void Bootloader_Main(void) {
    BootFlags_Init();

    /* Check if update was requested by application */
    if (g_boot_flags.update_pending) {
        g_boot_flags.update_pending = 0;
        BootFlags_UpdateCRC();
        Update_Run();   /* blocking — returns only on abort or completion */
    }

    /* Increment boot counter — application must confirm boot to reset it */
    g_boot_flags.boot_count++;
    BootFlags_UpdateCRC();

    uint8_t bank = g_boot_flags.active_bank;

    /* Try active bank */
    if (Bootloader_IsAppValid(bank)) {
        Bootloader_JumpToApp(bank);
        /* never returns */
    }

    LOG_WARN("Active bank %d invalid. Trying other bank.", bank);

    /* Try other bank */
    uint8_t other = bank ^ 1u;
    if (Bootloader_IsAppValid(other)) {
        BootFlags_CommitBank(other);
        NVIC_SystemReset();
        /* never returns */
    }

    LOG_ERROR("Both banks invalid. Booting golden image.");

    /* Last resort: jump to golden image (write-protected, always valid) */
    Bootloader_JumpToGolden();
    /* never returns */
}
Daily exercises
MonImplement boot counter: increment on each boot, check against MAX_BOOT_COUNT (3). If exceeded: rollback to other bank. Test: flash a valid app to Bank A, set active_bank to Bank B with an invalid image. Verify rollback to A after 3 resets.
TueImplement app confirmation: application calls BootFlags_ConfirmBoot() in main() after init. Bootloader checks this on next boot — if called: reset boot_count. If not called (app crashed before main): count still incremented.
WedProgram the golden image: a 4kB minimal app that blinks an LED at 0.5Hz and outputs a UART message "GOLDEN IMAGE RUNNING". Write-protect its sector using WRP option bytes.Verify: attempt to erase the golden sector from the bootloader — should fail and return HAL_ERROR.
ThuFull failure scenario test: corrupt both Bank A and Bank B images (overwrite magic word). Reset. Verify bootloader tries A, tries B, both fail, boots golden. Log should show each step.
FriIntegrate IWDG: bootloader kicks it at every state transition. If update stalls (UART cable disconnected mid-transfer), watchdog resets the system, bootloader re-runs, tries active bank, jumps to app. No brick.
SatSoak test: 50 power cycles in succession. Verify the correct bank boots every time. Verify boot counter resets correctly when app calls ConfirmBoot. Document results in GitHub.

Week 6 — CRC32 and firmware image tools

Tooling
CRC32STM32 CRC peripheral firmware headerimage signing Python image toolbinary patching version management
Reading
STM32 RM — CRC peripheral chapterHardware CRC32 with polynomial 0x04C11DB7. Much faster than software over large images.
AN4187 — using CRC peripheral in STM32 (free, st.com)Covers polynomial configuration, initial value, reverse input/output bits.
Why CRC is not enough for production
CRC detects accidental corruption but not malicious modificationAn attacker can modify the firmware and update the CRC to match
Production uses HMAC-SHA256 or RSA signature over the imageWe implement HMAC in Week 8. Phase 4 (UDS) adds full security access.
For this phase: CRC is sufficient. Understand the limitation and document it.Always note what your current implementation does NOT protect against.
Python image builder tool
#!/usr/bin/env python3
"""
build_image.py — append firmware header to a .bin file for bootloader consumption

Usage: python build_image.py input.bin output_with_header.bin --version 1.2.3
"""
import struct, zlib, argparse, pathlib, datetime

MAGIC       = 0xDEADC0DE
HEADER_SIZE = 32   # bytes (must match FirmwareHeader_t in C)

def crc32(data: bytes) -> int:
    return zlib.crc32(data) & 0xFFFFFFFF

def build_image(input_path: str, output_path: str, version: str) -> None:
    fw_bytes = pathlib.Path(input_path).read_bytes()

    major, minor, patch = (int(x) for x in version.split('.'))
    ver_word = (major << 24) | (minor << 16) | patch

    # CRC covers everything AFTER the header
    fw_crc = crc32(fw_bytes)
    total_len = HEADER_SIZE + len(fw_bytes)

    # Pack the header (matches FirmwareHeader_t, first two fields from linker)
    # stack_pointer and reset_handler come from the binary's first 8 bytes
    sp     = struct.unpack_from('<I', fw_bytes, 0)[0]
    reset  = struct.unpack_from('<I', fw_bytes, 4)[0]

    header = struct.pack('<IIIIIII2I',
        sp,          # stack_pointer
        reset,       # reset_handler
        MAGIC,       # magic
        ver_word,    # version
        total_len,   # length
        fw_crc,      # crc32
        0, 0         # reserved
    )

    output = header + fw_bytes
    pathlib.Path(output_path).write_bytes(output)
    print(f"Image built: {output_path}")
    print(f"  Version : {version}")
    print(f"  Size    : {total_len} bytes ({total_len/1024:.1f} kB)")
    print(f"  CRC32   : 0x{fw_crc:08X}")
    print(f"  SP      : 0x{sp:08X}")
    print(f"  Reset   : 0x{reset:08X}")

if __name__ == '__main__':
    p = argparse.ArgumentParser()
    p.add_argument('input')
    p.add_argument('output')
    p.add_argument('--version', default='0.1.0')
    args = p.parse_args()
    build_image(args.input, args.output, args.version)
Daily exercises
MonConfigure STM32 hardware CRC peripheral: polynomial 0x04C11DB7, initial value 0xFFFFFFFF. Compute CRC32 over a 64kB region. Compare timing against software CRC32. Document the speedup.Hardware CRC on STM32G4: ~0.5ms for 64kB. Software: ~20ms. Matters for boot time.
Tue–WedBuild and integrate the Python image builder. Run it on your application binary. Verify the bootloader reads the header correctly and CRC matches what the Python tool computed.
ThuVersion management: bootloader reads app version from header, logs it on boot: "App v1.2.3 verified. Jumping." Add a UART command version that prints both bootloader and application versions.
FriAdd the image builder to your CMakeLists.txt as a post-build step: after linking the application, automatically call build_image.py to produce a flashable .fwu file. The development workflow should be: make → produces app.fwu ready to flash.
SatIntentional corruption test: flip a byte in the .fwu file using a hex editor. Attempt to flash. Bootloader must reject it with a specific CRC error message. Verify the active bank is untouched after rejection.

Week 7 — UART firmware update protocol

Transport
custom framing protocolchunk transfer ACK / NAKtimeout handling flow controlprogress reporting error recovery
Protocol design
Frame structure: [SOH 0x01][CMD 1B][LENGTH 2B][PAYLOAD nB][CRC16 2B][EOT 0x04]SOH/EOT delimiters allow resync after noise. CRC16 detects corruption per frame.
Commands: CMD_START (total image size + version), CMD_DATA (chunk offset + payload), CMD_END (final CRC32), CMD_ABORTKeep it minimal. Complexity in the protocol = more failure modes.
Responses: ACK (0x06), NAK (0x15, with 1-byte error code)NAK error codes: ERR_CRC, ERR_ADDR, ERR_FLASH, ERR_TIMEOUT, ERR_INVALID
Timeout handling
Inter-frame timeout: 2 seconds. If no frame received: send NAK ERR_TIMEOUT, stay in current state.Do NOT reset on timeout — the host may have paused. Only abort after 3 consecutive timeouts.
CMD_START timeout: 30 seconds from entering update mode. If no start command: boot active bank.
Flash write timeout: CMD_DATA must arrive within 500ms of ACK being sent. Erase timeout is excluded — erase can take 20ms+.Use a hardware timer for timeouts, not a busy-wait counter.
Daily exercises
MonImplement the frame parser as an FSM: WAIT_SOH → RECV_CMD → RECV_LEN → RECV_PAYLOAD → RECV_CRC → COMPLETE. Each byte received advances the state. CRC mismatch → NAK + reset to WAIT_SOH.
TueImplement CMD_START handler: validate total length (must fit in inactive bank), validate version (reject downgrades if configured), erase inactive bank, send ACK.
WedImplement CMD_DATA handler: validate chunk offset (must be sequential), write to flash, verify readback, send ACK. Track bytes written and report progress percentage in the ACK payload.
ThuImplement CMD_END handler: compute CRC32 over the full written image, compare against CMD_END payload, commit bank if match, send ACK + reset. If CRC fails: NAK, erase inactive bank, wait for new CMD_START.
FriImplement timeout FSM: hardware timer fires every 500ms, increments a timeout counter, resets on frame received. After 3 timeouts: send NAK ERR_TIMEOUT, abort update, boot active bank.
SatManual test with a Python serial script sending hand-crafted frames. Verify each state transition and each error case. Log every frame sent and received.

Week 8 — Python host flasher and integration

Host tool
Python host scriptserial communication progress barretry logic CRC verificationend-to-end test
Python flasher — complete reference
#!/usr/bin/env python3
"""
flash_uart.py — flash firmware to STM32 bootloader over UART

Usage: python flash_uart.py --port /dev/ttyUSB0 --baud 115200 firmware.fwu
"""
import serial, struct, zlib, time, sys, argparse
from pathlib import Path

SOH, EOT = 0x01, 0x04
ACK, NAK  = 0x06, 0x15
CMD_START, CMD_DATA, CMD_END, CMD_ABORT = 0x01, 0x02, 0x03, 0xFF

CHUNK_SIZE = 256   # bytes per CMD_DATA payload

def crc16(data: bytes) -> int:
    crc = 0xFFFF
    for b in data:
        crc ^= b << 8
        for _ in range(8):
            crc = (crc << 1) ^ 0x1021 if crc & 0x8000 else crc << 1
    return crc & 0xFFFF

def crc32(data: bytes) -> int:
    return zlib.crc32(data) & 0xFFFFFFFF

def build_frame(cmd: int, payload: bytes) -> bytes:
    body = bytes([cmd]) + struct.pack('<H', len(payload)) + payload
    return bytes([SOH]) + body + struct.pack('<H', crc16(body)) + bytes([EOT])

def send_recv(ser: serial.Serial, frame: bytes, timeout: float = 2.0) -> tuple[int, bytes]:
    ser.write(frame)
    ser.flush()
    deadline = time.time() + timeout
    buf = b''
    while time.time() < deadline:
        chunk = ser.read(ser.in_waiting or 1)
        buf += chunk
        if len(buf) >= 1:
            resp_code = buf[0]
            if resp_code == ACK:
                payload = buf[1:] if len(buf) > 1 else b''
                return ACK, payload
            elif resp_code == NAK:
                err = buf[1] if len(buf) > 1 else 0
                return NAK, bytes([err])
    return 0xFF, b''   # timeout

def flash(port: str, baud: int, image_path: str) -> bool:
    data = Path(image_path).read_bytes()
    total = len(data)
    img_crc = crc32(data)
    chunks = [data[i:i+CHUNK_SIZE] for i in range(0, total, CHUNK_SIZE)]

    print(f"Image  : {image_path} ({total} bytes, CRC=0x{img_crc:08X})")
    print(f"Port   : {port} @ {baud} baud")
    print(f"Chunks : {len(chunks)} × {CHUNK_SIZE}B")
    print()

    with serial.Serial(port, baud, timeout=0.1) as ser:
        # CMD_START
        payload = struct.pack('<II', total, img_crc)
        code, _ = send_recv(ser, build_frame(CMD_START, payload), timeout=30.0)
        if code != ACK:
            print(f"[FAIL] CMD_START rejected (NAK 0x{(_ or b'')[0]:02X})")
            return False
        print("[OK]  START — erase complete")

        # CMD_DATA
        for i, chunk in enumerate(chunks):
            offset = i * CHUNK_SIZE
            payload = struct.pack('<I', offset) + chunk
            for attempt in range(3):
                code, _ = send_recv(ser, build_frame(CMD_DATA, payload))
                if code == ACK:
                    break
                print(f"  Retry {attempt+1}/3 at offset 0x{offset:06X}")
            else:
                print(f"[FAIL] CMD_DATA at offset 0x{offset:06X} after 3 retries")
                return False

            pct = (i + 1) / len(chunks) * 100
            bar = '#' * int(pct / 2) + '.' * (50 - int(pct / 2))
            print(f"\r  [{bar}] {pct:5.1f}%", end='', flush=True)

        print("\n[OK]  Transfer complete")

        # CMD_END
        payload = struct.pack('<I', img_crc)
        code, _ = send_recv(ser, build_frame(CMD_END, payload), timeout=5.0)
        if code != ACK:
            print(f"[FAIL] CMD_END rejected — CRC mismatch on target?")
            return False

    print("[OK]  Image verified and committed — target rebooting")
    return True

if __name__ == '__main__':
    p = argparse.ArgumentParser()
    p.add_argument('--port',  default='/dev/ttyUSB0')
    p.add_argument('--baud',  type=int, default=115200)
    p.add_argument('image')
    args = p.parse_args()
    sys.exit(0 if flash(args.port, args.baud, args.image) else 1)
Daily exercises
Mon–TueImplement and test the Python flasher against the bootloader. First test: flash a valid image, verify it boots. Second test: corrupt image, verify rejection.
WedRetry logic: disconnect UART cable mid-transfer, reconnect, retry. Verify the bootloader waits (up to timeout), receives the retry, and continues from where it left off.Hint: bootloader must track current write offset — if a chunk with a previously written offset arrives again, ACK it without re-writing.
ThuMeasure transfer performance: time a 64kB flash over UART at 115200 baud and at 921600 baud. Calculate throughput including protocol overhead. Document the numbers.
FriApp-triggered update: add a UART command to the application that sets update_pending in backup SRAM and calls NVIC_SystemReset(). The Python flasher should detect the reboot (wait for a bootloader banner) and then send the new image automatically.This is the production flow: the app requests its own update, the flasher does the rest.
Sat50-cycle soak test: flash script loops 50 times, each time flashing alternating firmware versions. Verify: correct version running after each flash, boot counter resets, no partial writes survive.

Week 9 — Hardening and Phase 3 capstone

Capstone
fault handler in bootloaderHardFault logging unit testscode coverage documentationGitHub CI
Reading
Ganssle — Art of Designing Embedded Systems: reliability chapterRead before hardening. His checklist for production firmware is worth going through line by line.
memfault "Zero to main()" Part 4: fault handlingCovers HardFault in bootloader context — more constrained than in a full application.
What to test this week
CRC computation: known test vectors, empty image, max-size image
Header validation: every invalid case (wrong magic, bad SP, bad reset handler, CRC mismatch)
Boot flags: init, read, write, CRC update, corruption detection
Bank selection: A valid B invalid, A invalid B valid, both invalid, both valid
Protocol parser: every NAK case, out-of-order chunk, oversized payload
HardFault handler in bootloader context
/* In the bootloader, a HardFault during update is a catastrophic event.
   The goal: log what happened, invalidate the partially-written image,
   and reboot cleanly to the active bank.                                */

void HardFault_Handler_C(uint32_t *frame) {
    /* Save fault context to boot flags (backup SRAM — survives reset) */
    g_boot_flags.last_fault_pc = frame[6];   /* stacked PC */
    g_boot_flags.last_fault_lr = frame[5];   /* stacked LR */
    BootFlags_UpdateCRC();

    /* If we were in the middle of an update: mark inactive bank as invalid
       so the bootloader won't try to boot a partially-written image      */
    uint8_t inactive = g_boot_flags.active_bank ^ 1u;
    Flash_EraseSector(Bank_GetFirstSector(inactive));   /* wipe header */

    /* Try to log over UART before resetting */
    LOG_ERROR("BL HardFault PC=0x%08X LR=0x%08X CFSR=0x%08X",
              frame[6], frame[5], SCB->CFSR);

    NVIC_SystemReset();
}

__attribute__((naked)) void HardFault_Handler(void) {
    __asm volatile (
        "tst lr, #4     \n"
        "ite eq         \n"
        "mrseq r0, msp  \n"
        "mrsne r0, psp  \n"
        "b HardFault_Handler_C \n"
    );
}
Phase 3 capstone — deliverable checklist
Flash driver: erase, write, verify — no HAL dependencyUnit tested on host with memory-mapped stub
Linker script: bootloader at 0x08000000, dedicated app regions, boot flags in backup SRAMDiagram in README showing full flash memory map with addresses
Jump mechanism: CRC-verified header, VTOR relocation, full peripheral de-init before jumpVerified: interrupt in app correctly vectors to app handler, not bootloader
Dual-bank A/B: always write inactive, commit after verify, atomic bank swap via backup SRAM50-cycle soak test: zero partial writes, zero incorrect boots
Rollback: boot counter, 3-attempt limit, automatic failover to other bank, golden image last resortVerified with deliberate bad firmware in both banks
UART update protocol: custom framing, ACK/NAK, timeout FSM, retry on NAKPython flasher: progress bar, retry logic, app-triggered update flow
Python tools: build_image.py (header + CRC), flash_uart.py (host flasher)Both integrated into CMake build as post-build steps
Unit tests: ≥85% branch coverage on CRC, header validation, boot flags, bank selection, protocol parserRuns on host with Ceedling. CI badge in GitHub.
GitHub README: flash memory map diagram, update sequence flowchart, build and flash instructionsThis is the foundation that Phase 4 (CAN/UDS) plugs directly into
What Phase 4 builds on top of this
The UART protocol in Weeks 7–8 is replaced by CAN-FD + ISO-TP + UDS in Phase 4. Every other component stays identical: flash driver, dual-bank logic, CRC verification, rollback mechanism, boot flags, golden image.Phase 4 is a transport swap, not a redesign. Build Phase 3 well and Phase 4 integration is straightforward.
The Python flasher becomes a UDS client (python-udsoncan) sending 0x34/0x36/0x37 services instead of custom frames. The STM32 side replaces the UART frame parser with a UDS service handler calling the same flash driver functions.