Skip to content

Capstone Project45 min read

Capstone IV — Storage, Bootloader, and Updates

This lesson makes the node keep its state and accept new firmware in the field. You will write the flash driver, replace the storage stubs from Capstone III with a power-fail-safe A/B configuration store, a circular event log, and an update receiver, then write the bootloader that validates both slots, picks one, counts trial boots, and rolls back an image that never confirms itself. Two Python tools complete the chain: one patches the image header after linking, the other sends an image over the UART. At the end you will update the node in Renode from version 1.0.0 to 1.1.0, then push a 1.2.0 that “fails” and watch the bootloader put 1.1.0 back.

common/flash.h
#ifndef FLASH_H
#define FLASH_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "err.h"
err_t flash_erase_sector(uint32_t sector); /* 0..11, blocks up to seconds */
err_t flash_program(uint32_t addr, const void *data, size_t len); /* addr, len multiples of 4 */
void flash_read(uint32_t addr, void *dst, size_t len);
bool flash_is_erased(uint32_t addr, size_t len); /* addr, len multiples of 4 */
#endif /* FLASH_H */
common/flash.c
#include <string.h>
#include "flash.h"
#include "stm32f407_regs.h"
static void unlock(void)
{
if ((FLASH->CR & FLASH_CR_LOCK) != 0u) {
FLASH->KEYR = FLASH_KEY1;
FLASH->KEYR = FLASH_KEY2;
}
}
static void lock(void)
{
FLASH->CR = FLASH_CR_LOCK; /* also clears PG and SER */
}
static void wait_idle_and_clear(void)
{
while ((FLASH->SR & FLASH_SR_BSY) != 0u) { }
FLASH->SR = FLASH_SR_EOP | FLASH_SR_ERRORS; /* write 1 to clear stale flags */
}
static err_t wait_done(void)
{
while ((FLASH->SR & FLASH_SR_BSY) != 0u) { }
uint32_t sr = FLASH->SR;
FLASH->SR = sr & (FLASH_SR_EOP | FLASH_SR_ERRORS);
return ((sr & FLASH_SR_ERRORS) != 0u) ? ERR_FLASH : ERR_OK;
}
/* The ART data cache may still hold the old contents of what we just changed. */
static void flush_data_cache(void)
{
FLASH->ACR &= ~FLASH_ACR_DCEN;
FLASH->ACR |= FLASH_ACR_DCRST;
FLASH->ACR &= ~FLASH_ACR_DCRST;
FLASH->ACR |= FLASH_ACR_DCEN;
}
err_t flash_erase_sector(uint32_t sector)
{
if (sector > 11u) {
return ERR_PARAM;
}
unlock();
wait_idle_and_clear();
FLASH->CR = FLASH_CR_PSIZE_X32 | FLASH_CR_SER | (sector << FLASH_CR_SNB_Pos);
FLASH->CR |= FLASH_CR_STRT;
err_t e = wait_done();
lock();
flush_data_cache();
return e;
}
err_t flash_program(uint32_t addr, const void *data, size_t len)
{
if ((addr & 3u) != 0u || (len & 3u) != 0u) {
return ERR_PARAM;
}
const uint8_t *src = data;
err_t e = ERR_OK;
unlock();
wait_idle_and_clear();
FLASH->CR = FLASH_CR_PSIZE_X32 | FLASH_CR_PG;
for (size_t i = 0; i < len && e == ERR_OK; i += 4u) {
uint32_t word;
memcpy(&word, src + i, 4u); /* source may be unaligned */
*(volatile uint32_t *)(addr + i) = word;
e = wait_done();
}
lock();
flush_data_cache();
if (e == ERR_OK && memcmp((const void *)addr, data, len) != 0) {
e = ERR_FLASH; /* read back and verify */
}
return e;
}
void flash_read(uint32_t addr, void *dst, size_t len)
{
memcpy(dst, (const void *)addr, len);
}
bool flash_is_erased(uint32_t addr, size_t len)
{
const volatile uint32_t *p = (const volatile uint32_t *)addr;
for (size_t i = 0; i < len / 4u; i++) {
if (p[i] != 0xFFFFFFFFu) {
return false;
}
}
return true;
}

The register sequences are the ones from Flash, EEPROM, and non-volatile storage: unlock with the two keys, wait for BSY to clear, clear stale error flags, set the operation bits, start, wait, check the five error flags, lock. The product-level decisions are these.

32-bit programming only. PSIZE = x32 requires a supply of 2.7 to 3.6 V, which a 3.3 V regulator provides. Every structure written to flash (config record, log record, image header, trailer word) is therefore a multiple of 4 bytes, and the _Static_asserts on their sizes protect that.

Verify after write. A flash cell that is worn out or a word that was not erased first programs to the wrong value with no error flag (programming can only turn 1s into 0s). The only way to know is to read back. Before reading, the ART data cache is reset, because it may hold a line read before the program or erase and would happily return the stale copy.

Unbounded BSY waits, on purpose. A 128 KB sector erase takes 1 to 2 s typically and up to about 4 s at 32-bit parallelism on this part. There is no sensible shorter timeout, and a flash controller that never finishes is a hardware failure the watchdog handles (the shortest IWDG timeout, about 8 s, is longer than the longest erase).

What the rest of the chip does meanwhile. The F407 has a single flash bank, so while an erase or program is running, any instruction fetch or data read from flash stalls the core until it finishes. Interrupt handlers live in flash too, so interrupts are delayed by up to the whole erase time. The USART can buffer one byte, so at 115200 baud anything received during a 2-second erase beyond the first byte is lost. The design accounts for this: the update protocol is lockstep (the host waits for an ACK, so it never sends during an erase), and config saves happen only on an explicit shell command. The alternative, copying the flash routines and the vector table into RAM, is described in Flash, EEPROM, and non-volatile storage; it is required on parts where executing from a bank being erased is forbidden rather than merely slow.

Delete app/storage/storage_stubs.c now; the three files below replace it, and the interfaces in config.h, evlog.h and update.h from Capstone III do not change.

