Firmware logic written as nested conditionals degrades quickly. A flag checked here, an interrupt modifying it there, behavior that depends on the sequence of events rather than the current state — this is the failure mode. A state machine imposes structure: the system is always in one known state, and only defined inputs can move it.
The model
typedef enum { IDLE, ACTIVE, FAULT, RESETTING } State;
typedef enum { CMD_START, CMD_STOP, ERR_DETECTED, CMD_RESET } Event;
State transition(State current, Event event) {
switch (current) {
case IDLE:
if (event == CMD_START) return ACTIVE;
break;
case ACTIVE:
if (event == CMD_STOP) return IDLE;
if (event == ERR_DETECTED) return FAULT;
break;
case FAULT:
if (event == CMD_RESET) return RESETTING;
break;
case RESETTING:
return IDLE;
}
return current; // unhandled event — hold state
}
Unhandled events return the current state. Nothing silently mutates. Every possible transition is visible in one function.
Entry and exit actions
State transitions usually carry side effects — activating a relay, setting a GPIO, starting a timer. Separate these from the transition table. Entry and exit actions run on state change, not on every event.
void on_exit(State s) {
if (s == ACTIVE) gpio_set(PIN_MOTOR, 0);
}
void on_enter(State s) {
if (s == ACTIVE) gpio_set(PIN_MOTOR, 1);
if (s == FAULT) gpio_set(PIN_ALARM, 1);
if (s == RESETTING) start_reset_sequence();
}
// In the event loop:
State next = transition(current, event);
if (next != current) {
on_exit(current);
on_enter(next);
current = next;
}
This ensures on_enter fires exactly once per state entry, regardless of how many events are processed in that state.
Encoding the table as data
For larger machines, a transition table is cleaner than a switch statement:
typedef struct {
State from;
Event event;
State to;
} TransitionRule;
static const TransitionRule rules[] = {
{ IDLE, CMD_START, ACTIVE },
{ ACTIVE, CMD_STOP, IDLE },
{ ACTIVE, ERR_DETECTED, FAULT },
{ FAULT, CMD_RESET, RESETTING },
{ RESETTING, CMD_START, IDLE },
};
State transition(State current, Event event) {
for (size_t i = 0; i < sizeof(rules)/sizeof(rules[0]); i++) {
if (rules[i].from == current && rules[i].event == event)
return rules[i].to;
}
return current;
}
The full state model is now a static, readable data structure. Adding a state or transition is a one-line edit.
When to use it
Use state machines in firmware when:
- A component has discrete operational modes with defined transitions between them
- Safety or reliability requirements demand auditability — every transition must be traceable
- You find yourself writing
if (started && !stopped && !in_fault && mode == 2)
Avoid flat state machines when state count exceeds ~10–12. Beyond that, use a hierarchical state machine (HSM): substates that inherit transitions from their parent, reducing the combinatorial explosion of large flat tables. The UML statechart model is the standard reference.