Skip to content

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_ms spins the CPU at full speed doing nothing. The core could be asleep in WFI drawing 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 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 _poll function 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 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.

coop.c
#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.

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.

event.h
#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 */
#endif

The 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.

evq.c
#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.

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.

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.

timer_service.c
#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.

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 LED
ctrl.c
#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 as EV_BUTTON_UP in CONFIG, which acts only on EV_BUTTON_DOWN, so the release is ignored instead of being counted as a press.
  • The stale timeout. If EV_BUTTON_UP is queued just before TMR_LONGPRESS expires, the up event is handled first (back to IDLE) and the late EV_TIMEOUT(LONGPRESS) arrives in IDLE, which ignores it. If the timeout was queued first, the controller enters CONFIG and ignores the release. Both orders give sensible behavior.
  • No default label. With -Wall (which enables -Wswitch), adding a new state_t value without a case produces a warning. A default would 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.

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.

ctrl_states.c
#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.

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
rx_fsm.h
#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
rx_fsm.c
#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.

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.

test_ctrl.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;
}
Terminal window
gcc -std=c11 -Wall -Wextra -Wpedantic -I. ctrl.c test_ctrl.c -o test_ctrl && ./test_ctrl
all ctrl tests passed

Each 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.

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.

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.

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, and assert in 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.
trace.c
#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.

  • Blocking inside a state handler. One delay_ms stalls 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.
  1. Compile rx_fsm.c on the host and write tests: crc8_buf of "123456789" returns 0xF4; a valid frame with a 3-byte payload returns RX_FRAME_OK on the last byte; flipping any single bit of that frame never yields RX_FRAME_OK; a length byte of 0 or 65 increments len_errors.
  2. 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.
  3. 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_DOWN and EV_BUTTON_UP. Read evq_dropped in gdb after hammering the button. It should stay 0.
  4. Compile ctrl.c (switch version) and ctrl_states.c for the target with -O2 and compare arm-none-eabi-size output and the disassembly of the dispatch functions. Does the compiler turn the switch into a jump table or a compare chain? How many bytes of flash does each version’s state data use?
  5. Convert the button controller and the receiver into two active objects with separate queues, run by ao_run. Make the receiver post an EV_FRAME event to the controller when a frame containing the single byte 0x01 arrives, and have the controller treat it like a short press.

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?

Question 2:The main loop does if (evq_empty()) __WFI(); with interrupts enabled. What can go wrong?

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?

Question 4:In the version 3 state machine, a handler returns its own state's address instead of NULL. What is the difference?

Question 5:The receiver omits the b > FRAME_MAX_LEN check. A noise burst produces the bytes 0xA5 0xC8. What happens next?

  • 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_TIMEOUT events 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.