app/storage/config.c
#include <stdbool.h>
#include <stddef.h>
#include <string.h>
#include "config.h"
#include "crc.h"
#include "flash.h"
#include "flash_layout.h"
#define CFG_MAGIC 0x43464731u /* "CFG1" */
#define CFG_VERSION 1u
typedef struct {
uint32_t magic;
uint16_t version; /* layout version of cfg_t */
uint16_t length; /* sizeof(cfg_record_t) */
uint32_t sequence; /* +1 on every save; newest wins */
cfg_t cfg;
uint32_t crc; /* CRC-32 of every field above */
} cfg_record_t;
_Static_assert(sizeof(cfg_record_t) == 32, "cfg_record_t layout changed");
static const uint32_t s_bank_addr[2] = { CFG_A_BASE, CFG_B_BASE };
static const uint32_t s_bank_sector[2] = { CFG_A_SECTOR, CFG_B_SECTOR };
static cfg_t s_cfg;
static uint32_t s_seq; /* sequence of the record s_cfg came from */
static int s_bank = -1; /* bank holding that record, -1 if none */
static void cfg_defaults(cfg_t *c)
{
*c = (cfg_t){ .sample_period_s = 10u, .report_mode = REPORT_TEXT, .log_every = 6u,
.verbose = 2u, .node_id = 1u };
}
static bool cfg_valid(const cfg_t *c)
{
return c->sample_period_s >= 2u && c->sample_period_s <= 3600u
&& c->report_mode <= REPORT_BINARY && c->log_every >= 1u && c->verbose <= 3u
&& c->temp_offset_cdeg >= -1000 && c->temp_offset_cdeg <= 1000
&& c->hum_offset_cpct >= -1000 && c->hum_offset_cpct <= 1000;
}
static bool record_valid(const cfg_record_t *r)
{
return r->magic == CFG_MAGIC && r->version == CFG_VERSION
&& r->length == (uint16_t)sizeof(cfg_record_t)
&& r->crc == crc32(r, offsetof(cfg_record_t, crc))
&& cfg_valid(&r->cfg);
}
err_t cfg_load(void)
{
cfg_record_t r[2];
bool ok[2];
for (int i = 0; i < 2; i++) {
flash_read(s_bank_addr[i], &r[i], sizeof r[i]);
ok[i] = record_valid(&r[i]);
}
int best = -1;
if (ok[0] && ok[1]) {
best = ((int32_t)(r[1].sequence - r[0].sequence) > 0) ? 1 : 0;
} else if (ok[0]) {
best = 0;
} else if (ok[1]) {
best = 1;
}
if (best < 0) {
cfg_defaults(&s_cfg);
s_seq = 0u;
s_bank = -1;
return ERR_NOT_FOUND;
}
s_cfg = r[best].cfg;
s_seq = r[best].sequence;
s_bank = best;
return ERR_OK;
}
static err_t cfg_save(void)
{
int target = (s_bank == 0) ? 1 : 0; /* never touch the newest good copy */
cfg_record_t r = {
.magic = CFG_MAGIC, .version = CFG_VERSION, .length = (uint16_t)sizeof(cfg_record_t),
.sequence = s_seq + 1u, .cfg = s_cfg,
};
r.crc = crc32(&r, offsetof(cfg_record_t, crc));
err_t e = flash_erase_sector(s_bank_sector[target]);
if (e == ERR_OK) {
e = flash_program(s_bank_addr[target], &r, sizeof r);
}
if (e == ERR_OK) {
s_seq = r.sequence;
s_bank = target;
}
return e;
}
static bool parse_i32(const char *s, int32_t *out)
{
bool neg = (*s == '-');
if (neg) {
s++;
}
if (*s == '\0') {
return false;
}
uint32_t v = 0u;
for (; *s != '\0'; s++) {
if (*s < '0' || *s > '9') {
return false;
}
uint32_t d = (uint32_t)(*s - '0');
if (v > (0x7FFFFFFFu - d) / 10u) {
return false; /* would overflow int32_t */
}
v = v * 10u + d;
}
*out = neg ? -(int32_t)v : (int32_t)v;
return true;
}
err_t cfg_set(const char *key, const char *value)
{
cfg_t c = s_cfg;
int32_t v = 0;
if (strcmp(key, "mode") == 0) {
if (strcmp(value, "text") == 0) {
c.report_mode = REPORT_TEXT;
} else if (strcmp(value, "binary") == 0) {
c.report_mode = REPORT_BINARY;
} else {
return ERR_PARAM;
}
} else {
if (!parse_i32(value, &v)) {
return ERR_PARAM;
}
if (strcmp(key, "period") == 0) {
if (v < 2 || v > 3600) { return ERR_RANGE; }
c.sample_period_s = (uint32_t)v;
} else if (strcmp(key, "log_every") == 0) {
if (v < 1 || v > 255) { return ERR_RANGE; }
c.log_every = (uint8_t)v;
} else if (strcmp(key, "verbose") == 0) {
if (v < 0 || v > 3) { return ERR_RANGE; }
c.verbose = (uint8_t)v;
} else if (strcmp(key, "temp_off") == 0) {
if (v < -1000 || v > 1000) { return ERR_RANGE; }
c.temp_offset_cdeg = (int16_t)v;
} else if (strcmp(key, "hum_off") == 0) {
if (v < -1000 || v > 1000) { return ERR_RANGE; }
c.hum_offset_cpct = (int16_t)v;
} else if (strcmp(key, "node") == 0) {
if (v < 0) { return ERR_RANGE; }
c.node_id = (uint32_t)v;
} else {
return ERR_NOT_FOUND;
}
}
if (!cfg_valid(&c)) {
return ERR_RANGE;
}
if (memcmp(&c, &s_cfg, sizeof c) == 0) {
return ERR_OK; /* unchanged: spare the flash */
}
cfg_t old = s_cfg;
s_cfg = c;
err_t e = cfg_save();
if (e != ERR_OK) {
s_cfg = old; /* RAM matches what flash holds */
}
return e;
}
const cfg_t *cfg_get(void) { return &s_cfg; }
uint32_t cfg_sequence(void) { return s_seq; }

The store holds one 32-byte record in each of two sectors. The record carries a magic number, a layout version, its own length, a sequence number, the payload, and a CRC-32 over everything before the CRC.

Loading reads both copies, keeps the ones whose magic, version, length, CRC and field ranges all check out, and takes the one with the higher sequence number. The comparison (int32_t)(b - a) > 0 is the same wraparound-safe form used for time, so a sequence counter that wraps after four billion saves still works. If neither copy is valid (a new device, or both corrupted) the module uses defaults and reports ERR_NOT_FOUND, which the banner prints as a warning.

Saving always writes to the bank that does not hold the current record. Walk through a power cut at every point. Before the erase finishes: the other bank is untouched, so the old config loads. After the erase, before programming: the target is blank (magic is 0xFFFFFFFF), so the old config loads. In the middle of programming: the target record is incomplete and its CRC fails, so the old config loads. After programming: both are valid and the new one has the higher sequence number. There is no instant at which a power cut loses both, which is requirement R4.

Setting parses the value with a hand-written parse_i32 (no atoi, which cannot report errors or overflow), range-checks it before narrowing it into a uint8_t or int16_t (so log_every 257 is rejected instead of becoming 1), validates the whole structure, and skips the save if nothing changed. Each save costs one erase of a 128 KB sector, rated for 10,000 cycles. Ten changes a day alternate between the two banks, five erases per sector per day, which reaches the rated endurance after about 5.5 years; a config changed once a week lasts far longer than the product. If the save fails, the RAM copy reverts, so the running configuration always equals what flash will load at the next boot.

