Capstone Project49 min read
Capstone III — Application Core
Capstone II ended with drivers and a busy-waiting main. This lesson replaces that main with the real application core: an ISR-safe event queue, a cooperative scheduler, the application state machine (init, sampling, reporting, sleeping, updating), a UART shell, a binary protocol with CRC-16 framing, a logging module, a HardFault handler that leaves a crash record for the next boot, and watchdog supervision that only feeds the dog when every part of the system has proven it is alive. The storage modules arrive in Capstone IV, so this lesson defines their interfaces and builds against temporary stubs, which is exactly how you keep a team unblocked on a real project.
How the core fits together
Section titled “How the core fits together”There are two execution contexts. ISRs do the minimum: SysTick increments a counter, the USART2 ISR moves a byte into the ring buffer and posts one event. Everything else runs in the main loop, one step at a time, and no step waits for anything.
for (;;) { app_dispatch_events(); drain the queue; each event runs the state machine sched_run_due(now); run every task whose deadline has passed wdg_checkin(LOOP); this iteration completed wdg_service(); feed the IWDG only if LOOP, EVENTS, HEARTBEAT all set idle until next interrupt (lesson 05: Stop mode until the next deadline) }Tasks never do real work themselves when that work belongs to the state machine. The sample task’s whole body is “post EV_SAMPLE_DUE”. This keeps every decision about what the product does in one switch, and keeps the scheduler a dumb timer service.
CRC and the handoff block
Section titled “CRC and the handoff block”Two small common modules come first because everything else uses them.
#ifndef CRC_H#define CRC_H
#include <stddef.h>#include <stdint.h>
#define CRC16_INIT 0xFFFFu
/* CRC-32 (IEEE 802.3, reflected 0xEDB88320), zlib-compatible: start with 0, chain by passing the previous result. */uint32_t crc32_update(uint32_t crc, const void *data, size_t len);uint32_t crc32(const void *data, size_t len);
/* CRC-16/CCITT-FALSE: poly 0x1021, init 0xFFFF, no reflection. */uint16_t crc16_update(uint16_t crc, const void *data, size_t len);uint16_t crc16_ccitt(const void *data, size_t len);
#endif /* CRC_H */#include "crc.h"
uint32_t crc32_update(uint32_t crc, const void *data, size_t len){ const uint8_t *p = data; crc = ~crc; while (len--) { crc ^= *p++; for (int k = 0; k < 8; k++) { crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u))); } } return ~crc;}
uint32_t crc32(const void *data, size_t len){ return crc32_update(0u, data, len);}
uint16_t crc16_update(uint16_t crc, const void *data, size_t len){ const uint8_t *p = data; while (len--) { crc ^= (uint16_t)(*p++ << 8); for (int k = 0; k < 8; k++) { crc = (crc & 0x8000u) ? (uint16_t)((crc << 1) ^ 0x1021u) : (uint16_t)(crc << 1); } } return crc;}
uint16_t crc16_ccitt(const void *data, size_t len){ return crc16_update(CRC16_INIT, data, len);}Both are the bitwise implementations from Flash, EEPROM, and non-volatile storage: slow (about 8 iterations per byte) but tiny, which matters for the bootloader. 0u - (crc & 1u) is all ones when the low bit is set and zero otherwise, so the polynomial is XORed in without a branch. CRC-32 is chosen to be identical to Python’s zlib.crc32, so the host tools need no CRC code of their own; the standard check value is crc32("123456789") == 0xCBF43926. CRC-16/CCITT-FALSE protects the short UART frames, and its check value is 0x29B1.
#include <stddef.h>#include "crc.h"#include "handoff.h"
handoff_t g_handoff __attribute__((section(".noinit")));
static uint32_t handoff_crc(void){ return crc32(&g_handoff, offsetof(handoff_t, crc));}
bool handoff_valid(void){ return g_handoff.magic == HANDOFF_MAGIC && g_handoff.crc == handoff_crc();}
void handoff_reset(void){ g_handoff = (handoff_t){ .magic = HANDOFF_MAGIC, .trial_slot = HANDOFF_NO_TRIAL }; handoff_seal();}
void handoff_seal(void){ g_handoff.crc = handoff_crc();}g_handoff goes into .noinit, which both linker scripts place at 0x2001FF00 and which startup code never touches. The CRC covers every field before crc, so a half-updated block (a crash between changing a field and resealing) or random power-on contents are both detected. The rule for every writer is: change fields, then call handoff_seal().
The event queue
Section titled “The event queue”#ifndef EVENT_H#define EVENT_H
#include <stdbool.h>#include <stdint.h>
typedef enum { EV_NONE = 0, EV_SAMPLE_DUE, /* sample timer fired, or the 'sample' command */ EV_SAMPLE_READY, /* arg: temp in low 16 bits, humidity in high 16 */ EV_SENSOR_ERROR, /* arg: err_t */ EV_REPORT_DONE, EV_UART_RX, /* bytes are waiting in the UART ring */ EV_UPDATE_START, EV_UPDATE_DONE, EV_UPDATE_ABORT, /* arg: err_t */} event_type_t;
typedef struct { event_type_t type; uint32_t arg;} event_t;
#define EVENT_QUEUE_LEN 16u /* power of two */
bool event_post(event_type_t type, uint32_t arg); /* safe from ISRs */bool event_get(event_t *out);bool event_pending(void);uint32_t event_dropped(void);uint32_t event_high_water(void);
#endif /* EVENT_H */#include "event.h"#include "stm32f407_regs.h"
static event_t s_queue[EVENT_QUEUE_LEN];static volatile uint32_t s_head, s_tail;static uint32_t s_dropped, s_high_water;
bool event_post(event_type_t type, uint32_t arg){ bool ok = false; uint32_t pm = irq_save(); /* producers: ISRs AND main, so lock */ uint32_t used = s_head - s_tail; if (used < EVENT_QUEUE_LEN) { s_queue[s_head % EVENT_QUEUE_LEN] = (event_t){ type, arg }; s_head = s_head + 1u; if (used + 1u > s_high_water) { s_high_water = used + 1u; } ok = true; } else { s_dropped++; } irq_restore(pm); return ok;}
bool event_get(event_t *out){ bool ok = false; uint32_t pm = irq_save(); if (s_tail != s_head) { *out = s_queue[s_tail % EVENT_QUEUE_LEN]; s_tail = s_tail + 1u; ok = true; } irq_restore(pm); return ok;}
bool event_pending(void) { return s_head != s_tail; }uint32_t event_dropped(void) { return s_dropped; }uint32_t event_high_water(void) { return s_high_water; }An event is a tagged value: a type and one 32-bit argument, eight bytes. That is enough for everything this product posts, including a whole sensor sample packed into the argument. If you need richer payloads, the tagged union from State machines and event-driven firmware is the next step.
Unlike the UART ring, this queue has several producers: the USART2 ISR posts EV_UART_RX, and main-loop code posts most other events. Two producers updating s_head at once would lose an event, so posting runs inside a short PRIMASK critical section. The critical section is a dozen instructions, far below anything that would affect interrupt latency. event_post returns false when the queue is full and counts the drop; the info command prints the drop count and the high-water mark, which is how you size the queue from evidence instead of guesswork. Sixteen entries is generous for this product, where the high-water mark stays at 3 or 4.
The cooperative scheduler
Section titled “The cooperative scheduler”#ifndef SCHED_H#define SCHED_H
#include <stdbool.h>#include <stdint.h>
#define SCHED_MAX_TASKS 8u#define SCHED_NEVER UINT32_MAX
typedef void (*task_fn_t)(void);
void sched_define(uint32_t id, const char *name, task_fn_t fn);void sched_start(uint32_t id, uint32_t delay_ms, uint32_t period_ms); /* 0 = one-shot */void sched_stop(uint32_t id);void sched_run_due(uint32_t now_ms);uint32_t sched_ms_until_next(uint32_t now_ms); /* SCHED_NEVER if nothing armed */const char *sched_name(uint32_t id);uint32_t sched_max_ms(uint32_t id); /* worst observed run time */
#endif /* SCHED_H */#include <stddef.h>#include "sched.h"#include "systick.h"
typedef struct { const char *name; task_fn_t fn; uint32_t next_ms; uint32_t period_ms; uint32_t max_ms; bool armed;} task_t;
static task_t s_tasks[SCHED_MAX_TASKS];
void sched_define(uint32_t id, const char *name, task_fn_t fn){ if (id < SCHED_MAX_TASKS) { s_tasks[id] = (task_t){ .name = name, .fn = fn }; }}
void sched_start(uint32_t id, uint32_t delay_ms, uint32_t period_ms){ if (id < SCHED_MAX_TASKS) { s_tasks[id].next_ms = systick_now_ms() + delay_ms; s_tasks[id].period_ms = period_ms; s_tasks[id].armed = true; }}
void sched_stop(uint32_t id){ if (id < SCHED_MAX_TASKS) { s_tasks[id].armed = false; }}
void sched_run_due(uint32_t now_ms){ for (uint32_t i = 0; i < SCHED_MAX_TASKS; i++) { task_t *t = &s_tasks[i]; if (!t->armed || t->fn == NULL || (int32_t)(now_ms - t->next_ms) < 0) { continue; } if (t->period_ms == 0u) { t->armed = false; /* one-shot: may re-arm itself */ } else { t->next_ms += t->period_ms; /* no drift */ if ((int32_t)(now_ms - t->next_ms) >= 0) { t->next_ms = now_ms + t->period_ms; /* fell behind: skip, do not burst */ } } uint32_t start = systick_now_ms(); t->fn(); uint32_t took = systick_now_ms() - start; if (took > t->max_ms) { t->max_ms = took; } }}
uint32_t sched_ms_until_next(uint32_t now_ms){ uint32_t best = SCHED_NEVER; for (uint32_t i = 0; i < SCHED_MAX_TASKS; i++) { const task_t *t = &s_tasks[i]; if (!t->armed || t->fn == NULL) { continue; } int32_t d = (int32_t)(t->next_ms - now_ms); if (d <= 0) { return 0u; } if ((uint32_t)d < best) { best = (uint32_t)d; } } return best;}
const char *sched_name(uint32_t id){ return (id < SCHED_MAX_TASKS && s_tasks[id].name != NULL) ? s_tasks[id].name : "?";}
uint32_t sched_max_ms(uint32_t id){ return (id < SCHED_MAX_TASKS) ? s_tasks[id].max_ms : 0u;}This is the run-to-completion task table from State machines and event-driven firmware, with three production features.
Tasks double as software timers. A period of 0 makes a task one-shot: it disarms before running, so the task can re-arm itself with sched_start if it needs another look. The sensor read uses exactly that to poll again 5 ms later when the result is not ready.
Periodic tasks do not drift and do not burst. The next deadline is the previous deadline plus the period, not “now plus the period”, so a task that runs 1 ms late does not push every later run 1 ms later. If the system fell behind by more than a whole period (a 2-second flash erase, for example), the task runs once and is rescheduled from now rather than running several times back to back to catch up.
The scheduler knows when it is next needed. sched_ms_until_next returns the time to the nearest deadline. The idle code uses it to decide how long it may sleep, which is what makes the low-power design in Capstone V possible without any change here. The worst-case run time of each task is recorded too, because in a cooperative system one slow task delays every other task, and you want to find it from a shell command rather than with a scope.
Task IDs are small integers that the application owns (an enum in app.h). The scheduler holds no names of its own, so it has no dependency on anything above it.
Watchdog supervision
Section titled “Watchdog supervision”#ifndef WDG_H#define WDG_H
#include <stdint.h>
#define WDG_LIVE_LOOP (1u << 0) /* a main-loop iteration completed */#define WDG_LIVE_EVENTS (1u << 1) /* the event queue was drained to empty */#define WDG_LIVE_HEARTBEAT (1u << 2) /* the scheduler ran the heartbeat task */#define WDG_LIVE_ALL (WDG_LIVE_LOOP | WDG_LIVE_EVENTS | WDG_LIVE_HEARTBEAT)
void wdg_init(uint32_t timeout_ms);void wdg_checkin(uint32_t bits);void wdg_service(void);
#endif /* WDG_H */#include "stm32f407_regs.h"#include "wdg.h"
static uint32_t s_live;
void wdg_init(uint32_t timeout_ms){ uint32_t reload = timeout_ms / 8u; /* LSI 32 kHz / 256 = 125 Hz: 8 ms per count */ if (reload > 4096u) { reload = 4096u; } if (reload == 0u) { reload = 1u; } IWDG->KR = 0xCCCCu; /* start: cannot be stopped until reset */ IWDG->KR = 0x5555u; /* unlock PR and RLR */ IWDG->PR = 6u; /* prescaler /256 */ IWDG->RLR = reload - 1u; for (uint32_t i = 0; i < 100000u && IWDG->SR != 0u; i++) { } /* PVU, RVU */ IWDG->KR = 0xAAAAu; /* reload with the new value */}
void wdg_checkin(uint32_t bits){ s_live |= bits; /* thread mode only: no ISR writes this */}
void wdg_service(void){ if ((s_live & WDG_LIVE_ALL) == WDG_LIVE_ALL) { IWDG->KR = 0xAAAAu; s_live = 0u; }}The independent watchdog runs from its own 32 kHz LSI oscillator, so it keeps counting when the main clock is misconfigured or the core is in Stop mode, and once started nothing but a reset stops it. The register sequence is the one from Watchdogs, faults, and recovering from them: start, unlock, set prescaler and reload, wait for the update flags to clear, reload. With a 12-second request the reload value is 1500 counts of 8 ms.
The feeding policy is the important part. wdg_service reloads the counter only when three independent facts have all been reported since the last reload:
WDG_LIVE_LOOP: the main loop completed an iteration, so no task and no event handler is stuck in an infinite loop.WDG_LIVE_EVENTS: the dispatcher drained the event queue to empty, so events are being consumed, not piling up.WDG_LIVE_HEARTBEAT: the heartbeat task ran, so SysTick is ticking and the scheduler is dispatching timed work. A stopped time base would otherwise leave the loop spinning happily while nothing periodic ever happens again.
The heartbeat runs every 4 seconds, so the dog is fed about every 4 seconds. The LSI is only accurate to roughly 17 to 47 kHz on this part, which stretches the nominal 12-second timeout to anywhere between about 8 and 22 seconds; the shortest of those is still twice the feeding interval. Never feed the watchdog from a timer interrupt: the timer keeps firing while the main loop is dead, and the watchdog becomes decoration.
Logging
Section titled “Logging”#ifndef LOG_H#define LOG_H
#include <stdbool.h>
typedef enum { LOG_ERR = 0, LOG_WRN = 1, LOG_INF = 2, LOG_DBG = 3 } log_level_t;
void log_set_level(log_level_t max_level);void log_mute(bool mute); /* while the UART carries binary update frames */void log_msg(log_level_t level, const char *fmt, ...) __attribute__((format(printf, 2, 3)));void log_print(const char *fmt, ...) __attribute__((format(printf, 1, 2))); /* raw */
#define LOG_E(...) log_msg(LOG_ERR, __VA_ARGS__)#define LOG_W(...) log_msg(LOG_WRN, __VA_ARGS__)#define LOG_I(...) log_msg(LOG_INF, __VA_ARGS__)#define LOG_D(...) log_msg(LOG_DBG, __VA_ARGS__)
#endif /* LOG_H */#include <inttypes.h>#include <stdarg.h>#include <stdio.h>#include "log.h"#include "systick.h"#include "uart.h"
#define LOG_LINE_MAX 160u
static log_level_t s_max_level = LOG_INF;static bool s_muted;
void log_set_level(log_level_t max_level) { s_max_level = max_level; }void log_mute(bool mute) { s_muted = mute; }
static void emit(const char *buf, int n){ if (n <= 0) { return; } size_t len = ((size_t)n < LOG_LINE_MAX) ? (size_t)n : LOG_LINE_MAX - 1u; /* truncated */ uart_write(buf, len);}
void log_msg(log_level_t level, const char *fmt, ...){ if (s_muted || level > s_max_level) { return; } static const char tags[] = "EWID"; char buf[LOG_LINE_MAX]; uint32_t now = systick_now_ms(); int n = snprintf(buf, sizeof buf, "[%6" PRIu32 ".%03" PRIu32 "] %c ", now / 1000u, now % 1000u, tags[level]); va_list ap; va_start(ap, fmt); int m = vsnprintf(buf + n, sizeof buf - (size_t)n, fmt, ap); va_end(ap); emit(buf, (m < 0) ? n : n + m); uart_puts("\r\n");}
void log_print(const char *fmt, ...){ if (s_muted) { return; } char buf[LOG_LINE_MAX]; va_list ap; va_start(ap, fmt); int n = vsnprintf(buf, sizeof buf, fmt, ap); va_end(ap); emit(buf, n);}Two entry points: log_msg for diagnostics with a timestamp, a level letter, and a level filter, and log_print for console output that the user asked for (shell replies, reports). Both format into a stack buffer with vsnprintf and then write, so a message is never interleaved character by character with another. The format(printf, ...) attribute makes GCC check every call’s format string against its arguments, which catches the %d-for-uint32_t class of bug at compile time.
vsnprintf returns the length the output would have had, not the length written, so emit clamps it to what fits in the buffer. Logging runs only in thread mode: it is not reentrant, and printing from an ISR would stall that ISR for milliseconds (the reasons are in UART: serial communication). log_mute exists for one situation: during a firmware update the UART carries binary frames, and a stray log line in the middle of one would corrupt the transfer.
The binary protocol
Section titled “The binary protocol”Text is for humans. Machines want frames with a length and a checksum: a gateway parsing sample reports, and the update tool in Capstone IV. Every frame has the same shape.
+------+------+------+-----+--------+--------+-------------+--------+--------+ | 0xA5 | 0x5A | type | seq | len lo | len hi | payload ... | crc lo | crc hi | +------+------+------+-----+--------+--------+-------------+--------+--------+ sync (2) length, little-endian CRC-16/CCITT-FALSE <------------- covered by the CRC ------------->| Type | Name | Direction | Payload |
|---|---|---|---|
0x01 | MSG_SAMPLE | node to host | node id (4), sample count (4), uptime s (4), temp (2, signed), humidity (2) |
0x10 | MSG_UPD_BEGIN | host to node | the 64-byte image header |
0x11 | MSG_UPD_DATA | host to node | offset in slot (4) + 4 to 256 data bytes, multiple of 4 |
0x12 | MSG_UPD_END | host to node | none |
0x13 | MSG_UPD_ABORT | host to node | none |
0x7F | MSG_ACK | node to host | acked type (1), status err_t (1, signed), reserved (2), value (4) |
All multi-byte fields are little-endian and are written with shifts. The sequence number lets the host match an ACK to its request and lets the node recognize a retransmission.
#ifndef PROTO_H#define PROTO_H
#include <stdbool.h>#include <stddef.h>#include <stdint.h>
#define PROTO_SYNC0 0xA5u#define PROTO_SYNC1 0x5Au#define PROTO_MAX_PAYLOAD 260u /* 4-byte offset + 256 data bytes */#define PROTO_OVERHEAD 8u /* sync(2) type seq len(2) crc(2) */#define PROTO_BYTE_TIMEOUT_MS 200u /* silence inside a frame resets the parser */
typedef enum { MSG_SAMPLE = 0x01, MSG_UPD_BEGIN = 0x10, MSG_UPD_DATA = 0x11, MSG_UPD_END = 0x12, MSG_UPD_ABORT = 0x13, MSG_ACK = 0x7F,} msg_type_t;
typedef struct { uint8_t type; uint8_t seq; uint16_t len; uint8_t payload[PROTO_MAX_PAYLOAD];} proto_frame_t;
typedef struct { uint8_t state; uint16_t index; uint16_t crc; uint16_t rx_crc; uint32_t last_ms; uint32_t crc_errors; uint32_t len_errors; proto_frame_t frame;} proto_rx_t;
void proto_rx_init(proto_rx_t *rx);bool proto_rx_feed(proto_rx_t *rx, uint8_t byte, uint32_t now_ms); /* true: frame ready */size_t proto_encode(uint8_t *out, size_t cap, uint8_t type, uint8_t seq, const uint8_t *payload, uint16_t len); /* 0 if it does not fit */
static inline void put_le16(uint8_t *p, uint16_t v){ p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8);}
static inline void put_le32(uint8_t *p, uint32_t v){ p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); p[2] = (uint8_t)(v >> 16); p[3] = (uint8_t)(v >> 24);}
static inline uint32_t get_le32(const uint8_t *p){ return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);}
#endif /* PROTO_H */#include <string.h>#include "crc.h"#include "proto.h"
enum { S_SYNC0, S_SYNC1, S_TYPE, S_SEQ, S_LEN0, S_LEN1, S_PAYLOAD, S_CRC0, S_CRC1 };
void proto_rx_init(proto_rx_t *rx){ memset(rx, 0, sizeof *rx); /* state = S_SYNC0 */}
static void crc_byte(proto_rx_t *rx, uint8_t b){ rx->crc = crc16_update(rx->crc, &b, 1u);}
bool proto_rx_feed(proto_rx_t *rx, uint8_t b, uint32_t now_ms){ if (rx->state != S_SYNC0 && (now_ms - rx->last_ms) > PROTO_BYTE_TIMEOUT_MS) { rx->state = S_SYNC0; /* the rest of that frame is never coming */ } rx->last_ms = now_ms; proto_frame_t *f = &rx->frame;
switch (rx->state) { case S_SYNC0: if (b == PROTO_SYNC0) { rx->state = S_SYNC1; } break; case S_SYNC1: if (b == PROTO_SYNC1) { rx->state = S_TYPE; } else if (b != PROTO_SYNC0) { rx->state = S_SYNC0; /* A5 A5 5A still syncs on the second A5 */ } break; case S_TYPE: rx->crc = CRC16_INIT; crc_byte(rx, b); f->type = b; rx->state = S_SEQ; break; case S_SEQ: crc_byte(rx, b); f->seq = b; rx->state = S_LEN0; break; case S_LEN0: crc_byte(rx, b); f->len = b; rx->state = S_LEN1; break; case S_LEN1: crc_byte(rx, b); f->len = (uint16_t)(f->len | (b << 8)); if (f->len > PROTO_MAX_PAYLOAD) { rx->len_errors++; /* never trust a length from the wire */ rx->state = S_SYNC0; break; } rx->index = 0u; rx->state = (f->len == 0u) ? S_CRC0 : S_PAYLOAD; break; case S_PAYLOAD: crc_byte(rx, b); f->payload[rx->index++] = b; if (rx->index == f->len) { rx->state = S_CRC0; } break; case S_CRC0: rx->rx_crc = b; rx->state = S_CRC1; break; case S_CRC1: rx->rx_crc = (uint16_t)(rx->rx_crc | (b << 8)); rx->state = S_SYNC0; if (rx->rx_crc == rx->crc) { return true; } rx->crc_errors++; break; default: rx->state = S_SYNC0; break; } return false;}
size_t proto_encode(uint8_t *out, size_t cap, uint8_t type, uint8_t seq, const uint8_t *payload, uint16_t len){ if (len > PROTO_MAX_PAYLOAD || cap < (size_t)len + PROTO_OVERHEAD) { return 0u; } out[0] = PROTO_SYNC0; out[1] = PROTO_SYNC1; out[2] = type; out[3] = seq; put_le16(&out[4], len); if (len > 0u) { memcpy(&out[6], payload, len); } uint16_t crc = crc16_ccitt(&out[2], 4u + (size_t)len); put_le16(&out[6 + len], crc); return (size_t)len + PROTO_OVERHEAD;}The receiver is a byte-at-a-time state machine, the “UART protocol receiver FSM” pattern from State machines and event-driven firmware. It is pure: it takes a byte and a timestamp and touches no hardware, so lesson 05 tests it on the host with corrupted, truncated, and oversized frames.
The parser defends itself in four ways. Resynchronization: anything that is not A5 5A is skipped, so text output or line noise ahead of a frame is harmless. Length check before use: a corrupted length field larger than the buffer would otherwise turn the payload loop into a buffer overflow; this single check is the difference between a parser and a security hole. Inter-byte timeout: if a frame stops halfway (the host was killed, a cable was pulled), the next byte after 200 ms of silence starts a fresh search for sync instead of being treated as payload. CRC over everything after sync: type, sequence, length and payload are all covered, so a flipped bit anywhere is caught. Errors are counted, not just discarded, because a rising CRC error count is the first sign of a noisy cable.
The sync bytes can appear inside a payload; that is fine, because the parser only looks for sync between frames, and the length field says where the frame ends. This is the length-prefix framing choice. COBS (from UART: serial communication) gives a guaranteed delimiter at the cost of encoding every byte; for a point-to-point link with a CRC and a timeout, the length prefix is simpler and cheaper.
The shell
Section titled “The shell”#ifndef SHELL_H#define SHELL_H
#include <stdint.h>
void shell_init(void);void shell_input(uint8_t c); /* feed one received character */
#endif /* SHELL_H */#include <inttypes.h>#include <stdlib.h>#include <string.h>#include "app.h"#include "config.h"#include "event.h"#include "evlog.h"#include "log.h"#include "shell.h"#include "stm32f407_regs.h"#include "uart.h"#include "update.h"
#define SHELL_LINE_MAX 64u#define SHELL_MAX_ARGS 4
typedef void (*cmd_fn_t)(int argc, char **argv);typedef struct { const char *name; const char *help; cmd_fn_t fn;} cmd_t;
static char s_line[SHELL_LINE_MAX];static uint32_t s_len;
static void cmd_help(int argc, char **argv);
static void cmd_info(int argc, char **argv){ (void)argc; (void)argv; app_print_info();}
static void cmd_sample(int argc, char **argv){ (void)argc; (void)argv; log_print(event_post(EV_SAMPLE_DUE, 0u) ? "sample requested\r\n" : "event queue full\r\n");}
static void cmd_cfg(int argc, char **argv){ if (argc == 2 && strcmp(argv[1], "get") == 0) { const cfg_t *c = cfg_get(); log_print("period %" PRIu32 " s\r\nmode %s\r\nlog_every %u\r\n" "verbose %u\r\ntemp_off %d\r\nhum_off %d\r\nnode %" PRIu32 "\r\nsequence %" PRIu32 "\r\n", c->sample_period_s, c->report_mode == REPORT_BINARY ? "binary" : "text", (unsigned)c->log_every, (unsigned)c->verbose, c->temp_offset_cdeg, c->hum_offset_cpct, c->node_id, cfg_sequence()); } else if (argc == 4 && strcmp(argv[1], "set") == 0) { err_t e = cfg_set(argv[2], argv[3]); log_print("cfg set: %s\r\n", err_str(e)); if (e == ERR_OK) { app_on_config_changed(); } } else { log_print("usage: cfg get | cfg set <period|mode|log_every|verbose|temp_off|hum_off|node> <value>\r\n"); }}
static void print_record(const evlog_rec_t *r){ log_print("%6" PRIu32 " %8" PRIu32 " s %-12s %6d %6u\r\n", r->seq, r->uptime_s, evlog_type_name(r->type), r->a, (unsigned)r->b);}
static void cmd_log(int argc, char **argv){ if (argc < 2 || strcmp(argv[1], "dump") != 0) { log_print("usage: log dump [n]\r\n"); return; } uint32_t n = (argc >= 3) ? (uint32_t)strtoul(argv[2], NULL, 10) : 20u; uint32_t shown = evlog_dump(n, print_record); log_print("%" PRIu32 " shown, %" PRIu32 " stored\r\n", shown, evlog_count());}
static void cmd_update(int argc, char **argv){ (void)argc; (void)argv; err_t e = update_begin(); /* on success it prints READY and mutes logging */ if (e != ERR_OK) { log_print("update: %s\r\n", err_str(e)); }}
static void cmd_reboot(int argc, char **argv){ (void)argc; (void)argv; log_print("rebooting\r\n"); uart_flush(); system_reset();}
static const cmd_t s_cmds[] = { { "help", "list commands", cmd_help }, { "info", "firmware, boot and health summary", cmd_info }, { "sample", "take a sample now", cmd_sample }, { "cfg", "cfg get | cfg set <key> <value>", cmd_cfg }, { "log", "log dump [n]: newest n records", cmd_log }, { "update", "receive an image (binary protocol)", cmd_update }, { "reboot", "software reset", cmd_reboot },};
static void cmd_help(int argc, char **argv){ (void)argc; (void)argv; for (size_t i = 0; i < sizeof s_cmds / sizeof s_cmds[0]; i++) { log_print(" %-7s %s\r\n", s_cmds[i].name, s_cmds[i].help); }}
static void execute(void){ char *argv[SHELL_MAX_ARGS]; int argc = 0; char *p = s_line; while (*p != '\0' && argc < SHELL_MAX_ARGS) { while (*p == ' ') { p++; } if (*p == '\0') { break; } argv[argc++] = p; while (*p != '\0' && *p != ' ') { p++; } if (*p == ' ') { *p++ = '\0'; } } if (argc == 0) { return; } for (size_t i = 0; i < sizeof s_cmds / sizeof s_cmds[0]; i++) { if (strcmp(argv[0], s_cmds[i].name) == 0) { s_cmds[i].fn(argc, argv); return; } } log_print("unknown command '%s', try 'help'\r\n", argv[0]);}
static void prompt(void){ log_print("node> ");}
void shell_init(void){ s_len = 0u; log_print("type 'help' for commands\r\n"); prompt();}
void shell_input(uint8_t c){ if (c == '\r' || c == '\n') { if (c == '\n' && s_len == 0u) { return; /* the LF of a CRLF pair */ } log_print("\r\n"); s_line[s_len] = '\0'; execute(); s_len = 0u; prompt(); } else if (c == 0x08u || c == 0x7Fu) { if (s_len > 0u) { /* backspace: erase on screen too */ s_len--; log_print("\b \b"); } } else if (c >= 0x20u && c < 0x7Fu && s_len < SHELL_LINE_MAX - 1u) { s_line[s_len++] = (char)c; uart_putc((char)c); /* echo */ }}The shell is a table of commands. Adding a command is one function and one table row; the table is const, so it lives in flash. Each handler gets argc and argv like a C main, produced by execute, which splits the line in place by replacing spaces with NUL characters. There is no strtok, which keeps hidden static state out of the parser (the reentrancy problem from Arrays, strings, and tables).
The input side is defensive in small ways that matter on a real terminal. The line buffer is bounded and extra characters are dropped, not written past the end. Non-printable characters are ignored, so a terminal’s escape sequences do not end up in a command. CR, LF and CRLF all end a line, because terminal programs disagree. Backspace erases on the screen with the "\b \b" sequence. cmd_cfg calls back into the application after a successful change so the new period takes effect immediately; the shell itself never touches the scheduler.
The update command is the bridge to Capstone IV: when update_begin accepts, the UART stops being a terminal and starts carrying protocol frames until the update finishes, fails, or times out.
Storage interfaces, implemented later
Section titled “Storage interfaces, implemented later”The shell and the state machine need configuration, the event log, and the update receiver. Their implementations need the flash driver and come in Capstone IV, but their interfaces are decided now, and the application builds against stubs.
#ifndef CONFIG_H#define CONFIG_H
#include <stdint.h>#include "err.h"
enum { REPORT_TEXT = 0, REPORT_BINARY = 1 };
typedef struct { uint32_t sample_period_s; /* 2..3600 */ uint8_t report_mode; /* REPORT_TEXT or REPORT_BINARY */ uint8_t log_every; /* log every Nth sample, 1..255 */ uint8_t verbose; /* 0..3, a log_level_t */ uint8_t reserved; int16_t temp_offset_cdeg; /* calibration, -1000..1000 */ int16_t hum_offset_cpct; /* calibration, -1000..1000 */ uint32_t node_id;} cfg_t;
err_t cfg_load(void); /* ERR_NOT_FOUND: defaults in use */const cfg_t *cfg_get(void);err_t cfg_set(const char *key, const char *value); /* validate, apply, save */uint32_t cfg_sequence(void);
#endif /* CONFIG_H */#ifndef EVLOG_H#define EVLOG_H
#include <stdint.h>#include "err.h"
typedef enum { EVLOG_BOOT = 1, /* a: boot count, b: reset flags (RCC_CSR >> 24) */ EVLOG_SAMPLE, /* a: temp cdeg, b: humidity cpct */ EVLOG_SENSOR_ERROR, /* a: err_t */ EVLOG_CFG_CHANGED, /* b: new config sequence */ EVLOG_UPDATE_OK, EVLOG_UPDATE_FAIL, /* a: err_t */ EVLOG_CONFIRMED, /* b: slot that confirmed itself */} evlog_type_t;
typedef struct { uint32_t seq; uint32_t uptime_s; uint8_t type; uint8_t reserved; int16_t a; uint16_t b; uint16_t crc; /* CRC-16 of the 14 bytes above */} evlog_rec_t;
typedef void (*evlog_visit_t)(const evlog_rec_t *rec);
err_t evlog_init(void);err_t evlog_append(evlog_type_t type, int16_t a, uint16_t b);uint32_t evlog_dump(uint32_t max_records, evlog_visit_t visit); /* oldest first */uint32_t evlog_count(void);const char *evlog_type_name(uint8_t type);
#endif /* EVLOG_H */#ifndef UPDATE_H#define UPDATE_H
#include <stdbool.h>#include <stdint.h>#include "err.h"
err_t update_begin(void); /* the 'update' command */void update_on_byte(uint8_t byte, uint32_t now_ms);void update_task(void); /* body of TASK_UPDATE */bool update_active(void);uint32_t update_running_slot(void);bool update_running_confirmed(void);err_t update_confirm_running(void);
#endif /* UPDATE_H */#include "config.h"#include "evlog.h"#include "flash_layout.h"#include "update.h"
static const cfg_t s_cfg = { .sample_period_s = 10u, .log_every = 6u, .verbose = 2u, .node_id = 1u };
err_t cfg_load(void) { return ERR_NOT_FOUND; }const cfg_t *cfg_get(void) { return &s_cfg; }err_t cfg_set(const char *k, const char *v) { (void)k; (void)v; return ERR_STATE; }uint32_t cfg_sequence(void) { return 0u; }
err_t evlog_init(void) { return ERR_OK; }err_t evlog_append(evlog_type_t t, int16_t a, uint16_t b) { (void)t; (void)a; (void)b; return ERR_OK; }uint32_t evlog_dump(uint32_t n, evlog_visit_t v) { (void)n; (void)v; return 0u; }uint32_t evlog_count(void) { return 0u; }const char *evlog_type_name(uint8_t type) { (void)type; return "?"; }
err_t update_begin(void) { return ERR_STATE; }void update_on_byte(uint8_t b, uint32_t now) { (void)b; (void)now; }void update_task(void) { }bool update_active(void) { return false; }uint32_t update_running_slot(void) { return APP_SLOT; }bool update_running_confirmed(void) { return true; }err_t update_confirm_running(void) { return ERR_OK; }The stubs return values that keep the application on its normal path: defaults for configuration, success for logging, “not now” for updates. Building against interfaces before their implementations exist is not a teaching trick; on a team, it is how the application developer and the storage developer work in parallel, and it forces the interface to be designed rather than to fall out of an implementation.
The application state machine
Section titled “The application state machine” +-------+ boot ---> | init | +---+---+ | app_init() done v EV_SAMPLE_DUE +-----------+ EV_SAMPLE_READY +-----------+ +--------------> | sampling | ----------------> | reporting | | +-----+-----+ +-----+-----+ | EV_SENSOR_ERROR | | EV_REPORT_DONE | v | | +-----------+ <-----------------------+ +--------------- | sleeping | +-----+-----+ EV_UPDATE_START | ^ EV_UPDATE_ABORT v | +-----------+ | EV_UPDATE_DONE: reset into the bootloader | updating | -+ +-----------+#ifndef APP_H#define APP_H
#include <stdbool.h>
enum { TASK_HEARTBEAT, TASK_SAMPLE, TASK_SENSOR_READ, TASK_UPDATE, TASK_COUNT };
typedef enum { APP_INIT, APP_SAMPLING, APP_REPORTING, APP_SLEEPING, APP_UPDATING } app_state_t;
void app_init(void);void app_dispatch_events(void);bool app_can_deep_sleep(void);app_state_t app_state(void);void app_on_config_changed(void);void app_print_info(void);
#endif /* APP_H */#include <inttypes.h>#include <stdio.h>#include "app.h"#include "board.h"#include "clock.h"#include "config.h"#include "event.h"#include "evlog.h"#include "handoff.h"#include "i2c.h"#include "image.h"#include "log.h"#include "proto.h"#include "sched.h"#include "shell.h"#include "systick.h"#include "th20.h"#include "uart.h"#include "update.h"#include "wdg.h"
#define HEARTBEAT_MS 4000u /* well inside the shortest IWDG timeout */#define CONFIRM_AFTER_MS 30000u /* healthy uptime before a trial image confirms */#define SHELL_IDLE_MS 30000u /* no deep sleep while someone is typing */#define SENSOR_BUSY_POLLS 3u
_Static_assert(TASK_COUNT <= SCHED_MAX_TASKS, "too many tasks");
static app_state_t s_state = APP_INIT;static th20_t s_sensor;static th20_sample_t s_last;static uint32_t s_sample_count, s_sensor_errors, s_busy_polls, s_last_rx_ms;static bool s_confirm_done;static volatile bool s_rx_posted;
static const char *const s_state_names[] = { "init", "sampling", "reporting", "sleeping", "updating" };
/* ------------------------------------------------------------- helpers */static const char *fmt_centi(char *buf, size_t len, int32_t v){ uint32_t a = (uint32_t)(v < 0 ? -v : v); snprintf(buf, len, "%s%" PRIu32 ".%02" PRIu32, v < 0 ? "-" : "", a / 100u, a % 100u); return buf;}
static const char *reset_cause(uint32_t csr){ if ((csr & RCC_CSR_IWDGRSTF) != 0u) { return "watchdog"; } if ((csr & RCC_CSR_WWDGRSTF) != 0u) { return "window watchdog"; } if ((csr & RCC_CSR_LPWRRSTF) != 0u) { return "low-power"; } if ((csr & RCC_CSR_SFTRSTF) != 0u) { return "software"; } if ((csr & RCC_CSR_PORRSTF) != 0u) { return "power-on"; } if ((csr & RCC_CSR_BORRSTF) != 0u) { return "brown-out"; } if ((csr & RCC_CSR_PINRSTF) != 0u) { return "reset pin"; } return "unknown";}
static void on_uart_rx_isr(void) /* runs in the USART2 ISR */{ if (!s_rx_posted) { s_rx_posted = true; (void)event_post(EV_UART_RX, 0u); }}
/* --------------------------------------------------------------- tasks */static void task_heartbeat(void){ wdg_checkin(WDG_LIVE_HEARTBEAT); if (!s_confirm_done && systick_now_ms() >= CONFIRM_AFTER_MS) { s_confirm_done = true; if (!update_running_confirmed()) { err_t e = update_confirm_running(); LOG_I("trial image confirmed: %s", err_str(e)); if (e == ERR_OK) { (void)evlog_append(EVLOG_CONFIRMED, 0, (uint16_t)update_running_slot()); } } }}
static void task_sample(void){ (void)event_post(EV_SAMPLE_DUE, 0u);}
static void task_sensor_read(void){ th20_sample_t s; err_t e = th20_read(&s_sensor, &s); if (e == ERR_BUSY && ++s_busy_polls <= SENSOR_BUSY_POLLS) { sched_start(TASK_SENSOR_READ, 5u, 0u); /* not ready: look again soon */ return; } if (e == ERR_OK) { (void)event_post(EV_SAMPLE_READY, (uint32_t)(uint16_t)s.temp_cdeg | ((uint32_t)s.hum_cpct << 16)); } else { (void)event_post(EV_SENSOR_ERROR, (uint32_t)e); }}
/* ------------------------------------------------------------- actions */static void accept_sample(uint32_t arg){ const cfg_t *cfg = cfg_get(); int32_t t = (int16_t)(uint16_t)(arg & 0xFFFFu); int32_t h = (int32_t)(arg >> 16); t += cfg->temp_offset_cdeg; h += cfg->hum_offset_cpct; if (h < 0) { h = 0; } if (h > 10000) { h = 10000; } s_last.temp_cdeg = (int16_t)t; s_last.hum_cpct = (uint16_t)h; s_sample_count++;}
static void report_sample(void){ const cfg_t *cfg = cfg_get(); if (cfg->report_mode == REPORT_BINARY) { uint8_t payload[16]; uint8_t frame[sizeof payload + PROTO_OVERHEAD]; put_le32(&payload[0], cfg->node_id); put_le32(&payload[4], s_sample_count); put_le32(&payload[8], systick_now_ms() / 1000u); put_le16(&payload[12], (uint16_t)s_last.temp_cdeg); put_le16(&payload[14], s_last.hum_cpct); uart_write(frame, proto_encode(frame, sizeof frame, MSG_SAMPLE, (uint8_t)s_sample_count, payload, sizeof payload)); } else { char t[16]; log_print("sample %" PRIu32 " t=%s C rh=%" PRIu32 ".%02" PRIu32 " %%\r\n", s_sample_count, fmt_centi(t, sizeof t, s_last.temp_cdeg), (uint32_t)s_last.hum_cpct / 100u, (uint32_t)s_last.hum_cpct % 100u); } if ((s_sample_count % cfg->log_every) == 0u) { (void)evlog_append(EVLOG_SAMPLE, s_last.temp_cdeg, s_last.hum_cpct); }}
static void sensor_failed(err_t e){ s_sensor_errors++; LOG_W("sensor: %s", err_str(e)); (void)evlog_append(EVLOG_SENSOR_ERROR, (int16_t)e, 0u); if (e != ERR_BUSY && e != ERR_RANGE) { /* bus-level: reset and re-probe */ (void)i2c_init(clock_apb1_hz()); (void)th20_init(&s_sensor, (bus_t){ &i2c_bus_ops, NULL }, SENSOR_I2C_ADDR); }}
static void enter(app_state_t next){ if (next != s_state) { LOG_D("state %s -> %s", s_state_names[s_state], s_state_names[next]); } s_state = next; switch (next) { case APP_SAMPLING: { s_busy_polls = 0u; err_t e = th20_start(&s_sensor); if (e == ERR_OK) { sched_start(TASK_SENSOR_READ, TH20_CONVERSION_MS + 2u, 0u); } else { (void)event_post(EV_SENSOR_ERROR, (uint32_t)e); } break; } case APP_REPORTING: report_sample(); (void)event_post(EV_REPORT_DONE, 0u); break; case APP_UPDATING: sched_stop(TASK_SAMPLE); break; case APP_INIT: case APP_SLEEPING: break; }}
static void handle_uart(void){ s_rx_posted = false; /* clear BEFORE draining: a byte arriving now re-posts */ uint8_t c; while (uart_getc(&c)) { s_last_rx_ms = systick_now_ms(); if (update_active()) { update_on_byte(c, s_last_rx_ms); } else { shell_input(c); } }}
static void handle(const event_t *ev){ if (ev->type == EV_UART_RX) { /* handled the same way in every state */ handle_uart(); return; } if (ev->type == EV_UPDATE_START) { enter(APP_UPDATING); /* update_begin() only accepts when idle */ return; } switch (s_state) { case APP_SAMPLING: if (ev->type == EV_SAMPLE_READY) { accept_sample(ev->arg); enter(APP_REPORTING); } else if (ev->type == EV_SENSOR_ERROR) { sensor_failed((err_t)(int32_t)ev->arg); enter(APP_SLEEPING); } break; case APP_REPORTING: if (ev->type == EV_REPORT_DONE) { enter(APP_SLEEPING); } break; case APP_SLEEPING: if (ev->type == EV_SAMPLE_DUE) { enter(APP_SAMPLING); } break; case APP_UPDATING: if (ev->type == EV_UPDATE_DONE) { LOG_I("update stored, rebooting into it"); (void)evlog_append(EVLOG_UPDATE_OK, 0, 0u); uart_flush(); system_reset(); } else if (ev->type == EV_UPDATE_ABORT) { err_t e = (err_t)(int32_t)ev->arg; LOG_W("update aborted: %s", err_str(e)); (void)evlog_append(EVLOG_UPDATE_FAIL, (int16_t)e, 0u); uint32_t period = cfg_get()->sample_period_s * 1000u; sched_start(TASK_SAMPLE, period, period); enter(APP_SLEEPING); } break; case APP_INIT: break; }}
/* ------------------------------------------------------------ public */void app_init(void){ if (!handoff_valid()) { handoff_reset(); /* power-on, or started without the bootloader */ } err_t cfg_status = cfg_load(); const cfg_t *cfg = cfg_get(); log_set_level((log_level_t)cfg->verbose);
LOG_I("sensor-node %.32s, slot %c, clock %" PRIu32 " MHz", app_image_header.version_str, update_running_slot() == 0u ? 'A' : 'B', clock_sysclk_hz() / 1000000u); LOG_I("boot #%" PRIu32 ", reset cause: %s", g_handoff.boot_count, reset_cause(g_handoff.reset_flags)); if (cfg_status != ERR_OK) { LOG_W("config: %s, using defaults", err_str(cfg_status)); } if (g_handoff.crash_pc != 0u) { LOG_E("previous crash: pc=0x%08" PRIx32 " lr=0x%08" PRIx32 " cfsr=0x%08" PRIx32, g_handoff.crash_pc, g_handoff.crash_lr, g_handoff.crash_cfsr); g_handoff.crash_pc = 0u; handoff_seal(); } (void)evlog_init(); (void)evlog_append(EVLOG_BOOT, (int16_t)(g_handoff.boot_count & 0x7FFFu), (uint16_t)(g_handoff.reset_flags >> 24));
err_t e = i2c_init(clock_apb1_hz()); if (e == ERR_OK) { e = th20_init(&s_sensor, (bus_t){ &i2c_bus_ops, NULL }, SENSOR_I2C_ADDR); } LOG_I("th20: %s", err_str(e));
sched_define(TASK_HEARTBEAT, "heartbeat", task_heartbeat); sched_define(TASK_SAMPLE, "sample", task_sample); sched_define(TASK_SENSOR_READ, "sensor", task_sensor_read); sched_define(TASK_UPDATE, "update", update_task); sched_start(TASK_HEARTBEAT, HEARTBEAT_MS, HEARTBEAT_MS); sched_start(TASK_SAMPLE, cfg->sample_period_s * 1000u, cfg->sample_period_s * 1000u);
uart_set_rx_hook(on_uart_rx_isr); uart_rx_enable_irq(); shell_init(); enter(APP_SAMPLING); /* first sample immediately */}
void app_dispatch_events(void){ event_t ev; while (event_get(&ev)) { handle(&ev); } wdg_checkin(WDG_LIVE_EVENTS);}
bool app_can_deep_sleep(void){ bool idle = (s_state == APP_SLEEPING || s_state == APP_SAMPLING); return idle && !update_active() && (systick_now_ms() - s_last_rx_ms) > SHELL_IDLE_MS;}
app_state_t app_state(void){ return s_state;}
void app_on_config_changed(void){ const cfg_t *cfg = cfg_get(); log_set_level((log_level_t)cfg->verbose); if (s_state != APP_UPDATING) { sched_start(TASK_SAMPLE, cfg->sample_period_s * 1000u, cfg->sample_period_s * 1000u); } (void)evlog_append(EVLOG_CFG_CHANGED, 0, (uint16_t)cfg_sequence());}
void app_print_info(void){ const cfg_t *cfg = cfg_get(); char t[16]; log_print("fw %.32s (0x%06" PRIx32 ", build 0x%08" PRIx32 ")\r\n", app_image_header.version_str, app_image_header.fw_version, app_image_header.build_id); log_print("slot %c, %s\r\n", update_running_slot() == 0u ? 'A' : 'B', update_running_confirmed() ? "confirmed" : "on trial"); log_print("boot #%" PRIu32 ", reset cause %s, crashes %" PRIu32 "\r\n", g_handoff.boot_count, reset_cause(g_handoff.reset_flags), g_handoff.crash_count); log_print("uptime %" PRIu32 " s, state %s\r\n", systick_now_ms() / 1000u, s_state_names[s_state]); if (s_sample_count > 0u) { log_print("sample #%" PRIu32 " t=%s C rh=%" PRIu32 ".%02" PRIu32 " %%\r\n", s_sample_count, fmt_centi(t, sizeof t, s_last.temp_cdeg), (uint32_t)s_last.hum_cpct / 100u, (uint32_t)s_last.hum_cpct % 100u); } log_print("config period %" PRIu32 " s, mode %s, log every %u, seq %" PRIu32 "\r\n", cfg->sample_period_s, cfg->report_mode == REPORT_BINARY ? "binary" : "text", (unsigned)cfg->log_every, cfg_sequence()); log_print("health sensor errors %" PRIu32 ", events dropped %" PRIu32 " (peak %" PRIu32 "/%u), uart overruns %" PRIu32 "\r\n", s_sensor_errors, event_dropped(), event_high_water(), (unsigned)EVENT_QUEUE_LEN, uart_rx_overruns()); for (uint32_t i = 0; i < TASK_COUNT; i++) { log_print("task %-10s worst %" PRIu32 " ms\r\n", sched_name(i), sched_max_ms(i)); }}Read app.c in five parts.
Helpers. fmt_centi prints a centi-unit value with its sign handled separately (the -0.50 trap from Capstone II). reset_cause decodes the RCC->CSR snapshot the bootloader stored in the handoff block. Several flags can be set at once (a power-on reset also sets the pin reset flag), so it reports the most specific one first. on_uart_rx_isr is the hook the UART ISR calls for each byte. It posts EV_UART_RX only if one is not already pending, so a 260-byte update frame produces one event, not 260, and cannot flood the queue.
Tasks. Each is a few lines and decides nothing. The heartbeat proves the scheduler is alive and, once the image has run for 30 seconds, confirms a trial image (the rollback design is in Capstone IV). The sensor read task polls up to three more times if the result is not ready, then posts the outcome. The sample is packed into the event argument: temperature as a 16-bit two’s complement pattern in the low half, humidity in the high half. Unpacking reverses it with (int16_t)(uint16_t)(arg & 0xFFFF), which restores the sign.
Actions. accept_sample applies the calibration offsets and clamps humidity to its physical range. report_sample emits either a text line or a MSG_SAMPLE frame; both go out on the same UART, and a host can tell them apart because a frame starts with 0xA5, a byte that never appears in the text. It also appends every Nth sample to the event log. sensor_failed counts the error, logs it, and, for bus-level errors (NACK, timeout, bus error, or a sensor that was never found), re-runs the I2C recovery and re-probes the sensor. A sensor unplugged and plugged back in is picked up at the next sample without a reboot.
The state machine. enter performs entry actions: starting a conversion on entry to sampling, reporting on entry to reporting, stopping the sample timer on entry to updating. handle is the transition table written as a switch on the current state; an event that has no meaning in the current state is ignored by falling through the if chain. Two events are handled before the switch because they mean the same in every state: UART input, and the start of an update (which update_begin only allows from sleeping). Every state appears in the switch with no default, so adding a state without handling it is a -Wswitch warning.
Public functions. app_init validates the handoff block, loads configuration, prints the banner, reports a previous crash, logs the boot, probes the sensor, defines and starts the tasks, and enables UART receive last, after everything that input could reach is ready. app_can_deep_sleep will gate Stop mode in Capstone V: only when idle, not updating, and with no keyboard activity for 30 seconds, because Stop mode stops the USART clock and the first character typed after it is lost.
The fault handler
Section titled “The fault handler”A watchdog reset tells you the system stopped. A HardFault record tells you where.
#include <stdint.h>#include "handoff.h"#include "stm32f407_regs.h"
void fault_record(const uint32_t *frame, uint32_t exc_return) __attribute__((used, noreturn));
/* Pick the stack the exception frame was pushed to, then hand it to C. */__attribute__((naked)) void HardFault_Handler(void){ __asm volatile ( "tst lr, #4 \n" /* EXC_RETURN bit 2: 0 = MSP, 1 = PSP */ "ite eq \n" "mrseq r0, msp \n" "mrsne r0, psp \n" "mov r1, lr \n" "b fault_record \n");}
void fault_record(const uint32_t *frame, uint32_t exc_return){ (void)exc_return; if (!handoff_valid()) { handoff_reset(); } g_handoff.crash_pc = frame[6]; /* stacked r0 r1 r2 r3 r12 lr pc xpsr */ g_handoff.crash_lr = frame[5]; g_handoff.crash_cfsr = SCB->CFSR; g_handoff.crash_count++; handoff_seal(); system_reset();}This is the handler from Watchdogs, faults, and recovering from them, cut down to what a product needs: record, then reset. The naked assembly runs before the compiler could push anything, picks MSP or PSP from bit 2 of the EXC_RETURN value in lr (this firmware only uses MSP, but the test costs two instructions and keeps the handler correct if an RTOS is added later), and branches to C with the frame pointer in r0. The C half copies the stacked pc and lr and the fault status register into the handoff block and resets. On the next boot, app_init prints the record; arm-none-eabi-addr2line -e build/a/app.elf 0x0800xxxx turns the pc into a file and line.
Escalation does the rest: MemManage, BusFault and UsageFault are not enabled separately, so every fault arrives here. used keeps fault_record alive under --gc-sections, because the only reference to it is inside an assembly string the compiler cannot see into. If the fault handler itself faults (for example because the stack pointer is garbage), the core locks up, and the watchdog is what recovers the board.
The final main loop
Section titled “The final main loop”#include "app.h"#include "board.h"#include "clock.h"#include "event.h"#include "gpio.h"#include "sched.h"#include "stm32f407_regs.h"#include "systick.h"#include "uart.h"#include "wdg.h"
#define WDG_TIMEOUT_MS 12000u
int main(void){ (void)clock_init_168mhz(); /* falls back to HSI; app_init reports the clock */ systick_init(clock_sysclk_hz()); uart_init(clock_apb1_hz(), CONSOLE_BAUD); gpio_clock_enable(LED_PORT); gpio_config(LED_PORT, LED_BLUE_PIN, GPIO_OUTPUT, GPIO_NOPULL, false, 0u); gpio_set(LED_PORT, LED_BLUE_PIN); /* high while awake */ wdg_init(WDG_TIMEOUT_MS); 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); }}The idle sequence is the one piece of this file to study closely. The naive version, if (!event_pending()) wfi();, has a race: the UART ISR can fire after the check and before the WFI, post its event, and return, and the core then sleeps with an event waiting until the next SysTick a millisecond later, or forever once Capstone V stops SysTick. Masking interrupts with PRIMASK closes the window, and it does not prevent waking: on Cortex-M, an interrupt that becomes pending while PRIMASK is set still wakes the core from WFI; the handler simply runs when irq_restore unmasks. The DSB before WFI makes sure any outstanding memory writes complete before the core sleeps.
For now the core sleeps only until the next interrupt, and SysTick interrupts every millisecond, so the loop runs a thousand times a second doing nothing. That is correct but wasteful; Capstone V replaces these four lines with power_idle(), which sleeps until the scheduler’s next deadline.
Build, run, observe
Section titled “Build, run, observe”make apprenode renode/stage2.rescThe stage-2 script still works: it loads build/a/app.elf and starts it from slot A’s vector table. Type start in the monitor. The USART2 analyzer shows the boot banner and the first report, then a report every 10 seconds of simulated time:
[ 0.001] I sensor-node 1a2b3c4, slot A, clock 168 MHz[ 0.001] I boot #0, reset cause: unknown[ 0.002] W config: not found, using defaults[ 0.003] I th20: oktype 'help' for commandsnode> sample 1 t=21.50 C rh=45.00 %sample 2 t=21.50 C rh=45.00 %boot #0 and reset cause: unknown are expected: there is no bootloader yet to count boots and capture RCC->CSR, so the application found an invalid handoff block and reset it. The config warning comes from the stub. Click into the analyzer window and type:
node> infofw 1a2b3c4 (0x010000, build 0x1a2b3c4d)slot A, confirmedboot #0, reset cause unknown, crashes 0uptime 23 s, state sleepingsample #3 t=21.50 C rh=45.00 %config period 10 s, mode text, log every 6, seq 0health sensor errors 0, events dropped 0 (peak 2/16), uart overruns 0task heartbeat worst 0 mstask sample worst 0 mstask sensor worst 0 mstask update worst 0 msnode> samplesample requestednode> sample 4 t=21.50 C rh=45.00 %Now break things on purpose from the Renode monitor. Make the sensor report an impossible value with sysbus.i2c1.th20 Humidity 150 and watch W sensor: out of range appear at the next sample and the error count rise in info. Set it back to 50 and the reports resume without a reboot.
Common mistakes
Section titled “Common mistakes”- Doing work inside a task instead of posting an event. Two places now decide what the product does, and the state machine no longer describes the system. Tasks post; the state machine decides.
- Clearing the “event pending” flag after draining the ring. A byte that arrives between the last
uart_getcand the clear never produces an event and sits in the ring until the next byte. Clear first, then drain. - Checking for work and then calling
WFIwith interrupts enabled. The ISR fires in the gap and the core sleeps on top of a pending event. Mask, check,WFI, unmask. - Feeding the watchdog unconditionally in the main loop. A stuck event queue or a dead time base goes unnoticed while the loop spins. Feed only when every liveness bit is set.
- Trusting a length field from the wire. One corrupted byte becomes a buffer overflow. Check the length against the buffer before reading the payload.
- Logging from an ISR. The ISR blocks for milliseconds on the UART, other interrupts are delayed, and bytes are lost. Post an event and log from thread mode.
- Leaving logging on while the UART carries binary frames. A timestamped line lands in the middle of a frame and the CRC fails. Mute the log for the duration.
Exercises
Section titled “Exercises”- Add a
uptimeshell command that prints the uptime as days, hours, minutes and seconds. It should be one function and one table entry; if you touched anything else, reconsider. - Add a
crashcommand that reads from address0xFFFFFFF0(a read from an unmapped address raises a precise BusFault, which escalates to HardFault; a write would usually be buffered and arrive as an imprecise one). Run it on hardware or in Renode. After the reset, what does the banner print, and what doesarm-none-eabi-addr2line -e build/a/app.elf <pc>say? Check that thecfsrvalue hasPRECISERR(bit 9) andBFARVALID(bit 15) set. - Add a
hangcommand that loops forever. On hardware, time how long until the watchdog resets the board and confirminforeportsreset cause watchdog. Then movewdg_service()intoSysTick_Handlerand repeat: the board never resets, which is exactly why that design is wrong. - Make UART transmit interrupt driven: a 256-byte TX ring,
uart_writecopies into it and enablesTXEIE, the ISR feedsDRand disablesTXEIEwhen the ring is empty. Decide whatuart_writemust do when the ring is full, and makeuart_flushwait for the ring to drain and then forTC. Measure the worst run time of the sample task ininfobefore and after. - Switch the node to binary reports (this needs the real config module from Capstone IV, or temporarily change the stub default) and write a 30-line Python script that connects to the UART socket that Capstone IV adds to the Renode script, finds
MSG_SAMPLEframes with the same CRC-16 asproto.c, and prints node id, sample count, temperature and humidity. Corrupt one byte in the script’s input buffer and confirm the frame is rejected.
Check yourself
Section titled “Check yourself”Question 1:The UART hook posts EV_UART_RX only when s_rx_posted is false, and handle_uart sets it false. Why must handle_uart clear the flag before draining the ring rather than after?
Clearing first means any byte that arrives during or after the drain posts a new event, so no byte is ever stranded.
Question 2:In the idle code, interrupts are masked with irq_save() before checking event_pending() and calling WFI. A UART byte arrives right after the check. What happens?
A pending interrupt wakes the core from WFI regardless of PRIMASK; masking only defers the handler, which closes the check-then-sleep race.
Question 3:The main loop is healthy and SysTick has stopped because of a bug. Which liveness bit stops being set, and what follows?
The heartbeat task proves timed work still runs. The IWDG runs from its own LSI oscillator, so it still expires.
Question 4:A corrupted frame arrives with length field 0xFFFF. What does proto_rx_feed do?
The length is checked against PROTO_MAX_PAYLOAD before any payload byte is stored, so an attacker or a noisy line cannot overflow the buffer.
Question 5:The sample task runs every 10 s. A config save erases a flash sector and the main loop stalls for 25 s. What does the scheduler do when it resumes?
When the task has fallen more than a period behind, sched_run_due reschedules from the current time instead of bursting.
Key takeaways
Section titled “Key takeaways”- ISRs capture data and post events; the main loop decides. Keep every product decision in one state machine.
- A cooperative scheduler is a table of deadlines. Deadline plus period prevents drift; rescheduling from now when far behind prevents bursts; reporting the next deadline makes sleeping possible.
- The check-then-sleep race is closed by masking interrupts around the check and
WFI; a pending interrupt still wakes the core. - Feed the watchdog only when several independent liveness facts have been proven since the last feed, never from a timer ISR.
- A frame parser must resynchronize, bound its length before using it, time out mid-frame, and count its errors.
- Record faults in memory that survives reset and report them on the next boot; a crash you can read is a bug you can fix.
Next: Capstone IV — Storage, Bootloader, and Updates, where the flash driver, the A/B configuration, the event log, the update receiver, and the bootloader replace the stubs and make the node updatable in the field.