Skip to content

Capstone Project36 min read

Capstone V — Power, Testing, and Release

The node from Capstone IV does everything it should, but it never really sleeps: SysTick wakes it a thousand times a second. This lesson finishes the product. You will add Stop mode with an RTC wake-up timer so the CPU is off between samples, measure the duty cycle and turn it into a battery-life estimate, complete the host unit-test suite, write a Renode test suite that checks the requirements from Capstone I automatically, and add a release target that refuses to build from a dirty tree, enforces flash and RAM budgets, and produces versioned artifacts. The lesson ends with a release checklist and ten projects that extend the node in the directions real products go.

Before writing power code, estimate. The numbers below are illustrative orders of magnitude for an STM32F407 board; take your own from the datasheet and, better, from a measurement.

StateWhat is runningCurrent (illustrative)
Run, 168 MHzCore, flash, PLL, peripheralsabout 40 mA
Sleep (WFI)Core stopped, clocks and peripherals runningabout 10 to 15 mA
Stop, low-power regulator, flash powered downAll 1.2 V domain clocks off, SRAM and registers kept, LSI and RTC runninga few hundred µA (use 0.3 mA)
StandbyOnly the backup domain; SRAM lost, wake is a reseta few µA

The product spends a few milliseconds per sample awake. Everything else is sleep, so the sleep current sets the battery life, and the step from Sleep (about 12 mA) to Stop (about 0.3 mA) is a factor of 40. Standby would be lower still, but it loses SRAM, so every wake is a full reboot through the bootloader, the scheduler state is gone, and the shell cannot work; for a node that wakes every few seconds, Stop is the right trade. Choosing a part built for low power (an STM32L4 reaches about 1 µA in its Stop 2 mode) is the bigger lever, and exercise 5 asks you to estimate it.

Stop mode has three consequences the code must handle:

  • Clocks stop. SysTick stops, so time must be tracked across the sleep by something that keeps running: the RTC, clocked from the LSI. The USART stops too, so a byte arriving during Stop is lost, and the last byte of a transmission must be fully out (uart_flush) before entering it.
  • Wake-up needs an EXTI line. The RTC wake-up timer is EXTI line 22. The UART RX pin can wake the core through EXTI line 3 on its falling start-bit edge, which is how the shell stays reachable; the character that caused the wake is lost, and so is the next one or two while the HSE and PLL restart (about 2 ms).
  • The core wakes on HSI. The PLL is off after Stop. clock_init_168mhz must run again before anything that depends on the 168 MHz clock.