The version and length fields are for the future. Firmware 2.0 may add fields to cfg_t; it can recognize a version 1 record, read the fields it knows, and fill the new ones with defaults, instead of rejecting the user’s settings.

app/storage/evlog.c
#include <stdbool.h>
#include <stddef.h>
#include "crc.h"
#include "evlog.h"
#include "flash.h"
#include "flash_layout.h"
#include "systick.h"
_Static_assert(sizeof(evlog_rec_t) == 16, "evlog_rec_t layout changed");
#define REC_SIZE ((uint32_t)sizeof(evlog_rec_t))
#define RECS_PER_SECTOR (EVLOG_SECTOR_SIZE / REC_SIZE) /* 8192 */
#define TOTAL_RECS (RECS_PER_SECTOR * EVLOG_SECTOR_COUNT) /* 16384 */
static uint32_t s_next_index; /* slot for the next record */
static uint32_t s_next_seq = 1u;
static uint32_t s_count; /* valid records stored */
static uint32_t rec_addr(uint32_t index)
{
return EVLOG_BASE + index * REC_SIZE;
}
static bool read_valid(uint32_t index, evlog_rec_t *r)
{
flash_read(rec_addr(index), r, sizeof *r);
return r->seq != 0xFFFFFFFFu && r->crc == crc16_ccitt(r, offsetof(evlog_rec_t, crc));
}
static uint32_t count_valid(void)
{
uint32_t n = 0u;
evlog_rec_t r;
for (uint32_t i = 0; i < TOTAL_RECS; i++) {
if (read_valid(i, &r)) {
n++;
}
}
return n;
}
err_t evlog_init(void)
{
bool found = false;
uint32_t best_seq = 0u, best_index = 0u;
evlog_rec_t r;
s_count = 0u;
for (uint32_t i = 0; i < TOTAL_RECS; i++) {
if (!read_valid(i, &r)) {
continue;
}
s_count++;
if (!found || r.seq > best_seq) {
found = true;
best_seq = r.seq;
best_index = i;
}
}
s_next_seq = found ? best_seq + 1u : 1u;
s_next_index = found ? (best_index + 1u) % TOTAL_RECS : 0u;
return ERR_OK;
}
/* Make s_next_index point at a blank slot, erasing the oldest sector when we enter it. */
static err_t prepare_slot(void)
{
for (uint32_t guard = 0; guard < TOTAL_RECS; guard++) {
uint32_t idx = s_next_index;
if (idx % RECS_PER_SECTOR == 0u && !flash_is_erased(rec_addr(idx), EVLOG_SECTOR_SIZE)) {
err_t e = flash_erase_sector(EVLOG_FIRST_SECTOR + idx / RECS_PER_SECTOR);
if (e != ERR_OK) {
return e;
}
if (!flash_is_erased(rec_addr(idx), EVLOG_SECTOR_SIZE)) {
return ERR_FLASH;
}
s_count = count_valid();
}
if (flash_is_erased(rec_addr(idx), REC_SIZE)) {
return ERR_OK;
}
s_next_index = (idx + 1u) % TOTAL_RECS; /* skip a slot damaged by a power cut */
}
return ERR_FLASH;
}
err_t evlog_append(evlog_type_t type, int16_t a, uint16_t b)
{
err_t e = prepare_slot();
if (e != ERR_OK) {
return e;
}
evlog_rec_t r = { .seq = s_next_seq, .uptime_s = systick_now_ms() / 1000u,
.type = (uint8_t)type, .a = a, .b = b };
r.crc = crc16_ccitt(&r, offsetof(evlog_rec_t, crc));
e = flash_program(rec_addr(s_next_index), &r, sizeof r);
s_next_index = (s_next_index + 1u) % TOTAL_RECS; /* never program a slot twice */
if (e != ERR_OK) {
return e;
}
s_next_seq++;
s_count++;
return ERR_OK;
}
uint32_t evlog_dump(uint32_t max_records, evlog_visit_t visit)
{
uint32_t first_seq = (s_next_seq > max_records) ? s_next_seq - max_records : 0u;
uint32_t shown = 0u;
evlog_rec_t r;
for (uint32_t k = 0; k < TOTAL_RECS; k++) { /* physical order from the write point */
uint32_t i = (s_next_index + k) % TOTAL_RECS; /* is chronological order */
if (read_valid(i, &r) && r.seq >= first_seq) {
visit(&r);
shown++;
}
}
return shown;
}
uint32_t evlog_count(void)
{
return s_count;
}
const char *evlog_type_name(uint8_t type)
{
static const char *const names[] = { "?", "boot", "sample", "sensor-error", "config",
"update-ok", "update-fail", "confirmed" };
return (type < sizeof names / sizeof names[0]) ? names[type] : "?";
}

The log is an append-only ring of 16-byte records across two 128 KB sectors. Each record carries a sequence number and a CRC-16 over its other 14 bytes. Nothing is ever rewritten in place, which is the property that makes a flash log both power-fail safe and gentle on the cells.

Finding the end at boot. There is no “write pointer” stored anywhere, because storing it would mean rewriting it on every append. Instead, evlog_init scans every slot, and the slot after the record with the highest sequence number is the next write position. A scan of 16,384 slots takes a few tens of milliseconds at 168 MHz, because blank slots fail the seq != 0xFFFFFFFF test before any CRC is computed.

Wrapping. When the write position crosses into a sector, that sector holds the oldest records, and prepare_slot erases it first. The other sector keeps the most recent 8192 records, so at least one full sector of history always survives. The erase happens on an append, at most once every 8192 records: with the defaults (one record per minute) that is every 5.7 days, about 32 erases per sector per year.

Power cuts. A cut in the middle of programming leaves a slot that is neither blank nor valid. Its CRC fails, so it is not counted and never becomes “newest”; at the next boot prepare_slot sees the slot is not blank and skips it. evlog_append advances the write position even when programming fails, so a slot is never programmed twice. The guard counter and the post-erase check make the function return an error instead of looping forever if the flash cannot be erased at all.

Dumping. Walking the ring from the write position forward visits records oldest first, which is the order a person wants to read them in. log dump 10 shows the newest ten by skipping records whose sequence number is below next - 10.

The application receives a new image into the slot it is not running from. It never touches its own slot, so a failed or interrupted update leaves the running firmware exactly as it was.

