Article 05 left the Modbus RTU task with its behaviour implicit and no state machine architecture to hold it. Idle, receiving, processing, responding — those states exist, but they’re not written down anywhere. They’re encoded in where the program counter happens to be sitting inside a sequence of blocking calls.
That works until you need a timeout halfway through a frame, or a shutdown request during processing, or you want to know from a field log what the task was doing when it hung. Then you discover the state you need to inspect exists only as a stack frame, and stack frames aren’t queryable.
Making behaviour explicit is what this article is about. Not the textbook version — states, events, transitions, you know that part. The part that matters is why run-to-completion is the whole point, what it costs when you break it, and how a state machine becomes the only piece of firmware where you can achieve genuinely complete behavioural coverage.
The Flag Problem, Stated Precisely
Every firmware codebase starts here:
bool initialized;
bool connected;
bool transmitting;
bool error;
bool recovery;
Five booleans is 32 combinations. Your design has maybe six legal ones. The other 26 are reachable — nothing prevents them — and each is a bug waiting for the right interleaving.
Is connected == false, transmitting == true, error == true valid? Nobody knows. There’s no place in the code where the answer lives, which means every function that reads those flags re-derives the answer, slightly differently, and one of them gets it wrong.
A state variable replaces 2ⁿ representable combinations with n legal ones:
typedef enum {
ST_INIT, ST_IDLE, ST_RUNNING, ST_FAULT, ST_RECOVERY, ST_SAFE
} state_t;
Six states, all legal by construction. Illegal combinations aren’t guarded against — they’re unrepresentable, which is stronger.

That’s the first argument for state machines, and it’s the weakest one. The real argument is below.
Run-to-Completion Is the Point
Here’s the property that makes state machines architecturally valuable, and it’s routinely left out of introductions to them:
A state machine processes one event completely before it looks at the next one.
Handlers never block. They never wait. They take an event, possibly change state, possibly emit an action, and return — usually in microseconds.
The consequences are worth more than they first appear.
No locking on state. Nothing preempts a handler mid-transition, so the state variable needs no mutex. Article 05 spent a whole section on avoiding mutexes because their hold time becomes the B term in everyone else’s response-time analysis. A run-to-completion FSM has B = 0 for its own state by construction.
Bounded execution time. Each handler is straight-line code with no waiting, so the worst case over all handlers is your task’s C. That’s a number you can actually measure, which makes the article 05 arithmetic honest.
The state is inspectable. At any instant, the subsystem’s condition is one enum you can read, log, or transmit. Compare that to “somewhere inside uart_receive_frame(), three stack frames deep.”
Events that arrive during processing wait in a queue instead of racing. That’s why the FSM needs no reentrancy handling.

Break run-to-completion — put one blocking call in one handler — and you lose all four at once. Which brings us to the transformation that actually matters.
Turning Blocking Code Into States
The Modbus RTU receive path, written the way it usually gets written first:
/* Blocking. State lives in the program counter. */
void modbus_rtu_task(void *arg)
{
for (;;) {
uart_wait_for_start(portMAX_DELAY); /* blocks, unbounded */
len = uart_read_frame(buf, 250); /* blocks */
if (crc_ok(buf, len)) {
build_response(buf, len, rsp, &rlen);
rs485_set_direction(TX);
uart_write(rsp, rlen); /* blocks */
rs485_set_direction(RX);
}
}
}
It works. It’s also unanswerable to every question you’ll ask in the field. What was it doing when it hung? Can it be told to stop mid-frame? What happens if the master goes quiet after two bytes? The answers exist only as a stack frame, and the last one is “it waits forever,” which article 03 lists as anti-pattern #6.
The same behaviour, explicit:
| State | Entry action | Events handled | Timeout |
|---|---|---|---|
RX_IDLE | Direction = RX, reset buffer | EV_BYTE → RX_FRAME | none |
RX_FRAME | Restart t3.5 timer | EV_BYTE → self (restart timer)EV_T35 → VALIDATE | 1.75 ms → VALIDATE |
VALIDATE | Check CRC | EV_TICK → BUILD or RX_IDLE | 1 ms → RX_IDLE |
BUILD | Assemble response | EV_TICK → TX | 5 ms → RX_IDLE |
TX | Direction = TX, start DMA | EV_TX_DONE → RX_IDLEEV_TIMEOUT → FAULT | 20 ms → FAULT |
FAULT | Emit fault, reset UART | EV_TICK → RX_IDLE | — |
Six states. Every one has a defined exit. The 1.75 ms inter-frame silence that REQ-03 demanded is now a timer event rather than a blocking read with a hopeful timeout, and a shutdown request can be handled in any state because the task is always sitting in the queue wait, never inside a driver.

