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/onExit make 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 friend or accessor pattern to allow states to call setState can 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