host (fwupdate.py) node (update.c)
------------------ ---------------
"update\r\n" ------------> shell: update_begin(), prints "READY slot B",
<------------ mutes logging, UART now carries frames
UPD_BEGIN(header) ------------> check magic, header CRC, load_addr, size, version
erase slot B one sector per scheduler run
<------------ ACK(BEGIN, ok, next offset 0x200)
UPD_DATA(0x200, 256 B) ------------> program, verify
<------------ ACK(DATA, ok, 0x300)
... repeat ...
UPD_END ------------> CRC-32 of the whole body == header.image_crc ?
program the 64-byte header LAST (the commit)
<------------ ACK(END, ok, version)
EV_UPDATE_DONE -> reset -> bootloader
app/storage/update.c
#include <stddef.h>
#include <string.h>
#include "app.h"
#include "crc.h"
#include "event.h"
#include "flash.h"
#include "handoff.h"
#include "image.h"
#include "log.h"
#include "proto.h"
#include "sched.h"
#include "systick.h"
#include "uart.h"
#include "update.h"
#define UPDATE_IDLE_TIMEOUT_MS 10000u
#define UPDATE_TASK_MS 20u
#define HEADER_LEN ((uint16_t)sizeof(image_header_t))
typedef enum { UPD_IDLE, UPD_WAIT_BEGIN, UPD_ERASING, UPD_RECEIVING } upd_state_t;
static upd_state_t s_state = UPD_IDLE;
static proto_rx_t s_rx;
static image_header_t s_hdr; /* from BEGIN; written last, as the commit */
static uint32_t s_target; /* slot being written */
static uint32_t s_next_offset; /* next expected offset within the slot */
static uint32_t s_erase_next, s_erase_end;
static uint32_t s_last_activity_ms;
static uint8_t s_seq; /* sequence number of the frame being answered */
static const uint8_t s_first_sector[SLOT_COUNT] = { SLOT_A_FIRST_SECTOR, SLOT_B_FIRST_SECTOR };
static const uint8_t s_sector_count[SLOT_COUNT] = { SLOT_A_SECTOR_COUNT, SLOT_B_SECTOR_COUNT };
uint32_t update_running_slot(void) { return APP_SLOT; }
bool update_active(void) { return s_state != UPD_IDLE; }
static uint32_t inactive_slot(void)
{
return (APP_SLOT == 0) ? 1u : 0u;
}
static void send_ack(uint8_t type, err_t status, uint32_t value)
{
uint8_t p[8];
uint8_t frame[sizeof p + PROTO_OVERHEAD];
p[0] = type;
p[1] = (uint8_t)(int8_t)status;
p[2] = 0u;
p[3] = 0u;
put_le32(&p[4], value);
uart_write(frame, proto_encode(frame, sizeof frame, MSG_ACK, s_seq, p, sizeof p));
}
static void finish(err_t result)
{
s_state = UPD_IDLE;
sched_stop(TASK_UPDATE);
uart_flush();
log_mute(false);
(void)event_post(result == ERR_OK ? EV_UPDATE_DONE : EV_UPDATE_ABORT, (uint32_t)result);
}
static err_t check_header(const image_header_t *h, uint32_t slot)
{
if (h->magic != IMAGE_MAGIC || h->header_version != IMAGE_HEADER_VERSION) {
return ERR_PARAM;
}
if (crc32(h, offsetof(image_header_t, header_crc)) != h->header_crc) {
return ERR_CRC;
}
if (h->load_addr != slot_base(slot)) {
return ERR_PARAM; /* linked for the other slot */
}
if (h->image_size < 8u || h->image_size > IMAGE_MAX_SIZE || (h->image_size & 3u) != 0u) {
return ERR_RANGE;
}
if (h->fw_version <= app_image_header.fw_version) {
return ERR_VERSION; /* no downgrades, no re-installs */
}
return ERR_OK;
}
static void on_begin(const proto_frame_t *f)
{
if (s_state == UPD_ERASING) {
return; /* retransmission: the ACK follows the erase */
}
if (s_state == UPD_RECEIVING && f->len == HEADER_LEN &&
memcmp(f->payload, &s_hdr, sizeof s_hdr) == 0) {
send_ack(f->type, ERR_OK, s_next_offset); /* the host missed our ACK */
return;
}
if (s_state != UPD_WAIT_BEGIN || f->len != HEADER_LEN) {
send_ack(f->type, ERR_STATE, 0u);
return;
}
memcpy(&s_hdr, f->payload, sizeof s_hdr);
s_target = inactive_slot();
err_t e = check_header(&s_hdr, s_target);
if (e != ERR_OK) {
send_ack(f->type, e, 0u);
finish(e);
return;
}
s_erase_next = s_first_sector[s_target];
s_erase_end = s_erase_next + s_sector_count[s_target];
s_state = UPD_ERASING;
}
static void on_data(const proto_frame_t *f)
{
if (s_state != UPD_RECEIVING) {
send_ack(f->type, ERR_STATE, s_next_offset);
return;
}
if (f->len < 8u || ((f->len - 4u) & 3u) != 0u) {
send_ack(f->type, ERR_PARAM, s_next_offset);
return;
}
uint32_t offset = get_le32(f->payload);
uint32_t n = (uint32_t)f->len - 4u;
uint32_t end = IMAGE_HEADER_SIZE + s_hdr.image_size;
if (offset >= IMAGE_HEADER_SIZE && offset + n <= s_next_offset) {
send_ack(f->type, ERR_OK, s_next_offset); /* duplicate of a chunk we already have */
return;
}
if (offset != s_next_offset || offset + n > end) {
send_ack(f->type, ERR_PARAM, s_next_offset);
return;
}
err_t e = flash_program(slot_base(s_target) + offset, &f->payload[4], n);
if (e != ERR_OK) {
send_ack(f->type, e, s_next_offset);
finish(e);
return;
}
s_next_offset += n;
send_ack(f->type, ERR_OK, s_next_offset);
}
static void on_end(const proto_frame_t *f)
{
if (s_state != UPD_RECEIVING || s_next_offset != IMAGE_HEADER_SIZE + s_hdr.image_size) {
send_ack(f->type, ERR_STATE, s_next_offset);
return;
}
const void *body = (const void *)(slot_base(s_target) + IMAGE_HEADER_SIZE);
if (crc32(body, s_hdr.image_size) != s_hdr.image_crc) {
send_ack(f->type, ERR_CRC, 0u);
finish(ERR_CRC);
return;
}
err_t e = flash_program(slot_base(s_target), &s_hdr, sizeof s_hdr); /* commit */
send_ack(f->type, e, s_hdr.fw_version);
finish(e);
}
static void handle_frame(const proto_frame_t *f)
{
s_seq = f->seq;
s_last_activity_ms = systick_now_ms();
switch (f->type) {
case MSG_UPD_BEGIN: on_begin(f); break;
case MSG_UPD_DATA: on_data(f); break;
case MSG_UPD_END: on_end(f); break;
case MSG_UPD_ABORT:
send_ack(f->type, ERR_OK, 0u);
finish(ERR_STATE);
break;
default:
send_ack(f->type, ERR_PARAM, 0u);
break;
}
}
err_t update_begin(void)
{
if (app_state() != APP_SLEEPING) {
return ERR_BUSY;
}
proto_rx_init(&s_rx);
s_state = UPD_WAIT_BEGIN;
s_last_activity_ms = systick_now_ms();
sched_start(TASK_UPDATE, UPDATE_TASK_MS, UPDATE_TASK_MS);
log_print("READY slot %c\r\n", inactive_slot() == 0u ? 'A' : 'B');
uart_flush();
log_mute(true);
(void)event_post(EV_UPDATE_START, 0u);
return ERR_OK;
}
void update_on_byte(uint8_t byte, uint32_t now_ms)
{
if (s_state != UPD_IDLE && proto_rx_feed(&s_rx, byte, now_ms)) {
handle_frame(&s_rx.frame);
}
}
void update_task(void)
{
if (s_state == UPD_ERASING) {
if (s_erase_next < s_erase_end) {
err_t e = flash_erase_sector(s_erase_next++); /* one sector per run */
s_last_activity_ms = systick_now_ms();
if (e != ERR_OK) {
send_ack(MSG_UPD_BEGIN, e, 0u);
finish(e);
}
return;
}
s_next_offset = IMAGE_HEADER_SIZE;
s_state = UPD_RECEIVING;
send_ack(MSG_UPD_BEGIN, ERR_OK, s_next_offset);
return;
}
if (s_state != UPD_IDLE && (systick_now_ms() - s_last_activity_ms) > UPDATE_IDLE_TIMEOUT_MS) {
finish(ERR_TIMEOUT); /* host vanished: back to normal */
}
}
bool update_running_confirmed(void)
{
slot_trailer_t t;
flash_read(slot_trailer_addr(APP_SLOT), &t, sizeof t);
return t.confirmed == SLOT_CONFIRM_MAGIC;
}
err_t update_confirm_running(void)
{
if (update_running_confirmed()) {
return ERR_OK;
}
const uint32_t magic = SLOT_CONFIRM_MAGIC;
err_t e = flash_program(slot_trailer_addr(APP_SLOT) + (uint32_t)offsetof(slot_trailer_t, confirmed),
&magic, sizeof magic);
if (e == ERR_OK && handoff_valid()) {
g_handoff.trial_slot = HANDOFF_NO_TRIAL;
g_handoff.trial_attempts = 0u;
handoff_seal();
}
return e;
}

