Programming · 55 pages
- C language — 15 lessonstutorial
- Lesson 1 — Hello, C
- Lesson 2 — Types & literals
- Lesson 3 — Expressions & operators
- Lesson 4 — Control flow
- Lesson 5 — Functions
- Lesson 6 — Arrays
- Lesson 7 — Pointers
- Lesson 8 — Strings
- Lesson 9 — Structs & unions
- Lesson 10 — Dynamic memory
- Lesson 11 — Preprocessor
- Lesson 12 — Multi-file projects
- Lesson 13 — Standard library
- Lesson 14 — Undefined behavior
- Lesson 15 — Modern C (C11/17/23)
- C++ programming — 15 lessonstutorial
- Lesson 1 — Hello, C++
- Lesson 2 — References & namespaces
- Lesson 3 — Classes & RAII
- Lesson 4 — Inheritance & polymorphism
- Lesson 5 — Templates
- Lesson 6 — STL containers
- Lesson 7 — Iterators & algorithms
- Lesson 8 — Smart pointers
- Lesson 9 — Move semantics
- Lesson 10 — Lambdas
- Lesson 11 — Error handling
- Lesson 12 — Concurrency
- Lesson 13 — Modules (C++20)
- Lesson 14 — Modern C++23
- Lesson 15 — C++ on Cortex-M
- Bare-metal C tutorial — 10 lessonsembedded
- Lesson 1 — Toolchain setup
- Lesson 2 — Bare-metal blink
- Lesson 3 — Registers in C
- Lesson 4 — Memory map
- Lesson 5 — Linker script
- Lesson 6 — Interrupts & ISRs
- Lesson 7 — Bit patterns
- Lesson 8 — Fixed-point math
- Lesson 9 — State machines
- Lesson 10 — Ring buffers + DMA
- Embedded C patterns
- volatile & memory-mapped IO
- CMSIS-Core
- CMSIS-DSP
- Linker scripts
- Startup & vector table
- C++ on Cortex-M
- Rust on Cortex-Mmodern
- Inline assembly
- Bare-metal libc & newlib-nano
- Bootloaders & DFU
- OTA update flow
Lesson 9 — State machines that survive
Switch-case FSMs, function-pointer-table FSMs, hierarchical state machines. A real UART command parser that dispatches LED ON / LED OFF / BLINK 5.
- What you'll learn
- Switch-case FSM, function-pointer-table FSM, hierarchical FSMs, integrating an FSM with interrupts and a tick
- Code provided
- About 150 lines — a full UART command parser FSM
- Time
- 75-90 minutes
- Hardware
- STM32F411 Nucleo with USART2 wired to the ST-Link virtual COM
Most firmware is, structurally, a collection of state machines. The UART receiver is a state machine (idle, receiving header, receiving payload, receiving CRC). The button handler is a state machine (released, pressed, held, repeating). The motor controller is a state machine (off, accelerating, running, decelerating, fault). Recognising this and writing state machines explicitly — rather than letting them sprawl as nested booleans and counters — is the single biggest leap in code quality.
This lesson covers three patterns, with a working UART command parser as the running example.
The pattern that fails: implicit state
Bad firmware looks like this:
bool receiving = false;
bool got_header = false;
int byte_count = 0;
uint8_t buf[64];
void on_byte(uint8_t b) {
if (!receiving && b == 0xA5) {
receiving = true;
got_header = true;
byte_count = 0;
} else if (receiving && got_header) {
buf[byte_count++] = b;
if (byte_count == 5) {
// ... process
receiving = false;
got_header = false;
}
}
}Four boolean and counter variables, mutual dependencies, no explicit notion of "what state are we in". When you need to add a "wait for ACK" phase, you add another bool. After three rounds of this the function is unreadable and bug-prone.
Pattern 1: switch-case FSM
The canonical embedded state machine:
typedef enum {
PARSE_IDLE,
PARSE_HEADER,
PARSE_LEN,
PARSE_DATA,
PARSE_CRC,
} parse_state_t;
static parse_state_t parse_state = PARSE_IDLE;
static uint8_t parse_buf[64];
static uint8_t parse_len;
static uint8_t parse_idx;
void parser_on_byte(uint8_t b) {
switch (parse_state) {
case PARSE_IDLE:
if (b == 0xA5) parse_state = PARSE_HEADER;
break;
case PARSE_HEADER:
parse_buf[0] = b; /* command byte */
parse_state = PARSE_LEN;
break;
case PARSE_LEN:
parse_len = b;
if (parse_len > sizeof(parse_buf) - 2) {
parse_state = PARSE_IDLE; /* invalid, restart */
break;
}
parse_idx = 0;
parse_state = parse_len ? PARSE_DATA : PARSE_CRC;
break;
case PARSE_DATA:
parse_buf[parse_idx + 1] = b;
parse_idx++;
if (parse_idx == parse_len) parse_state = PARSE_CRC;
break;
case PARSE_CRC:
if (b == crc8(parse_buf, parse_len + 1)) {
handle_packet(parse_buf, parse_len + 1);
}
parse_state = PARSE_IDLE;
break;
}
}Reads top-to-bottom in execution order. Every state's transitions are visible at a glance. Adding a new state is a single case plus a transition from somewhere else.
Coding rules that keep switch-case FSMs healthy:
- One enum, one variable.
parse_stateis the only state container. All "secondary" data (parse_buf,parse_len,parse_idx) is associated with the current state but not part of it. - Every case ends with
breakor an explicit transition. Fallthrough is a code smell here. - Default case logs and resets. If you somehow get to an undefined state value (memory corruption, ESD strike), default returns to IDLE.
- Log every transition during development. A
printf("FSM: %s → %s\n", old, new);line in a wrapper aroundparse_state = ...;is invaluable for debugging.
Pattern 2: function-pointer table
For state machines where the per-state code is large, switch cases get unwieldy. Move each state to its own function and dispatch via a table:
typedef parse_state_t (*state_handler_t)(uint8_t byte);
static parse_state_t handle_idle(uint8_t b) {
return (b == 0xA5) ? PARSE_HEADER : PARSE_IDLE;
}
static parse_state_t handle_header(uint8_t b) {
parse_buf[0] = b;
return PARSE_LEN;
}
static parse_state_t handle_len(uint8_t b) {
if (b > sizeof(parse_buf) - 2) return PARSE_IDLE;
parse_len = b;
parse_idx = 0;
return parse_len ? PARSE_DATA : PARSE_CRC;
}
/* ... handle_data, handle_crc ... */
static const state_handler_t state_table[] = {
[PARSE_IDLE] = handle_idle,
[PARSE_HEADER] = handle_header,
[PARSE_LEN] = handle_len,
[PARSE_DATA] = handle_data,
[PARSE_CRC] = handle_crc,
};
void parser_on_byte(uint8_t b) {
parse_state = state_table[parse_state](b);
}Three advantages:
- Each state is a separately-testable unit. You can unit-test
handle_lenwithout spinning up the whole FSM. - The dispatch is one indirect call. Roughly the same speed as a switch with 8+ cases on Cortex-M.
- The table can be const, living in flash. No RAM cost beyond the state variable.
The trade-off is that the whole-system view is in the table, not in the code. For a small FSM the switch form is easier to read; for a large FSM the table form scales better.
C99 designated initialisers ([PARSE_IDLE] = handle_idle) are essential — they make the table self-documenting and resistant to enum reordering.
Pattern 3: hierarchical state machines
Some systems have a state hierarchy: while in RUNNING, there are sub-states RUNNING_NORMAL, RUNNING_OVERHEATING, RUNNING_THROTTLED. While in STOPPED, there are sub-states STOPPED_IDLE, STOPPED_FAULT. Many events are handled at the parent level — STOP_BUTTON_PRESSED always transitions to STOPPED from any sub-state of RUNNING.
The pattern:
typedef enum {
/* Parent: STOPPED */
STOPPED_IDLE,
STOPPED_FAULT,
/* Parent: RUNNING */
RUNNING_NORMAL,
RUNNING_OVERHEATING,
RUNNING_THROTTLED,
} state_t;
static state_t state = STOPPED_IDLE;
static bool is_running(state_t s) {
return s == RUNNING_NORMAL || s == RUNNING_OVERHEATING || s == RUNNING_THROTTLED;
}
void on_event(event_t e) {
/* Parent-level: handle events that apply to any sub-state */
if (e == EV_STOP_BUTTON) {
state = STOPPED_IDLE;
return;
}
if (e == EV_TEMP_CRITICAL && is_running(state)) {
state = RUNNING_OVERHEATING;
return;
}
/* Sub-state-level: switch on the leaf state */
switch (state) {
case STOPPED_IDLE:
if (e == EV_START_BUTTON) state = RUNNING_NORMAL;
break;
case RUNNING_NORMAL:
if (e == EV_TEMP_HIGH) state = RUNNING_THROTTLED;
break;
/* ... */
}
}Hierarchical state machines reduce duplication. Without the hierarchy, every running sub-state would need its own copy of "if STOP_BUTTON, go to STOPPED". With it, the rule is written once.
For large hierarchies (audio codec firmware, USB stacks), formal HSM frameworks like QP/C or Miro Samek's QM tool are worth it. For most firmware, the manual two-level pattern above is enough.
A complete UART command parser
Let us put it all together: a parser that reads ASCII commands like LED ON\n, LED OFF\n, BLINK 5\n from the UART and dispatches actions.
#include <stdint.h>
#include <string.h>
#include "gpio.h"
#define CMD_BUF_SIZE 32
static char cmd_buf[CMD_BUF_SIZE];
static size_t cmd_len;
typedef enum {
CMD_IDLE, /* waiting for first character */
CMD_COLLECTING, /* collecting bytes until \n */
CMD_OVERFLOW, /* line too long, drop until \n */
} cmd_state_t;
static cmd_state_t cmd_state = CMD_IDLE;
/* Forward declaration */
static void dispatch_command(const char *line);
void cmd_on_byte(uint8_t b) {
switch (cmd_state) {
case CMD_IDLE:
if (b == '\n' || b == '\r') return; /* ignore stray line endings */
cmd_len = 0;
cmd_buf[cmd_len++] = (char)b;
cmd_state = CMD_COLLECTING;
break;
case CMD_COLLECTING:
if (b == '\n' || b == '\r') {
cmd_buf[cmd_len] = '\0';
dispatch_command(cmd_buf);
cmd_state = CMD_IDLE;
return;
}
if (cmd_len >= CMD_BUF_SIZE - 1) {
cmd_state = CMD_OVERFLOW;
return;
}
cmd_buf[cmd_len++] = (char)b;
break;
case CMD_OVERFLOW:
if (b == '\n' || b == '\r') {
cmd_state = CMD_IDLE;
}
break;
}
}
/* Action handlers */
static void cmd_led_on(const char *args) { (void)args; gpio_set(GPIOA, 5); }
static void cmd_led_off(const char *args) { (void)args; gpio_clear(GPIOA, 5); }
static void cmd_blink(const char *args) {
int n = atoi(args);
if (n < 1 || n > 20) return;
for (int i = 0; i < n; i++) {
gpio_set(GPIOA, 5);
delay_ms(100);
gpio_clear(GPIOA, 5);
delay_ms(100);
}
}
static void cmd_help(const char *args) {
(void)args;
uart_write("Commands: LED ON, LED OFF, BLINK n, HELP\r\n");
}
/* Command dispatch table */
typedef void (*cmd_handler_t)(const char *args);
typedef struct {
const char *name;
cmd_handler_t handler;
} cmd_entry_t;
static const cmd_entry_t cmd_table[] = {
{ "LED ON", cmd_led_on },
{ "LED OFF", cmd_led_off },
{ "BLINK", cmd_blink },
{ "HELP", cmd_help },
{ NULL, NULL },
};
static void dispatch_command(const char *line) {
for (const cmd_entry_t *e = cmd_table; e->name; e++) {
size_t n = strlen(e->name);
if (strncmp(line, e->name, n) == 0) {
const char *args = line + n;
while (*args == ' ') args++; /* skip whitespace */
e->handler(args);
return;
}
}
uart_write("Unknown command\r\n");
}This is the architecture of a production CLI. Three explicit states, a dispatch table, separate per-command handlers. Adding a new command is a four-line patch: a function plus a table entry.
Connecting the FSM to interrupts and the tick
Real systems have multiple FSMs running concurrently — the UART parser, a button debouncer, an LED animator. The clean pattern: each FSM gets one input function and runs in main loop time, not in the ISR.
/* USART2 RX ISR — drops bytes into a ring buffer, nothing else */
volatile uint8_t rx_ring[64];
volatile uint32_t rx_head, rx_tail;
void USART2_IRQHandler(void) {
if (USART2->SR & USART_SR_RXNE) {
uint8_t b = USART2->DR;
uint32_t next = (rx_head + 1) & 63; /* power-of-two size, mask is free */
if (next != rx_tail) {
rx_ring[rx_head] = b;
rx_head = next;
}
/* If full, byte is dropped — handler stays fast */
}
}
/* Main loop — drains the ring buffer through the FSM */
int main(void) {
/* ... init ... */
while (1) {
while (rx_head != rx_tail) {
uint8_t b = rx_ring[rx_tail];
rx_tail = (rx_tail + 1) & 63;
cmd_on_byte(b);
}
/* other periodic work */
}
}The ISR is six lines and runs in a handful of cycles. The FSM lives in main, where it can take milliseconds to run a long command without blocking other interrupts.
For periodic events (debouncing, animations), drive the FSM from a SysTick-derived tick:
static uint32_t last_tick;
while (1) {
/* RX byte handling */
if (millis() - last_tick >= 10) { /* 10 ms tick */
last_tick += 10;
button_fsm_tick();
led_fsm_tick();
}
}The += 10 (rather than = millis()) means a slow main loop will not lose ticks — if you took 25 ms to process a byte, the next two ticks will fire immediately on consecutive iterations.
Debugging FSMs
When an FSM misbehaves:
- Log every transition. A circular buffer of
(timestamp, from_state, to_state, trigger)tuples is gold. Dump it on fault or via a debug command. - Make state visible in the debugger. A
volatile parse_state_t parse_stateshows up directly in the watch window. Inspect every state variable at every breakpoint. - Add invariant checks. If
CMD_COLLECTINGrequirescmd_len > 0, write aassert(cmd_len > 0)at the top of that case. ASSERT macros mapped to__BKPT(0)are excellent — they stop the debugger exactly when the invariant fails. - Test transitions explicitly. A unit test that drives
cmd_on_bytewith a fixed sequence of bytes and checkscmd_stateat each step catches regressions early.
Exercises
Exercise 9.1. Add a STATUS command that prints uptime in seconds. You need access to the SysTick counter from lesson 6.
Exercise 9.2. Convert the command parser to use the function-pointer-table FSM pattern. Each state becomes a function returning the next state.
Exercise 9.3. Add a hierarchical layer: an outer "AUTHENTICATED / UNAUTHENTICATED" mode. Most commands require authentication. Add LOGIN <password> and LOGOUT commands. Where in the FSM does the auth check live?
What you learned
- Implicit state (loose booleans) does not scale; explicit enums plus a single state variable do.
- Switch-case FSM is the default; function-pointer-table FSM scales better with state count.
- Hierarchical state machines reduce duplication when many events apply to multiple sub-states.
- Drop bytes into a ring buffer in the ISR; drain through the FSM in main.
- Log transitions during development; make state visible in the debugger.
In lesson 10 we deepen the ring buffer and combine it with DMA — the production-grade pattern that lets the CPU sleep while data flows.
Next
| Previous | Next |
|---|---|
| Lesson 8 — Fixed-point math | Lesson 10 — Ring buffers and DMA-driven UART |