The task collapses to this, and stays this size forever:
void modbus_rtu_task(void *arg)
{
event_t ev;
for (;;) {
if (xQueueReceive(q_modbus, &ev, fsm_timeout_ticks(&rtu_fsm))) {
fsm_dispatch(&rtu_fsm, &ev);
} else {
fsm_dispatch(&rtu_fsm, &(event_t){ .id = EV_TIMEOUT });
}
}
}
That’s the whole task. Every line of behaviour lives in the FSM, where it can be tested without a UART.
Every Waiting State Needs a Timeout
The single most common state machine defect isn’t a wrong transition. It’s a state with no way out.
TX waits for EV_TX_DONE. If the DMA completion interrupt never fires — transceiver fault, clock glitch, a peripheral wedged by ESD — the state machine sits in TX forever. No hang, no crash, no watchdog trip, because the task is dutifully blocked on its queue. It’s just deaf.
So make the rule mechanical: every state that waits for an external event declares a timeout and a target state.
typedef struct {
state_handler_t handler;
uint32_t timeout_ms; /* 0 = no timeout */
state_t on_timeout;
} state_desc_t;
static const state_desc_t states[ST_COUNT] = {
[RX_IDLE] = { rx_idle_handler, 0, RX_IDLE },
[RX_FRAME] = { rx_frame_handler, 2, VALIDATE },
[TX] = { tx_handler, 20, FAULT },
/* ... */
};
Now a missing timeout is visible in a table review rather than discoverable in the field, and the review question is one line: which states have timeout_ms == 0, and is that deliberate for each?

Unexpected Events: Pick a Policy, Write It Down
An event arrives in a state that has no transition for it. What happens?
Most codebases silently ignore it, and silent ignoring is a genuine bug class — the event that got dropped is frequently the one that explains the failure. Three defensible policies, and the choice is per state machine, not per event:
Ignore and count. Correct for genuinely irrelevant events — a late EV_TX_DONE after you’ve already moved on. Increment a per-FSM counter so it’s visible in diagnostics.
Defer. The event is valid but premature. Hold it and re-deliver on the next transition. Powerful, and dangerous: deferred events need a bounded queue and a policy for when it fills, or you’ve built a memory leak with extra steps.
Fault. The event is impossible in this state, which means one of your assumptions is wrong. In a safety-relevant subsystem this is the right answer — article 02’s distinction between an error (expected, handle it) and a fault (invariant broken, don’t continue).
Whichever you pick, count it. A non-zero unexpected-event counter on a unit in the field has told me more than most logs.

Guards vs States
A recurring design argument: when does a condition become a guard, and when does it deserve its own state?
The rule that settles it: states are modes, guards are data.
/* Guard: same mode, different data */
case EV_SETPOINT:
if (ev->param <= MAX_SETPOINT) { apply(ev->param); }
else { emit_fault(RES_RANGE); }
return ST_RUNNING;
/* State: genuinely different mode, different event handling */
ST_RUNNING vs ST_DEGRADED
If the condition changes which events matter or what the entry and exit actions are, it’s a state. If it only changes what a single action does with a value, it’s a guard.

