ufsm generate cpp turns a model into one header-only C++ file
(<name>.hpp). It is as static as the C output: fixed-size
arrays, a ring buffer for signals, no heap, no virtual functions, no
exceptions thrown and no RTTI, and it includes nothing but <cstdint>, so
it builds for microcontrollers with -fno-exceptions -fno-rtti as well as
in ordinary programs. It needs C++11 or newer.
The machine is a class template over the application's handler type: the
actions and guards are ordinary member calls the compiler can inline, and a
missing one is a compile error, not a link error or a crash. The header
compiles without warnings under g++ and clang++ with -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Wshadow -Wold-style-cast -Wzero-as-null-pointer-constant and the other flags listed under
Testing.
Behaviour is defined by semantics.md; the generated code is
a direct print of the IR (ufsm ir <model>), the same IR the
C, Python, TypeScript,
Rust and Go backends print, and it is
tested against the reference interpreter.
The generated code may be used under any terms (output exception); uFSM itself is GPL-3.0-or-later.
npm run ufsm -- generate cpp examples/dimmer.ufsm -o build/
# wrote build/dimmer.hppThe model must pass ufsm check without errors. Settings come from the
model's targets.cpp section and can be overridden on the command line:
targets.cpp key |
Flag | Default | Meaning |
|---|---|---|---|
namespace |
model name as an identifier, lower case (New model → new_model) |
The namespace everything is declared in | |
className |
--class-name |
model name in PascalCase (test_guard1 → TestGuard1) |
Name of the machine class template |
file |
the namespace | File name: <file>.hpp (also the include guard) |
|
out |
-o |
current directory | Output folder, relative to the model's file or absolute (targets) |
Unknown keys, and the behaviour options below, are reported as warnings and ignored there.
The model's options section sets what every target
shares; the flags override it for one run:
options key |
Flag | Default | Meaning |
|---|---|---|---|
signalQueue |
--signal-queue |
16 | Capacity of the signal queue (only generated if the model emits signals) |
maxMicrosteps |
--max-microsteps |
10000 | Microsteps per reset/process call before Status::livelock; 0 removes the check |
debugInfo |
--debug-info |
false | Name lookup and an is-active query (Debugging) |
trace |
--trace |
false | A trace hook the handler implements (Debugging) |
signalQueue and maxMicrosteps change behaviour, so they are set in
options only, never per target: a model behaves the same in every language
and in the simulator, queue overflows included. debugInfo and trace change
only the generated API, so targets.cpp may override them for this language.
For examples/dimmer.ufsm:
namespace dimmer {
enum class Status : std::int8_t {
ok = 0, queue_overflow = -1, livelock = -2, unknown_event = -3, busy = -4, not_started = -5,
};
const char *status_code(Status status); // "ok", "queue-overflow", "livelock", ...
enum class Event : std::uint16_t { eButton = 0, eOffButton = 1 };
template <typename Handler>
class Dimmer {
public:
explicit Dimmer(Handler &handler); // keeps a reference; runs nothing yet
Handler &handler();
Status reset(); // start, or restart; no exit actions run
Status process(Event event);
};
} // namespace dimmer- The handler is any class with one member function per action
(
void lamp_off()) and guard (bool door_open()), named as in the model. The machine keeps a reference to it, so it must outlive the machine. Guards may be called more than once per event and in any order, so they should not have side effects. - The constructor does not start the machine. Call
reset()first;process()before that returnsStatus::not_started. - Errors are returned, never thrown. After an error the machine is in an
undefined state until
reset(), andprocess()returnsStatus::busyuntil then.status_code()gives the interpreter's error code. - An exception from an action or guard is not caught: it unwinds through
the machine and leaves it half-way, and
process()returnsStatus::busyuntilreset(), as it does when an action or guard callsprocess()on its own machine (that nested call changes nothing; the outer call goes on). See Failed and nested calls. - The busy flag is a plain
bool, not a lock: it detects a call from an interrupt handler on the same core, not concurrent calls from two threads. - Names are used as written (
Event::eButton,h.lamp_off()); a C++ keyword gets a trailing underscore (Event::not_,Timer::long_), and the generator refuses a model where that would clash with another name, or where a name is a macro of<cstdint>(INT8_MAX, ...). - Signals are internal: actions emit them, and
process()runs them (FIFO) after the event until the queue is empty.Status::queue_overflowmeans more thansignalQueuesignals were waiting at once. - After a terminate pseudo-state is reached,
process()does nothing untilreset(). - Everything
private(cfg_,step_eButton, ...) is internal and may change between uFSM versions.
A model with timers gets a Timer enum,
timer_count, timer_event() (the event to process when a timer expires)
and two more handler functions (none of this without timers):
enum class Timer : std::uint16_t { travel = 0, poll = 1 };
constexpr std::uint16_t timer_count = 2u;
Event timer_event(Timer timer);
struct DoorHandler {
void timer_start(door::Timer timer, std::uint32_t ms, bool periodic);
void timer_stop(door::Timer timer);
// ... actions and guards
};The contract, as in every language:
- Stopping a timer that is not running does nothing.
- A periodic timer is started once; the application re-arms it every period.
- After a stop returns, no expiry of the earlier start should be delivered. One that is already queued arrives as an ordinary event and is handled as such (in a state that does not react to it, nothing happens).
- Start and stop run inside
reset()andprocess(): they must not block or call the machine.reset()stops every timer first, whatever runs.
The C backend's Zephyr example carries over: keep one
k_timer per timer and queue timer_event(timer) for the thread that runs
the machine.
Always generated: a State enum (normal and final states in document order,
named after the states; a name that repeats is qualified with its parent
states, and free text is made an identifier: 10% becomes s10_) and the
model's fingerprint, which identifies how events, signals, states and
transitions are numbered (ufsm info prints it for a model file; see
Numbering and trace hooks).
enum class State : std::uint16_t { Off = 0, On = 1, s10_ = 2, /* ... */ };
constexpr std::uint32_t fingerprint = 0xc57ac6eau;With debugInfo, name lookup (names as in the model, nullptr for a value
out of range; the strings cost flash) and an is-active query:
const char *event_name(Event value);
const char *signal_name(Signal value); // when the model has signals
const char *state_name(State value);
const char *timer_name(Timer value); // when the model has timers
bool Dimmer<Handler>::is_active(State state) const;With trace, a TraceKind enum (event, signal, transition, enter,
exit) and one more handler function, called with ids only (the transition
numbers are listed in a comment above the class; a Signal enum is
generated when the model has signals):
void trace(dimmer::TraceKind kind, std::uint16_t id);It runs inside reset() and process(), so it must not block or call the
machine. An action named trace clashes with it while trace is on.
#include <cstdio>
#include "dimmer.hpp"
struct Lamp {
int percent = 0;
void lamp_off() { percent = 0; }
void lamp_10_percent() { percent = 10; }
void lamp_30_percent() { percent = 30; }
void lamp_50_percent() { percent = 50; }
void lamp_100_percent() { percent = 100; }
};
int main()
{
Lamp lamp;
dimmer::Dimmer<Lamp> machine(lamp);
machine.reset(); // Off
machine.process(dimmer::Event::eButton); // On, 10%
machine.process(dimmer::Event::eButton); // 30%
machine.process(dimmer::Event::eOffButton); // Off
machine.process(dimmer::Event::eButton); // On again: history restores 30%
std::printf("lamp at %d%%\n", lamp.percent); // lamp at 30%
return 0;
}A comment above the class lists the regions and their states (the value of
cfg_[region] for each state). The private member functions are the IR
functions: enter_rN, exit_rN, record_rN, restore_rN,
step_<event> / step_<signal> / eventless (one microstep; they return
whether one ran) and settle_. As in C, a function that can fail returns
an int, negative for an error, and every caller passes it up; only
reset() and process() turn it into a Status.
packages/core/test/backend-cpp.test.ts compiles the generated headers and
runs them against the same scripts as the interpreter. It skips without a
C++ compiler ($CXX, else the C++ driver next to $CC, else c++, g++
or clang++); CI sets UFSM_REQUIRE_CXX so a missing compiler fails
instead. It checks that:
- all conformance traces in
tests/traces/pass (including an action that throws and one that callsprocess()on its own machine); - on random event sequences (with random guard values), every repo model,
300 random models (
UFSM_CPP_RANDOM=<n>for more), the debugInfo + trace hooks, 100 random models with a 2-slot queue and a hand-built IR covering every expression shape call exactly the same actions, end in the same states and fail with the same errors as the reference interpreter; - every repo model and 40 random models, with and without
debugInfoandtrace, compile with every member function instantiated, as C++11 and C++20, with-fno-exceptions -fno-rttiand-Wall -Wextra -Wpedantic -Werror -Wconversion -Wsign-conversion -Wshadow -Wold-style-cast -Wcast-qual -Wundef -Wzero-as-null-pointer-constant -Wextra-semi -Wnon-virtual-dtor(clang also-Wtautological-overlap-compare -Wunreachable-code); - a handler that misses an action, or an
intpassed as an event, does not compile; - the example above prints
lamp at 30%.