app/power/power.h
#ifndef POWER_H
#define POWER_H
#include <stdbool.h>
#include <stdint.h>
void power_init(void);
void power_idle(uint32_t max_sleep_ms, bool deep_allowed);
uint32_t power_awake_ms(void);
uint32_t power_sleep_ms(void);
uint32_t power_stop_count(void);
#endif /* POWER_H */
app/power/power.c
#include <stdbool.h>
#include "board.h"
#include "clock.h"
#include "event.h"
#include "gpio.h"
#include "power.h"
#include "systick.h"
#include "uart.h"
#define POWER_MIN_STOP_MS 10u /* shorter sleeps are not worth the PLL restart */
#define RTC_WUT_TICKS_PER_MS 2u /* LSI ~32 kHz / 16 = ~2 kHz */
#define RTC_WUT_MAX_MS 30000u /* 16-bit WUTR at 2 kHz is 32.7 s */
static uint32_t s_awake_ms, s_sleep_ms, s_stop_count, s_awake_since;
#if !POWER_SIM
static bool wait_flag(volatile uint32_t *reg, uint32_t mask)
{
for (uint32_t i = 0; i < 100000u; i++) {
if ((*reg & mask) != 0u) {
return true;
}
}
return false;
}
static void rtc_unlock(void) { RTC->WPR = 0xCAu; RTC->WPR = 0x53u; }
static void rtc_lock(void) { RTC->WPR = 0xFFu; }
static void rtc_wakeup_arm(uint32_t ms)
{
rtc_unlock();
RTC->CR &= ~(RTC_CR_WUTE | RTC_CR_WUTIE);
(void)wait_flag(&RTC->ISR, RTC_ISR_WUTWF); /* WUTR is writable only now */
RTC->WUTR = ms * RTC_WUT_TICKS_PER_MS - 1u;
RTC->ISR &= ~RTC_ISR_WUTF;
RTC->CR |= RTC_CR_WUTE | RTC_CR_WUTIE;
rtc_lock();
}
static void rtc_wakeup_disarm(void)
{
rtc_unlock();
RTC->CR &= ~(RTC_CR_WUTE | RTC_CR_WUTIE);
rtc_lock();
}
void RTC_WKUP_IRQHandler(void)
{
RTC->ISR &= ~RTC_ISR_WUTF; /* flag bits are not write protected */
EXTI->PR = EXTI_LINE_RTC_WKUP; /* write 1 to clear */
}
void EXTI3_IRQHandler(void)
{
EXTI->PR = EXTI_LINE_UART_RX; /* the wake itself was the point */
}
/* Returns the milliseconds known to have passed: all of them if the RTC woke us. */
static uint32_t enter_stop(uint32_t ms)
{
uart_flush(); /* last stop bit out before clocks stop */
rtc_wakeup_arm(ms);
EXTI->PR = EXTI_LINE_UART_RX | EXTI_LINE_RTC_WKUP;
EXTI->IMR |= EXTI_LINE_UART_RX; /* a start bit on PA3 may wake us */
SysTick->CTRL &= ~SysTick_CTRL_ENABLE;
PWR->CR = (PWR->CR & ~PWR_CR_PDDS) | PWR_CR_LPDS | PWR_CR_FPDS | PWR_CR_CWUF;
SCB->SCR |= SCB_SCR_SLEEPDEEP;
dsb();
wfi(); /* Stop until EXTI 22 or EXTI 3 */
SCB->SCR &= ~SCB_SCR_SLEEPDEEP;
EXTI->IMR &= ~EXTI_LINE_UART_RX;
bool timed_out = (RTC->ISR & RTC_ISR_WUTF) != 0u;
rtc_wakeup_disarm();
(void)clock_init_168mhz(); /* we woke up on HSI */
SysTick->VAL = 0u;
SysTick->CTRL |= SysTick_CTRL_ENABLE;
s_stop_count++;
return timed_out ? ms : 0u;
}
#endif /* !POWER_SIM */
void power_init(void)
{
s_awake_since = systick_now_ms();
#if !POWER_SIM
RCC->APB1ENR |= RCC_APB1ENR_PWREN;
(void)RCC->APB1ENR;
PWR->CR |= PWR_CR_DBP; /* make the backup domain writable */
RCC->CSR |= RCC_CSR_LSION; /* already on if the IWDG runs */
(void)wait_flag(&RCC->CSR, RCC_CSR_LSIRDY);
if ((RCC->BDCR & RCC_BDCR_RTCEN) == 0u) { /* first boot after the battery went in */
RCC->BDCR = RCC_BDCR_BDRST; /* RTCSEL can only change after a reset */
RCC->BDCR = 0u;
RCC->BDCR = RCC_BDCR_RTCSEL_LSI | RCC_BDCR_RTCEN;
}
rtc_unlock();
RTC->CR &= ~(RTC_CR_WUTE | RTC_CR_WUTIE);
(void)wait_flag(&RTC->ISR, RTC_ISR_WUTWF);
RTC->CR &= ~RTC_CR_WUCKSEL_Msk; /* wake-up clock = RTCCLK / 16 */
rtc_lock();
EXTI->IMR |= EXTI_LINE_RTC_WKUP;
EXTI->RTSR |= EXTI_LINE_RTC_WKUP; /* line 22 is a rising-edge event */
nvic_set_priority(RTC_WKUP_IRQn, 6u);
nvic_enable_irq(RTC_WKUP_IRQn);
RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN;
(void)RCC->APB2ENR;
SYSCFG->EXTICR[0] &= ~(0xFu << 12); /* EXTI3 source = port A, so PA3 */
EXTI->FTSR |= EXTI_LINE_UART_RX; /* start bit is a falling edge */
nvic_set_priority(EXTI3_IRQn, 6u);
nvic_enable_irq(EXTI3_IRQn);
#endif
}
void power_idle(uint32_t max_sleep_ms, bool deep_allowed)
{
uint32_t pm = irq_save(); /* same race as Capstone III */
if (event_pending() || max_sleep_ms == 0u) {
irq_restore(pm);
return;
}
uint32_t before = systick_now_ms();
s_awake_ms += before - s_awake_since;
gpio_clear(LED_PORT, LED_BLUE_PIN); /* probe point: low while asleep */
#if POWER_SIM
(void)deep_allowed;
dsb();
wfi();
#else
if (deep_allowed && max_sleep_ms >= POWER_MIN_STOP_MS) {
uint32_t ms = (max_sleep_ms > RTC_WUT_MAX_MS) ? RTC_WUT_MAX_MS : max_sleep_ms;
systick_advance_ms(enter_stop(ms));
} else {
dsb();
wfi(); /* Sleep: SysTick wakes us within 1 ms */
}
#endif
gpio_set(LED_PORT, LED_BLUE_PIN);
irq_restore(pm);
s_awake_since = systick_now_ms();
s_sleep_ms += s_awake_since - before;
}
uint32_t power_awake_ms(void) { return s_awake_ms; }
uint32_t power_sleep_ms(void) { return s_sleep_ms; }
uint32_t power_stop_count(void) { return s_stop_count; }

power_init puts the RTC on the LSI. The RTC lives in the backup domain, whose registers are write-protected until PWR_CR.DBP is set, and the RTC clock source can only be selected after a backup-domain reset, so that reset is done only on the first boot (when RTCEN is still clear) to avoid disturbing a running RTC on every reboot. The RTC’s own registers have a second lock, the WPR key sequence 0xCA, 0x53. The wake-up timer is clocked at RTCCLK/16, about 2 kHz, so its 16-bit reload register covers up to 32 seconds. EXTI line 22 is enabled for the RTC wake-up and EXTI line 3 is routed to PA3 through SYSCFG, but line 3 is unmasked only while the core is in Stop: while awake, every falling edge on the RX pin would otherwise raise a pointless interrupt.

power_idle is called from the main loop with the time until the scheduler’s next deadline and the application’s permission to sleep deeply. It keeps the masked check from Capstone III, so an event posted at the wrong moment still prevents or ends the sleep. If deep sleep is allowed and the gap is at least 10 ms, it enters Stop with the RTC armed for that gap (capped at 30 seconds); otherwise it uses ordinary Sleep mode. In practice the gap is at most 4 seconds, the heartbeat period, so the node wakes at least every 4 seconds to feed the watchdog, which keeps running in Stop.