Getting this backwards in either direction has a signature. Too many states and you’ll have near-identical handlers differing by one line. Too many guards and you’ll have one handler with a five-deep if tree — which is the flag architecture you left at the top of this article, rebuilt inside a switch.
Four Implementations, and What Each Costs
There’s no universally right one. There is a right one for your state count, event count, and how much the transitions change.
Nested switch
switch (fsm->state) {
case ST_IDLE:
switch (ev->id) {
case EV_START: /* ... */ break;
}
break;
}
Zero RAM overhead, and the compiler usually produces a jump table for dense enums, so dispatch is a handful of cycles. Fine up to roughly six states and six events.
Past that it stops scaling, and the specific failure is that entry and exit actions get duplicated into every transition that targets a state. Add a seventh transition into ST_RUNNING and you copy the entry code a seventh time. Miss one and you have a bug that only appears on that path.
Transition table
typedef struct {
state_t from;
event_id_t ev;
bool (*guard)(const event_t *);
void (*action)(const event_t *);
state_t to;
} transition_t;
static const transition_t table[] = {
{ ST_IDLE, EV_START, NULL, act_start, ST_RUNNING },
{ ST_RUNNING, EV_STOP, NULL, act_stop, ST_IDLE },
{ ST_RUNNING, EV_FAULT, NULL, act_fault, ST_FAULT },
};
The behaviour becomes data. You can print it, diff it, generate a diagram from it, and — the part that matters — verify completeness by inspection.
Costs: a sparse list is sizeof(transition_t) × the number of transitions, which on a 32-bit target is around 20 bytes each. Dense 2D indexing [state][event] gives you constant-time dispatch at states × events × entry size, so 8 states × 12 events × 20 bytes is 1.9 KB of flash, most of it empty cells. Fine on an H7. Not fine on an L0.
Linear search through a sparse table is O(n) in transitions, which is nothing for 30 entries but worth knowing if it sits in a hot path.
Function pointer per state
typedef state_t (*state_fn_t)(fsm_t *, const event_t *);
One pointer of RAM, one indirect call to dispatch — a few cycles, and it blocks inlining. Entry and exit actions have a natural home, hierarchy is easy to add later, and each state’s logic sits in its own function that you can read on one screen.
This is my default for anything above six states. The indirect call costs less than the duplicated entry actions the switch version forces on you.
Hierarchical
When several states share transitions — EV_FAULT going to ST_FAULT from all nine states, say — a flat machine makes you write that transition nine times. A hierarchical machine puts it on a parent state once and lets children inherit it.
That’s the signal to go hierarchical, and it’s a countable one: when the same (event → target) pair appears in more than half your states, you have hierarchy trying to emerge.
Dispatch then costs a walk up the parent chain until a handler claims the event, so worst-case dispatch is proportional to nesting depth. Usually two or three levels, so two or three indirect calls.
Full treatment is in the hierarchical state machine series, which goes into UML state chart semantics, entry/exit ordering across levels, and the transition algorithm. I’d read that before adopting hierarchy, because the ordering rules on a transition between two deeply nested states are genuinely subtle and getting them wrong produces bugs that look like the state machine is haunted.

Entry, Exit, and Where Initialisation Belongs
Actions attach to three places, and putting them in the wrong one is why initialisation code ends up scattered.
Entry runs every time you enter the state, from anywhere. ST_TX entry sets the RS-485 direction pin and starts the DMA. Every path into ST_TX gets that for free, which is precisely the duplication the nested switch can’t avoid.
Exit runs every time you leave, to anywhere. ST_TX exit returns the direction pin to RX. Including on the fault path — which is the case that gets forgotten, and the one that leaves the transceiver driving the bus.
Transition actions run on one specific edge, between exit and entry. Use them only when the action genuinely belongs to that edge and not to either state.
The ordering is fixed and worth stating because people get it wrong:
exit(current) → transition action → entry(next)
Self-transitions are the trap. ST_RX_FRAME on EV_BYTE transitions to itself to restart the t3.5 timer. Does that run exit and entry? In UML semantics, an external self-transition does; an internal one doesn’t. Pick one, name it in your API, and be consistent — FSM_SELF_EXTERNAL vs FSM_INTERNAL in the table beats a comment nobody reads.

One State Machine Per Subsystem
Do not build one machine for the whole product. ST_RUNNING_WHILE_UPDATING_WITH_SENSOR_FAULT is what that produces, and it’s the state explosion everyone warns about without saying where it comes from.
The KIC-400 has five machines, each owned by one component:
| FSM | Owner | States |
|---|---|---|
| Device lifecycle | Device Manager | INIT, RUNNING, DEGRADED, UPDATING, FAULT |
| Modbus RTU frame | Modbus RTU task | 6, per the table above |
| MQTT session | Cloud task | DISCONNECTED, RESOLVING, TCP, TLS, MQTT_CONNECT, READY |
| Firmware update | Update service | IDLE, DOWNLOADING, VERIFYING, STAGED, CONFIRMING |
| Sensor health | Sensor service | OK, TRANSIENT, LOST (per sensor) |
Five small machines, not one machine with 5 × 6 × 6 × 5 × 3 states.

