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.

2001–2003

Reactor (GekkoWare)

hwConf.xml + XSD schema: typed, validated, cross-referenced (keyref). Runs on a Linux PC.

2017

SnowFlake

peripherals.json + a Python codegen: compile-time typed handles on a Cortex-M. Rigor traded away for reach.

2026

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.

flowchart TB BOARD["board.hpp<br/>the topology table<br/>(DATA — you edit only this)"] subgraph engine["Engine — read-only, compile-time"] TOPO["topology.hpp<br/>consteval joins + validation"] REFL["C++26 reflection<br/>generate handles"] end subgraph cpp["Typed C++ world (above the seam)"] DEV["firn::dev<br/>generated typed handles"] DYN["dynamic device tree<br/>devices that come and go"] end SEAM(["C ABI seam<br/>void(*)(void* ctx) + POD"]) subgraph below["Below the seam"] VENDOR["vendor drivers in C<br/>GPIO / USB / BLE"] LANG["other languages<br/>Rust / Zig / Go / script"] end BOARD --> TOPO --> REFL --> DEV REFL --> DYN DEV --> SEAM DYN --> SEAM SEAM <--> VENDOR SEAM <--> LANG classDef data fill:var(--mmd-data-fill),stroke:var(--mmd-data-stroke) classDef gen fill:var(--mmd-gen-fill),stroke:var(--mmd-gen-stroke) classDef seam fill:none,stroke:var(--mmd-seam-stroke),stroke-dasharray:6 4 class BOARD data class REFL,DEV,DYN gen class SEAM 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.

flowchart LR T["board.hpp<br/>kPins / kPorts table"] T --> J["consteval resolve()<br/>port base + offset -> abs pin<br/>(the keyref join)"] T --> V["consteval validate()<br/>names unique? refs real?"] J --> G["reflection<br/>define_aggregate"] G --> D["firn::dev.LedRed<br/>typed handle, named from the table"] V -- ok --> G V -- fail --> E["COMPILE ERROR<br/>'pin LedGreen references unknown port P9'"] classDef err stroke:var(--amber),color:var(--amber) class E err classDef data fill:var(--mmd-data-fill),stroke:var(--mmd-data-stroke) classDef gen fill:var(--mmd-gen-fill),stroke:var(--mmd-gen-stroke) classDef seam fill:none,stroke:var(--mmd-seam-stroke),stroke-dasharray:6 4 class T data class G,D gen

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.

flowchart TB APP["main.cpp (your app)<br/>sees ONLY firn::dev"] DEVI["devices.hpp / genboard<br/>binding: runs engine over config,<br/>exposes firn::dev"] BO["board.hpp<br/>CONFIG — data only (you edit this)"] EN["topology.hpp<br/>ENGINE — read-only, board-independent"] DR["iopin.hpp / i2c.hpp<br/>drivers"] APP --> DEVI DEVI --> BO DEVI --> EN EN --> DR classDef data fill:var(--mmd-data-fill),stroke:var(--mmd-data-stroke) classDef gen fill:var(--mmd-gen-fill),stroke:var(--mmd-gen-stroke) classDef seam fill:none,stroke:var(--mmd-seam-stroke),stroke-dasharray:6 4 class BO data class DEVI gen

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

flowchart TD START["static_assert( validate(board) )"] --> U{"two pins<br/>same name?"} U -- yes --> EU["error: duplicate pin name: LedRed"] U -- no --> K{"pin's port<br/>exists?"} K -- no --> EK["error: pin LedGreen references unknown port P9"] K -- yes --> X{"sensor's bus<br/>exists?"} X -- no --> EX["error: sensor AmbientLight references unknown bus I2C_9"] X -- yes --> OK["board valid — compiles"] classDef err stroke:var(--amber),color:var(--amber) classDef good stroke:var(--ok),color:var(--ok) class EU,EK,EX err class OK good

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);
sequenceDiagram participant HW as Hardware IRQ participant C as Vendor driver (C) participant TH as bridge thunk participant M as C++ method (typed) participant PS as publish (pub/sub) participant S as C++ subscribers HW->>C: interrupt fires C->>TH: cb(ctx) [void(*)(void*)] TH->>M: obj->method() [ctx cast to T* once] M->>PS: publish a typed event PS->>S: fan out to compile-time subscriber list Note over TH,M: at -O2 this is a DIRECT call —<br/>no registry, no virtual, no heap

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):