Validation happens before anything is erased. on_begin checks the header the host sends: magic and layout version, the header’s own CRC, that it was linked for the slot it is about to land in (load_addr), that the size fits and is a multiple of 4, and that the version is strictly newer than the running one. Only then does it start erasing. Rejecting downgrades here is a simple form of anti-rollback; it stops an attacker or a confused operator from reinstalling an old version with a known bug. (A determined attacker with a debug probe is a different threat, handled in Firmware security.)

Erasing is spread over scheduler runs. Slot A is four sectors, and erasing all of them back to back can take several seconds during which nothing else runs. update_task erases one sector per run, so between sectors the main loop dispatches events, runs the heartbeat, and feeds the watchdog. The ACK for BEGIN is sent only when the slot is fully erased, which is also why the host tool waits up to 20 seconds for it.

Data is accepted strictly in order and idempotently. Each DATA frame carries its offset. The receiver programs only the next expected offset, verifies by readback (inside flash_program), and answers with the next offset it expects. If the host’s copy of an ACK is lost and it resends a chunk the node already has, the node recognizes the duplicate and ACKs again without programming anything. Programming a flash word twice is not allowed, so this matters for correctness, not just efficiency.

The header is the commit record. Data chunks start at offset 0x200, after the header area, which stays erased. Only when END arrives and the CRC-32 of the entire body matches image_crc does the receiver program the 64-byte header. The bootloader ignores a slot without a valid header, so at every instant before that write the half-received image is invisible, and a power cut at any point simply leaves the old firmware running. This “write the commit flag last” pattern is the core of every power-fail-safe update scheme.

A silent host ends the session. Ten seconds without a valid frame aborts the update, unmutes the log, and returns the state machine to sleeping with sampling restarted.

Confirmation. update_confirm_running programs SLOT_CONFIRM_MAGIC into the running slot’s trailer, a single word written from 0xFFFFFFFF to a value, which needs no erase. The heartbeat task in app.c calls it once the image has run for 30 seconds: the main loop, the scheduler, the event queue and the watchdog have all proven themselves for that long. It deliberately does not wait for a successful sensor reading. A broken sensor is a hardware fault, and rolling back the firmware would not fix it.

The bootloader is small, runs on the 16 MHz HSI clock without touching the PLL, never changes in the field, and makes one decision: which slot to run.

