Why firmware needs state machines

Firmware is fundamentally reactive: external events (button press, timer tick, DMA complete) drive transitions between operating modes. Without explicit state machine structure, this logic ends up as deeply nested if-else chains scattered across ISRs and task loops — correct at first, unmaintainable at scale.

This article builds up three FSM implementations for the same device: a battery-powered sensor that needs to manage its power states.

States: IdleMeasuringTransmittingSleepingIdle Events: buttonPress, measureDone, txDone, wakeTimer


Approach 1: enum + switch

The starting point — explicit, readable, fragile at scale:

 1enum class State { Idle, Measuring, Transmitting, Sleeping };
 2
 3State currentState = State::Idle;
 4
 5void handleEvent(Event event) {
 6    switch (currentState) {
 7
 8    case State::Idle:
 9        if (event == Event::ButtonPress) {
10            startMeasurement();
11            currentState = State::Measuring;
12        }
13        break;
14
15    case State::Measuring:
16        if (event == Event::MeasureDone) {
17            startTransmission(latestReading);
18            currentState = State::Transmitting;
19        }
20        break;
21
22    case State::Transmitting:
23        if (event == Event::TxDone) {
24            enterSleep(30000);   // 30 s sleep
25            currentState = State::Sleeping;
26        }
27        break;
28
29    case State::Sleeping:
30        if (event == Event::WakeTimer) {
31            currentState = State::Idle;
32        }
33        break;
34    }
35}

Good: no overhead, obvious structure, works everywhere. Problem: adding a new state or event means searching through all switch cases. Entry/exit actions (e.g., disabling peripherals on sleep entry) are not structured — easy to forget in one case.


Adding entry and exit actions

 1void enterState(State next) {
 2    // Exit actions for current state
 3    switch (currentState) {
 4    case State::Measuring:    ADC_Stop();     break;
 5    case State::Transmitting: RADIO_Disable();break;
 6    case State::Sleeping:     RTC_StopAlarm();break;
 7    default: break;
 8    }
 9
10    currentState = next;
11
12    // Entry actions for next state
13    switch (currentState) {
14    case State::Measuring:    ADC_Start();    break;
15    case State::Transmitting: RADIO_Enable(); break;
16    case State::Sleeping:
17        RTC_SetAlarm(30000);
18        LowPower_Enter(SLEEP_MODE);
19        break;
20    default: break;
21    }
22}

Now every state transition goes through enterState — entry/exit actions are impossible to miss. But the code has doubled in length.


Approach 2: transition table

Replace the switch with a data table. Each row is one valid transition:

 1struct Transition {
 2    State   from;
 3    Event   on;
 4    State   to;
 5    void  (*action)();   // called on transition (nullptr = no action)
 6};
 7
 8static const Transition transitions[] = {
 9    { State::Idle,         Event::ButtonPress, State::Measuring,    startMeasurement  },
10    { State::Measuring,    Event::MeasureDone, State::Transmitting, startTransmission },
11    { State::Transmitting, Event::TxDone,      State::Sleeping,     enterSleep        },
12    { State::Sleeping,     Event::WakeTimer,   State::Idle,         nullptr           },
13};
14
15State currentState = State::Idle;
16
17void handleEvent(Event event) {
18    for (auto& t : transitions) {
19        if (t.from == currentState && t.on == event) {
20            if (t.action) t.action();
21            currentState = t.to;
22            return;
23        }
24    }
25    // No matching transition — ignore or log
26}

Adding a new transition is one line in the table. No switch to touch. The entire FSM structure is visible at a glance.

Entry/exit with a table:

 1struct StateConfig {
 2    State  state;
 3    void (*onEnter)();
 4    void (*onExit)();
 5};
 6
 7static const StateConfig stateConfigs[] = {
 8    { State::Idle,         nullptr,       nullptr        },
 9    { State::Measuring,    ADC_Start,     ADC_Stop       },
10    { State::Transmitting, RADIO_Enable,  RADIO_Disable  },
11    { State::Sleeping,     enterSleepHW,  nullptr        },
12};
13
14void transitionTo(State next) {
15    // Call exit for current
16    for (auto& cfg : stateConfigs)
17        if (cfg.state == currentState && cfg.onExit) cfg.onExit();
18
19    currentState = next;
20
21    // Call entry for next
22    for (auto& cfg : stateConfigs)
23        if (cfg.state == currentState && cfg.onEnter) cfg.onEnter();
24}

