How Fixo firmware is generated and compiled
From a preset to AVR ELF and native .so via embedded LLVM
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
FixoPresetis fed throughFirmwareGeneratorto 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.sogetsdlopen'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:
- Compute
preset.contentHash()— BLAKE3 of the canonical JSON serialisation. (BLAKE3 is provided by libllvm's bundled C API; falls back to SHA-256 whenOC_USE_FEATURE_LLVMis disabled.) - Look up
<cache>/<hash>.elfand<cache>/<hash>.so. - 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 (
7b26069828aaof LLVM 23 main) and theFirmwareGeneratorsorts 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:
buildStarted()buildProgress(percent, message)buildDiagnostic(severity, message)— clang warnings and errorsbuildFinished(bool success, quint32 binarySize)
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:
FirmwareFlasherwrites the AVR ELF via stk500v2 over the configured serial port. Background thread, progress signals, retries on protocol errors.FirmwareRuntime::load(soPath)dlopens the native.so, resolves thefixo_firmware_*symbols, and verifies the ABI version matches.
FixoRuntime orchestrates both — the FixoRuntime reference is the per-step API.
What this gets you
- Zero runtime configuration overhead on the AVR. Every actuator type, pin, sensor, and lobe is a compile-time constant.
- Live error feedback is technically possible (the toolchain is in-process; the type editor's debounced source-change signal is wired for it) — though the integration that maps clang diagnostics to inline editor annotations is still scaffolded, not active.
- Sub-second iteration after the first compile of a preset, thanks to BLAKE3 caching.
- No external toolchain to install — the Agent ships its compiler.
See also
- Why Fixo — design rationale
- FixoRuntime — driving the build / inject / start lifecycle
- Fixo Data Model — the input to
FirmwareGenerator