They communicate by events, never by reading each other’s state. The update FSM doesn’t check whether the device FSM is in RUNNING — it posts EV_UPDATE_REQUESTED and gets an accept or reject back. That’s article 02’s ownership rule, expressed as a communication pattern: reading another component’s state makes you a second owner of it.
And the cross-cutting transitions live in exactly one place. EV_FAULT from the sensor FSM becomes a request to the device FSM, which decides whether one dead sensor means degraded or means stop. That decision exists once.
The Part That Pays for Everything: Testability
Here’s the claim, and I’ll defend it: a state machine is the only part of your firmware where complete behavioural coverage is achievable.
General firmware has unbounded input space. You sample it and hope. A state machine with N states and M events has exactly N × M behaviours, and N × M is a number you can enumerate.
For the Modbus RTU machine — 6 states, 8 events — that’s 48 cases. All 48 are testable, in a loop, on a host, in milliseconds, because the FSM depends on nothing but its own table:
/* host test — no UART, no RTOS, no target */
for (state_t s = 0; s < ST_COUNT; s++) {
for (event_id_t e = 0; e < EV_COUNT; e++) {
fsm_t fsm; fsm_init(&fsm); fsm_force_state(&fsm, s);
state_t next = fsm_dispatch(&fsm, &(event_t){ .id = e });
assert(next < ST_COUNT); /* no undefined state */
assert(expected[s][e] == next); /* matches the spec table */
}
}
That loop finds the transitions you never specified. It runs in CI on every commit, and it’s what article 09 means when it says design for testability — the property came from the architecture, not from a testing effort bolted on afterwards.

This only works because the FSM has no hardware dependency, which is why handlers emit actions through an interface rather than calling drivers directly. Same telemetry_sink_t pattern from article 01: bind the real driver on target, bind a recorder in the test, assert on the recorded actions.
Transition Logging: 8 Bytes That Solve Field Bugs
Log transitions, not states. A state snapshot tells you where a unit is. A transition trace tells you how it got there, and the second one is what you actually need at 3 a.m.
typedef struct {
uint32_t timestamp_ms;
uint8_t fsm_id;
uint8_t from;
uint8_t event;
uint8_t to;
} fsm_trace_t; /* 8 bytes */
Sixty-four entries is 512 bytes in backup SRAM, which article 02 reserved and article 03 pointed out was never used. Those 512 bytes survive a reset and reconstruct the last sixty-four behavioural decisions the device made before it died.

I’d take that over a megabyte of printf output. Strings tell you what a developer thought was worth mentioning in 2023. A transition trace tells you what happened.
Add one counter per FSM for unexpected events and one for timeout-driven transitions. Three numbers and a ring buffer, and most field bugs stop being mysteries. Article 11 builds this into a full observability design.
Next
The state machines above all have a FAULT state, and so far it’s a box on a diagram. What happens inside it is undefined.
Which is the gap this series has been deferring since article 02. Article 02 separated errors from faults. Article 05 gave every blocking operation a bounded timeout. Neither said what recovery actually looks like — when to retry, when to reset a peripheral, when to degrade, when to give up and reset the system with the reason recorded, and how to do all of that without a boot loop.
Article 07: Error Handling and Recovery Architecture — designing the fault path before you need it.
Series Index
Phase 1 — Architecture
01. Embedded Firmware Architecture Fundamentals
02. Production-Grade Firmware Architecture
03. Firmware Architecture Anti-Patterns
Phase 2 — Implementation
04. HAL vs BSP vs Drivers vs Middleware
05. RTOS Architecture
06. State Machine Architecture (you are here)
Phase 3 — Failure & Resilience
07. Error Handling & Recovery
08. Firmware Security Architecture
Phase 4 — Verification
09. Designing Firmware for Testability
Phase 5 — Product Lifecycle
10. Designing Firmware for 10-Year Products
11. Field Diagnostics & Observability
12. OTA & Safe Firmware Updates