Header-only, no-allocation, tick-based HFSM for C++17/20. Designed as a foundation for embedded control systems where allocation is prohibited, exceptions are off, and execution order must be deterministic per clock tick.
C++17+ | Single header | MIT
- Hierarchical: states nest inside composite states; events bubble up the parent chain, and
on_updateticks down it — every active state runs, outermost-in. - Header-only: include hfsm.h in your project, no external dependencies needed.
- No heap, no exceptions: all tables are
constexpr; storage lives in the machine object. - Tick-based: update() drives execution — each call runs auto-transitions, then on_update, then the queued event, in that order. Dispatch never re-enters the engine.
- Context by reference: every callback receives
Context&. States and transitions hold no data of their own — they are duck-typed tags withstaticcallbacks, never instantiated. - Single-threaded by design: a machine is a single-owner object with no internal locking; drive each instance from one thread (see Thread safety).
- Compile-time verification: hierarchy correctness checked with
static_assert; a typo'd state tag is a hard compile error. Optionalhfsm::structure::validate<FSM>()adds deeper checks (duplicate triggers, automatic self-loops) at the call site.
A minimal power-toggle machine — two states, one event, one transition type.
#include "hfsm.h"
#include <iostream>
enum class Events{
EVT_POWER
};
struct AppCtx { bool powered = false; };
using M = hfsm::Machine<AppCtx&, Events>;
struct RootState {};
struct Off {
static void on_enter(AppCtx& ctx) { ctx.powered = false; }
};
struct On {
static void on_enter(AppCtx& ctx) { ctx.powered = true; }
};
struct Toggle {
// static bool on_guard(AppCtx& ctx) { return true; } // omit for the default (always-true) guard
static void on_action(AppCtx& ctx) {
std::cout << "Toggling power" << std::endl;
}
};
using States = M::Root<hfsm::Composite<RootState, Off, On>>; // Off is initial child
using Transitions = M::Transitions<
M::Transition<Off, On, Events::EVT_POWER, Toggle>,
M::Transition<On, Off, Events::EVT_POWER, Toggle>
>;
using FSM = M::Tcontroller<States, Transitions>;
int main() {
AppCtx ctx;
FSM machine{ctx};
machine.update(); // first tick: enters RootState then Off
// ctx.powered == false
machine.on_event(Events::EVT_POWER);
machine.update(); // exits Off, enters On
// ctx.powered == true
machine.on_event(Events::EVT_POWER);
machine.update(); // exits On, enters Off
// ctx.powered == false
}- Define a
Contextstruct — the shared data carrier for all callbacks. - Alias
using M = hfsm::Machine<Context&>;(addEventTypeas a second arg for typed events). - Write each state as a plain struct with any of the
staticcallbackson_enter,on_exit,on_update— provide only the ones you need. - Write each transition as a plain struct with
staticcallbackson_guard(returnfalseto reject) and/oron_action— omit either to accept the default. - Declare
M::Root<Composite<...>>andM::Transitions<...>. - Instantiate
M::Tcontroller<States, Transitions>, then callupdate()every tick.
Every machine has at least one Composite at the top level. Composite<Parent, Child1, Child2, ...>
nests children under a parent state; the first child is the default initial state. Leaf states are plain
(non-composite) types. The hierarchy is arbitrarily deep.
Composite<RootState, Idle, Composite<Active, Listening, Transmitting>, Sleep>
RootState
├── Idle ← initial child of RootState
├── Active
│ ├── Listening ← initial child of Active
│ └── Transmitting
└── Sleep
A state is just a tag type — it derives from nothing. Supply any subset of the static callbacks
on_enter, on_exit, on_update; each one you omit defaults to a no-op. A callback with the
wrong signature (not static, wrong parameter type) is a hard compile error, so typos don't
silently fall back to the no-op.
State types must be complete where the machine's tables are built — a forward declaration is not enough, since the library detects callbacks by probing the type. Declaring the machine as a member of the class that also declares its states is fully supported (including states forward-declared above the member and defined further down in the class body); the callback tables are instantiated lazily, at the point the controller's constructor is instantiated, by which time the enclosing class is complete. A state type that is never defined in the translation unit is a compile error, not a silent no-op.
The event type is the second template parameter: Machine<Context, EventType = int>. Any integral-like
type works — int, a plain enum, or an enum class. Using an enum makes the event table
self-documenting and prevents silent integer coercions.
enum class Events { EVT_START = 1, EVT_STOP = 2 };
using M = hfsm::Machine<AppCtx&, Events>;
// EventId must be exactly Events — passing int here is a compile error:
M::Transition<Idle, Active, Events::EVT_START, StartTransition>Transition and InternalTransition are nested inside Machine.
| Type | Effect |
|---|---|
M::Transition<From, To, Evt, T, [Kind]> |
External: full exit/enter cascade, UML semantics |
M::InternalTransition<State, Evt, T> |
Internal: calls on_action only, no exit/enter |
M::AutomaticTransition<From, To, T, [Kind]> |
Guard-driven; fires at the top of each update() tick |
struct MyTransition {
static bool on_guard(AppCtx& ctx) { return ctx.ready; } // return false to block
static void on_action(AppCtx& ctx) { ctx.ready = false; }
};If the guard returns false, the engine skips the row and continues bubbling up. Multiple rows with
the same source state and event form a priority chain — first guard that passes wins. Omit on_guard
to accept unconditionally; omit on_action for a transition that only moves state.
State and transition types are pure compile-time tags: they carry no data and are never instantiated —
the engine only takes the address of their static callbacks. Because there is no per-type instance,
any member variables would be pointless (there is nowhere to store them per machine); the Context&
passed to every callback is the only valid location for state.
Unhandled events walk up the parent chain. A row registered on RootState acts as a catch-all
for any descendant that doesn't handle the event itself.
on_event(EVT_X) → check leaf → check leaf's parent → ... → check root → discard
update()
1. dispatch queued event (if any)
2. evaluate auto-transitions (loop until none fires)
3. call on_update on every active state, outermost-in (root ancestor -> active leaf)
The first update() performs the initial entry cascade. There is no separate initialize().
Transitions resolve before the tick they land in. Because dispatch is step 1, the update()
that follows an on_event() exits the old state, runs the action, enters the new one, and then
ticks the chain the transition just entered — the state being left does not get a farewell
on_update. An event queued from inside an on_update is dispatched at the top of the next
update().
The whole active chain ticks. on_update is not leaf-only. If A is a composite whose current
child B is a composite with current leaf C, one update() call runs:
A::on_update -> B::on_update -> C::on_update
Outermost-in, the same order on_enter uses when entering that chain. This is what makes
on_update on a composite worth having: a parent runs the per-tick behaviour shared by all of its
children (integrating physics for every Grounded substate, feeding a watchdog for every substate
of Connected) while the leaf handles what is specific to it. States without an on_update are a
no-op in the walk, so a deep chain of callback-less ancestors costs a table lookup each and nothing
more. The chain is computed once, before any callback runs, from the leaf active at the start of
step 3 — a state cannot change the set of states ticked in the same tick, because on_update can
only queue an event (dispatched at the top of the next tick), never transition inline.
One event per tick. The engine holds a single pending-event slot. on_event(id) fills it and
returns true; if the slot is already occupied it returns false and the event is not stored —
the engine never silently drops or reorders events on your behalf. At most one event-driven
transition fires per update() call.
Event queuing is the caller's responsibility. If your system can produce events faster than one
per tick, maintain your own queue and drain it in coordination with update():
while (!my_queue.empty() && machine.on_event(my_queue.front()))
my_queue.pop();
machine.update(); // consumes at most one event from the slottransition_kind controls how a composite state is re-entered:
| Kind | Behaviour |
|---|---|
normal (default) |
Enter initial child chain |
shallow |
Re-enter the last direct child that was active; then enter its initial child chain |
deep |
Re-enter the exact leaf state that was last active in the subtree |
The machine's entire mutable state is the active leaf index plus the per-composite history
table — small and pointer-free, so it snapshots trivially (e.g. for power-loss / warm-restart
recovery). The engine doesn't impose a wire format: save() hands you the data and you
persist it however you like (file, EEPROM, flash, network); restore() / restore_cascade()
rebuild from values you read back.
// save: your sink receives (currentState, history pointer, count)
struct MySink {
void operator()(int current, const int* history, std::size_t count) { /* write however */ }
};
MySink sink;
machine.save(sink);
// restore: feed the values back. Range-checked; returns false and leaves the machine
// untouched if anything is out of range or the count doesn't match.
bool ok = machine.restore(current, history, count); // position only, no callbacks
bool ok = machine.restore_cascade(current, history, count); // also replays on_enter, root -> leafWhat's captured: currentState + the history table. Not captured: the queued event.
restore marks the machine initialized, so the next update() resumes normally without
re-running the initial entry cascade.
Frames are native-endian — intended for a device persisting and restoring its own state.
Optional, dev-time headers under tools/. They depend on the vendored nameof and
magic_enum (third_party/); the core hfsm.h does not.
hfsm::structure::validate<FSM>() performs additional compile-time checks on a fully wired
Tcontroller. It is opt-in and intended to be called alongside the machine definition or in a
dedicated test translation unit.
#include "tools/hfsm_structure.h"
using FSM = M::Tcontroller<States, Transitions>;
hfsm::structure::validate<FSM>(); // compile error if anything below firesChecks performed:
| Check | Error message |
|---|---|
Two event rows share the same (from, eventId) — second is silently unreachable |
"duplicate (from, eventId) pair in transition table: second transition is unreachable" |
An AutomaticTransition has From == To — guaranteed infinite loop |
"automatic transition self-loop detected: from == to causes an infinite loop" |
The function has no runtime cost: it instantiates an internal validator type (inheriting from
FSM to access its protected tables) and discards it with sizeof. Nothing executes at run
time.
hfsm::annotations::get_names<States>() returns a constexpr std::array<const char*, States::Size>
indexed by state ID. Each entry is the state's type name (via nameof), a NUL-terminated string with
static lifetime. It underlies the diagram generator, and lets you map any state ID the machine reports
— e.g. from get_current_state() — back to a readable name:
#include "tools/hfsm_annotations.h"
static constexpr auto names = hfsm::annotations::get_names<States>();
std::printf("now in %s\n", names[machine.get_current_state()]);For automatic per-transition tracing without wiring names yourself, use the logger
(tools/hfsm_logger.h), which resolves state and transition names internally.
Place above the overridden method inside a transition struct; each expands to a sibling
static constexpr std::string_view the generator reads via SFINAE. Optional and additive —
unannotated transitions fall back to generic markers, and the core is never aware of them.
struct AdvanceToRed {
HFSM_ACTION("ActionToRed")
static void on_action(int&);
HFSM_GUARD("ToRedGuard")
static bool on_guard(int&);
};hfsm::diagram::to_plantuml<States, Transitions>(title = {}) returns a std::string. It walks the
compile-time hierarchy (parentTable / initialStateTable) to emit nested state { … } blocks, and
the published Transitions::metas list for edges. State names come from get_names; event names from
magic_enum (non-enum EventType falls back to the integer value).
#include "tools/hfsm_generator.h"
std::cout << hfsm::diagram::to_plantuml<States, Transitions>("Traffic Light");Edge labels follow UML event [guard] / action, each segment emitted only when present. Guard/action
presence is detected from whether the transition type defines a static on_guard / on_action; the
bracketed/slash text is the HFSM_GUARD / HFSM_ACTION label when annotated, otherwise the generic
Guard / Action.
| Transition | Emitted label |
|---|---|
| no guard/action | Green --> Yellow : TICK |
on_guard, unannotated |
… : TICK [Guard] |
on_guard + HFSM_GUARD("Ready") |
… : TICK [Ready] |
on_action + HFSM_ACTION("Move") |
… : TICK/Move |
AutomaticTransition + guard |
A --> B : [Guard] |
InternalTransition + action |
A : EVT/Action (internal) |
History kinds render the target as a PlantUML history pseudostate: To[H] (shallow), To[H*] (deep).
See examples/traffic_light_annotated.cpp for a complete annotated machine.
hfsm::diagram::to_yaml<States, Transitions>(name = {}) emits a machine-readable YAML
description of the same machine. It is intended as an intermediate format for external tools
(e.g. a Python script) that render PlantUML, SCXML, or any other diagram format without
requiring a C++ toolchain.
#include "tools/hfsm_generator.h"
std::cout << hfsm::diagram::to_yaml<States, Transitions>("TrafficLight");Example output:
name: TrafficLight
states:
- name: RootState
initial: Red
states:
- name: Red
- name: Yellow
- name: Green
transitions:
- from: Red
to: Green
event: GO
guard: IsReady
action: StartGreen
- from: Green
to: Yellow
automatic: true
guard: TimerExpired
- from: Yellow
to: Red
event: TICK
kind: shallowSchema rules:
initialandstatesare emitted only for composite states.eventis omitted for automatic transitions;automatic: trueis emitted instead.internal: trueis emitted only when the transition is internal.kindis omitted fornormal;"shallow"or"deep"otherwise.guardandactionare omitted when the transition type does not define the correspondingstaticcallback.
The two export functions (to_plantuml, to_yaml) share the same edge-collection pass;
both reflect HFSM_ACTION / HFSM_GUARD labels when present.
Set-and-forget tracing that never touches the core. hfsm.h has no logging hooks; instead,
hfsm::log::Controller is a drop-in replacement for M::Tcontroller that builds its state- and
transition-ops tables from trampolines which log, then delegate to exactly the function the core
would have called. The engine runs byte-identical — it just happens to dispatch through entries
that log first. All naming lives here (type names via nameof); the core stays a pure integer world.
The only change to a machine is swapping the controller type and carrying a sink in your Context:
#include "tools/hfsm_logger.h"
// A sink is any type exposing the on_* methods you care about — implement only those.
// It's stream-free: this example uses printf, but RTT/UART/std::ostream all work.
struct Trace {
void on_enter (std::string_view s) { std::printf("-> enter %.*s\n", (int)s.size(), s.data()); }
void on_exit (std::string_view s) { std::printf("<- exit %.*s\n", (int)s.size(), s.data()); }
void on_action(std::string_view s) { std::printf(" / action %.*s\n", (int)s.size(), s.data()); }
};
struct AppCtx { Trace logger; bool powered = false; }; // sink located at ctx.logger by default
using M = hfsm::Machine<AppCtx&, Events>;
// ... States / Transitions declared exactly as usual ...
// The whole opt-in: hfsm::log::Controller instead of M::Tcontroller.
using FSM = hfsm::log::Controller<AppCtx&, Events, States, Transitions>;The sink is duck-typed — implement any subset of these; missing methods are detected with SFINAE and skipped at compile time. Each is passed the state's or transition-type's name:
| Sink method | Called when |
|---|---|
on_enter(std::string_view) |
a state is entered — once per state, including every intermediate state along the LCA path and the initial-state descent |
on_exit(std::string_view) |
a state is exited |
on_update(std::string_view) |
a state's on_update runs — once per active state per tick, root ancestor first |
on_guard(std::string_view, bool) |
a transition guard is evaluated; second arg is the result |
on_action(std::string_view) |
a transition or internal action runs |
Notes and boundaries:
- Where the sink lives. By default at
ctx.logger. Point it elsewhere by specializinghfsm::log::sink_access<YourCtx>— to a differently-named member, a pointer/reference deref, a reference to a virtual base (duck-typing is a superset of virtual dispatch), or astaticinstance for a global sink. Carrying it in the Context gives each machine its own sink with its own state, reachable from the singleContext&the engine threads through every callback. - No-op default.
hfsm::log::EmptyLoggeris a sink with emptyon_*methods, handy as a placeholder or a compile-time off switch. - Not observable: an event that matches no transition in the active path produces no callbacks
(no user code runs, so there is nothing to wrap) — you see the
on_eventsubmission, then silence. - Guard noise: guards are evaluated while scanning automatic transitions on every tick, so a
chatty
on_guardfires frequently; gate it in your sink if needed. - Cost when unused: zero — the core carries no logging machinery. Use
M::Tcontrollerfor a machine with no logging overhead,hfsm::log::Controllerfor one with it.
tests/RadioControllerTests.cpp is a realistic embedded reference: 16 states, 4 levels deep,
26 transition rows, 18 events. It exercises every dispatch mechanism in the engine.
Root
├── Idle (initial)
├── Active
│ ├── Listening (initial)
│ ├── Configuring
│ │ ├── SetFrequency (initial)
│ │ ├── SetPower
│ │ └── SetSpreadingFactor
│ └── Transmitting
│ ├── Preparing (initial)
│ ├── Sending
│ └── AwaitingAck
├── Fault
│ ├── Recoverable (initial)
│ └── Critical
└── Sleep
Highlights:
- Internal transitions at two different ancestor scopes (
ConfiguringandRootState). - Guarded retry chain:
AckTimeoutRetryTransitionpasses untilretryCount == MAX_RETRIES, then falls through to the parentTransmitting → Idlerow. - 3-level guard fallthrough on
EVT_EMERGENCY: leaf → mid-ancestor → root catch-all. - A permanently-blocked guard (
Critical → RecoverableonEVT_RESET) verified by test. - Shallow vs. deep history compared side-by-side from the same
Sleepescape state. - C++20
StrLiteralNTTP gives every state and transition type a unique compile-time name for event-log assertions without any runtime string storage. - Compile-time table verification with
static_assertagainst hand-authored baselines:
static_assert(States::Size == 16);
static_assert(States::Depth == 4);
static_assert(States::parentTable() == std::array<int, 16>{-1, 0, 0, 2, 2, 4, 4, 4, 2, 8, 8, 8, 0, 12, 12, 0});The engine holds a single pending-event slot: on_event() fills it, update() drains it — one
event dispatched per tick, no internal queue. This is a deliberate scope boundary: queuing policy
(bounded ring buffer, priority queue, drop-oldest, etc.) varies too much between applications to
bake into the engine, and adding it would require allocation or a size parameter. Keeping the slot
at depth-1 means the engine itself never allocates, and the caller retains full control over
backpressure. The false return from on_event() is the backpressure signal; what the caller
does with it — retry next tick, push to an external queue, or drop — is application logic.
The tick model also eliminates re-entrant dispatch entirely. Execution order per update() is
fixed: pending event → auto-transitions → on_update. There is no dispatching guard; the
engine is never called from inside a callback.
This is also what makes ticking the full active chain safe and cheap. The walk reuses the same scratch path buffer the transition machinery uses (sized at compile time to the machine's maximum depth, so no allocation), and because no callback can re-enter the engine, nothing can clobber that buffer mid-walk. Cost per tick is O(depth) rather than O(1) — depth being the nesting depth of the machine, typically a handful of levels.
States and transitions store nothing. Every callback receives Context& — the entire shared state
of the machine lives there. This makes unit-testing trivial (construct a context, observe it after
update()), enables the same transition type to be reused across different machines, and means
state/transition types are never instantiated at all.
State and transition tags are duck-typed: the runtime engine lives in a non-template
base_controller and stores each callback as a plain function pointer (void (*)(void*)).
The templated controller<Context, EventType> layer only synthesizes a tiny thunk per type
that recovers the concrete Context& from the void* and forwards to your static callback.
Detection is via SFINAE on the expected signature, with a static_assert that fires when a
callback of the right name but wrong signature is present (so typos are caught, not ignored).
This has three consequences the previous virtual-based design did not:
- No template bloat. The dispatch engine (
update, event bubbling, LCA / path computation, history) is compiled once inbase_controller, not re-instantiated perContext/EventType. - Smaller footprint. No vtables and no per-type instances; transition rows reference their ops by a small integer index into a de-duplicated ops table rather than by pointer.
- One indirection per call. A single function-pointer call replaces the two-load virtual dispatch — and there is nothing to instantiate, so states/transitions cost zero bytes of storage.
parentTable, initialStateTable, stateOpsTable, transitionOpsTable, eventRows, and
autoRows are all resolved at compile time. parentTable, initialStateTable, eventRows, and
autoRows are plain static constexpr members of Tcontroller; the two that probe user callbacks
live in their own holder templates, reached as stateOpsStorage::value and
transitionOpsStorage::value. A holder is instantiated the first time ::value is named — in the
controller's constructor — rather than when Tcontroller is completed, which is what lets a machine
live inside the class that declares its states. Everything is evaluated at compile time; the generated machine object carries only mutable runtime state (current state,
history table, pending event). A static_assert fires if a state tag appears twice, a referenced
tag isn't in the tree, or the number of states overflows int.
Dispatch goes through plain function pointers — there are no virtual functions and no vtables.
The library compiles and runs correctly with -fno-rtti (and has nothing that would need
-fno-exceptions relaxed either).
The machine is not thread-safe, by design. A Tcontroller is a single-owner object: all of
its mutating methods — update(), on_event(), restore() / restore_cascade() — read and
write the same unsynchronized state (current state, history table, pending-event slot) with no
internal locking. Calling any of them concurrently on the same instance is a data race.
This is deliberate: locking policy belongs to the application, and forcing a mutex into every tick
would penalize the common embedded case of a single control loop. The supported patterns are to
drive each machine from one thread, or to serialize access behind your own mutex. Distinct
Tcontroller instances are fully independent and may be driven from different threads without
coordination (the shared constexpr tables are read-only).
The library header requires C++17.
A hfsm::span polyfill is defined for compilers below C++20; in C++20+ it aliases std::span.
The test suite stays at C++20 intentionally — it uses class-type NTTPs (StrLiteral) that have
no C++17 equivalent.
| Entity | Purpose |
|---|---|
hfsm::Machine<Context, EventType = int> |
Top-level alias hub; sets event ID type for all transitions |
| state tag (any struct) | static on_enter / on_exit / on_update, each optional (defaults to no-op) |
| transition tag (any struct) | static on_guard (default true) / on_action (default no-op), each optional |
M::Transition<From, To, Evt, T, [Kind]> |
External transition; Evt must be EventType |
M::InternalTransition<State, Evt, T> |
Internal transition (action only); Evt must be EventType |
M::Root<Composite<...>, ...> |
Declares state hierarchy |
M::Transitions<...> |
Declares event and automatic transition tables |
M::Tcontroller<States, Transitions> |
Concrete machine type to instantiate |
hfsm::Composite<State, Children...> |
Nests states; first child = default initial; namespace scope |
M::AutomaticTransition<From, To, T, [Kind]> |
Guard-driven automatic transition; no event ID |
hfsm::transition_kind |
normal / shallow / deep |
Tcontroller::update() |
One execution tick |
Tcontroller::on_event(EventType) |
Fill the single pending-event slot; returns false (event not stored) if already occupied — at most one event dispatched per update() |
Tcontroller::is_active_s<T>() |
true if state type T is currently active (includes ancestors) |
Tcontroller::is_current_s<T>() |
true if T is the active leaf state |
Tcontroller::get_current_state() |
Returns the active leaf state's integer ID |
Tcontroller::save(sink) |
Hands the snapshot to sink(int current, const int* history, std::size_t count); you persist it |
Tcontroller::restore(current, history, count) |
Restore position only (no callbacks); false if out of range / wrong count |
Tcontroller::restore_cascade(current, history, count) |
Restore and replay on_enter, root → leaf; false if invalid |
Tcontroller::layout_digest() |
std::uint32_t fingerprint of the structural tables, for your own integrity check |
hfsm::annotations::get_names<States>() |
constexpr std::array<const char*, Size> of state type names, indexed by state ID (tools/hfsm_annotations.h) |
HFSM_ACTION(name) / HFSM_GUARD(name) |
Annotate a transition's action/guard with a diagram label (tools/hfsm_annotations.h) |
hfsm::diagram::to_plantuml<States, Transitions>([title]) |
Generate a PlantUML state-diagram string (tools/hfsm_generator.h) |
hfsm::diagram::to_yaml<States, Transitions>([name]) |
Generate a YAML machine description for external diagram tools (tools/hfsm_generator.h) |
hfsm::structure::validate<FSM>() |
Compile-time structural checks: duplicate triggers, auto self-loops (tools/hfsm_structure.h) |
hfsm::log::Controller<Context, EventType, States, Transitions> |
Drop-in Tcontroller that logs every enter/exit/update/guard/action to a Context-carried sink (tools/hfsm_logger.h) |
hfsm::log::sink_access<Ctx> |
Customization point for locating the sink; defaults to ctx.logger (tools/hfsm_logger.h) |
hfsm::log::EmptyLogger |
No-op sink with empty on_* methods (tools/hfsm_logger.h) |
The library is header-only. For the tests:
cmake -B build
cmake --build build
ctest --test-dir buildTests require C++20. The library itself requires only C++17. The tools/ headers additionally
need the vendored nameof / magic_enum.
MIT — see LICENSE.