enter_stop does the sequence in a strict order: drain the UART, arm the RTC, clear stale EXTI pending bits, unmask the UART wake line, stop SysTick so it cannot fire during the transition, select Stop (not Standby: PDDS clear) with the low-power regulator and flash power-down, set SLEEPDEEP, then DSB and WFI. On wake it undoes each step, restarts the PLL, and tells the time base how long it slept. It reads WUTF directly instead of waiting for the RTC interrupt handler, because interrupts are still masked at that point; the handler runs a moment later at irq_restore and clears the flag.

Time across Stop is the weakest part of the design, and it is worth being honest about. When the RTC wakes the core, exactly the requested interval has passed in LSI time, but the LSI on this part may run anywhere from about 17 to 47 kHz, so “10 seconds” can really be 7 or 19. When the UART wakes the core early, the elapsed time is not known at all, and the code adds nothing, so the time base falls behind and the next sample is late by up to one sleep interval. A product would fit a 32.768 kHz LSE crystal, or calibrate the LSI against the HSE (the F4 can route the LSI to a timer input capture for exactly this), and read the RTC’s subsecond register before and after sleeping. Exercise 2 does the calibration.

app/main.c
#include "event.h"
#include "gpio.h"
#include "power.h"
#include "sched.h"
@@
wdg_init(WDG_TIMEOUT_MS);
power_init();
app_init();
for (;;) {
app_dispatch_events();
sched_run_due(systick_now_ms());
wdg_checkin(WDG_LIVE_LOOP);
wdg_service();
/* Sleep until the next interrupt unless work is already waiting.
Interrupts are masked around the check: an ISR that posts an event
between the check and WFI leaves its IRQ pending, and a pending IRQ
wakes WFI even while masked. The ISR then runs at irq_restore. */
uint32_t pm = irq_save();
if (!event_pending() && sched_ms_until_next(systick_now_ms()) > 0u) {
dsb();
wfi();
}
irq_restore(pm);
power_idle(sched_ms_until_next(systick_now_ms()), app_can_deep_sleep());
}
app/core/app.c
#include "i2c.h"
#include "image.h"
#include "log.h"
#include "power.h"
#include "proto.h"
@@ void app_print_info(void)
event_high_water(), (unsigned)EVENT_QUEUE_LEN, uart_rx_overruns());
uint32_t total = power_awake_ms() + power_sleep_ms();
uint32_t permille = (total != 0u) ? (uint32_t)((uint64_t)power_awake_ms() * 1000u / total) : 0u;
log_print("power awake %" PRIu32 ".%" PRIu32 " %%, %" PRIu32 " Stop entries\r\n",
permille / 10u, permille % 10u, power_stop_count());
for (uint32_t i = 0; i < TASK_COUNT; i++) {

app_can_deep_sleep from Capstone III already says when Stop is allowed: idle, not updating, and no keyboard activity for 30 seconds. The multiplication for the duty cycle is done in 64 bits because awake_ms * 1000 overflows 32 bits after 71 minutes of awake time.

Renode models the instruction set and the peripherals the firmware uses, but not power states, and whether its RTC model implements the wake-up timer depends on the version. Rather than debug a simulator, the Makefile’s SIM=1 (which sets POWER_SIM=1) builds a variant that only uses Sleep mode. The application, the scheduler, and the duty-cycle accounting are identical; only enter_stop is compiled out. Use make SIM=1 all for Renode and the default SIM=0 for hardware and releases.

Two independent measurements, so each checks the other.

The probe pin. PD15 (the blue LED) is high while awake and low while asleep. On hardware, put a scope or logic analyzer on it: the fraction of time it is high is the duty cycle, and each pulse shows exactly what one wake costs. Remove the LED’s resistor (or use a free pin) when you measure current, or the LED itself dominates the awake current.

The counters. info now prints the awake percentage. The counters have 1 ms resolution, and a wake usually lasts less than a millisecond, so any single interval is measured as 0 or 1 ms. That still works on average: a tick lands inside an awake interval with probability equal to the fraction of time spent awake, so over thousands of wakes the ratio converges on the true duty cycle. It is sampling, not timing, and it is the same idea a sampling profiler uses.

node> info
...
power awake 0.2 %, 412 Stop entries

Take one 10-second sample period with the illustrative currents above, and assume a pair of AA cells (about 2500 mAh usable).

Activity per 10 sTimeCurrentCharge (mA·ms)
Sample: wake, start conversion, read, report over UART8 ms40 mA320
Heartbeat wakes (2.5 per period)2.5 × 0.5 ms40 mA50
Stop for the restabout 9990 ms0.3 mA2997
Total10,000 ms3367

Average current is 3367 mA·ms / 10,000 ms = 0.34 mA, so 2500 mAh lasts about 7400 hours, roughly 10 months. Requirement R8 holds: 89 % of the charge goes to Stop mode. It also tells you where the next win is. Shortening the awake time further barely matters; halving the Stop current nearly doubles the battery life, which means a different MCU, not cleverer code. The architecture, an event loop that sleeps until the next deadline, carries over unchanged.

Everything above the hardware that is pure logic is tested on your PC, where a test runs in milliseconds and a sanitizer catches memory errors. Capstone II wrote the sensor tests. Three more files cover the CRCs, the protocol parser, and the configuration store, the last one through a fake flash that enforces the one rule real flash enforces silently.

tests/fake_flash.h
#ifndef FAKE_FLASH_H
#define FAKE_FLASH_H
#include <stdint.h>
/* RAM stand-in for sectors 8-11 (0x08080000-0x080FFFFF) behind flash.h. */
void fake_flash_reset(void); /* everything erased (0xFF) */
uint8_t *fake_flash_ptr(uint32_t addr); /* for tests that corrupt bytes */
extern uint32_t fake_flash_erases;
#endif /* FAKE_FLASH_H */
tests/fake_flash.c
#include <string.h>
#include "fake_flash.h"
#include "flash.h"
#define FAKE_BASE 0x08080000u
#define FAKE_SIZE 0x00080000u
static uint8_t s_mem[FAKE_SIZE];
uint32_t fake_flash_erases;
static bool in_range(uint32_t addr, size_t len)
{
return addr >= FAKE_BASE && (addr - FAKE_BASE) + len <= FAKE_SIZE;
}
void fake_flash_reset(void)
{
memset(s_mem, 0xFF, sizeof s_mem);
fake_flash_erases = 0u;
}
uint8_t *fake_flash_ptr(uint32_t addr)
{
return &s_mem[addr - FAKE_BASE];
}
err_t flash_erase_sector(uint32_t sector)
{
if (sector < 8u || sector > 11u) {
return ERR_PARAM;
}
memset(&s_mem[(sector - 8u) * 0x20000u], 0xFF, 0x20000u);
fake_flash_erases++;
return ERR_OK;
}
err_t flash_program(uint32_t addr, const void *data, size_t len)
{
if (!in_range(addr, len) || (addr & 3u) != 0u || (len & 3u) != 0u) {
return ERR_PARAM;
}
const uint8_t *src = data;
for (size_t i = 0; i < len; i++) {
uint8_t *cell = &s_mem[addr - FAKE_BASE + i];
if ((*cell & src[i]) != src[i]) {
return ERR_FLASH; /* would need a 0 -> 1 change: not erased */
}
*cell = src[i];
}
return ERR_OK;
}
void flash_read(uint32_t addr, void *dst, size_t len)
{
memcpy(dst, &s_mem[addr - FAKE_BASE], len);
}
bool flash_is_erased(uint32_t addr, size_t len)
{
for (size_t i = 0; i < len; i++) {
if (s_mem[addr - FAKE_BASE + i] != 0xFFu) {
return false;
}
}
return true;
}

The fake implements the four functions of flash.h over a 512 KB array, so config.c links against it unchanged. Its important line is the 1-to-0 check: real flash programs a non-erased word by ANDing old and new bits and reports nothing, which turns a missing erase into silent corruption. The fake makes that bug a test failure. It is the “fake flash with a byte array” from Testing embedded software, with one rule added.

tests/test_crc.c
#include "crc.h"
#include "test.h"
static const char check[] = "123456789"; /* the standard CRC check input */
static void test_crc32_check_value(void)
{
CHECK(crc32(check, 9) == 0xCBF43926u);
}
static void test_crc32_chains(void)
{
uint32_t c = crc32_update(0u, check, 4);
c = crc32_update(c, check + 4, 5);
CHECK(c == 0xCBF43926u);
}
static void test_crc16_check_value(void)
{
CHECK(crc16_ccitt(check, 9) == 0x29B1u);
}
static void test_empty_input(void)
{
CHECK(crc32(check, 0) == 0u);
CHECK(crc16_ccitt(check, 0) == 0xFFFFu);
}
int main(void)
{
RUN(test_crc32_check_value);
RUN(test_crc32_chains);
RUN(test_crc16_check_value);
RUN(test_empty_input);
return TEST_EXIT();
}
tests/test_proto.c
#include <stdbool.h>
#include <string.h>
#include "proto.h"
#include "test.h"
static proto_rx_t rx;
static bool feed(const uint8_t *buf, size_t len, uint32_t now)
{
bool done = false;
for (size_t i = 0; i < len; i++) {
done = proto_rx_feed(&rx, buf[i], now) || done;
}
return done;
}
static void test_roundtrip(void)
{
uint8_t payload[5] = { 1, 2, 3, 4, 5 };
uint8_t buf[32];
size_t n = proto_encode(buf, sizeof buf, MSG_ACK, 7, payload, sizeof payload);
CHECK(n == sizeof payload + PROTO_OVERHEAD);
proto_rx_init(&rx);
CHECK(feed(buf, n, 0));
CHECK(rx.frame.type == MSG_ACK);
CHECK(rx.frame.seq == 7);
CHECK(rx.frame.len == 5);
CHECK(memcmp(rx.frame.payload, payload, 5) == 0);
}
static void test_text_before_frame_is_skipped(void)
{
uint8_t buf[40] = "hello\r\nnode> "; /* 13 characters of shell output */
size_t n = proto_encode(buf + 13, sizeof buf - 13, MSG_SAMPLE, 1, NULL, 0);
proto_rx_init(&rx);
CHECK(feed(buf, 13 + n, 0));
CHECK(rx.frame.type == MSG_SAMPLE);
}
static void test_corrupt_byte_is_rejected(void)
{
uint8_t payload[4] = { 9, 9, 9, 9 };
uint8_t buf[16];
size_t n = proto_encode(buf, sizeof buf, MSG_UPD_DATA, 1, payload, sizeof payload);
buf[7] ^= 0x10u; /* flip one payload bit */
proto_rx_init(&rx);
CHECK(!feed(buf, n, 0));
CHECK(rx.crc_errors == 1u);
}
static void test_oversized_length_is_rejected(void)
{
const uint8_t buf[] = { 0xA5, 0x5A, 0x11, 0x00, 0xFF, 0xFF };
proto_rx_init(&rx);
CHECK(!feed(buf, sizeof buf, 0));
CHECK(rx.len_errors == 1u);
}
static void test_silence_resynchronizes(void)
{
uint8_t buf[16];
size_t n = proto_encode(buf, sizeof buf, MSG_UPD_END, 3, NULL, 0);
proto_rx_init(&rx);
CHECK(!feed(buf, 4, 0)); /* half a frame at t = 0 ms ... */
CHECK(feed(buf, n, 1000)); /* ... a whole one at t = 1000 ms */
CHECK(rx.frame.seq == 3);
}
int main(void)
{
RUN(test_roundtrip);
RUN(test_text_before_frame_is_skipped);
RUN(test_corrupt_byte_is_rejected);
RUN(test_oversized_length_is_rejected);
RUN(test_silence_resynchronizes);
return TEST_EXIT();
}
tests/test_config.c
#include <string.h>
#include "config.h"
#include "fake_flash.h"
#include "flash.h"
#include "flash_layout.h"
#include "test.h"
static void fresh(void)
{
fake_flash_reset();
(void)cfg_load();
}
static void test_fresh_device_uses_defaults(void)
{
fake_flash_reset();
CHECK(cfg_load() == ERR_NOT_FOUND);
CHECK(cfg_get()->sample_period_s == 10u);
CHECK(cfg_sequence() == 0u);
}
static void test_value_survives_reload(void)
{
fresh();
CHECK(cfg_set("period", "30") == ERR_OK);
CHECK(cfg_load() == ERR_OK); /* what the next boot sees */
CHECK(cfg_get()->sample_period_s == 30u);
}
static void test_saves_alternate_banks(void)
{
fresh();
(void)cfg_set("period", "30"); /* sequence 1 -> bank A */
(void)cfg_set("period", "40"); /* sequence 2 -> bank B */
uint32_t seq_a, seq_b; /* little-endian host assumed */
memcpy(&seq_a, fake_flash_ptr(CFG_A_BASE + 8), 4);
memcpy(&seq_b, fake_flash_ptr(CFG_B_BASE + 8), 4);
CHECK(seq_a == 1u);
CHECK(seq_b == 2u);
}
static void test_corrupt_newest_falls_back(void)
{
fresh();
(void)cfg_set("period", "30");
(void)cfg_set("period", "40");
fake_flash_ptr(CFG_B_BASE + 12)[0] ^= 0x01u; /* one bit in bank B's payload */
CHECK(cfg_load() == ERR_OK);
CHECK(cfg_get()->sample_period_s == 30u);
CHECK(cfg_sequence() == 1u);
}
static void test_power_cut_after_erase(void)
{
fresh();
(void)cfg_set("period", "30"); /* bank A holds sequence 1 */
(void)flash_erase_sector(CFG_B_SECTOR); /* next save erased B, then power died */
CHECK(cfg_load() == ERR_OK);
CHECK(cfg_get()->sample_period_s == 30u);
}
static void test_bad_input_writes_nothing(void)
{
fresh();
uint32_t erases = fake_flash_erases;
CHECK(cfg_set("period", "1") == ERR_RANGE);
CHECK(cfg_set("period", "abc") == ERR_PARAM);
CHECK(cfg_set("period", "99999999999") == ERR_PARAM); /* overflows int32_t */
CHECK(cfg_set("log_every", "257") == ERR_RANGE); /* would truncate to 1 */
CHECK(cfg_set("colour", "5") == ERR_NOT_FOUND);
CHECK(cfg_set("period", "10") == ERR_OK); /* unchanged: no save */
CHECK(fake_flash_erases == erases);
}
int main(void)
{
RUN(test_fresh_device_uses_defaults);
RUN(test_value_survives_reload);
RUN(test_saves_alternate_banks);
RUN(test_corrupt_newest_falls_back);
RUN(test_power_cut_after_erase);
RUN(test_bad_input_writes_nothing);
return TEST_EXIT();
}
tests/Makefile
HOSTCC ?= cc
CFLAGS := -std=c11 -Wall -Wextra -Wpedantic -Wshadow -g -O1 \
-fsanitize=address,undefined -fno-sanitize-recover=all \
-I. -I../common -I../app/drivers -I../app/core -I../app/storage
TESTS := test_th20 test_crc test_proto test_config
.PHONY: all clean
all: $(TESTS)
@for t in $(TESTS); do echo "== $$t"; ./$$t || exit 1; done
test_th20: test_th20.c fake_bus.c ../app/drivers/th20.c
test_crc: test_crc.c ../common/crc.c
test_proto: test_proto.c ../app/core/proto.c ../common/crc.c
test_config: test_config.c fake_flash.c ../app/storage/config.c ../common/crc.c
$(TESTS):
$(HOSTCC) $(CFLAGS) $^ -o $@
clean:
rm -f $(TESTS)

Each test binary links exactly the production source under test plus its fakes, never the whole firmware, so a test cannot accidentally depend on hardware. The protocol tests feed the parser the inputs a real link produces: shell text ahead of a frame, a flipped bit, a corrupted length, and a frame cut off and followed by silence (with the timestamp argument standing in for a clock, the “fake clock” technique). The config tests replay the power-cut analysis from Capstone IV as code: corrupt the newest copy, or erase the target bank and stop, and the older configuration must load. The last config test also checks that rejected input never erases anything, which protects flash endurance. -fno-sanitize-recover=all makes the first sanitizer report fatal, so CI cannot miss it. Run make test:

== test_th20
15 checks, 0 failures
== test_crc
5 checks, 0 failures
== test_proto
15 checks, 0 failures
== test_config
20 checks, 0 failures

(Each test also prints its name as it runs; those lines are omitted here.) The tests use HOSTCC rather than CC so that a CC set for the cross compiler can never leak into the host build.

Host tests prove the logic. The Renode suite proves the integrated firmware: the real bootloader, the real images, the real drivers against simulated peripherals. It is written for renode-test, which runs Robot Framework test files against Renode, and it uses only a handful of keywords: execute a monitor command, attach a tester to a UART, wait for a line, write a line.

renode/sensor_node.robot
*** Settings ***
Suite Setup Setup
Suite Teardown Teardown
Test Teardown Test Teardown
Resource ${RENODEKEYWORDS}
*** Variables ***
${BUILD} ${CURDIR}/../build
${V11} ${CURDIR}/../build-v11
${UART} sysbus.usart2
*** Keywords ***
Create Node
[Arguments] ${slot_b_image}=${EMPTY}
Execute Command mach create "sensor-node"
Execute Command include @${CURDIR}/th20.cs
Execute Command machine LoadPlatformDescription @platforms/boards/stm32f4_discovery-kit.repl
Execute Command machine LoadPlatformDescriptionFromString "th20: Sensors.TH20 @ i2c1 0x40"
Execute Command sysbus LoadBinary @${BUILD}/erased.bin 0x08008000
Execute Command sysbus LoadELF @${BUILD}/boot/boot.elf
Execute Command sysbus LoadBinary @${BUILD}/a/app.img 0x08008000
IF '${slot_b_image}' != ''
Execute Command sysbus LoadBinary @${slot_b_image} 0x08040000
END
Execute Command sysbus.cpu VectorTableOffset 0x08000000
Create Terminal Tester ${UART} timeout=60
*** Test Cases ***
Boots Slot A And Reports A Sample
Create Node
Start Emulation
Wait For Line On Uart boot: slot A
Wait For Line On Uart sensor-node
Wait For Line On Uart sample 1 t=21.50 C rh=45.00 %
Reading Follows The Sensor Model
Create Node
Start Emulation
Wait For Line On Uart sample 1
Execute Command sysbus.i2c1.th20 Temperature -7.25
Wait For Line On Uart t=-7.25 C
Shell Reports Health
Create Node
Start Emulation
Wait For Prompt On Uart node>
Write Line To Uart info
Wait For Line On Uart crashes 0
Wait For Line On Uart events dropped 0
Config Survives A Reboot
Create Node
Start Emulation
Wait For Prompt On Uart node>
Write Line To Uart cfg set period 5
Wait For Line On Uart cfg set: ok
Write Line To Uart reboot
Wait For Line On Uart boot: slot A
Wait For Prompt On Uart node>
Write Line To Uart info
Wait For Line On Uart period 5 s
Newer Image In Slot B Boots On Trial
Create Node ${V11}/b/app.img
Start Emulation
Wait For Line On Uart boot: slot B (trial), version 0x00010100
Corrupted Image Is Never Booted
Create Node ${V11}/b/app.img
Execute Command sysbus WriteDoubleWord 0x08041000 0x12345678
Start Emulation
Wait For Line On Uart boot: slot A

The suite builds the machine itself instead of including sensor_node.resc, because a headless test run has no use for the analyzer window and the TCP socket. Each test starts from a freshly erased flash and checks one requirement from Capstone I: R1 and R2 (a sample is reported), the sensor path end to end (a value set in the model appears in the report), R3 and R7 (the shell answers, nothing has crashed or been dropped), R4 (configuration persists across a reset), R6 (a newer image is preferred and starts on trial), and R6 again (a single corrupted word makes the bootloader fall back). Note that Robot Framework separates arguments with two or more spaces, so every expected string here contains single spaces only.

Build both versions and run it:

Terminal window
make SIM=1 all build/erased.bin
make SIM=1 images VERSION_MINOR=1 BUILD=build-v11
renode-test renode/sensor_node.robot
+++++ Finished test 'sensor_node.Boots Slot A And Reports A Sample' in 2.10 seconds with status OK
+++++ Finished test 'sensor_node.Reading Follows The Sensor Model' in 2.35 seconds with status OK
...
Tests finished successfully :)

