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
│ ├── 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/ one model per layout: gpio, tim
├── machine.rs
├── machine/
│ ├── armv7m.rs one per core family
│ └── pixracer.rs one per machine
├── 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, the register file, and the bus that serves an access |
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 the models of its GPIO port and its general-purpose timer in peripherals |
device |
The Device trait a model implements against one register layout, and the DeviceContext through which it sees time and its own interrupts |
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 |
A machine and its Targets, one per MCU on it, 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 on the ARMv7-M machine |
session |
The run loop that joins each target's bus to its engine and steps every target of a machine 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] 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.
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 one sidecar release through the protocol header it carries; version 0.0.2 pairs with 11.1.0-rigless.0.8e771748, the snapshot its CI pins, and one speaking another 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, bench, 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 VirtualNanos;
// What the generator emits for one register layout.
static REGISTERS: = ;
const SPI1: Peripheral = Peripheral ;
static MEMORY: = ;
// What a person writes.
let mut soc = builder
.bind
.build?;
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 test reaches a bound model through Soc::model, by instance name and type, and feeds it an input the bus does not carry through Soc::drive, 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 hands the runtime a Machine: its name and one Target per MCU on the board, each with its Soc, the SidecarCommand its engine is started as, and the route from that SoC's interrupt numbers to the engine's lines. 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; soc::stm32f427::peripherals::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 returning a Machine |
machine::pixracer, one STM32F427 on the ARMv7-M machine |
An SoC module holds the vendor's SVD, the register tables rigless svd generate emits from it, and the binding a person writes: the memory map from the reference manual and every correction where the vendor's file disagrees with it. A model is written against one of the SoC's generated layouts and bound to an instance of it; soc() is the SoC with no models bound, and builder() is where a model is bound before build():
#
#
#
#
The models
Each model's module opens with a table of what it covers: one row per register and per functional section of its chapter, the section number, and the test that proves it, and one row per erratum of the errata sheet, modeled or pending with its reason. A targeted peripheral is implemented in full, so nothing inside one is register-only.
A model's boundary is methods, because the runtime does not yet carry pin levels or the wires between peripherals. 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) for what a wire soldered to it would see and function(pin) for the alternate function a pin carries; 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. A test reaches a bound model through Soc::model by name and type, and drives one of those inputs at an instant through Soc::drive.
Machines
A machine is a module returning a Machine: one Target per MCU on the board, each with the SoC's peripherals and the machine's models bound, the SidecarCommand its engine takes, and the route from that SoC's interrupt numbers to the engine's lines.
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::machine() is the first machine, one STM32F427 with port A and TIM2 modeled, TIM2 on the 16 MHz HSI the SoC wakes up on, and every other instance register-only.
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 machine 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,builder(), andsoc(). -
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 coverage table: every register and every functional section of the chapter, each with its section number and the test that proves it, and every erratum of the errata sheet as modeled, pending with its ticket, or not applicable. The manual is the source of truth and a citation names the section it was read from.
- 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.
- Declare it in
src/soc/<name>/peripherals.rsand bind it in a machine with one line,builder().bind("<INSTANCE>", <Model>::default()).
Adding a machine
Return a Machine with a Target for each MCU: its name, its SoC with the models bound, 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.
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.