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: Idle → Measuring → Transmitting → Sleeping → Idle
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
- Finite state machines — three approaches — architecture-level FSM design
- Event-driven architecture — feeding events into an FSM from a bus
- FreeRTOS task and queue design — one task per state machine