reset
|
v
capture RCC->CSR, clear flags, validate or reset handoff, boot_count++
|
v
inspect slot A and slot B: header magic + header CRC + load_addr + size
+ body CRC-32 + sane SP and PC, and not rejected
|
v
pick the valid slot with the higher version
|
+-- confirmed? ------------------------------> clear trial state ----+
| |
+-- unconfirmed: trial_attempts++ for that slot |
| |
+-- attempts <= 3 --------------------------------------------->+
| |
+-- attempts > 3: program REJECT into its trailer, pick again |
v
start IWDG, deinit, VTOR, MSP, jump to app
no valid slot at all: print, red LED on, wait for SWD or the ROM bootloader
boot/boot_main.c
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "board.h"
#include "crc.h"
#include "flash.h"
#include "gpio.h"
#include "handoff.h"
#include "image.h"
#include "uart.h"
#define BOOT_VERSION 1u
#define BOOT_MAX_TRIALS 3u
#define BOOT_CLOCK_HZ 16000000u /* HSI: the bootloader never starts the PLL */
typedef struct {
bool valid;
bool confirmed;
uint32_t version;
} slot_info_t;
static void put_hex(uint32_t v)
{
static const char hex[] = "0123456789abcdef";
uart_puts("0x");
for (int shift = 28; shift >= 0; shift -= 4) {
uart_putc(hex[(v >> shift) & 0xFu]);
}
}
static void say(const char *text, uint32_t value)
{
uart_puts(text);
put_hex(value);
uart_puts("\r\n");
}
static bool image_valid(uint32_t slot)
{
const image_header_t *h = (const image_header_t *)slot_base(slot);
if (h->magic != IMAGE_MAGIC || h->header_version != IMAGE_HEADER_VERSION) {
return false;
}
if (crc32(h, offsetof(image_header_t, header_crc)) != h->header_crc) {
return false;
}
if (h->load_addr != slot_base(slot) || h->image_size < 8u || h->image_size > IMAGE_MAX_SIZE) {
return false;
}
uint32_t code = slot_base(slot) + IMAGE_HEADER_SIZE;
if (crc32((const void *)code, h->image_size) != h->image_crc) {
return false;
}
const uint32_t *vt = (const uint32_t *)code;
uint32_t sp = vt[0], pc = vt[1];
if (sp < RAM_BASE || sp > RAM_BASE + RAM_SIZE || (sp & 3u) != 0u) {
return false; /* initial stack must be in SRAM */
}
if ((pc & 1u) == 0u || pc < code || pc >= code + h->image_size) {
return false; /* reset vector: Thumb, inside the image */
}
return true;
}
static slot_info_t inspect(uint32_t slot)
{
slot_info_t info = { .valid = false, .confirmed = false, .version = 0u };
const slot_trailer_t *t = (const slot_trailer_t *)slot_trailer_addr(slot);
if (t->rejected == SLOT_REJECT_MAGIC || !image_valid(slot)) {
return info;
}
info.valid = true;
info.confirmed = (t->confirmed == SLOT_CONFIRM_MAGIC);
info.version = ((const image_header_t *)slot_base(slot))->fw_version;
return info;
}
static void reject(uint32_t slot)
{
const uint32_t magic = SLOT_REJECT_MAGIC;
(void)flash_program(slot_trailer_addr(slot) + (uint32_t)offsetof(slot_trailer_t, rejected),
&magic, sizeof magic);
}
static int choose_slot(void)
{
for (int pass = 0; pass < 2; pass++) { /* second pass only after a rejection */
slot_info_t s[SLOT_COUNT] = { inspect(0u), inspect(1u) };
int pick;
if (s[0].valid && s[1].valid) {
pick = (s[1].version > s[0].version) ? 1 : 0;
} else if (s[0].valid) {
pick = 0;
} else if (s[1].valid) {
pick = 1;
} else {
return -1;
}
if (s[pick].confirmed) {
g_handoff.trial_slot = HANDOFF_NO_TRIAL;
g_handoff.trial_attempts = 0u;
return pick;
}
if (g_handoff.trial_slot != (uint32_t)pick) {
g_handoff.trial_slot = (uint32_t)pick; /* first trial boot of this image */
g_handoff.trial_attempts = 0u;
}
g_handoff.trial_attempts++;
if (g_handoff.trial_attempts <= BOOT_MAX_TRIALS) {
return pick;
}
uart_puts(pick == 0 ? "boot: trial failed, rejecting slot A\r\n"
: "boot: trial failed, rejecting slot B\r\n");
reject((uint32_t)pick);
g_handoff.trial_slot = HANDOFF_NO_TRIAL;
g_handoff.trial_attempts = 0u;
}
return -1;
}
static void iwdg_start(void)
{
IWDG->KR = 0xCCCCu; /* same settings as wdg_init(12000) */
IWDG->KR = 0x5555u;
IWDG->PR = 6u;
IWDG->RLR = 1499u;
for (uint32_t i = 0; i < 100000u && IWDG->SR != 0u; i++) { }
IWDG->KR = 0xAAAAu;
}
static void __attribute__((noreturn)) jump_to_app(uint32_t vectors)
{
const uint32_t *vt = (const uint32_t *)vectors;
uint32_t sp = vt[0];
uint32_t pc = vt[1];
__asm volatile ("cpsid i" ::: "memory");
SysTick->CTRL = 0u;
USART2->CR1 = 0u;
for (uint32_t i = 0; i < 8u; i++) {
NVIC->ICER[i] = 0xFFFFFFFFu; /* disable every IRQ */
NVIC->ICPR[i] = 0xFFFFFFFFu; /* and forget pending ones */
}
SCB->VTOR = vectors;
dsb();
isb();
__asm volatile ("cpsie i" ::: "memory"); /* safe: every source is disabled */
__asm volatile ("msr msp, %0\n\tbx %1" : : "r"(sp), "r"(pc) : "memory");
__builtin_unreachable();
}
int main(void)
{
uint32_t csr = RCC->CSR;
RCC->CSR |= RCC_CSR_RMVF; /* next reset reports only its own cause */
if (!handoff_valid()) {
handoff_reset();
}
g_handoff.boot_count++;
g_handoff.reset_flags = csr;
g_handoff.boot_version = BOOT_VERSION;
uart_init(BOOT_CLOCK_HZ, CONSOLE_BAUD);
say("\r\nboot: v1, boot count ", g_handoff.boot_count);
say("boot: reset flags ", csr);
int slot = choose_slot();
if (slot < 0) {
handoff_seal();
uart_puts("boot: no valid image\r\n");
gpio_clock_enable(LED_PORT);
gpio_config(LED_PORT, LED_RED_PIN, GPIO_OUTPUT, GPIO_NOPULL, false, 0u);
gpio_set(LED_PORT, LED_RED_PIN);
for (;;) { } /* recover over SWD or with BOOT0 high */
}
const image_header_t *h = (const image_header_t *)slot_base((uint32_t)slot);
g_handoff.booted_slot = (uint32_t)slot;
handoff_seal();
uart_puts(slot == 0 ? "boot: slot A" : "boot: slot B");
uart_puts(g_handoff.trial_slot == (uint32_t)slot ? " (trial)" : "");
say(", version ", h->fw_version);
uart_flush();
iwdg_start();
jump_to_app(slot_base((uint32_t)slot) + IMAGE_HEADER_SIZE);
}

What “valid” means. image_valid trusts nothing in a slot until it has been checked, in order of cost: the magic and layout version, the CRC of the header itself (so image_size is trustworthy before it is used as a length), the link address and size, then the CRC-32 of the whole body. Finally it sanity-checks the first two vector table entries: the initial stack pointer must point into SRAM and the reset vector must be an odd (Thumb) address inside the image. A CRC proves the bytes are the ones the build produced. It does not prove who produced them; that is what a signature adds in Firmware security.

Choosing. Among the valid, non-rejected slots, the higher version wins. A confirmed winner boots and clears any trial state. An unconfirmed winner is on trial: the bootloader counts its boots in the handoff block, and on the fourth boot without a confirmation it programs the reject word into that slot’s trailer and runs the selection again, which now picks the other slot. Rejection is permanent for that image: only a new image written into the slot (which erases the trailer) clears it.

Why the bootloader starts the watchdog. A trial image that hangs before it reaches wdg_init, say in a clock loop or a corrupted .data copy, would otherwise hang forever and never be counted as failed. With the IWDG started before the jump, that hang becomes a reset, the bootloader counts another trial, and the rollback happens. The application’s wdg_init simply reprograms the same running watchdog.

The jump. jump_to_app follows the sequence from Bootloaders and firmware update: mask interrupts, stop every interrupt source the bootloader might have started (SysTick, the USART, every NVIC line, including clearing pending bits), point VTOR at the application’s table, re-enable interrupts (nothing can fire now), then load the application’s initial stack pointer into MSP and branch to its reset vector. The last two steps are one asm statement because once MSP changes, any compiler-generated stack access would read the wrong memory. The application’s Reset_Handler then sets VTOR again to its own table, harmlessly.

Recovery when nothing is valid. The bootloader lights the red LED and waits. On real hardware you recover with a debug probe, or by holding BOOT0 high so the chip starts ST’s ROM bootloader. A product with a radio would instead stay in a minimal receive mode; that is exercise 5.

The linker cannot compute a CRC of its own output, so a post-link step fills in the header. The Makefile from Capstone I runs it for every .img target.