Approach 3: state-object pattern

Each state becomes a class. State-specific behaviour is encapsulated inside it. The context holds a pointer to the current state object.

 1// Forward declarations
 2class SensorFsm;
 3
 4// Base state — default: ignore all events
 5class SensorState {
 6public:
 7    virtual void onButtonPress(SensorFsm&) {}
 8    virtual void onMeasureDone(SensorFsm&, float value) {}
 9    virtual void onTxDone(SensorFsm&) {}
10    virtual void onWakeTimer(SensorFsm&) {}
11
12    virtual void enter(SensorFsm&) {}
13    virtual void exit(SensorFsm&) {}
14    virtual ~SensorState() = default;
15};
16
17// Context — holds a pointer to the active state, forwards events
18class SensorFsm {
19    SensorState* current_;
20public:
21    explicit SensorFsm(SensorState* initial) : current_(initial) {
22        current_->enter(*this);
23    }
24
25    void transitionTo(SensorState* next) {
26        current_->exit(*this);
27        current_ = next;
28        current_->enter(*this);
29    }
30
31    void onButtonPress()              { current_->onButtonPress(*this); }
32    void onMeasureDone(float value)   { current_->onMeasureDone(*this, value); }
33    void onTxDone()                   { current_->onTxDone(*this); }
34    void onWakeTimer()                { current_->onWakeTimer(*this); }
35};

Concrete state classes:

 1class IdleState : public SensorState {
 2public:
 3    void onButtonPress(SensorFsm& fsm) override {
 4        startMeasurement();
 5        fsm.transitionTo(&MeasuringState::instance());
 6    }
 7};
 8
 9class MeasuringState : public SensorState {
10public:
11    void enter(SensorFsm&) override { ADC_Start(); }
12    void exit(SensorFsm&)  override { ADC_Stop();  }
13
14    void onMeasureDone(SensorFsm& fsm, float value) override {
15        startTransmission(value);
16        fsm.transitionTo(&TransmittingState::instance());
17    }
18
19    static MeasuringState& instance() {
20        static MeasuringState s;
21        return s;
22    }
23};
24
25class TransmittingState : public SensorState {
26public:
27    void enter(SensorFsm&) override { RADIO_Enable(); }
28    void exit(SensorFsm&)  override { RADIO_Disable(); }
29
30    void onTxDone(SensorFsm& fsm) override {
31        fsm.transitionTo(&SleepingState::instance());
32    }
33
34    static TransmittingState& instance() { static TransmittingState s; return s; }
35};
36
37class SleepingState : public SensorState {
38public:
39    void enter(SensorFsm&) override { RTC_SetAlarm(30000); }
40    void exit(SensorFsm&)  override { RTC_CancelAlarm(); }
41
42    void onWakeTimer(SensorFsm& fsm) override {
43        fsm.transitionTo(&IdleState::instance());
44    }
45
46    static SleepingState& instance() { static SleepingState s; return s; }
47};

Usage:

1SensorFsm fsm(&IdleState::instance());
2
3// In button ISR:
4fsm.onButtonPress();
5
6// In ADC DMA complete callback:
7fsm.onMeasureDone(latestVoltage);

Benefits:

  • Adding a new state = new class file, no modification to existing states
  • State-specific behaviour is colocated — no scattered switch cases
  • Compiler prevents accessing wrong state’s data

Cost: virtual dispatch per event. On Cortex-M4 at 168 MHz: ~3 ns — negligible unless events arrive at MHz rates.


Choosing the right approach

Criterion enum+switch Table-driven State objects
States < 5 5–15 > 10
Events per state < 3 any any
State-specific data no no yes
Entry/exit actions awkward structured natural
Adding new state edit switch add row add class
Overhead none small (linear search) vtable
Testing one big unit table validation per-state unit tests

For a simple 3-state, 2-event FSM in an ISR — use enum+switch. For a 10-state HVAC controller with complex entry/exit logic — use state objects.


Summary

  • enum+switch: 1–5 states, no entry/exit, fast to write — the embedded default
  • Table-driven: 5–15 states, structured transitions, one row per edge
  • State objects: 10+ states, per-state data, entry/exit actions, independently testable

What’s next