rigless 0.0.2

Virtual hardware-in-the-loop runtime: run unmodified firmware on an emulated MCU with every peripheral, chip model, and unit of virtual time in Rust; needs the rigless-sidecar package from rigless-labs/qemu
Documentation
# rigless

The open runtime of [Rigless](https://riglesslabs.com): 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.

```text
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](https://gitlab.com/rigless-labs/qemu) packages on its [releases page](https://gitlab.com/rigless-labs/qemu/-/releases), 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.

```text
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 `Target`s, 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](https://gitlab.com/rigless-labs/qemu). The plant interface and the example projects for the commercial engine live in [rigless-labs/hil](https://gitlab.com/rigless-labs/hil). The API documentation of `main` is published at [rigless-labs.gitlab.io/rigless](https://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:

```text
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.

```text
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:

```text
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:

```rust
use rigless::device::{Device, DeviceContext, RegisterFile};
use rigless::soc::{Access, Cpu, Layout, Peripheral, ReadAction, Region, RegisterSpec, Soc, Width, WriteModify};
use rigless::time::VirtualNanos;

// What the generator emits for one register layout.
static REGISTERS: [RegisterSpec; 2] = [
    RegisterSpec { name: "CR1", offset: 0x0, width: Width::W32, reset: 0, access: Access::ReadWrite, read: ReadAction::None, write: WriteModify::None, fields: &[] },
    RegisterSpec { name: "DR", offset: 0xC, width: Width::W32, reset: 0, access: Access::ReadWrite, read: ReadAction::None, write: WriteModify::None, fields: &[] },
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Register { Cr1, Dr }
impl Layout for Register {
    const REGISTERS: &'static [RegisterSpec] = &REGISTERS;
    fn from_index(index: usize) -> Option<Self> { [Self::Cr1, Self::Dr].get(index).copied() }
    fn index(self) -> usize { self as usize }
}
const SPI1: Peripheral = Peripheral { name: "SPI1", base: 0x4001_3000, size: 0x400, interrupts: &[35], registers: &REGISTERS };
static MEMORY: [Region; 1] = [Region::ram("sram", 0x2000_0000, 192 << 10)];

// What a person writes.
#[derive(Default)]
struct Spi { sent: Vec<u8> }
impl Device for Spi {
    type Register = Register;
    fn write(&mut self, register: Register, value: u64, _registers: &mut RegisterFile, _context: &mut DeviceContext<'_>) {
        if register == Register::Dr { self.sent.push(value as u8); }
    }
}

let mut soc = Soc::builder(Cpu { name: "a-core", fpu: true, interrupt_lines: 91 }, &MEMORY, [SPI1])
    .bind("SPI1", Spi::default())
    .build()?;
let data_register = SPI1.base + u64::from(Register::Dr.spec().offset);
let now = VirtualNanos(0);
soc.write(now, data_register, Width::W32, 0x5A)?;
assert_eq!(soc.read(now, data_register, Width::W32)?, 0x5A);
# Ok::<(), Box<dyn std::error::Error>>(())
```

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:

```text
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.

```text
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()`:

```rust
# #[cfg(feature = "stm32f427")]
# fn main() -> Result<(), Box<dyn std::error::Error>> {
use rigless::soc::{Width, stm32f427};
use rigless::time::VirtualNanos;

let mut soc = stm32f427::soc()?;
assert_eq!(soc.cpu(), stm32f427::CPU);
soc.write(VirtualNanos(0), stm32f427::generated::GPIOA.base, Width::W32, 0)?;
# Ok(())
# }
# #[cfg(not(feature = "stm32f427"))]
# fn main() {}
```

### 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

1. Make the directory `src/soc/<name>/` and put the vendor's SVD in it, unchanged, with its origin, version, and license in `src/soc/<name>/README.md`.
2. Generate the tables from the repository root; the directory must hold exactly one `.svd` file, and `generated.rs` and `generated/` land beside it:

   ```text
   rigless svd generate src/soc/<name>
   ```

3. Write the two files beside it as `stm32f427` does: `src/soc/<name>.rs` is the index, declaring `generated` and `peripherals` publicly, `memory_map` privately, and re-exporting the surface a machine uses; `src/soc/<name>/memory_map.rs` holds the map and every correction as a struct update with the manual's section named, and exposes `CPU`, `MEMORY`, `builder()`, and `soc()`.
4. Add the feature to `Cargo.toml`, `<name> = []` and a place in `default`, and the module to `src/soc.rs` under `#[cfg(feature = "<name>")]`.
5. Add the feature to the two places that name every SoC: the `extern crate self as rigless;` gate at the top of `src/lib.rs`, which is what the generated tables import through, and the `features` job 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

1. Put it under its SoC's `peripherals`, `src/soc/<name>/peripherals/<peripheral>.rs`, written against one generated layout and named by it.
2. 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.
3. 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.
4. Declare it in `src/soc/<name>/peripherals.rs` and 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:

```rust,ignore
/// The registers by name; a variant's index is its row in [`REGISTERS`].
pub enum Register {
    /// `CR1` at offset `0x0`, resets to `0x0000_0000`, read-write.
    Cr1 = 0,
    /// `SR` at offset `0x8`, resets to `0x0000_0002`, read-write.
    Sr = 2,
}

/// The fields of `CR1`.
pub mod cr1 {
    /// `SPE`, bit 6.
    pub const SPE: &FieldSpec = &super::REGISTERS[0].fields[6];
}

pub static REGISTERS: [RegisterSpec; 9] = [ /* one register per line */ ];
```

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:

```text
rigless svd generate tests/fixture
```

## Tests

```text
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](https://gitlab.com/rigless-labs/rigless/-/blob/main/LICENSE) and [NOTICE](https://gitlab.com/rigless-labs/rigless/-/blob/main/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.