Why state machines matter in firmware
Most firmware is a state machine whether it was designed as one or not. A device
that boots, configures peripherals, waits for data, processes it, and handles
errors has at least five states. When that logic lives in a tangle of flags,
nested if statements, and global variables, it becomes impossible to reason
about — and impossible to test.
An explicit FSM forces you to enumerate all states and all transitions.
This article compares three implementation approaches using a connection manager
as a running example: Idle → Connecting → Connected → Disconnecting → Idle.
Approach 1: Enum + switch
The simplest FSM. A State enum and a switch in the event handler.
1enum class State { Idle, Connecting, Connected, Disconnecting };
2
3class ConnectionManager {
4 State state_ = State::Idle;
5
6public:
7 void onConnect() {
8 switch (state_) {
9 case State::Idle:
10 startConnection();
11 state_ = State::Connecting;
12 break;
13 case State::Connected:
14 // already connected — ignore
15 break;
16 default:
17 logError("onConnect in unexpected state");
18 break;
19 }
20 }
21
22 void onConnected() {
23 if (state_ == State::Connecting) {
24 state_ = State::Connected;
25 notifyReady();
26 }
27 }
28
29 void onDisconnect() {
30 if (state_ == State::Connected) {
31 beginShutdown();
32 state_ = State::Disconnecting;
33 }
34 }
35
36 void onTimeout() {
37 if (state_ == State::Connecting || state_ == State::Disconnecting) {
38 state_ = State::Idle;
39 notifyError();
40 }
41 }
42
43private:
44 void startConnection() { /* ... */ }
45 void beginShutdown() { /* ... */ }
46 void notifyReady() { /* ... */ }
47 void notifyError() { /* ... */ }
48};
When to use it
- Few states (≤ 6) and few events (≤ 6)
- States and transitions are stable — they won’t change often
- The team is unfamiliar with more complex patterns
Problems at scale
Each new event requires touching every switch block. The logic for one state
is scattered across multiple functions. Adding a new state means auditing every
switch to decide if the new state needs handling there. At 8+ states the
switch becomes unmaintainable.
Approach 2: Table-driven FSM
Encode transitions in a data table: {current_state, event} → {action, next_state}.
1enum class State { Idle, Connecting, Connected, Disconnecting, COUNT };
2enum class Event { Connect, Connected, Disconnect, Timeout, COUNT };
3
4struct Transition {
5 State from;
6 Event event;
7 State to;
8 void (*action)(ConnectionManager&);
9};
10
11static void doConnect (ConnectionManager& cm) { cm.startConnection(); }
12static void doReady (ConnectionManager& cm) { cm.notifyReady(); }
13static void doShutdown (ConnectionManager& cm) { cm.beginShutdown(); }
14static void doError (ConnectionManager& cm) { cm.notifyError(); }
15
16static const Transition transitions[] = {
17 { State::Idle, Event::Connect, State::Connecting, doConnect },
18 { State::Connecting, Event::Connected, State::Connected, doReady },
19 { State::Connected, Event::Disconnect, State::Disconnecting, doShutdown },
20 { State::Connecting, Event::Timeout, State::Idle, doError },
21 { State::Disconnecting, Event::Timeout, State::Idle, doError },
22};
23
24class ConnectionManager {
25 State state_ = State::Idle;
26
27public:
28 void process(Event event) {
29 for (const auto& t : transitions) {
30 if (t.from == state_ && t.event == event) {
31 t.action(*this);
32 state_ = t.to;
33 return;
34 }
35 }
36 // No matching transition — ignore or log
37 }
38 // ... action implementations
39};
The entire state machine is the table. To add a state or event, add a row. The dispatch loop never changes.
Advantages
- All transitions visible in one place — easy to audit
- Add states/events without touching existing code (Open/Closed Principle)
- The table is self-documenting — matches a state diagram directly
- Easy to generate from a tool or spreadsheet
Optimising dispatch
For small machines, a linear scan is fine. For machines with many states and events, use a 2D array indexed by state and event:
1// action_table[state][event] = {action, next_state}
2// nullptr action means "ignore this event in this state"
This gives O(1) dispatch at the cost of a larger table (most entries null).
Approach 3: State-object pattern
Each state becomes a class that handles events and decides the transition. This is the fullest application of OCP and SRP to FSMs.
1class ConnectionManager; // forward declaration
2
3struct IState {
4 virtual void onEnter(ConnectionManager&) {}
5 virtual void onExit (ConnectionManager&) {}
6 virtual void onConnect (ConnectionManager&) {}
7 virtual void onConnected (ConnectionManager&) {}
8 virtual void onDisconnect(ConnectionManager&) {}
9 virtual void onTimeout (ConnectionManager&) {}
10 virtual ~IState() = default;
11};
12
13// ---- Concrete states ----
14
15struct IdleState : IState {
16 void onConnect(ConnectionManager& cm) override; // transitions to Connecting
17};
18
19struct ConnectingState : IState {
20 void onEnter(ConnectionManager& cm) override; // starts timer
21 void onConnected(ConnectionManager& cm) override;
22 void onTimeout (ConnectionManager& cm) override;
23};
24
25struct ConnectedState : IState {
26 void onEnter (ConnectionManager& cm) override; // notifyReady
27 void onDisconnect(ConnectionManager& cm) override;
28};
29
30struct DisconnectingState : IState {
31 void onEnter(ConnectionManager& cm) override; // beginShutdown
32 void onTimeout(ConnectionManager& cm) override;
33};
34
35// ---- Manager ----
36
37class ConnectionManager {
38public:
39 void setState(IState* s) {
40 if (state_) state_->onExit(*this);
41 state_ = s;
42 if (state_) state_->onEnter(*this);
43 }
44
45 void onConnect() { state_->onConnect(*this); }
46 void onConnected() { state_->onConnected(*this); }
47 void onDisconnect() { state_->onDisconnect(*this); }
48 void onTimeout() { state_->onTimeout(*this); }
49
50 // Accessor for states to call transitions
51 void startConnection() { /* ... */ }
52 void notifyReady() { /* ... */ }
53 void beginShutdown() { /* ... */ }
54 void notifyError() { /* ... */ }
55
56private:
57 IState* state_ = &idle_;
58
59 IdleState idle_;
60 ConnectingState connecting_;
61 ConnectedState connected_;
62 DisconnectingState disconnecting_;
63};
64
65// ---- State implementations ----
66
67void IdleState::onConnect(ConnectionManager& cm) {
68 cm.startConnection();
69 cm.setState(&cm.connecting_); // or use a friend / accessor
70}
71
72void ConnectingState::onConnected(ConnectionManager& cm) {
73 cm.setState(&cm.connected_);
74}
75
76void ConnectingState::onTimeout(ConnectionManager& cm) {
77 cm.notifyError();
78 cm.setState(&cm.idle_);
79}
Note: states are stored as members of ConnectionManager — no heap allocation.
setState calls onExit and onEnter automatically, centralising transition logic.
Advantages
- Each state is a separate class with its own file — easy to read and test
- Adding a state: add a new class and update the relevant transitions in existing states only
onEnter/onExitmake entry/exit actions explicit and guaranteed- States can be individually unit-tested by constructing them in isolation
Disadvantages
- More files and boilerplate than the switch approach
- The
friendor accessor pattern to allow states to callsetStatecan be awkward - Overkill for a 3-state machine
Comparison
| Criterion | Enum + switch | Table-driven | State objects |
|---|---|---|---|
| Complexity to set up | Low | Medium | High |
| Scalability | Poor (>6 states) | Good | Excellent |
| Transitions in one place | No | Yes | No (in each state) |
| Entry/exit actions | Manual | Manual | Built-in |
| Testability | Low | Medium | High |
| Code size (embedded) | Smallest | Small | Larger |
| Readable for newcomers | Yes | Yes | Requires OOP familiarity |
Embedded-specific considerations
Stack depth: state-object pattern with virtual calls adds one level of indirection per event. On Cortex-M with limited stack (4–8 kB), this is negligible.
Memory: all three approaches avoid heap allocation if states are stored as members. The table approach stores function pointers — 4 bytes per entry on 32-bit targets.
ISR safety: the process(Event) or onEvent() call must not be made from
an ISR directly. Post the event to a queue and process it in the task loop:
1// ISR:
2eventQueue.push(Event::Timeout); // non-blocking SPSC push
3
4// Task loop:
5Event e;
6while (eventQueue.pop(e)) {
7 fsm.process(e);
8}
ROM vs RAM: the transition table in approach 2 can be placed in flash
(const at file scope with LTO, or explicitly __attribute__((section(".rodata")))),
saving RAM on resource-constrained devices.
Choosing an approach
≤ 5 states, stable? → Enum + switch
Many states, data-driven? → Table-driven
Complex entry/exit logic? → State objects
Testability critical? → State objects
Code size critical? → Enum + switch or table-driven
For most firmware, the table-driven approach hits the sweet spot: the full transition matrix is visible at a glance, adding states doesn’t touch existing logic, and there’s no vtable overhead.
What’s next
- Event-driven architecture — wiring FSMs together with an event bus
- Active object pattern — FSM + queue + thread as one unit
- ADC + DMA on STM32 — an FSM controlling a DMA acquisition pipeline