tools/mkimage.py
#!/usr/bin/env python3
"""Patch an application binary's image header: size, body CRC-32, header CRC-32."""
import struct
import sys
import zlib
HEADER_SIZE = 0x200 # IMAGE_HEADER_SIZE in common/flash_layout.h
HDR_FMT = "<7I32sI" # image_header_t in common/image.h, 64 bytes
MAGIC = 0x4E4F4445
def main(src, dst):
data = bytearray(open(src, "rb").read())
if len(data) <= HEADER_SIZE:
sys.exit(f"{src}: too small to hold a header and a vector table")
while len(data) % 4:
data.append(0xFF) # receiver programs 32-bit words
magic, hver, fw, _size, _crc, load, build, vstr, _hcrc = struct.unpack_from(HDR_FMT, data)
if magic != MAGIC:
sys.exit(f"{src}: no image header at offset 0 (built with app.ld?)")
body = bytes(data[HEADER_SIZE:])
size, crc = len(body), zlib.crc32(body) & 0xFFFFFFFF
head = struct.pack("<7I32s", magic, hver, fw, size, crc, load, build, vstr)
data[0:64] = head + struct.pack("<I", zlib.crc32(head) & 0xFFFFFFFF)
open(dst, "wb").write(data)
print(f"{dst}: v{fw >> 16}.{(fw >> 8) & 0xFF}.{fw & 0xFF} for 0x{load:08X}, "
f"{size} bytes, crc 0x{crc:08X}")
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit("usage: mkimage.py app.bin app.img")
main(sys.argv[1], sys.argv[2])

The script unpacks the header with the same layout as image_header_t, pads the binary to a multiple of 4 bytes with 0xFF, computes the body CRC with zlib.crc32 (the same algorithm as crc32() in common/crc.c), and writes size, CRC and header CRC back. It refuses a binary without the magic number at offset 0, which catches the most common mistake: an ELF linked with the wrong script.

tools/fwupdate.py
#!/usr/bin/env python3
"""Send an application image to the sensor node over its UART (needs pyserial)."""
import argparse
import re
import struct
import sys
import time
import serial
SYNC = b"\xA5\x5A"
MSG_UPD_BEGIN, MSG_UPD_DATA, MSG_UPD_END, MSG_ACK = 0x10, 0x11, 0x12, 0x7F
HEADER_SIZE, HEADER_LEN, CHUNK = 0x200, 64, 256
def crc16(data, crc=0xFFFF):
for byte in data:
crc ^= byte << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) if crc & 0x8000 else (crc << 1)
crc &= 0xFFFF
return crc
def frame(mtype, seq, payload=b""):
body = struct.pack("<BBH", mtype, seq, len(payload)) + payload
return SYNC + body + struct.pack("<H", crc16(body))
def read_frame(port, timeout):
deadline, buf = time.monotonic() + timeout, bytearray()
while time.monotonic() < deadline:
buf += port.read(256)
start = buf.find(SYNC)
if start < 0 or len(buf) < start + 8:
continue
mtype, seq, length = struct.unpack_from("<BBH", buf, start + 2)
end = start + 6 + length + 2
if length > 260:
del buf[:start + 1] # impossible length: resync
continue
if len(buf) < end:
continue
body = bytes(buf[start + 2:end - 2])
if struct.unpack_from("<H", buf, end - 2)[0] == crc16(body):
return mtype, seq, body[4:]
del buf[:start + 1] # bad CRC: resync after this byte
return None
def transact(port, mtype, seq, payload, timeout=1.0, retries=3):
for _ in range(retries):
port.write(frame(mtype, seq, payload))
reply = read_frame(port, timeout)
if reply and reply[0] == MSG_ACK and reply[1] == seq and len(reply[2]) == 8:
acked, status, _reserved, value = struct.unpack("<BbHI", reply[2])
if acked == mtype:
return status, value
raise RuntimeError(f"no ACK for message 0x{mtype:02X} (seq {seq})")
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("port", help="COM5, /dev/ttyUSB0 or socket://localhost:3456")
ap.add_argument("build", help="build directory containing a/app.img and b/app.img")
args = ap.parse_args()
port = serial.serial_for_url(args.port, baudrate=115200, timeout=0.05)
port.write(b"\r\nupdate\r\n")
text, deadline, match = b"", time.monotonic() + 3.0, None
while match is None and time.monotonic() < deadline:
text += port.read(64)
match = re.search(rb"READY slot ([AB])", text)
if match is None:
sys.exit("node did not enter update mode (busy sampling? run again)")
slot = match.group(1).decode().lower()
img = open(f"{args.build}/{slot}/app.img", "rb").read()
print(f"node receives into slot {slot.upper()}; sending {len(img) - HEADER_SIZE} bytes")
seq = 1
status, _ = transact(port, MSG_UPD_BEGIN, seq, img[:HEADER_LEN], timeout=20.0)
if status != 0:
sys.exit(f"BEGIN rejected, error {status}")
offset = HEADER_SIZE
while offset < len(img):
seq = (seq + 1) & 0xFF
payload = struct.pack("<I", offset) + img[offset:offset + CHUNK]
status, offset_next = transact(port, MSG_UPD_DATA, seq, payload)
if status != 0:
sys.exit(f"DATA at 0x{offset:X} rejected, error {status}")
offset = offset_next
print(f"\r {offset - HEADER_SIZE}/{len(img) - HEADER_SIZE} bytes", end="", flush=True)
print()
seq = (seq + 1) & 0xFF
status, version = transact(port, MSG_UPD_END, seq, b"", timeout=5.0)
if status != 0:
sys.exit(f"END rejected, error {status}")
print(f"accepted v{version >> 16}.{(version >> 8) & 0xFF}.{version & 0xFF}; "
"the node reboots into a trial boot")
if __name__ == "__main__":
main()

The tool asks the node which slot it will write, picks the image built for that slot from the build directory, and runs the lockstep exchange: every frame waits for its ACK, and a missing ACK triggers up to three retransmissions of the same frame with the same sequence number, which the receiver’s duplicate handling makes safe. Error codes come back as the signed err_t values from Capstone I, so -10 means ERR_VERSION. serial_for_url accepts a COM port, a device node, or a socket:// URL, so the same tool talks to a board on a USB adapter and to Renode.

renode/sensor_node.resc
:name: Sensor node
:description: Bootloader + slot A image, TH20 on I2C1, USART2 on TCP port 3456
$boot?=@build/boot/boot.elf
$app_a?=@build/a/app.img
$erased?=@build/erased.bin
$th20?=@renode/th20.cs
using sysbus
mach create "sensor-node"
include $th20
machine LoadPlatformDescription @platforms/boards/stm32f4_discovery-kit.repl
machine LoadPlatformDescriptionFromString "th20: Sensors.TH20 @ i2c1 0x40"
showAnalyzer usart2
emulation CreateServerSocketTerminal 3456 "hostlink" false
connector Connect usart2 hostlink
sysbus LoadBinary $erased 0x08008000
sysbus LoadELF $boot
sysbus LoadBinary $app_a 0x08008000
cpu VectorTableOffset 0x08000000

