C++ Module 21 – Embedded C++
State machines for firmware in C++
Model a device as states, events and transitions, write it as a switch, a constexpr transition table and std::variant, and test every transition on a laptop.
What you will learn
- Model a device's behaviour as states, events and a decision for every pair
- Implement one state machine as a switch, a constexpr transition table and a std::variant
- Test every transition of a state machine on the host
- Diagnose transitions that go wrong only when events come from interrupts and timers
Before you start
What you’ll learn · Before you start
What you will learn
- Model a device's behaviour as states, events and a decision for every pair
- Implement one state machine as a switch, a constexpr transition table and a std::variant
- Test every transition of a state machine on the host
- Diagnose transitions that go wrong only when events come from interrupts and timers
Before you start
On this page
A door lock with six states and five events has 30 pairs of state and event, and firmware written as a tangle of
flags such as motor_on and door_open leaves combinations that nobody has checked. A state machine decides every
pair: it describes the device as a small set of states, the events that can arrive, and the transition each
event causes in each state. The lock is locked, unlocking, unlocked or locking, and a correct code, the motor reaching
its end stop or a timer running out moves it from one state to the next.
Firmware is full of such behaviour, because interrupts and timers deliver events at any moment. In C++ the machine can
be a switch on an scoped enumeration: An enumeration declared with enum class: its enumerators are written with the type's name, as Light::red, and never convert to an integer without a cast. More in “C++ enums: enum class and std::to_underlying”, a constexpr transition table or a
std::variant with one type per state. Whatever its form, it should handle every event in every state and leave the
hardware to other code, so that every transition can be tested on a laptop.
Six states and five events make 30 decisions
The door lock's states and the events that move it between them
Text description of the diagram
The diagram shows six states as boxes, with arrows labelled by the event that causes each transition. Any event without an arrow leaves the state unchanged.
- locked: code_ok goes to unlocking. A third code_bad in a row goes to lockout.
- lockout, in which the keypad is off: timeout goes back to locked.
- unlocking, while the motor opens the bolt: motor_done goes to unlocked; timeout goes to fault, because the bolt did not move in time.
- unlocked: timeout goes to locking, so the door locks again by itself.
- locking, while the motor closes the bolt: motor_done goes to locked; timeout goes to fault.
- fault, in which the motor is stopped, drawn with a double border: only reset goes back to locked.
The lock has six states and five events. A correct code (code_ok) starts the motor; motor_done says the bolt
reached its end; a timeout while the motor runs means the bolt jammed, so the lock stops in fault, which only a
service reset leaves. An unlocked door locks itself again when its timer runs out, and three wrong codes in a row
switch the keypad off until a timeout.
Six states times five events make 30 pairs, and each pair needs a decision. The diagram draws only the nine that
change the state; the other 21 are ignored on purpose. A second correct code while the bolt is moving should not
restart anything, and a reset outside fault should not unlock the door. Writing those decisions down is what
turns a drawing into a specification. Follow one walk through seven of those decisions:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- A correct code. The keypad reports code_ok while the lock is locked. dispatch takes the case for (locked, code_ok), clears the count of wrong codes and moves to unlocking.
- Outside the machine. Code outside the machine sees the new state and starts the motor to open the bolt. dispatch itself never touches the motor, which is why a laptop can run it.
- A second code. Another code_ok arrives while the bolt is moving. (unlocking, code_ok) is one of the 21 pairs that change nothing, and that is a decision: a second code must not restart the motor.
- The bolt is open. motor_done says the bolt reached its end stop, so the lock becomes unlocked. The timer that locks the door again starts.
- Locking again. The timer runs out. A timeout in unlocked means lock again, so the state becomes locking and the motor code closes the bolt.
- The bolt jams. No motor_done comes before the motor's time limit, so the next event is another timeout. In locking that means the bolt jammed, and the lock stops in fault.
- Codes do nothing now. In fault every event except reset is ignored, so this code_ok leaves the lock where it is. A jammed bolt waits for a person to look at it.
- Back to locked. A service reset returns the lock to locked. Every one of the 30 pairs of state and event has a decision like the seven in this walk, written in the code.
Checkpoint
Check: States, events and a decision for every pair
A switch, a table or a variant can hold the same rules
The three forms keep the same 30 decisions in different places, and the compiler checks each one differently:
| Form | Where the rules live, and what the compiler checks |
|---|---|
A switch on an enum class |
In code, one case per pair; -Wswitch reports a forgotten event |
A constexpr transition table |
In data, one row per transition; static_assert can check the rows |
std::variant states |
In one handler per state and event; each state’s data lives in its own type |
A switch on the state, then on the event, is the simplest form and lets the compiler check that every event is
listed. A constexpr transition table holds one row per transition, so a reviewer reads the behaviour in one place
and the compiler can check the rows while building. A std::variant with one type per state lets each state carry its
own data, such as the count of wrong codes, and std::visit picks the code for the current state and event.
Checkpoint
Check: Three forms of the same machine
The switch version passes all 30 cells on a laptop
The switch version first, then tests that work for any version of the lock. Read the printed table row by row against the diagram:
C header (door.h) · door.h
// A door lock's states and events, shared by the three implementations and their tests.
#pragma once
#include <cstdint>
enum class State : std::uint8_t { locked, unlocking, unlocked, locking, lockout, fault };
enum class Event : std::uint8_t { code_ok, code_bad, motor_done, timeout, reset };
inline constexpr int state_count = 6;
inline constexpr int event_count = 5;
inline constexpr int max_attempts = 3; // three wrong codes in a row lock the keypad out
constexpr const char* name(State s) {
constexpr const char* names[] = {"locked", "unlocking", "unlocked", "locking", "lockout", "fault"};
return names[static_cast<int>(s)];
}
constexpr const char* name(Event e) {
constexpr const char* names[] = {"code_ok", "code_bad", "motor_done", "timeout", "reset"};
return names[static_cast<int>(e)];
} C header (door_switch.h) · door_switch.h
// The door lock as a switch on the state, then a switch on the event. Every event is listed in every state, so the
// compiler's -Wswitch warning reports a case that a future edit forgets.
#pragma once
#include "door.h"
class DoorSwitch {
public:
State state() const { return state_; }
void dispatch(Event e) {
switch (state_) {
case State::locked:
switch (e) {
case Event::code_ok: attempts_ = 0; state_ = State::unlocking; break;
case Event::code_bad:
if (++attempts_ == max_attempts) {
attempts_ = 0;
state_ = State::lockout;
}
break;
case Event::motor_done: case Event::timeout: case Event::reset: break; // ignored
}
break;
case State::unlocking:
switch (e) {
case Event::motor_done: state_ = State::unlocked; break;
case Event::timeout: state_ = State::fault; break; // the bolt did not move in time: jammed
case Event::code_ok: case Event::code_bad: case Event::reset: break;
}
break;
case State::unlocked:
switch (e) {
case Event::timeout: state_ = State::locking; break; // locks again by itself
case Event::code_ok: case Event::code_bad: case Event::motor_done: case Event::reset: break;
}
break;
case State::locking:
switch (e) {
case Event::motor_done: state_ = State::locked; break;
case Event::timeout: state_ = State::fault; break;
case Event::code_ok: case Event::code_bad: case Event::reset: break;
}
break;
case State::lockout:
switch (e) {
case Event::timeout: state_ = State::locked; break; // the keypad works again
case Event::code_ok: case Event::code_bad: case Event::motor_done: case Event::reset: break;
}
break;
case State::fault:
switch (e) {
case Event::reset: state_ = State::locked; break; // only a service reset leaves a fault
case Event::code_ok: case Event::code_bad: case Event::motor_done: case Event::timeout: break;
}
break;
}
}
private:
State state_ = State::locked;
std::uint8_t attempts_ = 0; // wrong codes in a row; meaningful only while locked
}; C header (door_test.h) · door_test.h
// Tests that work for any implementation of the door lock: every event in every state (and, in door_walk.h, a long
// random walk).
#pragma once
#include <cstdint>
#include <format>
#include <print>
#include <string>
#include "door.h"
#include "door_walk.h"
// A path of events from the start that reaches each state.
template <class Door>
Door door_in(State target) {
Door d;
auto go = [&d](std::initializer_list<Event> events) { for (Event e : events) d.dispatch(e); };
switch (target) {
case State::locked: break;
case State::unlocking: go({Event::code_ok}); break;
case State::unlocked: go({Event::code_ok, Event::motor_done}); break;
case State::locking: go({Event::code_ok, Event::motor_done, Event::timeout}); break;
case State::lockout: go({Event::code_bad, Event::code_bad, Event::code_bad}); break;
case State::fault: go({Event::code_ok, Event::timeout}); break;
}
return d;
}
// Prints the state that each event leads to from each state: the whole transition table, as the code behaves.
template <class Door>
void print_transitions() {
std::string header = std::format("{:<11}", "");
for (int e = 0; e < event_count; ++e) header += std::format("{:<11}", name(static_cast<Event>(e)));
std::println("{}", header.substr(0, header.find_last_not_of(' ') + 1));
for (int s = 0; s < state_count; ++s) {
std::string row = std::format("{:<11}", name(static_cast<State>(s)));
for (int e = 0; e < event_count; ++e) {
Door d = door_in<Door>(static_cast<State>(s));
d.dispatch(static_cast<Event>(e));
row += std::format("{:<11}", name(d.state()));
}
std::println("{}", row.substr(0, row.find_last_not_of(' ') + 1));
}
} C header (door_walk.h) · door_walk.h
// 10,000 events from a fixed seed and an FNV-1a checksum of the states visited: the same numbers on any machine, so
// the laptop and the Cortex-M3 can be compared. Only <cstdint>, no printing.
#pragma once
#include <cstdint>
#include "door.h"
template <class Door>
std::uint32_t walk_checksum() {
Door d;
std::uint32_t seed = 2026, hash = 2166136261u;
for (int i = 0; i < 10'000; ++i) {
seed = seed * 1664525u + 1013904223u;
d.dispatch(static_cast<Event>((seed >> 16) % event_count));
hash = (hash ^ static_cast<std::uint32_t>(d.state())) * 16777619u;
}
return hash;
} C++ · switch_test.cpp
// Every event in every state, for the switch version.
#include "door_switch.h"
#include "door_test.h"
int main() {
print_transitions<DoorSwitch>();
DoorSwitch d;
for (int i = 0; i < 3; ++i) d.dispatch(Event::code_bad);
std::println("three wrong codes from locked: {}", name(d.state()));
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra -Werror switch_test.cpp -o switch_test
./switch_test
""") Output
$ clang++ -std=c++23 -Wall -Wextra -Werror switch_test.cpp -o switch_test
$ ./switch_test
code_ok code_bad motor_done timeout reset
locked unlocking locked locked locked locked
unlocking unlocking unlocking unlocked fault unlocking
unlocked unlocked unlocked unlocked locking unlocked
locking locking locking locked fault locking
lockout lockout lockout lockout locked lockout
fault fault fault fault fault locked
three wrong codes from locked: lockout
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 switch_session.py
door_in drives a fresh lock into each state along a known path, and print_transitions then sends each event and
prints where it leads: the whole table of 30 cells, as the code really behaves. Comparing it with the diagram is the
review. Eight of the nine arrows show up as a change of state, every other cell repeats its row’s state, and the
last line checks the ninth arrow, three wrong codes in a row. A table like this also raises the questions a drawing
hides, for example whether a correct code while unlocked should restart the timer that locks the door again.
Two rules make this test possible. First, every event is handled in every state, even if only to ignore it, so the
inner switches name all five events. Second, the machine touches no hardware: dispatch receives a plain Event and
changes state_, and other code turns interrupts into events and states into motor commands. Nothing in the test
needs a board.
Key idea
Decide every pair of state and event in the code and keep the hardware out of the machine: then one test can send every event in every state on a laptop and compare the result with the diagram.
Checkpoint
Check: Testing every transition on the host
Practise
Practice that adapts to you
Practise this lesson
9 questions about 9 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
The same lock as a table and as std::variant, measured Pro: opens with a Pro, Premium or Ultimate pass
The table and the variant versions below pass the same tests as the switch, and the same 10,000-event walk gives the same checksum for all three. They differ in what the compiler checks for you and in what they cost on a device.
In this part:
- A transition table that the compiler checks while building
- States that carry their own data with std::variant
- One test for three implementations, measured
- What each version costs on a Cortex-M3, measured
- Choosing a form, with the trade-offs in numbers
8 runnable examples (C header, C++, C++ (fw_none.cpp), C++ (fw_switch.cpp), C++ (fw_table.cpp), C++ (fw_variant.cpp), Python) · about 11 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Compiler checks, events from interrupts, and a machine to build Pro: opens with a Pro, Premium or Ultimate pass
A state machine is only as good as the checks around it and the way events reach it. This part reproduces two failures that tests of the machine alone can miss, then sets an exercise with its transition tests.
In this part:
- A forgotten event found by the compiler, and what default hides
- Events raised during a transition: the failure reproduced
- A timeout from the previous state: the stale-timer failure reproduced
- Your turn: a vending machine controller with its transition tests
2 diagrams · 8 runnable examples (C header, C++, Python) · about 10 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher to mid · Warm-up
Why should a firmware state machine handle every event in every state, even the events it ignores?
Because events arrive whenever the hardware and the user produce them, not when the code expects them, so every pair of state and event happens sooner or later. A pair without a decision behaves however the code happens to behave, for example by falling into a default branch or acting on stale data. Listing the ignored pairs makes each decision visible to a reviewer and testable: a test can send every event in every state and compare the result with the specification. In C++, a switch on an enum class that names every enumerator, without a default label, also lets -Wswitch report an event that is added later and handled nowhere. Ignoring is often right, such as a second correct code while a bolt is moving, but it should be chosen, not left to chance.
-
Mid to senior
An interrupt handler calls the door controller's dispatch() when the motor reaches its end stop, and the main loop calls dispatch() for keypad events. About once a week a unit ends up in the wrong state. What do you suspect, and how do you restructure the code?
-
Senior to staff
A battery charger controller has 12 states and 15 events, and two states must remember data: a charge timer and a fault code. Your team argues between a switch, a transition table and std::variant on a Cortex-M0+ with 32 KB of flash. How do you decide?
-
Mid to senior
How do you test a state machine with 8 states and 10 events so that you know every transition is covered, and what can such tests still miss?
Key takeaways
- A state machine lists states, events and the transition for every pair; ignoring an event is a decision too.
- A
switchon anenum class, listing every event, lets-Wswitchcatch an event that a later edit forgets. - A
constexprtable is behaviour as data that a reviewer can read andstatic_assertcan check. std::variantlets each state carry its own data, andstd::visitpicks the code for the current state and event.- Keep hardware out of the machine and feed it plain events, so every transition can be tested on a laptop.
References
- Working Draft, Programming Languages: C++ (N4950): [stmt.switch], [dcl.enum], [variant] (ISO/IEC JTC1/SC22/WG21)
- std::variant (cppreference.com)
- std::visit (cppreference.com)
- Enumeration declaration (cppreference.com)
- Options to Request or Suppress Warnings (-Wswitch, -Wswitch-enum) (GNU Project)
- Diagnostic flags in Clang: -Wswitch (LLVM Project)
- libc++ ABI Versioning (LLVM Project)
- libc++'s ABI Guarantees: _LIBCPP_ABI_VARIANT_INDEX_TYPE_OPTIMIZATION (LLVM Project)
- Arm GNU Toolchain downloads (Arm)
- Arm MPS2 and MPS3 boards (mps2-an385 and others) (QEMU Project)
- Semihosting for AArch32 and AArch64 (Arm)
Report a problem with this lesson
Kept only in this browser. Your Learn progress