How Fixo firmware is generated and compiled

Fixo's defining trick is that the Agent itself ships a copy of LLVM/Clang and can compile firmware in-process, with no external toolchain. This page explains how that happens from end to end so the rest of the docs read sensibly.

One sentence

A FixoPreset is fed through FirmwareGenerator to produce a tree of C++ sources, the embedded LLVM/Clang compiles those into both an AVR ELF (board-side) and a native .so (node-side), the artefacts are content-addressed by BLAKE3 so unchanged presets don't recompile, then the ELF gets flashed and the .so gets dlopen'd into the running Agent.


The pipeline

FixoPreset
   │
   ▼
FirmwareGenerator.generateFromPreset()
   │
   │   ├─ Config.hpp            (constants from preset)
   │   ├─ PinTraits.hpp         (board-specific pin map)
   │   ├─ main.cpp              (board entry point)
   │   ├─ Node API wrappers     (C ABI for the .so)
   │   ├─ #include <fixo/lobes/...>      ← from FixoLobeType.mainHeader
   │   └─ #include <fixo/actuators/...>  ← from FixoActuatorType.mainHeader
   │
   ▼
FixoBuildManager (background thread, debounced)
   │
   ├─▶ libllvm/clang/lld   ─▶  AVR ELF        ─▶  FirmwareFlasher (stk500v2)
   │
   └─▶ libllvm/clang/lld   ─▶  Native .so     ─▶  FirmwareRuntime (dlopen)
                                    │
                                    └─ Implements fixo_firmware_* C API

The two outputs are produced from the same sources but different target triples and HAL specialisations. Lobe placement (per-lobe placement field) decides which target a given lobe's code lands in.


Why embedded LLVM

Earlier OctoMY™ releases shipped a runtime-configurable firmware (Fluxo / ArduMY). The motivation for that approach was that compiling C++ for AVR was a multi-tool toolchain dance — installing avr-gcc, AVR libc, dragging in Arduino's build system — and asking users to do it before flashing was a friction wall. So the firmware was made generic and the user just sent configuration over serial.

Once we vendored libllvm/clang+lld into the Agent's own binary, that wall disappeared. The Agent can compile firmware itself, with no external dependencies, in seconds. With the friction gone, the runtime-config drawbacks (heap allocations on 2 KB SRAM, parser overhead, runtime type dispatch, lobes that couldn't run because everything had to be data-driven) became reasons to stop using it. The unified path — one preset, two compiled artefacts, both refreshed automatically — replaces both the runtime-config controller and the manual-flash workflow.

The libllvm vendoring is non-trivial

The Agent statically links a stripped-down libllvm (~50 MB binary cost) configured for AVR + x86_64 targets only. You do not have to do anything to get it: the build fetches the pinned LLVM sources itself, verifies them against a recorded digest and extracts the slice it needs. After that, the resulting static archive is part of the Agent like any other library.


Source generation

FirmwareGenerator::generateFromPreset(FixoPreset) produces a tree of C++ files in a temporary build directory. The shape:

build/
├── Config.hpp             — preset constants
├── PinTraits.hpp          — pin map specialised for the preset's board
├── main.cpp               — AVR-side entry point
├── NodeApi.cpp            — fixo_firmware_* C API for the .so
├── PresetIntrospect.hpp   — name/min/max/default tables
└── (implicit)             — #includes pull in actuator / sensor / lobe / board headers from
                             libfixo's qrc and from custom-type fixo.json sources by relative path

Every preset value lands as a constexpr. The C++ optimiser inlines all template parameters, dead-strips unused branches, and produces the smallest possible binary for the exact configuration. There's no runtime configuration parsing, no heap allocation, no virtual dispatch.


The two compile passes

The same source tree is compiled twice:

Pass Triple Linker Output Consumer
AVR avr-unknown-unknown avr-ld (via lld) .elf (board flash) FirmwareFlasher writes via stk500v2
Native host (x86_64-linux-gnu etc.) lld .so (shared library) FirmwareRuntime dlopens into the Agent process

Lobes with placement: "board-only" are compiled into the AVR pass and stubbed out in the native pass; placement: "native-only" is the opposite; placement: "both" is included in both, and the runtime's per-stanza override decides which copy actually runs at execution time.


Caching with BLAKE3

FixoFirmwareCache content-addresses the compiled artefacts by a BLAKE3 hash of the canonical preset JSON. Workflow:

  1. Compute preset.contentHash() — BLAKE3 of the canonical JSON serialisation. (BLAKE3 is provided by libllvm's bundled C API; falls back to SHA-256 when OC_USE_FEATURE_LLVM is disabled.)
  2. Look up <cache>/<hash>.elf and <cache>/<hash>.so.
  3. If both exist, skip the compile and reuse them. Otherwise, run FixoBuildManager, write the outputs into the cache, and proceed.

This is what makes "click Build" feel instant after the first compile of a given preset.

Determinism matters

Canonical JSON ordering, deterministic clang invocations, and deterministic linker output are all required for the cache hits to be reliable. The build script pins LLVM to a specific commit (7b26069828aa of LLVM 23 main) and the FirmwareGenerator sorts every collection before serialisation.


Build manager and debouncing

FixoBuildManager runs the compile on a background thread. By default it debounces — scheduleBuild() defers for 500 ms so rapid preset edits collapse into one compile. buildNow() bypasses the debounce for explicit user requests.

While compiling, the manager emits:

FirmwareSizeInfo slots into this stream and exposes a live size bar in the preset editor: a coloured progress widget showing how much of the target board's flash the current build occupies.


Flash and load

After a successful compile:

FixoRuntime orchestrates both — the FixoRuntime reference is the per-step API.


What this gets you


See also