State Machines in Embedded Software: Table-Driven Design and Hierarchical Structures
How to design clean, maintainable embedded firmware using table-driven state machines and hierarchical state machines to manage complex device behavior.
Every embedded device that reacts to buttons, sensors, timers, or communication events is running a state machine, whether the firmware team designed it that way on purpose or not. Left implicit, that logic tends to sprawl into a maze of flags and nested if-statements that only the original author can safely modify. Made explicit, with states and transitions defined as data rather than buried in control flow, the same behavior becomes something a reviewer can verify at a glance and a new engineer can extend without fear of breaking an untested edge case. This is especially important in regulated environments like medical device firmware, where traceability from requirement to state transition is often part of the design history file.
Why Ad Hoc State Logic Breaks Down
A typical first pass at stateful firmware uses an enum for the current mode and a large switch statement scattered across the main loop, interrupt handlers, and helper functions. It works fine for three or four states. By the time a device has a dozen states and two dozen possible events, engineers usually find the same transition logic duplicated in multiple places, invalid transitions that were never explicitly forbidden, and a growing set of boolean flags standing in for states nobody wanted to name. Bugs in this style of code are rarely caught by inspection because the full set of legal transitions is never written down anywhere; it only exists as the union of every if-statement in the file.
Table-Driven State Machines: Making Transitions Data
A table-driven state machine flips this around. Instead of asking "what code runs when I'm in state X and event Y happens," you build a table that answers that question directly, and a small, fixed piece of engine code that looks up the answer. The core data structure is usually a two-dimensional table indexed by current state and event, where each cell holds the next state and an optional action function pointer:
- States — an enum listing every mode the system can be in, such as IDLE, ARMING, RUNNING, FAULT, and SHUTDOWN.
- Events — an enum listing every input that can trigger a transition, such as BUTTON_PRESS, SENSOR_TIMEOUT, or COMMS_ERROR.
- Transition table — a table mapping (state, event) pairs to a next state and an action to run during the transition.
Because the entire behavior lives in one table, adding a new state or event means adding a row or column, not hunting through scattered conditionals. Missing transitions are also immediately visible: any (state, event) cell left as "no transition" is, by definition, an event the system ignores in that state, which is a design decision you can see rather than one that falls out of whatever the switch statement happened to handle.
Implementing the Transition Table in C
In C, the cleanest implementation uses a table of structs rather than a raw two-dimensional array of function pointers, since it keeps the table readable and lets rows use don't-care wildcards for the current state when a transition applies broadly:
- Define a struct with fields for current_state, event, next_state, and an action function pointer.
- Build a static const array of these structs listing every valid transition in the system.
- Write a single dispatch function that takes the current state and an incoming event, scans the table for a matching row, calls the associated action, and updates the current state variable.
- Log or assert on any event that finds no matching row, so an unhandled combination is caught during development rather than silently ignored in the field.
This pattern keeps the dispatch engine itself trivial and testable in isolation. Because the table is just data, it can be unit tested on a desktop build without any hardware dependencies, and the same table can be walked by a script to auto-generate a state diagram for design reviews or a regulatory submission.
Hierarchical State Machines: Taming Real-World Complexity
Flat table-driven state machines work well until the number of states grows large enough that many states share the same response to certain events. A common example is an error or fault event that should abort almost any state and transition to a common FAULT state. In a flat design, that means repeating the same transition in every row, or a single wildcard row per event, which starts to blur the line between exceptions and normal flow.
Hierarchical state machines solve this by organizing states into parent-child relationships, where a child state inherits the transitions of its parent unless it explicitly overrides them. For example, an OPERATING superstate might handle COMMS_ERROR and OVER_TEMP events identically for all of its substates, such as IDLE, RUNNING, and PAUSED. Each substate only needs to define the transitions unique to it, while the shared safety behavior is defined once at the parent level. This mirrors how UML statecharts are typically drawn, and it maps naturally onto a table-driven implementation: the dispatch engine first checks the table for a match on the current substate, and if none is found, falls back to checking the table for a match on the parent state.
When the Added Structure Is Worth It
Not every embedded project needs a full hierarchical framework. A device with five or six states and a handful of events is often clearer as a flat transition table. Hierarchical structuring earns its complexity once a system has several states that share large blocks of common behavior, once safety or fault handling needs to apply uniformly across many modes, or once the state model needs to be reviewed and traced against requirements as part of a design history file. Existing open-source frameworks such as QP/C implement hierarchical state machines according to the UML statechart specification and are worth evaluating before building one from scratch, particularly on projects where certification and long-term maintainability matter as much as initial development speed.
Modeling State Machines with PlantUML
Once a state machine grows past a handful of states, a diagram earns its keep as a design artifact, not just a comment. PlantUML lets you describe state diagrams as plain text using its state diagram syntax, so the diagram lives right next to the firmware source instead of trapped in a drawing tool that only one person on the team can edit. A transition table and a PlantUML state diagram describe the same information two ways: the table is what the dispatch engine reads, the diagram is what a reviewer or auditor reads, and keeping both close together makes it easy to catch the case where one has drifted from the other.
- state A --> B : EVENT_NAME syntax reads almost like the rows of a transition table, which makes translating between the two a matter of copying rows rather than re-deriving the design.
- Composite states, written as nested state X { ... } blocks, map directly onto hierarchical state machines, letting a shared fault transition be drawn once on the parent state instead of repeated on every child.
- Because the .puml file is plain text, it stores cleanly in the same Git repository as the firmware, and a two-line change to a transition shows up in git diff and git blame like any other source change, rather than as an opaque binary diff.
- Diagrams can be regenerated automatically as part of a build or documentation step, so the exported PNG or SVG in a design document is always derived from the same source that a code reviewer just approved, which is useful evidence for a design history file.
Keeping the .puml source under revision control alongside the C source it describes also means the state diagram gets reviewed in the same pull request as the code change that alters a transition, instead of living in a separate document that quietly falls out of date.
Previewing PlantUML Diagrams in VS Code
The PlantUML extension for VS Code renders these text files as diagrams without leaving the editor. After installing the extension, opening a .puml file and pressing Alt+D (Option+D on macOS) opens a live preview panel that re-renders as you type, which makes it practical to sketch a state machine and see it take shape in real time during a design discussion. The extension can render locally if Graphviz is installed, or fall back to a PlantUML rendering server, so a team can get previews working with either a local toolchain or zero local dependencies beyond the extension itself. Exporting the current diagram to PNG or SVG is a command available from the same preview panel, which is the easiest way to drop an up-to-date diagram into a design document or datasheet.
A Worked Example: Motor Controller State Machine
To make this concrete, here is a small state machine for a motor controller with three states and four events. It is simple enough to read in one sitting, but it demonstrates the full pattern: a diagram as the design artifact, and a transition table as the implementation.
The diagram below is rendered directly from the PlantUML source that follows it:
@startuml
[*] --> IDLE
IDLE --> RUNNING : EV_START / motor_on()
RUNNING --> IDLE : EV_STOP / motor_off()
RUNNING --> FAULT : EV_ERROR / motor_off(); alarm_on()
FAULT --> IDLE : EV_RESET / alarm_off()
@endumlAnd here is the C implementation of the same four transitions, using the table-driven pattern described above:
typedef enum { ST_IDLE, ST_RUNNING, ST_FAULT } state_t;
typedef enum { EV_START, EV_STOP, EV_ERROR, EV_RESET } event_t;
typedef void (*action_fn)(void);
typedef struct {
state_t current_state;
event_t event;
state_t next_state;
action_fn action;
} transition_t;
static void motor_on(void) { /* enable motor driver */ }
static void motor_off(void) { /* disable motor driver */ }
static void alarm_on(void) { /* light fault LED */ }
static void alarm_off(void) { /* clear fault LED */ }
static const transition_t transition_table[] = {
{ ST_IDLE, EV_START, ST_RUNNING, motor_on },
{ ST_RUNNING, EV_STOP, ST_IDLE, motor_off },
{ ST_RUNNING, EV_ERROR, ST_FAULT, motor_off },
{ ST_FAULT, EV_RESET, ST_IDLE, alarm_off },
};
static state_t current_state = ST_IDLE;
void sm_dispatch(event_t event)
{
for (size_t i = 0; i < sizeof(transition_table) / sizeof(transition_table[0]); i++) {
if (transition_table[i].current_state == current_state &&
transition_table[i].event == event) {
if (transition_table[i].action) {
transition_table[i].action();
}
current_state = transition_table[i].next_state;
return;
}
}
/* No matching row: event is ignored in this state */
}Notice how directly the table rows map onto the diagram's arrows: each PlantUML transition line becomes one row in transition_table, and sm_dispatch() never needs to change as new states or events are added, only the table does.
Whether you're debugging a flag-based state machine that has outgrown itself or designing firmware architecture from a blank sheet, getting the state model right early pays off for the life of the product. If you're planning a new embedded design and want an engineering partner who treats firmware architecture as seriously as the schematic, get in touch with our team.
Written by
SiGenix Engineering
Pittsburgh-based electronics and medical device engineering firm. Our president holds 14 granted U.S. patents spanning medical device monitoring, therapeutic systems, and patient compliance technology.