The exact report format depends on your Renode version, as does whether the Setup, Teardown and ${RENODEKEYWORDS} lines are still needed (recent versions provide them implicitly). The rollback test from Capstone IV is left out of this suite on purpose: it depends on RAM surviving a software reset in the simulator, which you should verify on your Renode version before relying on it in CI.

Add the release machinery to the Makefile from Capstone I.

Makefile (additions)
APP_FLASH_BUDGET ?= 65536
BOOT_FLASH_BUDGET ?= 16384
APP_RAM_BUDGET ?= 32768
REL := release/$(VERSION_STR)
.PHONY: budget layers simtest release
budget: $(BUILD)/boot/boot.elf $(BUILD)/a/app.elf
@$(SIZE) $(BUILD)/a/app.elf | awk -v f=$(APP_FLASH_BUDGET) -v r=$(APP_RAM_BUDGET) \
'NR == 2 { fl = $$1 + $$2; ram = $$2 + $$3; \
printf "app : flash %6d of %d, ram %6d of %d\n", fl, f, ram, r; \
if (fl > f || ram > r) { print "app over budget"; exit 1 } }'
@$(SIZE) $(BUILD)/boot/boot.elf | awk -v f=$(BOOT_FLASH_BUDGET) \
'NR == 2 { fl = $$1 + $$2; printf "boot: flash %6d of %d\n", fl, f; \
if (fl > f) { print "bootloader over budget"; exit 1 } }'
layers:
@! grep -rnE '#include "(app|event|sched|config|evlog|update|log|shell|proto|th20|bus|i2c|clock|systick|power|wdg)\.h"' common/ \
|| (echo "common/ includes an application header"; exit 1)
simtest:
$(MAKE) SIM=1 all $(BUILD)/erased.bin
$(MAKE) SIM=1 images VERSION_MINOR=$$(($(VERSION_MINOR) + 1)) BUILD=build-v11
renode-test renode/sensor_node.robot
release:
@git diff --quiet HEAD || (echo "refusing to release from a dirty tree"; exit 1)
$(MAKE) clean
$(MAKE) all OPT=-Os SIM=0
$(MAKE) test budget layers
@mkdir -p $(REL)
cp $(BUILD)/boot/boot.elf $(BUILD)/boot/boot.map $(REL)/
for s in a b; do \
cp $(BUILD)/$$s/app.img $(REL)/app_$$s.img; \
cp $(BUILD)/$$s/app.elf $(REL)/app_$$s.elf; \
cp $(BUILD)/$$s/app.map $(REL)/app_$$s.map; \
done
cd $(REL) && sha256sum * > SHA256SUMS
@echo "release $(VERSION_STR) in $(REL)"