The $name?= lines set defaults that a test script can override (lesson 05 does). The flash is first filled with 0xFF from build/erased.bin so slot B, the config sectors and the log look like a freshly erased chip, then the bootloader ELF and the patched slot A image are loaded on top. The vector table offset now points at the bootloader, as the reset hardware would. USART2 is connected both to an analyzer window and to TCP port 3456 for the host tools; if your Renode version allows only one connection per UART, remove the showAnalyzer line and use telnet localhost 3456 as the console instead.

Build everything and start:

Terminal window
make all build/erased.bin
renode renode/sensor_node.resc

Type start in the monitor. The first boot of a freshly flashed slot A is a trial, because nothing has confirmed it yet:

boot: v1, boot count 0x00000001
boot: reset flags 0x00000000
boot: slot A (trial), version 0x00010000
[ 0.001] I sensor-node 1a2b3c4, slot A, clock 168 MHz
[ 0.001] I boot #1, reset cause: unknown
[ 0.002] W config: not found, using defaults
[ 0.004] I th20: ok
type 'help' for commands
node> sample 1 t=21.50 C rh=45.00 %
...
[ 32.000] I trial image confirmed: ok

On real hardware the reset flags after power-up typically read 0x0e000000 (the power-on, brown-out and pin flags are all set by a power-on reset), and the cause prints as power-on; the simulator may not model these flags. Now exercise each requirement.

Configuration survives a reset. Type cfg set period 5, then reboot. The boot count rises and cfg get shows period 5 s with sequence 1. Do it again with period 7 and the sequence becomes 2, written to the other bank.

The log persists. log dump lists a boot record per reset, the config changes, the confirmed record, and every sixth sample.

Update to 1.1.0. In a second terminal, build a newer version into its own directory and send it:

Terminal window
make images VERSION_MINOR=1 BUILD=build-v11
python3 tools/fwupdate.py socket://localhost:3456 build-v11
node receives into slot B; sending 21468 bytes
21468/21468 bytes
accepted v1.1.0; the node reboots into a trial boot

The node prints boot: slot B (trial), version 0x00010100, the banner says slot B, and 30 seconds later trial image confirmed: ok. Sending the same build-v11 again is refused with error -10: the version is not newer.

Rollback. Build 1.2.0 and send it (it lands in slot A, since B is now running), then make it “fail” before it confirms by typing reboot three times within 30 seconds of each boot:

Terminal window
make images VERSION_MINOR=2 BUILD=build-v12
python3 tools/fwupdate.py socket://localhost:3456 build-v12
boot: slot A (trial), version 0x00010200 <- after the update's own reboot
boot: slot A (trial), version 0x00010200 <- reboot 1
boot: slot A (trial), version 0x00010200 <- reboot 2
boot: trial failed, rejecting slot A <- reboot 3: fourth trial boot
boot: slot B, version 0x00010100

The confirmed 1.1.0 image runs again, and slot A stays rejected until a new image is written to it. This demonstration depends on RAM surviving a software reset in the simulator, as it does on silicon; if your Renode version clears RAM on reset, every boot looks like a first trial and you will need hardware to see the rollback.

  • Writing the image header first. A power cut halfway through the body leaves a slot with a valid-looking header and garbage code. Write the body, verify its CRC, then write the header as the commit.
  • Validating the header but not the body. A header CRC proves the header is intact, not the code. Check the CRC of every byte you are about to execute.
  • Programming a flash word twice. Flash only clears bits, so the second value is ANDed with the first and no error is raised. Never reuse a slot or a word without erasing; verify by readback.
  • Saving config into the bank that holds the current copy. A power cut between erase and program loses everything. Always write the other bank.
  • Jumping with an interrupt still enabled. The application’s vector table may not handle it yet, or the handler runs on half-initialized data. Disable and clear every source before changing VTOR and MSP.
  • Not starting the watchdog before the jump. A trial image that hangs before its own watchdog setup is never counted as failed, and rollback never happens.
  • A trailer flag that needs an erase to set. If confirming an image required erasing the sector, the confirm itself could destroy the image on a power cut. Flags must be single 1-to-0 writes.
  1. Interrupt an update: start fwupdate.py and kill it halfway. Confirm the node aborts after 10 seconds, prints update aborted: timeout, keeps running the old image, and that log dump shows an update-fail record. Then run the update again to completion.
  2. Corrupt one config bank from the Renode monitor (sysbus WriteDoubleWord 0x080A0010 0x12345678 writes into the payload of bank B) and reset. Which bank loads, and what sequence number does cfg get show? Then corrupt both and confirm the defaults and the config: not found warning.
  3. Replace the bitwise CRC-32 in the bootloader with a 256-entry table version (or the STM32 CRC peripheral at 0x40023000, noting that its default algorithm differs from zlib’s). Measure the boot time difference with the DWT cycle counter for a 20 KB image and a 200 KB image, and check the bootloader still fits in 16 KB.
  4. Move the trial counter into flash: use reserved[0] and reserved[1] of the trailer, and program a bit pattern on each trial boot so the count survives power cycles. Explain why programming successive bits to 0 in one word is allowed without an erase.
  5. Make the “no valid image” path useful: instead of waiting forever, have the bootloader accept an image over the UART with the same protocol (reusing proto.c would break the dependency rule; decide where the shared parser should live). Keep the bootloader under 16 KB.

Question 1:Power fails after the update receiver has programmed every data chunk into slot B but before UPD_END arrives. What runs at the next boot?

Question 2:Config bank A holds sequence 41 and bank B holds sequence 40. cfg set period 30 is typed and power fails right after the erase of the target sector. What loads at the next boot?

Question 3:Why does on_data answer a chunk whose offset is below s_next_offset with an ACK instead of an error?

Question 4:A new image hangs in an infinite loop inside its clock setup, before wdg_init is called. How does the system recover?

Question 5:The bootloader checks that the reset vector of an image is odd and inside the image. What does the odd check catch?

  • Flash only clears bits: erase before reuse, never program a word twice, verify by reading back after resetting the cache.
  • Power-fail safety comes from never modifying the only good copy: A/B records with sequence numbers for config, append-only records with CRCs for logs, and a commit record written last for images.
  • Validate everything before trusting it: header CRC before using its fields, body CRC before executing, vector table sanity before jumping.
  • A trial boot needs three pieces: a counter, a confirm flag the healthy image writes, and a watchdog running before the new code starts.
  • Make protocol handlers idempotent so retransmissions are safe, and spread long operations such as erases across scheduler runs so the watchdog keeps being fed.

Next: Capstone V — Power, Testing, and Release, where the node learns to sleep between samples, the host and Renode test suites check every requirement, and a release build with budgets and a checklist turns the project into something you could ship.