Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 20 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 7 of 20 minutes free
Free: Six states and five events make 30 decisions · A switch, a table or a variant can hold the same rules · The switch version passes all 30 cells on a laptop · Practise. With a pass: 2 in-depth parts and 3 interview answers, about 13 minutes.

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

State diagram of the door lock: six states joined by the events code_ok, code_bad, motor_done, timeout and reset.lockedlockout(keypad off)unlocking(motor opening)unlockedlocking(motor closing)fault(motor stopped)code_okthird code_badin a rowtimeoutmotor_donetimeouttimeoutmotor_donetimeoutreset

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:

Interactive · message by message One walk of the door lock, event by event Step through seven events as they reach dispatch. The middle column is the machine with its state under its name; the right one is the code outside it that drives the motor.
One walk of the door lock, event by event, step 8 of 8. Messages so far: Events to DoorSwitch: code_ok; DoorSwitch to Motor code: open the bolt; Events to DoorSwitch: code_ok; Events to DoorSwitch: motor_done; Events to DoorSwitch: timeout; Events to DoorSwitch: timeout; Events to DoorSwitch: code_ok; Events to DoorSwitch: reset.EventshardwareDoorSwitchlockedMotor codemotor offcode_okopen the boltcode_okmotor_donetimeouttimeoutcode_okreset

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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

  1. Question 1 of 2 A pump controller has 7 states and 6 events. How many pairs of state and event need a decision?

    Type a number.

    A hint

    Every event can arrive in every state.

    Show the answer: Question 1 of 2

    Answer: 42 pairs

    7 × 6 = 42. Each pair needs a written decision, even if the decision is to ignore the event, as the door lock's 6 × 5 = 30 pairs do.

  2. Question 2 of 2 The door lock is in fault. Which event moves it to another state?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: reset, the service reset

    fault means the bolt jammed while the motor ran. Leaving it on a keypad code or a timer would drive a jammed motor again, so the only arrow out of fault is reset.

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

  1. Question 1 of 2 In which form do the rules of the machine live in data rather than in code?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The constexpr transition table, one row per transition

    A switch keeps the rules in code, a table keeps them in data, and a variant keeps them in one handler per state and event.

  2. Question 2 of 2 In the std::variant form, what chooses the code that handles an event?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: std::visit, from the type of the current state and the event

    A std::variant with one type per state lets each state carry its own data, and std::visit picks the code for the current state and event.

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:

The states and events, and the switch version

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
};
Tests for any version of the lock

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()));
}
Every event in every state, with warnings as errors Python · switch_session.py
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

  1. Question 1 of 2 In the recorded table of 30 cells, how many show a state other than the state of their row?

    Type a number.

    A hint

    Count the arrows of the diagram that a single event draws.

    Show the answer: Question 1 of 2

    Answer: 8 cells

    Eight of the nine arrows show up as a change of state. The ninth, three wrong codes in a row, needs three events, so the last line of the run checks it on its own: three wrong codes from locked give lockout.

  2. Question 2 of 2 print_transitions builds a fresh lock with door_in for every one of the 30 cells. Why a fresh one each time?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Each cell then starts from a known state, reached along a known path, whatever the previous cell did

    door_in drives a new lock into the state of the row along a known path of events, so every cell tests exactly one pair of state and event.

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.

  1. Question 1 of 9 How many pairs of state and event does the door lock have to decide?

    Type a number.

    Show the answer: Question 1 of 9

    Answer: 30 pairs

    Six states times five events make 30 pairs. Nine of them change the state; the other 21 are ignored on purpose.

  2. Question 2 of 9 In the recorded table of transitions, what does code_ok do while the lock is unlocking?

    Choose one answer.

    Show the answer: Question 2 of 9

    Answer: Nothing; the lock stays unlocking

    The row for unlocking shows unlocking under code_ok. Ignoring the event is a decision written in the code, in the case labels that list it, not an accident: a second correct code while the bolt is moving should not change anything.

  3. Question 3 of 9 A battery_low event is added to the door lock. How many new pairs of state and event need a decision?

    Type a number.

    A hint

    The new event can arrive in every state.

    Show the answer: Question 3 of 9

    Answer: 6 pairs

    One new event in each of the six states makes 6 new pairs, 36 in all. Each one needs a decision, even if most of them only ignore the event.

  4. Question 4 of 9 Which form lets the compiler's -Wswitch warning report an event that a later edit forgets?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: A switch on an enum class that lists every event

    A switch on the state, then on the event, is the simplest form and lets the compiler check that every event is listed.

  5. Question 5 of 9 Which form keeps the count of wrong codes in existence only while the door is locked?

    Choose one answer.

    Show the answer: Question 5 of 9

    Answer: std::variant, with the count as a member of the Locked type

    In the variant version each state is a type, and only Locked has an attempts member, so the count is created when the door locks and disappears when it leaves that state. The switch and table versions keep a member that exists in every state and is meaningful in only one.

  6. Question 6 of 9 A safety engineer who does not write C++ has to compare the lock's behaviour with a written list of transitions. Which form gives them the behaviour in one place?

    Choose one answer.

    Show the answer: Question 6 of 9

    Answer: The constexpr transition table

    A constexpr transition table holds one row per transition, so a reader finds the behaviour in one place and the compiler can check the rows while building.

  7. Question 7 of 9 Why does each inner switch list every event, even the events that change nothing?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: So that the compiler's -Wswitch warning reports an event that a later edit adds to the enum

    With every enumerator listed and no default label, a new enumerator that nobody handled makes -Wswitch name the switch that misses it. A default label would quietly cover the new event instead.

  8. Question 8 of 9 The lock drives a motor. Why can its transitions be tested on a laptop?

    Choose one answer.

    Show the answer: Question 8 of 9

    Answer: The machine only changes its state; events arrive as plain values and the motor is driven outside it

    dispatch() receives an Event and updates the state, nothing more. Code outside the machine turns states into motor commands and turns interrupts and timers into events, so the test can feed events directly and read the state back.

  9. Question 9 of 9 A seventh state, charging, is added to State. Besides the machine itself, what must change before print_transitions covers it?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: state_count, the names, and a path in door_in that reaches charging

    print_transitions loops over every state and event, and door_in reaches each state along a known path, so a new state needs its count, its name and its path; then its five cells appear in the table.

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.

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

  1. 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.

  2. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

  4. 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?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A state machine lists states, events and the transition for every pair; ignoring an event is a decision too.
  • A switch on an enum class, listing every event, lets -Wswitch catch an event that a later edit forgets.
  • A constexpr table is behaviour as data that a reviewer can read and static_assert can check.
  • std::variant lets each state carry its own data, and std::visit picks 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.