budget reads the Berkeley output of arm-none-eabi-size (text, data, bss): flash use is text plus data, because initialized data has its load image in flash, and static RAM is data plus bss. The build fails when either exceeds its budget, so growth is a decision someone makes in a code review rather than something discovered when the linker finally overflows. A typical result for this project at -Os is in the low tens of kilobytes of flash for the application, a few kilobytes for the bootloader, and under 4 KB of static RAM plus the 4 KB stack reserve; your numbers depend on the compiler version.

layers enforces the dependency rule from Capstone I with a single grep: no file in common/ may include an application header. The bootloader build would also catch it, but only when someone builds the bootloader; this check runs every time.

simtest builds the simulation variant and the “next version” image the suite needs, then runs it. The shell arithmetic $$(($(VERSION_MINOR) + 1)) makes the second image exactly one minor version newer, whatever the current version is.

release refuses to run from a tree with uncommitted changes, because then git describe would end in -dirty and the binary could never be rebuilt from a commit. It cleans, builds the hardware variant at -Os, runs the host tests, the budgets and the layer check, then copies the bootloader, both slot images, the ELFs (for addr2line on crash reports from the field) and the map files into a directory named after the version, with SHA-256 checksums. Because nothing in the build embeds a date or a path, building the same commit twice produces byte-identical images, which you can verify by comparing SHA256SUMS from two clean checkouts (exercise 4).

