rigless
The open runtime of Rigless: a virtual hardware-in-the-loop rig for embedded control software. An unmodified firmware image runs on an emulated MCU, with every peripheral, chip model, and unit of virtual time owned by Rust, and a fork of QEMU executing instructions as a sidecar process, one per MCU on the machine.
rigless run --machine pixracer --until 2ms firmware/stm32f427/bear-bones/target/thumbv7em-none-eabihf/release/bear-bones
That needs rigless-sidecar, the fork of QEMU that rigless-labs/qemu packages on its releases page, on PATH, or --sidecar PATH naming the binary.
What is here
One Apache-2.0 crate, one tree, one version. The library is the runtime and the binary is how a person drives it; a change to either is one merge request against this repository.
src/
├── lib.rs
├── runtime/ what knows no SoC and no processor family, re-exported at the root
│ ├── device.rs
│ ├── mcu.rs
│ ├── time.rs
│ ├── session.rs
│ ├── sidecar.rs
│ └── sidecar/ the wire, the backend, and the one unsafe file
├── soc.rs
├── soc/
│ ├── stm32f427.rs one index per SoC, behind a Cargo feature
│ └── stm32f427/
│ ├── STM32F427.svd the vendor's file, committed unchanged
│ ├── memory_map.rs the map and the corrections, private to the module
│ ├── generated.rs, generated/
│ └── peripherals.rs, peripherals/ the MCU, and one model per layout: gpio, pwr, rcc, spi, tim
├── machine.rs
├── machine/
│ ├── armv7m.rs one per core family
│ ├── pixracer.rs one per board: what it has, and what it is made of
│ └── stm32f427_bench.rs one per SoC, for proving it before a board
├── svd.rs, svd/
└── cli/
├── main.rs
└── signal.rs
Each area is a directory with its index file beside it, and runtime is private and re-exported, so the paths stay flat: rigless::device, not rigless::runtime::device.
| Module | Role |
|---|---|
soc |
The static description of a system on chip: its CPU, its memory map, its peripheral instances, the register layouts the generator emits, and the Bus that decodes an address to a slot and holds that slot's registers. A bus owns no models |
soc::stm32f427 |
The STM32F427, behind the feature of that name: ST's own SVD, the tables generated from it, the memory map and the corrections from RM0090 rev 22 in memory_map, and Stm32f427 itself in peripherals — the whole MCU, holding a model per instance as a field |
device |
The Device trait a model implements against one register layout, and the DeviceContext through which it sees time and its own interrupts |
mcu |
The Mcu trait that joins a bus to the fields holding its models, with read, write and advance_to written once against it; plus Single, one model on a bus of its own, and Bare, a bus with none |
time |
VirtualNanos, the virtual clock every access and every event is stamped with |
sidecar |
The CpuBackend seam to whatever executes instructions, the shared-memory protocol to the QEMU sidecar, the Sidecar backend that runs it, and the SidecarCommand a machine module fills in to say what one target's engine is started as |
machine |
The Board trait a board implements, its Targets borrowed one per MCU, and every machine this build carries |
machine::armv7m |
The Rust side of the sidecar's ARMv7-M machine: one typed field per property that machine declares |
machine::pixracer |
The Pixracer: one STM32F427, its two crystals, and its rail |
machine::stm32f427_bench |
The board that proves the STM32F427 rather than being a board: the whole MCU, a rail, one crystal, and a peer on every serial interface |
coverage |
Which of an MCU's registers a run reached, resolved from the accesses a session already reports |
session |
The run loop that joins each target's MCU to its engine and steps every target of a board together |
svd |
The generator, behind the svd feature: a vendor's CMSIS-SVD file becomes the register tables an SoC is built from |
| Feature | Default | What it adds |
|---|---|---|
cli |
yes | The rigless binary and the argument parsing it needs; implies svd |
svd |
through cli |
rigless::svd, so a build can generate tables as a library |
stm32f427 |
yes | The STM32F427 SoC, its models, and the Pixracer machine |
The bare-metal test firmware lives under firmware/, one directory per SoC. The QEMU sidecar lives in rigless-labs/qemu. The plant interface and the example projects for the commercial engine live in rigless-labs/hil. The API documentation of main is published at rigless-labs.gitlab.io/rigless.
The command line
run executes a firmware image on each MCU of a machine and prints every peripheral access, then for each MCU its stop, the addresses no register covers, and the interrupts it has no line for:
rigless run --machine pixracer [--until 10ms] [--gdb 3333] [--wait-for-debugger] [--sidecar PATH] [--coverage PATH] firmware.elf
--until is virtual time with a unit, ns, us, ms, or s. Without it the run has no deadline and ends when the firmware stops or you interrupt it; virtual time is not wall-clock time, so any deadline you can name passes faster than you can type. The sidecar is rigless-sidecar on PATH unless --sidecar names the binary; --gdb serves GDB on the port while the target runs, and --wait-for-debugger holds it before its first instruction instead.
--coverage PATH writes what the run reached when it ends: every instance a model is bound to with how many of its registers were touched, then every register of one that was not. It is what a bench run is read for, and rigless run --machine stm32f427-bench is that run.
A machine with more than one MCU takes an image for each by name, and a debugger port the same way; every line of its output gains a target=<name> column after its kind. A machine with one MCU takes the image on its own, as above, and names nothing, so its output is what it always was. Either machine also accepts the named form, so --firmware stm32f427=firmware.elf and --gdb stm32f427=3333 drive a one-MCU machine as well.
rigless run --machine <name> --until 10ms --firmware fc=fc.elf --firmware aux=aux.elf --gdb fc=3333
The sidecar is a separate GPL-2.0-or-later program, so it is not in this crate and cannot be. Each release of this crate pairs with the sidecar release of the same number: the fork is tagged <qemu version>-rigless.<this version>, so release 0.1.0 of the crate is built against 11.1.0-rigless.0.1.0 of the sidecar, and publish refuses to publish a crate whose pinned sidecar is anything else. There is no table to keep: the two numbers are one number. Between releases a branch may pin a snapshot, named -rigless.snapshot.<commit>, which is how a change to the fork is tried before it is cut; no published crate may name one. A sidecar speaking another protocol version is refused at the handshake with both named. The exit status is zero when the run reached its deadline and one when the backend failed; Ctrl-C ends the run through that same path, so the sidecar is killed and its shared files removed before the process dies of the signal.
svd generate turns a vendor's SVD into the register tables of an SoC:
rigless svd generate src/soc/stm32f427
The trace, benchmark, and gdb commands follow.
Modeling a peripheral
A model matches on the layout's registers. The register file has already stored the value, applied the access rules, and done the write-modify arithmetic, so the model writes only the side effects:
use ;
use ;
use ;
use VirtualNanos;
// What the generator emits for one register layout.
static REGISTERS: = ;
const SPI1: Peripheral = Peripheral ;
static MEMORY: = ;
// What a person writes.
// A bus describes where things answer; the model goes on the MCU built over it. `Single` is one model
// on a bus of its own, which is what a test of one model wants.
let bus = builder;
let mut soc = new?;
let data_register = SPI1.base + u64from;
let now = VirtualNanos;
soc.write?;
assert_eq!;
# Ok::
Nothing in soc itself knows which processor family the SoC belongs to: addresses and values are 64-bit, a Cpu is a name, an FPU flag, and an interrupt count, and the tables are whatever the vendor's SVD says. A bus access names its width, so a byte store into a 32-bit register touches only its lane, and a lane of a write-only register has nothing to merge with, so the rest lands as zero. Where the vendor's file disagrees with the manual the SoC corrects it on the builder: reset for a reset value and write_modify for a write rule, which replaces the register's and every field's, as a status register whose flags clear on a written zero needs. A model is reached as a field of the MCU that has it, so there is nothing to look up and nothing to downcast; an input the bus does not carry goes in through the MCU's drive_ method for that field, which runs a closure over the model, its registers, and a DeviceContext at the instant given.
Time and events
Every access reaches the bus at an instant of the virtual clock, the same clock the engine's icount runs under, and a model sees that instant and nothing else about time through the DeviceContext every method receives. An event is QEMU's virtual-clock timer in this vocabulary: nothing ticks and nothing polls. The time and soc modules document how events are ordered and how a model raises an interrupt it cannot know the number of.
The sidecar
The two processes share one page of memory, the control block, and take turns writing to it: the sidecar writes a message into its slot and hands over the turn, the runtime reads it, writes its answer into the other slot, and hands the turn back. Seven messages, strict alternation, no rings and no sequence numbers. The page's layout, the hand-over, the wait, the hand-written C header the fork compiles against, and what Sidecar::spawn puts on QEMU's command line are the sidecar module's own documentation.
The machine and the session
A machine module declares a struct whose fields are what is on the board and implements Board: its name, and one Target per MCU, each borrowing the MCU the board has, with the SidecarCommand its engine is started as and the route from that MCU's interrupt numbers to the engine's lines. The board keeps ownership throughout, so between rounds it is whole again and a caller holding it reaches any model on it by field. A Session runs one firmware per target over any engine behind CpuBackend, stepping every target in rounds on the earliest instant any of them is due at. The machine and session modules document the rest.
rigless run is the session as a command. It builds the machine by name, spawns one sidecar per target, runs to --until of virtual time, or until you interrupt it when --until is not given, and prints one line per access, then each target's stop, then its unmodeled and unrouted reports:
read t=20 n=5 pc=0x080001c0 at=0x40023830 w=32 v=0x00100000
write t=28 n=7 pc=0x080001c6 at=0x40023830 w=32 v=0x00100001
stop t=2000000 n=500000 reason=deadline
unmodeled at=0x40023800 pc=0x080001e0
t is the virtual time in nanoseconds and n the instruction count, both as the engine reported them; the line is the access frame of the trace, which arrives with its hash in a later slice.
The debugger
--gdb PORT serves GDB on that port and the target runs from reset, so a debugger attaches whenever it likes and detaches without ending the run. --wait-for-debugger holds every target that serves GDB before its first instruction instead, adding QEMU's -S; the wait for Hello then lasts until the debugger's first continue, which the wait allows since it only fails when the process exits or the run is cancelled. The command says which it did, sidecar serving GDB on :PORT or sidecar waiting for a debugger on :PORT, labelled with the target on a machine that has more than one.
rigless run --machine <name> --gdb 3333 firmware.elf
gdb-multiarch firmware.elf
(gdb) target remote :3333
(gdb) break bear_bones::tim2_update
(gdb) continue
Set a breakpoint before the first continue. Virtual time is not wall-clock time: the idle warp skips every wfi instantly, so a continue with nothing to stop at reaches the deadline and ends the run before you can type again. That is why --until has no default. A breakpoint is the only thing that holds the CPU for you, and a name that does not resolve is not one: GDB prints Function ... not defined. and continues anyway. The firmware's symbols are reset, bear_bones::tim2_update, and bear_bones::default_handler; there is no main.
next over an idle loop never returns. loop { wfi } is one source line, so next has no other line to reach and steps the halted CPU forever, while the warp that would wake it only runs when the engine is free-running to a deadline. stepi returns; next does not. Step into the handler with a breakpoint instead.
A peripheral read the debugger makes itself, through x or print, crosses to the runtime like any other access but with a program counter of zero, since no instruction made it, and it appears in the run's output.
The SoCs and the machines
Everything a firmware runs on: the SoCs, the models of their peripherals, the machines built from them, and the Rust side of the sidecar's machine for each core family.
| What | Where | Now |
|---|---|---|
| An SoC | one module under soc, behind a Cargo feature, on by default |
soc::stm32f427: the STM32F427, RM0090 rev 22 and ES0206 rev 24 |
| Its models | one module each under the SoC's peripherals |
soc::stm32f427::peripherals::gpio, the GPIO port of chapter 8; pwr, the power controller of section 5.5; rcc, the reset and clock controller of chapter 6; spi, the serial peripheral interface of chapter 28; tim, the general-purpose timer of chapter 18 against the tim2 layout |
| A core family | one module under machine, compiled always, naming no SoC |
machine::armv7m, mirroring the sidecar's rigless-armv7m machine |
| A machine | one module under machine declaring a board struct that implements Board |
machine::pixracer, one STM32F427 with its crystals and its rail |
An SoC module holds the vendor's SVD, the register tables rigless svd generate emits from it, the memory map from the reference manual with every correction where the vendor's file disagrees with it, and the MCU those add up to. A model is written against one of the generated layouts; the MCU holds one per instance as a field, and reaching it is a field path:
#
#
#
#
The models
Each model's module opens with two tables. The first is what it covers: Register or section | Behaviour | Source | Test, one row per register and per functional section of its chapter, with the section number it was read from and the test that proves it, and one row per erratum of the errata sheet that the model implements. The second is what it does not: Behaviour | Source | Reason, one row for each clause of the chapter and each applicable erratum the model leaves out, with why — a wire the runtime does not have and its ticket, an analogue effect with no register behaviour, or a simplification named as the model's own. A row belongs in the first table only if a test proves it, so no row there is ever empty.
A targeted peripheral is implemented in full, so nothing inside one is register-only. Where that is departed from, the departure goes in the second table with its reason and its ticket, and the model refuses to pretend: spi is the SPI half of a register block the I2S shares, and in the other mode it stores every write, starts nothing, and raises nothing.
A model's boundary is methods, because the runtime does not yet carry pin levels or the wires between peripherals. What the outside world puts in takes a drive_ name and a level; what a board attaches takes an attach_ or set_ name and is given after the part is built; what nothing consumes yet is answered by a method so that the logic is complete and only the wiring waits.
Gpio takes drive(pin, level) for what the outside world puts on a pin and drive_function(pin, level) for what a peripheral puts on one, and answers level(pin) and function(pin). Tim takes with_clock(hz) from the machine, drive_input, drive_etr, drive_internal_trigger, and halt, and answers output(channel), trigger_output, and dma_requests; the counter itself is read the way the firmware reads it, through CNT. Rcc takes attach_crystals(hse_hz, lse_hz) for the two a board fits and fail_hse for one of them stopping, and answers the whole clock tree — sysclk, hclk, pclk1, pclk2, timer_clock, pll48, rtc_clock, mco1, mco2, plli2s_clock, sai_clock. Pwr takes set_supply(millivolts) for the board's rail, which is also how it moves, and answers scale. Spi takes with_clock(hz), drive_miso(words) for what the device on the other side of the wires puts on them, and drive_nss(level), and answers shifted_out. A test reaches a model as a field of the MCU that has it — mcu.rcc, board.mcu.spi[0] — and drives one of those inputs at an instant through the MCU's drive_ method for that field.
Machines
A machine is a module declaring a board struct that has its parts: Pixracer { mcu: Stm32f427, hse: Crystal, lse: Crystal, rail: Supply }. It implements Board, which hands the session one Target per MCU — the MCU borrowed, the SidecarCommand its engine takes, and the route from that MCU's interrupt numbers to the engine's lines.
A machine composes, it does not choose. Which of a part's own peripherals are modelled is the part's business: Stm32f427::new() gives a part with a model on every instance it has one for, each its own field, and that is the MCU, complete and runnable on its own. What a board adds is what a board adds — the crystals on the oscillator pins, the rail the regulator sees, and the integrated circuits on its buses — put on the part's fields when the board is built. A board that fits no crystal is a board with no crystal, not a part missing a clock tree.
The whole of it is a tree of concrete types, so every step of reaching anything is a field:
Pixracer ── mcu ──▶ Stm32f427 ── rcc ──▶ Rcc
├─ hse ──▶ Crystal ├─ pwr ──▶ Pwr
├─ lse ──▶ Crystal ├─ spi ──▶ [Spi; 6] ── peer ──▶ dyn Peer
└─ rail ─▶ Supply └─ tim2 ──▶ Tim
The bus underneath decodes an address to a slot and holds that slot's registers; it owns no models. Mcu::split is the one place a slot becomes a field, a match each SoC writes once, and it is why nothing after construction looks a peripheral up by name or downcasts to its type.
The command comes from the module of the MCU's core family. armv7m is the first: one typed field per property the sidecar's ARMv7-M machine declares, with NonZero where that machine refuses a zero, plus the core it runs, the instruction rate, and the region the SoC's boot configuration puts where the core fetches its reset vector. An SoC's numbers are therefore typed and named where the engine names them, and nothing is written twice: the Pixracer's interrupt count is its SoC's own.
Pixracer is the first board: one whole STM32F427 in its mcu field, the board's 24 MHz and 32.768 kHz crystals, and its 3.3 V rail, so a firmware can take the part from the internal oscillator it wakes on up to 168 MHz on the over-drive rail, the way the manual sets that out. Its sensors follow when there are chip models to hang on its buses.
Stm32f427Bench is the same MCU with as little fitted as still lets something happen: a rail, one crystal, and a peer on each serial interface that answers a known pattern and records every word it was sent. It is the part run standalone, which is what a conformance run wants — a board's crystal and its sensors are things that run should not depend on, and a part has to be provable before there is a board to put it on.
Adding an SoC
-
Make the directory
src/soc/<name>/and put the vendor's SVD in it, unchanged, with its origin, version, and license insrc/soc/<name>/README.md. -
Generate the tables from the repository root; the directory must hold exactly one
.svdfile, andgenerated.rsandgenerated/land beside it:rigless svd generate src/soc/<name> -
Write the two files beside it as
stm32f427does:src/soc/<name>.rsis the index, declaringgeneratedandperipheralspublicly,memory_mapprivately, and re-exporting the surface a board uses;src/soc/<name>/memory_map.rsholds the map and every correction as a struct update with the manual's section named, and exposesCPU,MEMORY, andbuilder(). -
Add the feature to
Cargo.toml,<name> = []and a place indefault, and the module tosrc/soc.rsunder#[cfg(feature = "<name>")]. -
Add the feature to the two places that name every SoC: the
extern crate self as rigless;gate at the top ofsrc/lib.rs, which is what the generated tables import through, and thefeaturesjob in.gitlab-ci.yml, which builds each feature on its own.
CI regenerates every SoC and fails when the committed tables differ from the result; the tables are never edited by hand.
Adding a model
- Put it under its SoC's
peripherals,src/soc/<name>/peripherals/<peripheral>.rs, written against one generated layout and named by it. - Open it with the two tables above: what it covers, each row naming the section it was read from and the test that proves it, and what it leaves out, each row naming why. Every erratum of the errata sheet that applies to the part goes in one table or the other. The manual is the source of truth and a citation names the section it was read from, never memory.
- Give the inputs the runtime cannot yet deliver an explicit method, and the outputs nobody consumes yet a method as well, so the logic is complete and only the wiring waits.
- Give the MCU a field for it in
src/soc/<name>/peripherals.rs, aFieldvariant, aninventory()row per instance, and an arm insplitandlayout. The compiler will not let you forget one, andMcu::checkcatches a field wired to the wrong slot when the part is built.
Adding a machine
Declare a struct whose fields are what is on the board — its MCU or MCUs, its crystals, its rail, the chips on its buses — and put each on the part's own fields when the board is built. Implement Board: targets hands the session one Target per MCU, borrowing it, with a SidecarCommand from its core family's module and the interrupt route, which is InterruptLine::new wherever the controller is inside the core. Then add it to the table machine::all() returns. A board with two MCUs is two targets; the session runs one engine for each, and targets answers with the same MCUs in the same order every time.
Adding a core family
One module beside armv7m, mirroring the machine the sidecar carries for that family: a typed field per property it declares, an enum of the cores it accepts, a PROPERTIES table of the names and their types, and a command() that renders them. Copy machine_matches_the_fork for it, so the sidecar itself says whether the two agree. Nothing in sidecar changes, and neither does any other family.
The generator
rigless::svd turns a vendor's CMSIS-SVD file into plain Rust a person can implement a peripheral against. For each register layout it emits a Register enum to match on, a constant per field, and the table the runtime serves reads and writes from; generated.rs names every instance at its address. All of it uses the types in soc, and none of it knows which processor family the SoC belongs to.
What a layout file looks like, trimmed:
/// The registers by name; a variant's index is its row in [`REGISTERS`].
/// The fields of `CR1`.
pub static REGISTERS: = ;
Register and peripheral names that are Rust keywords get a trailing underscore, so a register called LOOP is the module loop_ and the variant Loop. Not emitted: descriptions, enumerated values. The provenance line at the top of each file names the source file and its SHA-256.
The library is [svd::generate] for the tables of one SVD as strings, and [svd::write_module] for a whole SoC directory. The fixture under tests/fixture is an SoC directory of its own; after changing the emitter, regenerate it the same way and review the diff:
rigless svd generate tests/fixture
Tests
cargo test
cargo test --test bear_bones_runs --test machine_matches_the_fork
RUSTFLAGS="--cfg loom" cargo test --lib --no-default-features --release
MIRIFLAGS=-Zmiri-disable-isolation cargo +nightly-2026-09-06 miri test --no-default-features
The second line needs rigless-sidecar on PATH; those two tests fail rather than skip without it, naming what to install. bear_bones_runs is the proof of the whole stack: the test firmware's setup writes checked against the register file, its TIM2 handler firing inside the window the manual puts the update in, a twin with the interrupt misrouted that proves the first would notice, and a machine whose two targets run two sidecars at once and repeat exactly. machine_matches_the_fork asks the installed sidecar for its own machine properties and its own core list and holds armv7m against both, so a property renamed on the C side fails here rather than in the field.
The last two run --no-default-features, which is the runtime and the protocol alone, with no SoC and no command line. Everything else runs with no QEMU: the protocol round-trips through a slot with proptest, two threads exchange a million messages in strict alternation, and Link drives a scripted sidecar thread. The loom model covers the atomics of the hand-over under every interleaving; it does not cover the spin-then-sleep wait, which has a second implementation under cfg(loom) that yields instead. Miri runs the ordinary tests with isolation off, all but the ones that spawn a process or map a file. The only unsafe code in the crate is the mmap and the cast to &ControlBlock in runtime/sidecar/mapping.rs.
What it is not
The open runtime boots firmware, runs unit tests in CI, serves GDB, and writes traces. Coupling that firmware to a simulated plant in lockstep, injecting faults, and replaying runs bit-for-bit is the Rigless engine, rigless-hil, which is a separate, commercial binary.
License
Apache-2.0; see LICENSE and NOTICE. By submitting a contribution you license it under the same terms, as section 5 of the license states; no sign-off or paperwork is needed.
Rigless is a trademark of Rigless Labs.