Part 3 · Firmware Systems33 min read
State Machines and Event-Driven Firmware
Most firmware bugs that survive into the field are not arithmetic errors. They are control-flow errors: a button press lost because the code was inside a delay, a protocol parser that wedges after one corrupted byte, a mode that can be entered but never left. This lesson gives you the tools that make control flow explicit: an event queue that ISRs feed and the main loop drains, a cooperative scheduler, three ways to write a finite state machine, timeouts as ordinary events, and host tests that drive a state machine through event sequences. These are the patterns that scale from a two-button gadget up to the active objects that become RTOS tasks in the next lesson.
Why delay_ms() inside logic breaks everything
Section titled “Why delay_ms() inside logic breaks everything”Here is a main loop that every beginner writes and every product eventually outgrows:
for (;;) { if (button_is_down()) { led_toggle(); delay_ms(50); /* "debounce" */ } if (uart_rx_ready()) { handle_byte(uart_getc()); } led_on(LED_HEARTBEAT); delay_ms(500); led_off(LED_HEARTBEAT); delay_ms(500);}Count what goes wrong:
- Missed input. The USART2 receiver on the STM32F407 holds one byte in
DR. At 115200 baud a byte arrives every 87 µs. During a one-second heartbeat, roughly 11,000 bytes could arrive and all but one are lost with an overrun (ORE). A button tap shorter than a second may never be seen. - Coupled timing. The UART’s response time depends on the heartbeat’s delay. Add a feature with its own delay and every other feature slows down.
- Wasted power.
delay_msspins the CPU at full speed doing nothing. The core could be asleep inWFIdrawing a fraction of the current. - Untestable logic. The behavior is smeared across delays. You cannot ask “what does the system do when the button is pressed during a heartbeat” without running it in real time.
The underlying problem is that the code uses the program counter as its state. Where execution is parked (inside which delay) encodes what the system is doing. The fix is to store state in variables, react to events as they arrive, and never wait in line.
The superloop and its limits
Section titled “The superloop and its limits”The first fix is a superloop of non-blocking functions. Each one checks whether it has work, does a bounded amount, and returns:
for (;;) { button_poll(); /* reads pin, debounces, returns in microseconds */ uart_poll(); /* drains the RX ring buffer */ heartbeat_poll(); /* toggles the LED if 500 ms have passed */}This is a legitimate architecture. Plenty of shipping products are exactly this. Its limits are worth knowing precisely:
- The worst-case response time for any function is the sum of the worst-case run times of all the others. One slow path delays everyone.
- There is no priority except order and ISRs. Urgent work waits its turn.
- Each
_pollfunction must carry its own timing logic (if (millis() - last >= 500)), duplicated everywhere. - Communication between functions is through shared globals, and it quickly becomes unclear who changes what.
The next three sections fix those limits one at a time: a scheduler for timing, an event queue for communication, and state machines for logic.
A cooperative scheduler
Section titled “A cooperative scheduler”A cooperative scheduler runs tasks from a table at fixed periods. Each task is a function that runs to completion and returns; nothing pre-empts it except interrupts. That is why the model is called run-to-completion.
#include <stddef.h>#include <stdint.h>#include "stm32f4xx.h" /* __WFI, __DSB */#include "clock.h" /* uint32_t millis(void), from Clocks, reset, and timing */#include "tasks.h" /* button_poll, sensor_sample, heartbeat */
typedef struct { void (*fn)(void); uint32_t period_ms; uint32_t last_ms;} coop_task_t;
static coop_task_t tasks[] = { { .fn = button_poll, .period_ms = 5u }, { .fn = sensor_sample, .period_ms = 100u }, { .fn = heartbeat, .period_ms = 500u },};
void coop_run(void){ for (;;) { const uint32_t now = millis(); for (size_t i = 0; i < sizeof tasks / sizeof tasks[0]; i++) { coop_task_t *t = &tasks[i]; if ((uint32_t)(now - t->last_ms) >= t->period_ms) { t->last_ms += t->period_ms; /* schedule from the ideal time, not from now */ t->fn(); } } __DSB(); __WFI(); /* SysTick wakes us every millisecond */ }}The important line is t->last_ms += t->period_ms. Writing t->last_ms = now instead would let each late run push every later run back, and a 100 ms task would drift to 101, 102, 103 ms periods. Advancing from the ideal time keeps the long-term rate exact. The cost is that if a task falls several periods behind (say the CPU was busy for 300 ms), it runs back-to-back to catch up. If that is wrong for a given task, reset last_ms = now for that one.
The scheduler’s contract is strict: every task must return quickly. If sensor_sample takes 30 ms, button_poll is late by 30 ms. Measure each task’s worst case with the DWT cycle counter. Work that takes longer must be split into steps that run one per call, which is exactly what a state machine does.
The WFI at the bottom of the loop sleeps until the next interrupt. With SysTick at 1 kHz the loop wakes every millisecond, checks the table, and sleeps again. Power management goes further by sleeping until the next deadline instead of the next tick.
Events and the event queue
Section titled “Events and the event queue”Periods handle “do this every N ms”. Most firmware logic is instead “do this when that happens”: a button changed, a byte arrived, a timer expired. Represent each happening as an event and put events in a queue.
An event is a tagged union: a type, and data whose meaning depends on the type.
#ifndef EVENT_H#define EVENT_H
#include <stdbool.h>#include <stdint.h>
typedef enum { EV_NONE = 0, EV_BUTTON_DOWN, EV_BUTTON_UP, EV_TIMEOUT, /* .timer_id says which timer */ EV_UART_BYTE, /* .byte holds the data */} ev_type_t;
typedef struct { ev_type_t type; union { /* C11 anonymous union: e.timer_id, e.byte */ uint8_t timer_id; uint8_t byte; };} event_t;
enum { TMR_LONGPRESS, TMR_BLINK, TMR_INACTIVITY, TMR_RX_GAP, TMR_COUNT };
bool evq_post(event_t e); /* ISR or thread context */bool evq_get(event_t *out); /* main loop only */bool evq_empty(void); /* call with interrupts disabled */
#endifThe queue is a ring buffer of event_t. Unlike the byte ring in the UART lesson, it has several producers: the UART ISR, the EXTI ISR and the main loop itself can all post. Two ISRs at different priorities can pre-empt each other in the middle of a post, so each post runs inside a short critical section.
#include "event.h"#include "stm32f4xx.h" /* __get_PRIMASK, __disable_irq, __set_PRIMASK */
#define EVQ_LEN 32u /* power of two */
static event_t evq_buf[EVQ_LEN];static uint32_t evq_head; /* free-running; next slot to write */static uint32_t evq_tail; /* free-running; next slot to read */static uint32_t evq_dropped; /* events lost because the queue was full */
bool evq_post(event_t e){ const uint32_t primask = __get_PRIMASK(); __disable_irq(); const bool ok = (evq_head - evq_tail) < EVQ_LEN; if (ok) { evq_buf[evq_head & (EVQ_LEN - 1u)] = e; evq_head++; } else { evq_dropped++; } __set_PRIMASK(primask); return ok;}
bool evq_get(event_t *out){ const uint32_t primask = __get_PRIMASK(); __disable_irq(); const bool ok = (evq_head != evq_tail); if (ok) { *out = evq_buf[evq_tail & (EVQ_LEN - 1u)]; evq_tail++; } __set_PRIMASK(primask); return ok;}
bool evq_empty(void){ return evq_head == evq_tail;}Saving and restoring PRIMASK instead of unconditionally re-enabling interrupts means evq_post is safe to call from code that already has interrupts disabled. The critical section is a handful of instructions: an 8-byte struct copy and two index updates. The indices are free-running uint32_t values masked only when indexing, so head - tail is the fill level even after they wrap. evq_dropped matters more than it looks: a full queue is a design error (events arrive faster than you handle them) and a counter you can read in the debugger turns a mystery into a number.
The CMSIS __disable_irq and __set_PRIMASK include a "memory" clobber, so the compiler cannot move the buffer accesses outside the critical section. Concurrency, atomics, and memory ordering explains why that matters and shows a lock-free alternative for the single-producer case.
The main loop
Section titled “The main loop”for (;;) { event_t e; while (evq_get(&e)) { app_dispatch(&e); /* hand the event to the state machines */ } timer_service_poll(); /* may post EV_TIMEOUT events */
__disable_irq(); if (evq_empty()) { __DSB(); __WFI(); /* a pending interrupt still wakes WFI */ } __enable_irq(); /* the waking ISR runs here */}The sleep sequence closes a race. If you checked evq_empty() with interrupts enabled, an ISR could post an event after the check and just before WFI; the CPU would then sleep with work pending until some unrelated interrupt woke it. With PRIMASK set, an interrupt that becomes pending still wakes the core from WFI (masking stops the handler from running, not the wake-up), and the handler runs the moment __enable_irq executes.
Timeouts are events too
Section titled “Timeouts are events too”Logic like “if the button is held for one second” needs time. Do not read millis() inside the state machine. Instead, a timer service turns expired deadlines into EV_TIMEOUT events, so time arrives through the same queue as everything else.
#include <stdbool.h>#include <stdint.h>#include "clock.h"#include "event.h"#include "timer_service.h"
typedef struct { bool active; uint32_t deadline; uint32_t period; /* 0 = one-shot */} swtimer_t;
static swtimer_t timers[TMR_COUNT];
void timer_start(uint8_t id, uint32_t ms, uint32_t period_ms){ timers[id] = (swtimer_t){ .active = true, .deadline = millis() + ms, .period = period_ms };}
void timer_stop(uint8_t id){ timers[id].active = false;}
void timer_service_poll(void){ const uint32_t now = millis(); for (uint8_t id = 0; id < TMR_COUNT; id++) { swtimer_t *t = &timers[id]; if (t->active && (int32_t)(now - t->deadline) >= 0) { if (t->period != 0u) { t->deadline += t->period; } else { t->active = false; } evq_post((event_t){ .type = EV_TIMEOUT, .timer_id = id }); } }}timer_service_poll runs in the main loop, not in an ISR, so the timer table needs no locking. The (int32_t)(now - deadline) >= 0 comparison is the wraparound-safe idiom: it works across the 49.7-day rollover of a 32-bit millisecond counter.
There is one subtlety every event-driven system must handle: the stale timeout. A timer can expire and post its event, and then, before that event is dispatched, the state machine stops the timer. The event is already in the queue and will arrive anyway. The robust answer is to design states so that a timeout they do not expect is ignored, which the examples below do. Larger systems add a generation counter to each timer and drop events whose generation does not match.
Finite state machines
Section titled “Finite state machines”A finite state machine (FSM) is a set of states, a set of events, and a rule for each (state, event) pair: what action to take and which state to go to next. You already know the theory. The engineering question is how to write one in C so that it stays correct as it grows.
The running example is a one-button LED controller:
- A short press (released within 1 s) toggles the LED.
- A long press (held for 1 s) enters config mode, where the LED blinks at 4 Hz.
- In config mode, each press cycles a setting through four levels.
- Ten seconds without a press in config mode returns to normal, restoring the LED.
Draw it before writing code:
EV_BUTTON_DOWN +--------+ start LONGPRESS +-----------+ | IDLE | -----------------> | PRESSED | | | <----------------- | | +--------+ EV_BUTTON_UP +-----------+ ^ stop LONGPRESS, | | toggle LED | EV_TIMEOUT(LONGPRESS) | | start BLINK, INACTIVITY | EV_TIMEOUT(INACTIVITY) v | stop BLINK, restore LED +----------+ EV_BUTTON_DOWN: next level, +-------------------------- | CONFIG | restart INACTIVITY +----------+ EV_TIMEOUT(BLINK): flip LEDVersion 1: enum and switch
Section titled “Version 1: enum and switch”#include <stdbool.h>#include <stdint.h>#include "ctrl.h" /* ctrl_t, state_t, prototypes */#include "event.h"#include "led.h" /* void led_set(bool on) */#include "timer_service.h"
#define LONGPRESS_MS 1000u#define BLINK_MS 125u /* flip every 125 ms = 4 Hz blink */#define INACTIVITY_MS 10000u
/* in ctrl.h: typedef enum { ST_IDLE, ST_PRESSED, ST_CONFIG } state_t; typedef struct { state_t state; bool led_on; uint8_t level; bool blink_phase; } ctrl_t; */
void ctrl_dispatch(ctrl_t *c, const event_t *e){ switch (c->state) { case ST_IDLE: if (e->type == EV_BUTTON_DOWN) { timer_start(TMR_LONGPRESS, LONGPRESS_MS, 0u); c->state = ST_PRESSED; } break;
case ST_PRESSED: if (e->type == EV_BUTTON_UP) { timer_stop(TMR_LONGPRESS); c->led_on = !c->led_on; led_set(c->led_on); c->state = ST_IDLE; } else if (e->type == EV_TIMEOUT && e->timer_id == TMR_LONGPRESS) { c->blink_phase = false; timer_start(TMR_BLINK, BLINK_MS, BLINK_MS); timer_start(TMR_INACTIVITY, INACTIVITY_MS, 0u); c->state = ST_CONFIG; } break;
case ST_CONFIG: if (e->type == EV_BUTTON_DOWN) { c->level = (uint8_t)((c->level + 1u) % 4u); timer_start(TMR_INACTIVITY, INACTIVITY_MS, 0u); /* restart */ } else if (e->type == EV_TIMEOUT && e->timer_id == TMR_BLINK) { c->blink_phase = !c->blink_phase; led_set(c->blink_phase); } else if (e->type == EV_TIMEOUT && e->timer_id == TMR_INACTIVITY) { timer_stop(TMR_BLINK); led_set(c->led_on); c->state = ST_IDLE; } break; }}Three details carry the correctness:
- The release after a long press. The user is still holding the button when the controller enters
CONFIG. The release arrives asEV_BUTTON_UPinCONFIG, which acts only onEV_BUTTON_DOWN, so the release is ignored instead of being counted as a press. - The stale timeout. If
EV_BUTTON_UPis queued just beforeTMR_LONGPRESSexpires, the up event is handled first (back toIDLE) and the lateEV_TIMEOUT(LONGPRESS)arrives inIDLE, which ignores it. If the timeout was queued first, the controller entersCONFIGand ignores the release. Both orders give sensible behavior. - No
defaultlabel. With-Wall(which enables-Wswitch), adding a newstate_tvalue without acaseproduces a warning. Adefaultwould silence it.
The switch form is compact and fast, and most firmware FSMs should start here. It gets unwieldy past roughly ten states, and the transitions are buried in code rather than visible at a glance.
Version 2: table-driven
Section titled “Version 2: table-driven”Turn each arrow in the diagram into a row:
typedef void (*action_fn)(ctrl_t *c, const event_t *e);
typedef struct { state_t state; ev_type_t event; uint8_t timer_id; /* only compared for EV_TIMEOUT rows */ action_fn action; /* may be NULL */ state_t next;} transition_t;
static void act_start_longpress(ctrl_t *c, const event_t *e){ (void)c; (void)e; timer_start(TMR_LONGPRESS, LONGPRESS_MS, 0u);}/* act_toggle_led, act_enter_config, act_next_level, act_blink and act_leave_config contain the same statements as the switch version. */
static const transition_t table[] = { { ST_IDLE, EV_BUTTON_DOWN, 0u, act_start_longpress, ST_PRESSED }, { ST_PRESSED, EV_BUTTON_UP, 0u, act_toggle_led, ST_IDLE }, { ST_PRESSED, EV_TIMEOUT, TMR_LONGPRESS, act_enter_config, ST_CONFIG }, { ST_CONFIG, EV_BUTTON_DOWN, 0u, act_next_level, ST_CONFIG }, { ST_CONFIG, EV_TIMEOUT, TMR_BLINK, act_blink, ST_CONFIG }, { ST_CONFIG, EV_TIMEOUT, TMR_INACTIVITY, act_leave_config, ST_IDLE },};
void ctrl_dispatch_table(ctrl_t *c, const event_t *e){ for (size_t i = 0; i < sizeof table / sizeof table[0]; i++) { const transition_t *t = &table[i]; if (t->state == c->state && t->event == e->type && (e->type != EV_TIMEOUT || t->timer_id == e->timer_id)) { if (t->action != NULL) { t->action(c, e); } c->state = t->next; return; } } /* no matching row: the event is ignored in this state */}The table is const, so it lives in flash. It reads like the diagram, which makes it easy to review: a missing row is visible. Adding a transition is adding a line. The linear search is fine for tens of rows; for larger machines index a two-dimensional array by [state][event].
The weakness is guards. A transition that depends on data (“go to ERROR only if retries exceed 3”) needs a guard function pointer per row, and the table stops being simple. Use tables when transitions are mostly unconditional.
Version 3: one handler per state, with entry and exit
Section titled “Version 3: one handler per state, with entry and exit”The switch version repeats cleanup: timer_stop(TMR_BLINK) must be called on every path out of CONFIG. Miss one path and the blink timer keeps posting events forever. Entry and exit actions fix this structurally: code that runs whenever a state is entered or left, regardless of which transition caused it.
#include <stdbool.h>#include <stddef.h>#include <stdint.h>#include "event.h"#include "led.h"#include "timer_service.h"#include "trace.h" /* void trace_transition(const char *from, const char *to) */
#define LONGPRESS_MS 1000u#define BLINK_MS 125u#define INACTIVITY_MS 10000u
typedef struct fsm fsm_t;typedef struct state_desc state_desc_t;
struct state_desc { const char *name; void (*entry)(fsm_t *f); /* may be NULL */ void (*exit)(fsm_t *f); /* may be NULL */ const state_desc_t *(*handle)(fsm_t *f, const event_t *e); /* next state, or NULL to stay */};
struct fsm { const state_desc_t *cur; bool led_on; uint8_t level; bool blink_phase;};
static const state_desc_t st_idle, st_pressed, st_config; /* defined below */
static void fsm_transition(fsm_t *f, const state_desc_t *next){ if (f->cur->exit != NULL) { f->cur->exit(f); } trace_transition(f->cur->name, next->name); f->cur = next; if (f->cur->entry != NULL) { f->cur->entry(f); }}
void fsm_dispatch(fsm_t *f, const event_t *e){ const state_desc_t *next = f->cur->handle(f, e); if (next != NULL) { fsm_transition(f, next); }}
/* ---- IDLE ---- */static const state_desc_t *idle_handle(fsm_t *f, const event_t *e){ (void)f; return (e->type == EV_BUTTON_DOWN) ? &st_pressed : NULL;}
/* ---- PRESSED ---- */static void pressed_entry(fsm_t *f) { (void)f; timer_start(TMR_LONGPRESS, LONGPRESS_MS, 0u); }static void pressed_exit(fsm_t *f) { (void)f; timer_stop(TMR_LONGPRESS); }
static const state_desc_t *pressed_handle(fsm_t *f, const event_t *e){ if (e->type == EV_BUTTON_UP) { f->led_on = !f->led_on; led_set(f->led_on); return &st_idle; } if (e->type == EV_TIMEOUT && e->timer_id == TMR_LONGPRESS) { return &st_config; } return NULL;}
/* ---- CONFIG ---- */static void config_entry(fsm_t *f){ f->blink_phase = false; timer_start(TMR_BLINK, BLINK_MS, BLINK_MS); timer_start(TMR_INACTIVITY, INACTIVITY_MS, 0u);}
static void config_exit(fsm_t *f){ timer_stop(TMR_BLINK); timer_stop(TMR_INACTIVITY); led_set(f->led_on);}
static const state_desc_t *config_handle(fsm_t *f, const event_t *e){ if (e->type == EV_BUTTON_DOWN) { f->level = (uint8_t)((f->level + 1u) % 4u); timer_start(TMR_INACTIVITY, INACTIVITY_MS, 0u); return NULL; } if (e->type == EV_TIMEOUT && e->timer_id == TMR_BLINK) { f->blink_phase = !f->blink_phase; led_set(f->blink_phase); return NULL; } if (e->type == EV_TIMEOUT && e->timer_id == TMR_INACTIVITY) { return &st_idle; } return NULL;}
static const state_desc_t st_idle = { "IDLE", NULL, NULL, idle_handle };static const state_desc_t st_pressed = { "PRESSED", pressed_entry, pressed_exit, pressed_handle };static const state_desc_t st_config = { "CONFIG", config_entry, config_exit, config_handle };
void fsm_init(fsm_t *f){ f->cur = &st_idle; f->led_on = false; f->level = 0u; f->blink_phase = false; led_set(false);}The line static const state_desc_t st_idle, st_pressed, st_config; is a set of tentative definitions: it tells the compiler the names exist so the handlers can return their addresses before the initialized definitions at the bottom. Each state is a const struct in flash; the only RAM is the fsm_t.
Now CONFIG cannot be left without stopping both timers and restoring the LED, because config_exit runs on every exit. pressed_exit stops the long-press timer even when the exit was caused by that timer expiring; stopping an already inactive one-shot is harmless, and it means no path forgets. Returning NULL means “handled, stay here, do not run exit and entry”, while returning the current state’s own address would perform an external self-transition that re-runs exit and entry. That gives you two distinct tools, and the choice is explicit in the code.
fsm_transition is also the single choke point for every state change, which is where the trace call lives.
Worked example: a protocol receiver
Section titled “Worked example: a protocol receiver”The second classic FSM consumes bytes. Here is a receiver for frames of the form:
+------+-----+---------------------+------+| 0xA5 | LEN | PAYLOAD (LEN bytes) | CRC8 |+------+-----+---------------------+------+ SOF 1..64 CRC-8, poly 0x07, init 0x00, over LEN and PAYLOAD#ifndef RX_FSM_H#define RX_FSM_H
#include <stddef.h>#include <stdint.h>
#define FRAME_SOF 0xA5u#define FRAME_MAX_LEN 64u
typedef enum { RX_IDLE, RX_LEN, RX_PAYLOAD, RX_CRC } rx_state_t;typedef enum { RX_NONE, RX_FRAME_OK } rx_result_t;
typedef struct { rx_state_t state; uint8_t len; uint8_t pos; uint8_t crc; uint8_t payload[FRAME_MAX_LEN]; uint32_t crc_errors; uint32_t len_errors;} rx_fsm_t;
uint8_t crc8_buf(const uint8_t *p, size_t n);rx_result_t rx_feed(rx_fsm_t *r, uint8_t b);void rx_timeout(rx_fsm_t *r);
#endif#include "rx_fsm.h"
static uint8_t crc8_update(uint8_t crc, uint8_t data){ crc ^= data; for (int i = 0; i < 8; i++) { crc = (crc & 0x80u) ? (uint8_t)((crc << 1) ^ 0x07u) : (uint8_t)(crc << 1); } return crc;}
uint8_t crc8_buf(const uint8_t *p, size_t n){ uint8_t crc = 0u; for (size_t i = 0; i < n; i++) { crc = crc8_update(crc, p[i]); } return crc;}
rx_result_t rx_feed(rx_fsm_t *r, uint8_t b){ switch (r->state) { case RX_IDLE: if (b == FRAME_SOF) { r->state = RX_LEN; } break; /* anything else is noise: keep hunting */
case RX_LEN: if (b == 0u || b > FRAME_MAX_LEN) { r->len_errors++; r->state = RX_IDLE; } else { r->len = b; r->pos = 0u; r->crc = crc8_update(0u, b); r->state = RX_PAYLOAD; } break;
case RX_PAYLOAD: r->payload[r->pos++] = b; /* pos < len <= FRAME_MAX_LEN, checked above */ r->crc = crc8_update(r->crc, b); if (r->pos == r->len) { r->state = RX_CRC; } break;
case RX_CRC: r->state = RX_IDLE; if (b == r->crc) { return RX_FRAME_OK; } r->crc_errors++; break; } return RX_NONE;}
void rx_timeout(rx_fsm_t *r){ r->state = RX_IDLE;}The length check in RX_LEN is what makes the payload write safe: a corrupted length byte of 200 is rejected before a single payload byte is stored. Without it, a noisy line writes past payload[] into whatever follows in RAM. Treat every byte from the outside world as hostile.
What if the sender stops halfway through a frame (cable pulled, sender reset)? The FSM would sit in RX_PAYLOAD and interpret the start of the next frame as the rest of the old one. The inter-byte gap timer fixes this:
case EV_UART_BYTE: if (rx_feed(&rx, e->byte) == RX_FRAME_OK) { app_handle_frame(rx.payload, rx.len); } if (rx.state != RX_IDLE) { timer_start(TMR_RX_GAP, 20u, 0u); /* restart on every byte mid-frame */ } else { timer_stop(TMR_RX_GAP); } break;
case EV_TIMEOUT: if (e->timer_id == TMR_RX_GAP) { rx_timeout(&rx); /* abandon the partial frame */ } break;After any error the receiver returns to RX_IDLE and hunts for the next 0xA5. A 0xA5 byte inside a damaged frame’s payload can cause a false start; the CRC catches it and the receiver resynchronizes within a frame or two. If you need resynchronization that never depends on luck, use byte stuffing such as COBS, covered in UART: serial communication.
Testing state machines on the host
Section titled “Testing state machines on the host”State machines are the easiest firmware to unit test, because their entire interface is “here is an event, what did you do”. The hardware-facing calls (led_set, timer_start, timer_stop) are plain functions declared in headers, so the test provides its own definitions and records what the FSM asked for. This is link-time substitution: the test binary links fakes instead of the real led.c and timer_service.c.
#include <stdbool.h>#include <stdio.h>#include "ctrl.h"#include "event.h"
static int failures;#define CHECK(cond) \ do { \ if (!(cond)) { \ printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ failures++; \ } \ } while (0)
/* Fakes for the functions ctrl.c calls. */static bool led;static bool timer_on[TMR_COUNT];void led_set(bool on) { led = on; }void timer_start(uint8_t id, uint32_t ms, uint32_t period_ms) { (void)ms; (void)period_ms; timer_on[id] = true; }void timer_stop(uint8_t id) { timer_on[id] = false; }
static void feed(ctrl_t *c, ev_type_t type, uint8_t timer_id){ const event_t e = { .type = type, .timer_id = timer_id }; ctrl_dispatch(c, &e);}
static ctrl_t fresh(void){ led = false; for (int i = 0; i < TMR_COUNT; i++) { timer_on[i] = false; } return (ctrl_t){ .state = ST_IDLE };}
static void test_short_press_toggles(void){ ctrl_t c = fresh(); feed(&c, EV_BUTTON_DOWN, 0u); feed(&c, EV_BUTTON_UP, 0u); CHECK(c.state == ST_IDLE); CHECK(led == true); CHECK(!timer_on[TMR_LONGPRESS]);}
static void test_long_press_enters_config_and_ignores_release(void){ ctrl_t c = fresh(); feed(&c, EV_BUTTON_DOWN, 0u); feed(&c, EV_TIMEOUT, TMR_LONGPRESS); CHECK(c.state == ST_CONFIG); feed(&c, EV_BUTTON_UP, 0u); /* the release of the long press */ CHECK(c.state == ST_CONFIG); CHECK(c.level == 0u); /* release did not count as a press */}
static void test_inactivity_restores_led(void){ ctrl_t c = fresh(); feed(&c, EV_BUTTON_DOWN, 0u); feed(&c, EV_BUTTON_UP, 0u); /* LED on */ feed(&c, EV_BUTTON_DOWN, 0u); feed(&c, EV_TIMEOUT, TMR_LONGPRESS); /* config */ feed(&c, EV_TIMEOUT, TMR_BLINK); /* blink phase on */ feed(&c, EV_TIMEOUT, TMR_BLINK); /* blink phase off: LED dark */ feed(&c, EV_TIMEOUT, TMR_INACTIVITY); CHECK(c.state == ST_IDLE); CHECK(led == true); /* user setting restored */ CHECK(!timer_on[TMR_BLINK]);}
static void test_stale_longpress_timeout_ignored(void){ ctrl_t c = fresh(); feed(&c, EV_BUTTON_DOWN, 0u); feed(&c, EV_BUTTON_UP, 0u); feed(&c, EV_TIMEOUT, TMR_LONGPRESS); /* was already queued */ CHECK(c.state == ST_IDLE);}
int main(void){ test_short_press_toggles(); test_long_press_enters_config_and_ignores_release(); test_inactivity_restores_led(); test_stale_longpress_timeout_ignored(); if (failures == 0) { puts("all ctrl tests passed"); } return failures == 0 ? 0 : 1;}gcc -std=c11 -Wall -Wextra -Wpedantic -I. ctrl.c test_ctrl.c -o test_ctrl && ./test_ctrlall ctrl tests passedEach test is a sequence of events and a check of the resulting state and outputs. The stale timeout test pins down a race that would take hours to reproduce on hardware. The same approach tests the receiver: verify crc8_buf against the standard check value first (CRC-8 with polynomial 0x07 and initial value 0x00 over the ASCII string "123456789" is 0xF4), then build frames with crc8_buf, feed them byte by byte, and corrupt one byte at a time to confirm every corruption is rejected.
Hierarchical state machines
Section titled “Hierarchical state machines”Flat FSMs have a scaling problem. Suppose any state must go to FAULT on EV_OVERTEMP. In a flat machine that is one transition per state, and the next person to add a state will forget it. Hierarchical state machines (HSMs) let states nest. An event not handled by a state is passed to its parent:
+-- OPERATING ----------------------------------------------+| EV_OVERTEMP -> FAULT (written once, applies to all) || || +-- NORMAL -----------+ +-- CONFIG -----------+ || | IDLE <-> PRESSED | ------> | EDIT_LEVEL | || +---------------------+ +---------------------+ |+------------------------------------------------------------+Transitions between nested states run the exit actions up to the common ancestor and the entry actions down to the target, in order. This is the semantics of UML statecharts. Implementing it correctly is subtle enough that you should use an existing framework rather than invent one: Miro Samek’s QP frameworks implement HSMs and active objects in C for microcontrollers, and the QM tool generates the code from diagrams. His book Practical UML Statecharts in C/C++ is the standard reference. For most firmware, flat machines with entry and exit actions, split into several cooperating machines, go a long way before you need hierarchy.
Active objects: the bridge to an RTOS
Section titled “Active objects: the bridge to an RTOS”Combine the pieces of this lesson into one unit and you get an active object: a private event queue, a state machine that is the only consumer of that queue, and a priority.
typedef struct { evq_t queue; /* the queue above as a type: evq_post_to(q, e), evq_get_from(q, &e) */ fsm_t fsm; /* behavior, run to completion per event */ uint8_t priority; /* who runs first when several have events */} active_t;The rules: active objects share no data. They communicate only by posting events to each other’s queues. Each processes one event at a time to completion. A tiny non-pre-emptive kernel runs them:
void ao_run(active_t *const aos[], size_t n) /* sorted, highest priority first */{ for (;;) { bool worked = false; for (size_t i = 0; i < n && !worked; i++) { event_t e; if (evq_get_from(&aos[i]->queue, &e)) { fsm_dispatch(&aos[i]->fsm, &e); worked = true; /* rescan from the top */ } } if (!worked) { idle_sleep(); /* the disable-check-WFI sequence */ } }}Because no two active objects share state, there are no data races between them by construction. Now replace “scan the queues in priority order” with “each active object is a task blocked on its queue, and a pre-emptive scheduler runs the highest-priority ready task”. That is an RTOS, and it is the next lesson. Active objects on an RTOS keep most of the safety of this lesson while gaining pre-emption.
Draw, guard, and log
Section titled “Draw, guard, and log”Habits that keep state machines correct over years:
- Draw the diagram first, with every state, event, guard and action. Review the diagram, not the code. It is the specification, and it is what you will hand to a tester.
- Decide what unhandled means for each state. Most unhandled events are deliberately ignored (the button release in
CONFIG). Some should never happen; count them, log them, andassertin debug builds. - Log transitions into a small RAM ring, not over
printf. A record of the last 32 transitions with timestamps is often the single most useful thing in a crash dump.
#include <stdint.h>#include "clock.h"#include "trace.h"
typedef struct { uint32_t t_ms; const char *from; /* pointers to state names in flash: 4 bytes each */ const char *to;} trace_rec_t;
static trace_rec_t trace_buf[32];static uint32_t trace_idx;
void trace_transition(const char *from, const char *to){ trace_buf[trace_idx++ & 31u] = (trace_rec_t){ .t_ms = millis(), .from = from, .to = to };}Inspect it in the debugger with p trace_buf, or dump it in the crash handler from Watchdogs, faults, and recovering from them.
Common mistakes
Section titled “Common mistakes”- Blocking inside a state handler. One
delay_msstalls every other machine and the queue overflows; turn the wait into a timer and a new state. - Forgetting cleanup on one exit path. A timer keeps firing after the mode ends; put cleanup in exit actions so every path runs it.
- Treating a queued timeout as current. A stopped timer’s event still arrives; ignore unexpected timeouts or tag timers with a generation count.
- Checking the queue then sleeping with interrupts enabled. An event posted in between waits for an unrelated interrupt; disable interrupts, check,
WFI, enable. - Trusting a length byte from the wire. A corrupted length overflows the payload buffer; validate it before storing anything.
- A
default:in every FSM switch. It hides missing states from-Wswitch; list every enum value explicitly. - Silent event loss. A full queue drops events with no trace; count drops and treat a non-zero count as a bug to investigate.
Exercises
Section titled “Exercises”- Compile
rx_fsm.con the host and write tests:crc8_bufof"123456789"returns0xF4; a valid frame with a 3-byte payload returnsRX_FRAME_OKon the last byte; flipping any single bit of that frame never yieldsRX_FRAME_OK; a length byte of 0 or 65 incrementslen_errors. - Add a fourth state to the button controller: a double press (two presses within 400 ms) turns the LED off regardless of its state. Draw the diagram first, then implement it in the version 3 style and add host tests including the case where the second press arrives at 401 ms.
- Run the button controller on the Discovery board with PA0 as the button and PD12 as the LED, using a 5 ms debounce task from the cooperative scheduler that posts
EV_BUTTON_DOWNandEV_BUTTON_UP. Readevq_droppedin gdb after hammering the button. It should stay 0. - Compile
ctrl.c(switch version) andctrl_states.cfor the target with-O2and comparearm-none-eabi-sizeoutput and the disassembly of the dispatch functions. Does the compiler turn theswitchinto a jump table or a compare chain? How many bytes of flash does each version’s state data use? - Convert the button controller and the receiver into two active objects with separate queues, run by
ao_run. Make the receiver post anEV_FRAMEevent to the controller when a frame containing the single byte0x01arrives, and have the controller treat it like a short press.
Check yourself
Section titled “Check yourself”Question 1:In the cooperative scheduler, a task's update is written t->last_ms = now; instead of t->last_ms += t->period_ms;. What changes?
Scheduling from now accumulates every lateness into the schedule. Advancing from the ideal time keeps the long-term rate exact.
Question 2:The main loop does if (evq_empty()) __WFI(); with interrupts enabled. What can go wrong?
The check and the sleep must be atomic with respect to interrupts. With PRIMASK set, a pending interrupt still wakes WFI, and the handler runs when interrupts are re-enabled.
Question 3:In the button controller, the user holds the button for 1.5 s and releases it. Which sequence of states does the controller pass through?
The long-press timeout moves the controller to CONFIG while the button is still held. CONFIG acts only on EV_BUTTON_DOWN, so the release is ignored.
Question 4:In the version 3 state machine, a handler returns its own state's address instead of NULL. What is the difference?
fsm_transition always calls exit then entry. That re-arms timers in entry, which can be what you want, but it is a different behavior from staying put.
Question 5:The receiver omits the b > FRAME_MAX_LEN check. A noise burst produces the bytes 0xA5 0xC8. What happens next?
0xC8 is 200. Without the length check, RX_PAYLOAD stores bytes until pos == 200, overflowing payload[]. The CRC is only checked after the damage.
Key takeaways
Section titled “Key takeaways”- Blocking delays make the program counter the state; store state in variables and react to events instead.
- A cooperative scheduler runs run-to-completion tasks at fixed periods; every task must return quickly, and periods should advance from the ideal time.
- An event queue of tagged unions, fed by ISRs inside short critical sections, decouples producers from the logic that consumes them.
- Turn time into
EV_TIMEOUTevents from a timer service, and design states to ignore stale timeouts. - Write FSMs as
switch(compact), tables (reviewable) or per-state handlers with entry and exit actions (cleanup on every path). - State machines are tested on the host by feeding event sequences and checking state and outputs.
- An active object is a queue, a state machine and a priority that share nothing; put it on a pre-emptive scheduler and you have an RTOS design.
Next: RTOS fundamentals: tasks and the context switch, where you build a pre-emptive scheduler for the Cortex-M4 from scratch.