Notes / Data Structures & Algorithms
State machine: the basic tool of firmware design
Organise a complex sequence of behaviour as states and transitions. When flags multiply and the if statements start to tangle, it is time to switch to a state machine.
A rice cooker is in one of a few states: standby, cooking, steaming, keeping warm. When an event such as a button press or a temperature arrives, it moves to the next state. Most firmware can be expressed this way.
The basic form with switch
typedef enum { IDLE, RUN, FAULT } state_t;
static state_t st = IDLE;
void motor_task(void) {
switch (st) {
case IDLE: if (start_pressed()) { pwm_on(); st = RUN; } break;
case RUN: if (overcurrent()) { pwm_off(); st = FAULT; }
else if (stop_pressed()) { pwm_off(); st = IDLE; } break;
case FAULT: if (reset_pressed()) st = IDLE; break;
}
}
What makes a good state machine
- Draw the state diagram first. Circles are states and arrows are events and actions.
- For every event in every state, "what happens" has to be defined. Undefined combinations become bugs.
- Separate what is done once on entering a state (entry) from what is done while staying in it.
- Always provide a way out of the fault state.
When the states multiply, manage the per-state handlers in a table of function pointers, or extend to a hierarchical state machine with parent states.
Why a state and not flags
Three flags such as is_running, is_error and is_paused make eight combinations. Among them the code ends up allowing combinations that must never exist, such as "running and in error". Keep the state in a single variable and only one can be true at a time.
Design with a table first
Taking the motor control of a vending machine as an example, fill in a table before writing any code.
| Current state | Event | Next state | Action |
|---|---|---|---|
| Idle | Start button | Accelerating | Turn the driver on |
| Accelerating | Target speed reached | Constant speed | None |
| Accelerating | Overcurrent | Error | Turn the driver off |
| Constant speed | Stop button | Decelerating | Target speed 0 |
| Constant speed | Overcurrent | Error | Turn the driver off |
| Decelerating | Speed 0 | Idle | Turn the driver off |
| Error | Reset button | Idle | Clear the error |
A blank is itself a decision: "that event is ignored in that state". Filling in the table exposes missed cases such as "what if the start button is pressed while decelerating?".
Implementing it with switch
typedef enum { ST_IDLE, ST_ACCEL, ST_RUN, ST_DECEL, ST_ERROR } state_t;
static state_t st = ST_IDLE;
void fsm_step(void) { // called every 1 ms
switch (st) {
case ST_IDLE:
if (btn_start()) { driver_on(); st = ST_ACCEL; }
break;
case ST_ACCEL:
if (overcurrent()) { driver_off(); st = ST_ERROR; }
else if (speed_ok()) { st = ST_RUN; }
break;
case ST_RUN:
if (overcurrent()) { driver_off(); st = ST_ERROR; }
else if (btn_stop()) { set_target(0); st = ST_DECEL; }
break;
case ST_DECEL:
if (speed_zero()) { driver_off(); st = ST_IDLE; }
break;
case ST_ERROR:
if (btn_reset()) { clear_fault(); st = ST_IDLE; }
break;
default:
driver_off(); st = ST_ERROR; // towards the safe side if the value is corrupted
break;
}
}
RoadmapSee where this note sits in Data Structures & Algorithms→Do not stop while waiting
Wait with delay_ms(500) and neither buttons nor communication are handled meanwhile. Record the time the state was entered and check the elapsed time on every pass.
case ST_WAIT:
if ((uint32_t)(now_ms() - entered_ms) >= 500u) st = ST_NEXT;
break;
With unsigned subtraction the difference is calculated correctly even when the tick counter wraps round.
Things to do once on entry
Work that should happen only at the moment the state changes (starting a timer, sending a message) is handled at the transition, or by remembering the previous state and running only when it differs.
if (st != prev) { on_enter(st); entered_ms = now_ms(); prev = st; }
Problems that come up in practice
- Too many states: give independent concerns (communication link state and motor state) separate state machines. Merged into one, they multiply by their combinations.
- Changing the state directly in an interrupt: the state changes while the main loop is in the middle of deciding. Interrupts only record events; transitions happen in one place.
- No way out of the error state: for every state, check in the table "how do you leave here?".
- Hard to debug: log the previous and next state on every change and most field problems are solved.