Automate what you can; walk through the rest by hand before tagging.

  • make release passed on a clean checkout of the tag, and make simtest passed on the same commit.
  • The version number was raised (the update receiver refuses equal versions), and the tag matches version_str in both images.
  • On hardware: flash the bootloader and slot A with OpenOCD, power-cycle, and see boot: slot A (trial) followed by trial image confirmed after 30 seconds.
  • On hardware: update from the previous release to this one with fwupdate.py, and from this one to a test build one minor version higher. Both must confirm.
  • On hardware: pull power in the middle of an update. The old image must still boot.
  • On hardware: run hang (the exercise from Capstone III, compiled into a test build only) and confirm the watchdog reset is reported as reset cause watchdog.
  • Measure sleep current with a debugger disconnected (an attached probe keeps debug clocks running in Stop and inflates the reading) and record it next to the battery-life estimate.
  • No debug-only commands, test hooks, or SIM=1 code in the release images: check the map file for symbols that should not be there.
  • Archive the release directory, including ELFs and map files, for as long as devices in the field run this version.
  • Release notes list user-visible changes, the config layout version, and whether the new image can roll back to the previous one.

Each of these is a real step products take, and each builds on a lesson from the course.

  1. Add BLE. Pair the F407 with a BLE module or port to an nRF52840. Report samples as GATT notifications and move the update transport onto BLE, keeping the same frame format above it.
  2. Add LittleFS. Put an SPI NOR flash on the board and store the event log in LittleFS through a block-device driver. Keep the evlog.h interface and replace only evlog.c.
  3. Port to FreeRTOS. Turn the state machine into an active object with its own queue and task, run the update receiver in a lower-priority task, and keep the watchdog liveness scheme by having each task check in.
  4. Secure boot with Ed25519. Sign images in CI, embed the public key in the bootloader, and verify the signature in place of trusting the CRC. Keep the CRC as a fast integrity check before the slower signature check.
  5. Real time. Fit a 32.768 kHz crystal, drive the RTC from the LSE, add a set-time protocol message, and timestamp log records with wall-clock time.
  6. Interrupt and DMA UART. Replace polled transmit with DMA, use the idle-line interrupt to receive update frames, and measure how much awake time each wake loses.
  7. Port to a low-power part. Move to an STM32L4 by writing a new register header, clock driver and power module. Everything above the platform layer should compile unchanged; measure the new battery life.
  8. Encrypted updates and anti-rollback. Encrypt images with AES-GCM so they can travel over untrusted links, and keep a monotonic minimum-version counter in flash that the bootloader enforces.
  9. Hardware-in-the-loop tests. Run the Robot suite’s scenarios against a real board through a USB-UART adapter and a relay on its power supply, including the power-cut-during-update test, in CI.
  10. Thresholds and alarms. Add configurable high and low limits with hysteresis, report alarms immediately instead of waiting for the next period, and add an alarm event type to the log.
  • Entering Stop with a character still in the UART shift register. The last byte of a log line is truncated. uart_flush waits for TC before every Stop entry.
  • Forgetting that the PLL is off after Stop. The firmware keeps running at 16 MHz with every baud rate and timer computed for 168 MHz. Re-run the clock setup on every wake.
  • Measuring sleep current with the debugger attached. The probe keeps clocks alive and the reading is off by milliamps. Disconnect it, and check the debug configuration bits are not left set by a previous session.
  • Letting the IWDG timeout be shorter than the longest sleep. The watchdog keeps running in Stop, and a node that sleeps longer than the timeout resets forever. Wake (here: the 4-second heartbeat) well inside the shortest timeout the LSI tolerance allows.
  • Unmasking the UART wake line while awake. Every falling edge on RX becomes an interrupt. Unmask it only around Stop.
  • Shipping a build that cannot be rebuilt. A dirty tree, a __DATE__, or an absolute path in the binary means a crash report from the field cannot be matched to an ELF. Refuse dirty releases and keep builds reproducible.
  • Tests that link the whole firmware. They need hardware stubs for everything and test nothing precisely. Link the unit under test and its fakes only.
  1. Run the node in Renode with SIM=1 for ten simulated minutes, then read info. Explain the awake percentage you see using the sampling argument from this lesson. Then lower the sample period to 2 seconds with cfg set period 2 and predict, before measuring, how the percentage changes.
  2. Calibrate the LSI on hardware: route the LSI to a timer input capture (on the F4, TIM5 channel 4 can be connected to the LSI internally), measure its period against the 168 MHz clock, and scale RTC_WUT_TICKS_PER_MS accordingly. Measure the sample period with a scope on PD15 before and after. Correct result: periods within about 1 % of the configured value after calibration.
  3. Add -fstack-usage to CFLAGS, rebuild, and find the deepest functions in the .su files (cat build/a/app/core/*.su | sort -t$'\t' -k2 -n | tail). Add up the frames on the deepest shell path, main to app_dispatch_events to shell_input to cmd_cfg to log_print to vsnprintf, add the exception frames of the interrupts that can nest on top of it (SysTick, the RTC wake-up, USART2), and compare the total with the 4 KB reserved in the linker scripts.
  4. Prove the build is reproducible: clone the repository twice into different directories, run make release in both, and compare the two SHA256SUMS files. If they differ, find out why with cmp -l on the images and the map files.
  5. Redo the battery-life table for an STM32L4 with 2 µA in Stop 2 and 10 mA in run mode at 80 MHz, assuming the awake time doubles at the lower clock. Which line of the table now dominates, and what would you optimize next?

Question 1:In Stop mode the RTC wakes the core after the requested 10 seconds. The LSI on this particular chip runs at 25 kHz instead of 32 kHz. How long did the node really sleep, and what does the firmware believe?

Question 2:After waking from Stop, the shell prints garbage until the next reset. Which step is most likely missing?

Question 3:The counters say the node was awake 0.2 % of the time, yet each wake lasts less than 1 ms and the counters only have 1 ms resolution. Why is the figure still meaningful?

Question 4:In the battery-life table, average current is 0.34 mA, of which about 0.30 mA is Stop current. Which change extends battery life the most?

Question 5:Why does the release target refuse to run when git diff --quiet HEAD fails?

  • Sleep current dominates a duty-cycled device: move from Sleep to Stop between events, and measure before optimizing awake time.
  • Stop mode stops the clocks: flush the UART first, wake through EXTI (RTC line 22, RX pin line 3), restart the PLL on wake, and correct the time base.
  • The watchdog keeps running in Stop, so the longest sleep must stay well inside its shortest possible timeout.
  • Test pure logic on the host with fakes that enforce hardware rules, and test the integrated images in a simulator against the written requirements.
  • A release is reproducible, built from a clean tagged commit, checked against flash and RAM budgets, and archived with its ELF and map files.
  • The architecture built here, an event queue, a cooperative scheduler, a state machine, and a validated two-slot update, is the skeleton of most small connected products; extend it rather than replace it.

Next: C quick reference, the first of the reference pages that collect the tables you will keep returning to.