flowchart TD HOST["USB host controller"] --> HUB["USB hub"] HUB --> COMP["USB composite device"] COMP --> CDC["CDC serial (function)"] COMP --> MSC["mass-storage (function)"] COMP --> HID["HID (function)"] CENTRAL["BLE central"] --> HRM["BLE heart-rate (wireless)"]

Lifecycle — driven by the vendor C stack across the same bridge:

sequenceDiagram participant C as Vendor stack (C) participant R as Registry (C++) participant P as Static pool (no heap) C->>R: usb_plug(transport, key, parent) [via bridge] R->>R: match descriptor -> driver type<br/>(reflection-generated probe table) R->>P: claim free slot, placement-new the typed driver R-->>C: node handle (index) Note over R: publish Attached C->>R: usb_unplug(handle) R->>P: destruct subtree (cascade), free slots Note over R: publish Detached

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.

flowchart TB DRV["Driver = a plain typed class<br/>(methods + config), written ONCE"] DRV --> ST["STATIC binding<br/>config in the TYPE (NTTP)<br/>fixed address<br/>folds to constants — hot path"] DRV --> DY["DYNAMIC binding<br/>config in MEMBERS (runtime)<br/>pool slot, runtime lifetime<br/>one indirection — come and go"] ST --> TREE["ONE device tree<br/>typed nodes"] DY --> TREE classDef data fill:var(--mmd-data-fill),stroke:var(--mmd-data-stroke) classDef gen fill:var(--mmd-gen-fill),stroke:var(--mmd-gen-stroke) classDef seam fill:none,stroke:var(--mmd-seam-stroke),stroke-dasharray:6 4 class DRV data class TREE gen

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

flowchart LR subgraph cppside["C++ — keeps its compile-time rigor here"] DEVX["firn::dev / device tree"] REFL["reflection GENERATES<br/>extern C exports + a C header (+ .rs)"] end ABI(["C ABI seam<br/>void(*)(void*) + repr(C) POD"]) subgraph langs["Other languages — keep THEIR rigor there"] RUST["Rust driver / app<br/>(borrow checker stays Rust-side)"] ZIG["Zig — cImport our header directly"] SCRIPT["script VM<br/>uses by-name reflect dispatch"] end DEVX --> REFL --> ABI ABI <--> RUST ABI <--> ZIG ABI <--> SCRIPT classDef data fill:var(--mmd-data-fill),stroke:var(--mmd-data-stroke) classDef gen fill:var(--mmd-gen-fill),stroke:var(--mmd-gen-stroke) classDef seam fill:none,stroke:var(--mmd-seam-stroke),stroke-dasharray:6 4 class REFL,DEVX gen class ABI seam

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

flowchart TB A["No official GCC16 ARM cross<br/>(apt = 14.2, ST older, clang = none)"] B["GCC 16 source<br/>(matches the host compiler)"] C["binutils + newlib<br/>download + extract, no sudo"] H["flex / bison / m4 (local)"] P["toolchain in a local prefix<br/>(no sudo anywhere)"] CFG["configure --target=arm-none-eabi<br/>single armv6-m soft-float (= M0+)"] BUILD["build gcc + libgcc + libstdc++"] PROOF["PROOF: reflection -> Cortex-M0+<br/>members_of(...) folds to 'movs r0, #2'"] A --> B --> CFG C --> P --> CFG H --> CFG --> BUILD --> PROOF classDef good stroke:var(--ok),color:var(--ok) class PROOF good

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.