Capstone Project30 min read
Capstone I — Requirements, Architecture, and Memory Map
The capstone is one firmware product, built from an empty directory to a release over five lessons: a battery-powered environmental sensor node on the STM32F407 that runs unmodified in Renode. It has a bootloader that checks CRCs and versions, an application that samples an I2C temperature and humidity sensor, a UART shell and a binary protocol, configuration and an event log in flash, firmware update with rollback, a watchdog, and low-power sleep. This lesson does the part juniors skip and seniors spend the most time on: deciding what the product must do, how the code is split, where every byte lives in memory, and which files define the contracts that every later file must obey. Every name and address fixed here is used unchanged in the next four lessons.
What we are building
Section titled “What we are building”Write the requirements down before any code. Each one is testable, and lesson 05 checks every one.
| ID | Requirement |
|---|---|
| R1 | Sample temperature (0.01 °C resolution) and relative humidity (0.01 %RH) every period seconds, 2 to 3600, default 10. |
| R2 | Report each sample on USART2 at 115200 8N1, as a text line or as a CRC-protected binary frame, selectable at run time. |
| R3 | Provide a text shell on the same UART: info, sample, cfg get, cfg set, log dump, update, reboot, help. |
| R4 | Keep configuration in internal flash, protected by CRC-32, in two copies so a power cut during a save never loses the old settings. |
| R5 | Append boots, errors, configuration changes and every Nth sample to an event log in flash that survives resets and wraps when full. |
| R6 | Accept a new application image over the UART into the slot that is not running, verify it, and boot it on trial. A trial image that does not confirm itself within 3 boots is rejected and the previous image runs again. |
| R7 | Never hang: an independent watchdog resets the chip if the main loop, the event queue, or the scheduler stops making progress. |
| R8 | Sleep between samples. With the CPU stopped most of the time, average current must be dominated by the sleep current, not by run time. |
| R9 | Build with one make command, run the same binaries in Renode, and pass host unit tests and a Renode test suite. |
Two non-requirements matter as much. There is no RTOS: a cooperative scheduler and an event queue are small enough that you can read every line, and they are what most battery products ship anyway. There is no heap: every object is statically allocated, so RAM use is known at link time.
The architecture
Section titled “The architecture”The firmware is two programs that share code: a bootloader that owns the first 32 KB of flash, and an application that is linked twice, once for each slot.
+------------------------------------------------+ application | app.c (state machine) shell.c proto.c | layer | log.c wdg.c fault.c main.c | +---------------+-----------------+--------------+ services | event.c sched.c | config.c evlog.c update.c | power.c +---------------+-----------------+--------------+ device drivers | th20.c (sensor) ---uses---> bus.h (interface)| +------------------------------------------------+ platform | clock.c systick.c i2c.c (implements bus.h) | +------------------------------------------------+ common | startup.c gpio.c uart.c flash.c crc.c | (boot + app) | handoff.c err.c + contract headers | +------------------------------------------------+ hardware | stm32f407_regs.h -> STM32F407 registers | +------------------------------------------------+Data flows up through events and down through function calls. A byte arriving on the UART is placed in a ring buffer by the ISR, which posts EV_UART_RX. The main loop takes the event and hands the bytes to the shell or, during an update, to the protocol parser. A sample starts when the scheduler’s sample task posts EV_SAMPLE_DUE; the state machine moves to sampling, triggers a conversion, and a one-shot task reads the result 22 ms later and posts EV_SAMPLE_READY. Nothing in the system waits in a loop for time to pass. When there is nothing to do, the main loop asks the scheduler how long until the next deadline and puts the CPU to sleep for that long.
ISR context main loop (thread mode) ----------- ----------------------- USART2 RX --ring buffer--> app_dispatch_events() --> state machine --EV_UART_RX---> | | SysTick --ms counter---> sched_run_due(now) --> tasks post events RTC wakeup --wakes CPU----> wdg_service() --> feed IWDG if all alive power_idle(ms until next deadline)The memory map
Section titled “The memory map”The STM32F407 flash is split into sectors of unequal size: four of 16 KB, one of 64 KB, then seven of 128 KB. Flash can only be erased a whole sector at a time, so every region that is erased independently must start and end on a sector boundary. That single fact drives the whole layout.
| Region | Address | Sectors | Size | Contents |
|---|---|---|---|---|
| Bootloader | 0x08000000 | 0 to 1 | 32 KB | Never updated in the field |
| Slot A | 0x08008000 | 2 to 5 | 224 KB | Header (512 B), vector table, app code; trailer in the last 16 B |
| Slot B | 0x08040000 | 6 to 7 | 256 KB, 224 KB used | Same layout as slot A |
| Config copy A | 0x08080000 | 8 | 128 KB | One 32-byte config record |
| Config copy B | 0x080A0000 | 9 | 128 KB | One 32-byte config record |
| Event log | 0x080C0000 | 10 to 11 | 256 KB | 16-byte records, two sectors used as a ring |
| SRAM | 0x20000000 | 127.75 KB | .data, .bss, stack (top at 0x2001FF00) | |
| Handoff block | 0x2001FF00 | 256 B | .noinit RAM shared by bootloader and app |
A few decisions deserve their reasons.
Slots are the same size even though the sectors are not. Slot A gets sectors 2 to 5 (16 + 16 + 64 + 128 = 224 KB). Slot B gets sectors 6 and 7 (256 KB). Capping both at 224 KB means an image that fits one slot fits the other.
Config uses two 128 KB sectors for 32 bytes. That looks wasteful and it is. The F407 has only four small sectors and they sit at the start of flash where the vector table must live at reset. The alternative, putting config in sectors 2 and 3, would push slot A to sector 4. On a part with uniform 2 KB pages (STM32L4, nRF52) you would never make this trade. Name the cost in your design document and move on.
Each slot has a 512-byte header in front of its vector table. The bootloader reads the header to decide whether the image is valid before it trusts a single instruction in it. Placing the vector table at slot base plus 0x200 keeps it aligned to 512 bytes, which VTOR requires for a table of 98 entries (392 bytes, rounded up to the next power of two).
Each slot ends with a 16-byte trailer holding two flag words, confirmed and rejected. Erased flash reads 0xFFFFFFFF. Programming a magic number into a flag word is a single 1-to-0 write that needs no erase, so the application can confirm itself and the bootloader can reject an image without touching the code.
The handoff block sits in RAM that neither program initializes. Both linker scripts reserve the top 256 bytes of SRAM as a NOLOAD region. The bootloader writes the boot count and reset cause there; the application reads them and writes crash records there for the next boot to report. Contents survive a watchdog or software reset but not a power cycle, so every reader checks a magic number and CRC first.
0x08000000 +-------------------+ | bootloader | sectors 0-1 0x08008000 +-------------------+ <- slot A base, image_header_t (64 B used of 512) 0x08008200 | vector table | VTOR value for slot A | .text .rodata | | .data (LMA) | | ... | 0x0803FFF0 | trailer | confirmed / rejected words 0x08040000 +-------------------+ <- slot B base, same layout 0x08078000 | (unused 32 KB) | 0x08080000 +-------------------+ config A (sector 8) 0x080A0000 +-------------------+ config B (sector 9) 0x080C0000 +-------------------+ event log (sectors 10-11) 0x08100000 +-------------------+ end of flashModules and the dependency rule
Section titled “Modules and the dependency rule”Every source file belongs to exactly one layer, and a file may include headers only from its own layer or the layers below it. Common code never includes application headers. Drivers never include the scheduler. The shell never touches a register.
| Module | Files | Layer | Responsibility | May depend on |
|---|---|---|---|---|
| Register map | common/stm32f407_regs.h | hardware | Typed register structs, bit names, NVIC and IRQ helpers | nothing |
| Contracts | common/flash_layout.h, err.h, image.h, handoff.h, board.h | common | Memory map, error codes, image format, handoff block, pin map | register map |
| Startup | common/startup.c | common | Vector table, .data/.bss init, FPU enable | register map |
| GPIO, UART | common/gpio.c, common/uart.c | common | Pin config; polled TX, interrupt RX ring | register map, contracts |
| Flash, CRC | common/flash.c, common/crc.c | common | Sector erase, word program; CRC-32, CRC-16 | register map |
| Handoff | common/handoff.c | common | Validate and seal the .noinit block | CRC |
| Clock, time | app/platform/clock.c, systick.c | platform | 168 MHz PLL, 1 ms tick | register map |
| I2C | app/platform/i2c.c | platform | I2C1 master, implements bus_ops_t | GPIO, bus.h |
| Sensor | app/drivers/th20.c | drivers | TH20 register protocol and validation | bus.h only |
| Events, scheduler | app/core/event.c, sched.c | services | ISR-safe queue, cooperative tasks | systick |
| Storage | app/storage/config.c, evlog.c, update.c | services | A/B config, flash log, update receiver | flash, CRC, image |
| Power | app/power/power.c | services | Sleep and Stop with RTC wake | clock, systick |
| Application | app/core/app.c, shell.c, proto.c, log.c, wdg.c, fault.c, app/main.c | application | State machine, shell, protocol, logging, liveness | everything below |
Two dependencies point the “wrong” way and are allowed on purpose. app/platform/i2c.c includes app/drivers/bus.h: the bus interface is owned by the driver layer that consumes it, and the platform implements it. This is dependency inversion, and it is what lets th20.c compile on your PC against a fake bus. And uart.c in common needs to tell the application that bytes arrived; instead of including event.h, it exposes uart_set_rx_hook() and the application registers a callback.
The build layout
Section titled “The build layout”sensor-node/ Makefile common/ shared by bootloader and application stm32f407_regs.h flash_layout.h err.h err.c image.h handoff.h handoff.c board.h startup.c gpio.h gpio.c uart.h uart.c flash.h flash.c crc.h crc.c boot/ boot.ld boot_main.c app/ app.ld main.c version.c platform/ clock.h clock.c systick.h systick.c i2c.h i2c.c drivers/ bus.h th20.h th20.c core/ event.h event.c sched.h sched.c app.h app.c shell.h shell.c proto.h proto.c log.h log.c wdg.h wdg.c fault.c storage/ config.h config.c evlog.h evlog.c update.h update.c power/ power.h power.c tests/ host unit tests (gcc, no hardware) renode/ th20.cs stage2.resc sensor_node.resc sensor_node.robot tools/ mkimage.py fwupdate.py build/ generated: boot/, a/, b/The contracts
Section titled “The contracts”Six small files define everything two modules must agree on. Write them first; every later lesson includes them and none changes them.
The memory map as code
Section titled “The memory map as code”/* flash_layout.h - the single source of truth for the memory map. Included by C code AND by the linker scripts (through the C preprocessor), so it may contain only #define and #if lines with plain integer literals: no casts, no U suffixes. */#ifndef FLASH_LAYOUT_H#define FLASH_LAYOUT_H
#define BOOT_BASE 0x08000000 /* sectors 0-1, 32 KB */#define BOOT_SIZE 0x00008000#define SLOT_A_BASE 0x08008000 /* sectors 2-5, 224 KB */#define SLOT_B_BASE 0x08040000 /* sectors 6-7, 224 KB used */#define SLOT_SIZE 0x00038000#define IMAGE_HEADER_SIZE 0x00000200 /* vector table follows header */#define SLOT_A_FIRST_SECTOR 2#define SLOT_A_SECTOR_COUNT 4#define SLOT_B_FIRST_SECTOR 6#define SLOT_B_SECTOR_COUNT 2
#define CFG_A_BASE 0x08080000 /* sector 8 */#define CFG_B_BASE 0x080A0000 /* sector 9 */#define CFG_A_SECTOR 8#define CFG_B_SECTOR 9#define EVLOG_BASE 0x080C0000 /* sectors 10-11 */#define EVLOG_SECTOR_SIZE 0x00020000#define EVLOG_FIRST_SECTOR 10#define EVLOG_SECTOR_COUNT 2
#define RAM_BASE 0x20000000#define RAM_SIZE 0x0001FF00 /* 128 KB minus handoff block */#define HANDOFF_ADDR 0x2001FF00#define HANDOFF_SIZE 0x00000100
/* The application is compiled twice, with -DAPP_SLOT=0 and -DAPP_SLOT=1. */#if defined(APP_SLOT)# if APP_SLOT == 0# define APP_SLOT_BASE SLOT_A_BASE# else# define APP_SLOT_BASE SLOT_B_BASE# endif#endif
#endif /* FLASH_LAYOUT_H */The header has one unusual constraint: the linker scripts include it too. The Makefile runs each linker script through the C preprocessor (gcc -E -P -x c), so ORIGIN = APP_SLOT_BASE in app.ld becomes ORIGIN = 0x08008000. GNU ld does not understand C’s U suffix or casts, which is why the literals are bare. In C, 0x08008000 fits in an int, so it is an int constant; wherever it meets a uint32_t it converts without surprise because it is positive. One file for the map removes the most common bootloader bug there is: the bootloader and the application disagreeing about an address because someone edited one copy.
Error codes
Section titled “Error codes”#ifndef ERR_H#define ERR_H
/* Project-wide status codes. 0 is success, every failure is negative. */typedef enum { ERR_OK = 0, ERR_TIMEOUT = -1, /* a wait exceeded its bound */ ERR_NACK = -2, /* I2C device did not acknowledge */ ERR_BUS = -3, /* bus error or arbitration lost */ ERR_PARAM = -4, /* caller passed something invalid */ ERR_CRC = -5, /* integrity check failed */ ERR_FULL = -6, ERR_EMPTY = -7, ERR_NOT_FOUND = -8, ERR_FLASH = -9, /* erase/program error or verify mismatch */ ERR_VERSION = -10, /* image is not newer than the running one */ ERR_STATE = -11, /* operation not allowed in this state */ ERR_NODEV = -12, /* device answered with the wrong identity */ ERR_BUSY = -13, /* try again later */ ERR_RANGE = -14, /* value outside its valid range */} err_t;
const char *err_str(err_t e);
#endif /* ERR_H */#include "err.h"
const char *err_str(err_t e){ switch (e) { case ERR_OK: return "ok"; case ERR_TIMEOUT: return "timeout"; case ERR_NACK: return "nack"; case ERR_BUS: return "bus error"; case ERR_PARAM: return "bad parameter"; case ERR_CRC: return "crc mismatch"; case ERR_FULL: return "full"; case ERR_EMPTY: return "empty"; case ERR_NOT_FOUND: return "not found"; case ERR_FLASH: return "flash error"; case ERR_VERSION: return "version not newer"; case ERR_STATE: return "wrong state"; case ERR_NODEV: return "no device"; case ERR_BUSY: return "busy"; case ERR_RANGE: return "out of range"; } return "unknown";}Every function that can fail returns err_t. Negative values survive a round trip through a uint32_t event argument ((err_t)(int32_t)arg) and through a signed byte on the wire. The switch has no default, so adding an enumerator without a string makes -Wall (through -Wswitch) warn at compile time; the return after the switch handles values that are not enumerators at all.
The image format
Section titled “The image format”#ifndef IMAGE_H#define IMAGE_H
#include <stddef.h>#include <stdint.h>#include "flash_layout.h"
#define IMAGE_MAGIC 0x4E4F4445u /* "NODE" */#define IMAGE_HEADER_VERSION 1u#define SLOT_CONFIRM_MAGIC 0x434F4E46u /* "CONF" */#define SLOT_REJECT_MAGIC 0x52454A54u /* "REJT" */
#define FW_VERSION_MAKE(maj, min, pat) \ (((uint32_t)(maj) << 16) | ((uint32_t)(min) << 8) | (uint32_t)(pat))
/* At offset 0 of every slot. Fields marked (P) are patched after linking by tools/mkimage.py; the C initializer holds 0xFFFFFFFF. */typedef struct { uint32_t magic; uint32_t header_version; uint32_t fw_version; /* FW_VERSION_MAKE(major, minor, patch) */ uint32_t image_size; /* (P) bytes from IMAGE_HEADER_SIZE on */ uint32_t image_crc; /* (P) CRC-32 of those bytes */ uint32_t load_addr; /* slot base the image was linked for */ uint32_t build_id; /* first 8 hex digits of the git commit */ char version_str[32]; /* "git describe" text, NUL padded */ uint32_t header_crc; /* (P) CRC-32 of all fields above */} image_header_t;
/* In the last 16 bytes of every slot. Erased = 0xFFFFFFFF = "no". */typedef struct { uint32_t confirmed; /* SLOT_CONFIRM_MAGIC once the app is healthy */ uint32_t rejected; /* SLOT_REJECT_MAGIC after a failed trial */ uint32_t reserved[2];} slot_trailer_t;
_Static_assert(sizeof(image_header_t) == 64, "image_header_t layout changed");_Static_assert(sizeof(slot_trailer_t) == 16, "slot_trailer_t layout changed");
#define SLOT_COUNT 2u#define IMAGE_MAX_SIZE (SLOT_SIZE - IMAGE_HEADER_SIZE - sizeof(slot_trailer_t))
static inline uint32_t slot_base(uint32_t slot){ return (slot == 0u) ? SLOT_A_BASE : SLOT_B_BASE;}
static inline uint32_t slot_trailer_addr(uint32_t slot){ return slot_base(slot) + SLOT_SIZE - (uint32_t)sizeof(slot_trailer_t);}
/* Defined only in the application (app/version.c). */extern const image_header_t app_image_header;
#endif /* IMAGE_H */The header is exactly 64 bytes with every field naturally aligned, so there is no padding and the Python tool can describe it with the format string "<7I32sI". The _Static_assert lines turn an accidental layout change into a compile error instead of a silent mismatch with the host tool. load_addr exists because the application is not position independent: an image linked for slot A branches to slot A addresses, so the bootloader and the update receiver both refuse an image whose load_addr does not match the slot it sits in.
The boot handoff block
Section titled “The boot handoff block”#ifndef HANDOFF_H#define HANDOFF_H
#include <stdbool.h>#include <stdint.h>
#define HANDOFF_MAGIC 0x48414E44u /* "HAND" */#define HANDOFF_NO_TRIAL 0xFFFFFFFFu
/* Lives in .noinit RAM at HANDOFF_ADDR. Survives watchdog and software resets, not power cycles. Always check handoff_valid() before use. */typedef struct { uint32_t magic; uint32_t boot_count; /* incremented by the bootloader */ uint32_t reset_flags; /* RCC->CSR captured by the bootloader */ uint32_t booted_slot; /* 0 = A, 1 = B */ uint32_t trial_slot; /* slot on trial, or HANDOFF_NO_TRIAL */ uint32_t trial_attempts; /* boots of the trial slot so far */ uint32_t boot_version; uint32_t crash_pc; /* written by the app's HardFault handler */ uint32_t crash_lr; uint32_t crash_cfsr; uint32_t crash_count; uint32_t crc; /* CRC-32 over every field above */} handoff_t;
extern handoff_t g_handoff;
bool handoff_valid(void);void handoff_reset(void); /* defaults: magic set, everything else 0 */void handoff_seal(void); /* recompute crc after changing a field */
#endif /* HANDOFF_H */The board
Section titled “The board”#ifndef BOARD_H#define BOARD_H
#include "stm32f407_regs.h"
/* STM32F4 Discovery pin map. Change this file, not the drivers, when the product board differs. */#define BOARD_HSE_HZ 8000000u#define LED_PORT GPIOD#define LED_GREEN_PIN 12u#define LED_ORANGE_PIN 13u#define LED_RED_PIN 14u /* bootloader: no valid image */#define LED_BLUE_PIN 15u /* high while awake: probe for duty cycle */#define CONSOLE_BAUD 115200u#define UART_PORT GPIOA#define UART_TX_PIN 2u#define UART_RX_PIN 3u#define UART_AF 7u#define I2C_PORT GPIOB#define I2C_SCL_PIN 6u#define I2C_SDA_PIN 9u#define I2C_AF 4u#define SENSOR_I2C_ADDR 0x40u
#endif /* BOARD_H */The version record
Section titled “The version record”#include "image.h"
#if !defined(FW_VERSION_MAJOR) || !defined(BUILD_ID) || !defined(FW_VERSION_STR)#error "version macros must come from the Makefile"#endif
/* Placed at the very start of the slot by app.ld. */__attribute__((section(".image_header"), used))const image_header_t app_image_header = { .magic = IMAGE_MAGIC, .header_version = IMAGE_HEADER_VERSION, .fw_version = FW_VERSION_MAKE(FW_VERSION_MAJOR, FW_VERSION_MINOR, FW_VERSION_PATCH), .image_size = 0xFFFFFFFFu, /* patched by tools/mkimage.py */ .image_crc = 0xFFFFFFFFu, /* patched */ .load_addr = APP_SLOT_BASE, .build_id = BUILD_ID, .version_str = FW_VERSION_STR, .header_crc = 0xFFFFFFFFu, /* patched */};used stops the compiler from discarding an object no C code references, and KEEP in the linker script (lesson 02) stops --gc-sections from discarding the section. A version string of up to 31 characters gets a NUL terminator. One of exactly 32 fills the array with no terminator, which C allows, so every reader in this project prints it with a length bound (%.32s). A longer string is a compile-time warning.
The Makefile
Section titled “The Makefile”One Makefile builds three programs from one source tree: the bootloader and the application twice. Each program gets its own object directory, because the same common/uart.c compiled with -DAPP_SLOT=0 and compiled for the bootloader are different objects.
# Sensor node: bootloader + application (two slots) + host testsCROSS ?= arm-none-eabi-CC := $(CROSS)gccOBJCOPY := $(CROSS)objcopySIZE := $(CROSS)sizePYTHON ?= python3BUILD ?= buildSIM ?= 0
VERSION_MAJOR ?= 1VERSION_MINOR ?= 0VERSION_PATCH ?= 0VERSION_STR ?= $(shell git describe --tags --always --dirty 2>/dev/null || \ echo $(VERSION_MAJOR).$(VERSION_MINOR).$(VERSION_PATCH))BUILD_ID ?= 0x$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo 0)
ARCH := -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16OPT ?= -O2INCLUDES := -Icommon -Iapp/platform -Iapp/drivers -Iapp/core -Iapp/storage -Iapp/powerCFLAGS := $(ARCH) $(OPT) -g -std=c11 -ffunction-sections -fdata-sections \ -Wall -Wextra -Wshadow -Wundef -MMD -MPDEFS := -DFW_VERSION_MAJOR=$(VERSION_MAJOR) -DFW_VERSION_MINOR=$(VERSION_MINOR) \ -DFW_VERSION_PATCH=$(VERSION_PATCH) -DFW_VERSION_STR='"$(VERSION_STR)"' \ -DBUILD_ID=$(BUILD_ID) -DPOWER_SIM=$(SIM)LDFLAGS := $(ARCH) -nostartfiles --specs=nano.specs --specs=nosys.specs \ -Wl,--gc-sections -Wl,--print-memory-usage
COMMON_SRC := $(wildcard common/*.c)BOOT_SRC := $(COMMON_SRC) $(wildcard boot/*.c)APP_SRC := $(COMMON_SRC) $(wildcard app/*.c app/*/*.c)BOOT_OBJ := $(BOOT_SRC:%.c=$(BUILD)/boot/%.o)APP_A_OBJ := $(APP_SRC:%.c=$(BUILD)/a/%.o)APP_B_OBJ := $(APP_SRC:%.c=$(BUILD)/b/%.o)
.PHONY: all boot app images test sim clean.SECONDARY:
all: boot imagesboot: $(BUILD)/boot/boot.elfapp: $(BUILD)/a/app.elf $(BUILD)/b/app.elfimages: $(BUILD)/a/app.img $(BUILD)/b/app.img
# --- compile: one object tree per program so -D flags never mix --------------$(BUILD)/boot/%.o: %.c @mkdir -p $(dir $@) $(CC) $(CFLAGS) $(DEFS) -Icommon -c $< -o $@$(BUILD)/a/%.o: %.c @mkdir -p $(dir $@) $(CC) $(CFLAGS) $(DEFS) $(INCLUDES) -DAPP_SLOT=0 -c $< -o $@$(BUILD)/b/%.o: %.c @mkdir -p $(dir $@) $(CC) $(CFLAGS) $(DEFS) $(INCLUDES) -DAPP_SLOT=1 -c $< -o $@
# --- linker scripts are preprocessed so they can include flash_layout.h ------$(BUILD)/boot/boot.ld: boot/boot.ld common/flash_layout.h @mkdir -p $(dir $@) $(CC) -E -P -x c -Icommon $< -o $@$(BUILD)/a/app.ld: app/app.ld common/flash_layout.h @mkdir -p $(dir $@) $(CC) -E -P -x c -Icommon -DAPP_SLOT=0 $< -o $@$(BUILD)/b/app.ld: app/app.ld common/flash_layout.h @mkdir -p $(dir $@) $(CC) -E -P -x c -Icommon -DAPP_SLOT=1 $< -o $@
# --- link ---------------------------------------------------------------------$(BUILD)/boot/boot.elf: $(BOOT_OBJ) $(BUILD)/boot/boot.ld $(CC) $(LDFLAGS) -T $(BUILD)/boot/boot.ld -Wl,-Map=$(@:.elf=.map) $(BOOT_OBJ) -o $@ $(SIZE) $@$(BUILD)/a/app.elf: $(APP_A_OBJ) $(BUILD)/a/app.ld $(CC) $(LDFLAGS) -T $(BUILD)/a/app.ld -Wl,-Map=$(@:.elf=.map) $(APP_A_OBJ) -o $@ $(SIZE) $@$(BUILD)/b/app.elf: $(APP_B_OBJ) $(BUILD)/b/app.ld $(CC) $(LDFLAGS) -T $(BUILD)/b/app.ld -Wl,-Map=$(@:.elf=.map) $(APP_B_OBJ) -o $@ $(SIZE) $@
# --- images: raw binary, then the header patched with size and CRCs ----------%.bin: %.elf $(OBJCOPY) -O binary $< $@%.img: %.bin tools/mkimage.py $(PYTHON) tools/mkimage.py $< $@
# 992 KB of 0xFF: loaded first so Renode's flash looks freshly erased$(BUILD)/erased.bin: @mkdir -p $(dir $@) $(PYTHON) -c "import sys; open(sys.argv[1], 'wb').write(b'\xff' * 0xF8000)" $@
test: $(MAKE) -C tests
sim: all $(BUILD)/erased.bin renode renode/sensor_node.resc
clean: rm -rf $(BUILD)
-include $(BOOT_OBJ:.o=.d) $(APP_A_OBJ:.o=.d) $(APP_B_OBJ:.o=.d)Read it top to bottom once.
- Variables with
?=can be overridden from the command line:make SIM=1builds for Renode (lesson 05 explains what changes),make VERSION_MINOR=1builds a newer image to test updates,make BUILD=build-v11puts that build somewhere else so it does not overwrite the first. - Version stamping comes from git.
git describegives text such asv1.0.0-3-g1a2b3c4-dirty, and the short commit hash becomes the numericBUILD_ID. Outside a git checkout both fall back to fixed values, so the build never fails for lack of git. There is no__DATE__anywhere: two builds of the same commit produce identical binaries, which lesson 05 relies on. - Sources are found by wildcard, so files appear in the build as you write them in later lessons without editing the Makefile.
- Three object trees (
build/boot,build/a,build/b). The bootloader is compiled with only-Icommon, which is the dependency rule enforced by the compiler. -MMD -MPwrites a.dfile next to each object listing the headers it included; the last line pulls them in so editingflash_layout.hrebuilds everything that uses it.-nostartfilesbecausestartup.cprovidesReset_Handlerand we do not want the C library’scrt0.nano.specsselects newlib-nano (smallsnprintf), andnosys.specssupplies stub system calls..SECONDARY:with no prerequisites tells make not to delete intermediate files such asapp.binproduced by the pattern-rule chain.elfto.binto.img.
Development order
Section titled “Development order”You will build the system in the order that keeps something runnable at every step and puts the riskiest unknowns first.
| Lesson | Stage | Runs in Renode as |
|---|---|---|
| 02 | Register header, startup, linker scripts, clock, SysTick, GPIO, UART, I2C, bus interface, TH20 driver, host test | build/a/app.elf started directly, prints a sample every second |
| 03 | CRC, handoff, event queue, scheduler, logging, protocol, shell, watchdog, fault handler, state machine; storage modules as stubs | Same ELF, now an interactive shell with periodic reports |
| 04 | Flash driver, A/B config, event log, update receiver, bootloader, image tools | Bootloader plus patched slot images, full update and rollback |
| 05 | Stop mode with RTC wake, duty-cycle measurement, host suite, Renode suite, release target and budgets | The release build under automated tests |
The driver stage comes first because hardware access is where surprises live: an I2C peripheral that will not leave the busy state is easier to debug in a 100-line program than inside a state machine. The bootloader comes late because once the image format is fixed (it is, above) it is small and mechanical. Power management comes last because a CPU that sleeps is harder to debug, and every earlier feature must already work before you make it sleep.
Done criteria
Section titled “Done criteria”The capstone is done when all of these hold, and lesson 05 automates most of them:
make clean all testsucceeds with zero warnings.- In Renode, the bootloader prints its banner, boots slot A, and the application reports a sample within one second and every
periodseconds after. cfg set period 5survives areboot. Corrupting the newest config copy falls back to the older one.log dumplists the boot records of every reset.tools/fwupdate.pysends a newer image into the other slot; the node reboots into it, and it confirms itself after 30 seconds of healthy running.- An image that reaches a fourth boot without confirming itself is rejected and the old image runs.
- A corrupted image (one flipped bit) is never booted.
- Flash use of the application stays under 64 KB, the bootloader under 16 KB, and static RAM under 32 KB.
Common mistakes
Section titled “Common mistakes”- Two copies of the memory map. The bootloader says slot A starts at
0x08008000, the application’s linker script says0x08010000, and the jump lands in erased flash. Keep every address inflash_layout.hand preprocess the linker scripts. - Putting data in a sector that holds code. Erasing a config “page” that shares a sector with the application erases the application. Every independently erased region must own whole sectors.
- Trusting
.noinitRAM without a check. After power-on it holds random bits. Validate a magic number and a CRC, and reinitialize when either fails. - Reading patched header fields through the C object. The compiler folds
constobjects with initializers. Read patched fields through a pointer to the slot address. - A vector table that is not aligned to its size. On Cortex-M4, a 98-entry table must be 512-byte aligned. Put headers in front of it only in multiples of that alignment.
- Designing the trial counter only in RAM without saying so. It resets on a power cycle. That is acceptable here (a hanging image triggers the watchdog, which preserves RAM) but it must be a written design decision, not a surprise.
Exercises
Section titled “Exercises”- The event log stores 16-byte records in two 128 KB sectors. How many records fit in one sector? With the default settings (sample every 10 s, log every 6th sample), how many days until each sector is erased once, and how many erase cycles is that per sector per year? Correct result: 8192 records per sector, about 5.7 days to fill one sector, each sector erased about every 11.4 days, so about 32 cycles per year against an endurance of 10,000.
- Redraw the memory map for an STM32F407VE, which has 512 KB of flash (sectors 0 to 7 only). Keep a 32 KB bootloader and two equal slots. What is the largest slot size you can get, and what do you give up?
- Write a shell one-liner that fails if any file under
common/includes a header that lives underapp/. Run it against your tree after lesson 03. - After lesson 02, open
build/a/app.mapandbuild/b/app.map. Findapp_image_headerandg_vectorsin each. Correct result:0x08008000and0x08008200for slot A,0x08040000and0x08040200for slot B. - Suppose someone changes
IMAGE_HEADER_SIZEto0x100to save 256 bytes. Explain, using the alignment rule forVTOR, why the image might still appear to work in some tests and why the change is still wrong. Then propose a header size that saves space and is correct.
Check yourself
Section titled “Check yourself”Question 1:Why do the two configuration copies live in 128 KB sectors 8 and 9 instead of a 32-byte area at the end of slot A?
Saving config requires an erase, and an erase clears the whole sector. Sharing a sector with application code would erase the code.
Question 2:At boot, slot A holds v1.0.0, confirmed. Slot B holds v1.1.0, valid but unconfirmed, and the handoff block says slot B has already been trial-booted 3 times. With a limit of 3 trials, what does the bootloader do?
The attempt counter exceeds the limit, so the bootloader programs the rejected flag (a 1-to-0 write, no erase) and falls back to the confirmed image.
Question 3:Firmware prints app_image_header.image_crc and gets 0xFFFFFFFF, although a hex dump of flash shows mkimage patched a real CRC there. What is the cause?
The compiler may assume a const object never changes. Reading through a pointer to APP_SLOT_BASE forces a real load from flash.
Question 4:Which of these survives an independent watchdog reset but not removal of the battery?
SRAM keeps its contents across a reset as long as power stays on, and nothing initializes the .noinit region. Flash survives both; .data is reinitialized by startup code.
Question 5:Why does app/platform/i2c.c include app/drivers/bus.h, against the general direction of the layers?
This is dependency inversion: the interface lives with its consumer, so th20.c depends only on bus.h and never on I2C hardware.
Key takeaways
Section titled “Key takeaways”- Write requirements as testable statements before code, and check every one at the end.
- Flash erase granularity decides the memory map: every region erased on its own must own whole sectors.
- Keep the memory map in one header and feed it to both C and the linker scripts through the preprocessor.
- An image header in front of the vector table, a trailer of write-once flags at the end of the slot, and a validated
.noinithandoff block are the three pieces a safe update scheme needs. - Enforce layer boundaries with include paths, and invert a dependency (interface owned by the consumer) when you need to test a module without hardware.
- Build in an order where something runs at every stage and the riskiest hardware work comes first.
Next: Capstone II — Platform, Drivers, and the Sensor, where the register header, startup code, linker scripts, and drivers bring the board up to its first sensor reading.