How it works
A plain-language tour of the whole system. Read top to
bottom; each section is "what it is, why, then the picture." File names
(board.hpp, devices.hpp, …) refer to a consuming
project's files — the engine itself is a read-only, header-only library.
1The 25-year arc
The idea — describe the hardware topology as data, and let a machine turn it into typed, validated handles — is 25 years old. What changed is where that happens.
Reactor (GekkoWare)
hwConf.xml + XSD schema: typed, validated, cross-referenced (keyref). Runs on a Linux PC.
SnowFlake
peripherals.json + a Python codegen: compile-time typed handles on a Cortex-M. Rigor traded away for reach.
Firn
board.hpp constexpr + C++26 reflection: 2001 rigor + 2017 binding + MCU footprint, all at once.
- 2001 had rigor (a validating schema) but needed a PC.
- 2017 got onto a microcontroller but dropped the schema (used a throwaway code generator).
- Now the compiler itself does both: the topology is in the language, validated at compile time, with zero-overhead typed handles — on the microcontroller.
The full story is its own page.
2The big picture
You author one file (the topology table). The compiler turns it into typed handles and validates it. Everything that isn't C++ — vendor drivers, other languages — meets us at a thin C ABI seam.
3Static topology — one file becomes typed handles
The "mandatory" (fixed, soldered-on) hardware lives in
board.hpp as plain data. The compiler joins
references, validates the whole thing, and reflection
generates the named, typed firn::dev handles. No handle
is written by hand.
Add a row to board.hpp → a new dev.* handle
appears, typed and validated. The table is the only thing you author. The
generating code, in full:
struct Devices;
consteval {
std::vector<std::meta::info> members;
for (const Pin& p : kPins)
members.push_back(std::meta::data_member_spec(
std::meta::substitute(^^IOPin,
{ std::meta::reflect_constant(
resolvePin(kPins, kPorts, p.name.view())) }),
{ .name = p.name.view() }));
std::meta::define_aggregate(^^Devices, members);
}
inline Devices dev; // dev.LedRed, dev.Button3, ...
// named from the DATA
4The config / engine seam (file layers)
Same rule the lineage always had: config (data) is separate from the engine (logic). The engine is a read-only library; the board is the one file you edit.
Why it matters: edit only board.hpp and the read-only engine
still catches every mistake at compile time. The engine can be shipped as a
header-only library you never touch (it must be header-only —
consteval runs in the compiler, so it can't be a precompiled
.a/.so).
5Compile-time validation
This is Reactor's 2001 XSD validator, reclaimed — but now the
compiler is the validator, and a bad topology is a
compile error with a descriptive message (C++26 lets
static_assert carry a computed message, P2741).
The cross-peripheral check (sensor → bus) is the hard case: a reference between two device subtrees, validated by the compiler. What it looks like when it fires — verbatim g++-16 output:
tests/fail/unknown_bus.cpp:8:23: error: static assertion failed:
sensor 'AmbientLight' references unknown bus 'I2C_9'
8 | static_assert(kDiag.ok(), kDiag);
| ~~~~~~~~^~
There are 21 such compile-fail regression tests; every one must fail with
its expected named-offender diagnostic. Wrong usage is a compile
error too: writing to an input pin, analog reads on a digital pin, a typo'd
name in a lookup — all rejected by requires clauses on the typed
handles and consteval keyref misses.
6The C ↔ C++ bridge (zero overhead)
Drivers come from vendors, in C. Their interrupt calls us. The bridge converts a plain C callback into a typed C++ method call — and it costs nothing.
// A vendor C driver's ISR calls a typed C++ method -- one cast, zero cost:
vendor_register(VENDOR_IRQ_GPIOTE,
reinterpret_cast<vendor_cb_t>(&firn::bridge::Thunk<&Button::on_irq>::call),
&dev::Button3);
Proven in assembly: the thunk compiles to the same instructions as a hand-written call, diffed instruction-for-instruction on every test run.
── reflection thunk Gpio<0x500>::set ──
0: endbr64
4: movl $0x501,0x0(%rip)
e: ret
── hand-written thunk ──
0: endbr64
4: movl $0x501,0x0(%rip)
e: ret
── direct dev.set() ──
10: endbr64
14: movl $0x501,0x0(%rip)
1e: ret
C-SIDE ISR CALL SITE (one indirect jmp -- same as any C callback):
── simulate_isr ──
20: endbr64
24: lea 0x0(%rip),%rdi
2b: jmp *0x0(%rip)
=> reflection thunk == hand-written == direct; bridge adds 0 instructions.
The only cost is the single jmp through the function pointer —
the irreducible minimum any callback pays, in any language.
The fan-out at the end of that sequence is Firn's compile-time pub/sub:
topics are types, subscriber lists are constexpr arrays of function pointers —
no registration, no std::function, no heap.
template <> struct firn::Subscribers<KeyTopic> {
static constexpr std::array<void(*)(const KeyEdge&), 1> list{
+[](const KeyEdge& e) {
if (e.pressed) proto::send_btn(e.id, true); // id == wire id, by data
}
};
};
7The dynamic device tree (come-and-go)
board.hpp is for fixed hardware. Hot-plug devices (USB, BLE)
can't be known at compile time — so they live in a runtime
tree: discovered, matched to a compile-time-closed set of drivers,
built into a static pool (no heap).
The tree is transport-agnostic (USB and wireless in one tree) and hierarchical (a hub or a composite device is an internal node):
Lifecycle — driven by the vendor C stack across the same bridge:
You still get a typed handle for a discovered device
(as<BleHrm>(node) → a real BleHrm*, never a
void*). Only presence is dynamic; the kind
comes from a compile-time-closed set.
8Static vs dynamic = ONE architecture
It looks like two worlds — a static device is a type, a dynamic device is a runtime object. But that's accidental. A driver is one typed class, written once. Only the binding differs.
The only real difference is binding time: a static device's config is a compile-time constant (so it can live in the type and fold to a single instruction); a dynamic device's config is known only at runtime (so it's a value, read with one load). That's one architecture choosing constant-vs-value — not two.
9Integrating other languages (Rust, Zig, …)
Only the C ABI crosses a language boundary:
extern "C" function pointers and
#[repr(C)]/POD structs — exactly what the bridge already speaks.
So any language plugs in where C does: as a driver (inbound)
or a caller (outbound).
Key idea: each language keeps its safety on its own side;
the seam carries only the validated result, as POD + function
pointers. And because reflection can generate the
extern "C" surface from board.hpp, the bindings
other languages consume fall out of the single source of truth — no
hand-maintained FFI. The call cost across the seam is the same proven
one-indirection thunk.
10The reflection cross-toolchain
C++26 reflection is GCC 16-only today, and no official
GCC 16 arm-none-eabi exists. So we built one (no sudo, into
a local prefix) and proved reflection runs on the Cortex-M0+.
Result: arm-none-eabi-g++ 16.0.1 with -freflection
works for the M0+. The reflection runs entirely at compile time and folds to
constants, so none of it reaches the firmware — exactly the
property the whole approach depends on.
Describe the board once as data; the compiler validates it and generates typed, zero-overhead handles; vendor drivers and other languages meet us at a thin C ABI seam; and devices that come and go live in a heap-free runtime tree of the same typed drivers — all on a Cortex-M0+, with C++26 reflection we cross-compiled ourselves.
Next: the case study — a complete production firmware built on all of the above — or get started.