rigless
A hardware-in-the-loop runtime for embedded firmware, in Rust, and the open half of Rigless. An unmodified firmware image runs on an emulated MCU: every peripheral, every chip on the board and every unit of virtual time is a Rust model, and the crate's own CPU core translates the firmware's instructions to host code and runs them, calling those models for every access.
It's written for any part and any board. An SoC is its vendor's SVD, the register tables generated from it, and the models written against those tables. A board is a struct that has its MCUs, its crystals and the chips on its buses. Each of those is a public trait, and you add a part or a board by implementing one. The parts and boards this build carries are behind Cargo features, so wherever one is named it's an example and not the subject.
Documentation
rigless-labs.gitlab.io/rigless is the API
documentation of main, and the guide is its front page: what you implement, how a peripheral is
modeled, how a machine is composed, and how to add an SoC, a model, a chip, a machine or a core
family. Every seam is a trait with its contract in its own module.
Start there for anything beyond the setup and the two commands below. Building your own device takes you from cargo add rigless to a firmware running on your own part.
Setup
You need Rust 1.98 or later and nothing else: the core is Rust too, and generates host code with
Cranelift. Every check runs on x86-64 Linux. AArch64 should work, but nobody has tried it. On a
host that's neither x86-64 nor AArch64, a run is refused. In a checkout, rust-toolchain.toml pins the version and rustup picks it up.
From crates.io:
From a checkout:
Both build the same core from the same sources.
rigless --version says which you've got. A release, from crates.io or a clean checkout of its tag,
says X.Y.Z. Anything else says X.Y.Z+<commit>, with .dirty on the end when the tree had
changes the commit doesn't hold. A tree that's neither a package nor a git checkout says
X.Y.Z+unknown. A target says the same in its Manifest.
Usage
rigless run executes a firmware image on one MCU of a machine and prints every peripheral access.
Then it prints the stop, each reset with what asked for it (the core, or a model like the watchdog),
and every gap, the places the emulator couldn't stand in for the part: the addresses no register
covers (unmapped), the accesses the bus refused for a reason other than a shut gate (refused),
the registers no model implements (unimplemented) and the interrupts it has no line for
(unrouted). Last comes anything a model told the run about a state the manual forbids (told).
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
unmapped at=0x4002381c pc=0x080001e0
| Flag | What it does |
|---|---|
--machine NAME |
The machine to run on. A build carries the machines machine::all() returns and no others, and the default build carries pixracer and stm32f427-bench. A binary of your own built on rigless::cli::main carries the machines you hand it, with every flag here |
--target MCU |
Which MCU to run, on a machine that has several |
--until TIME |
How much virtual time to run, with a unit: ns, us, ms, s. Without it the run ends when you press Ctrl-C, and its stop line says where it was, with reason=interrupted |
--gdb [HOST:]PORT |
Serve GDB at that address while the target runs. A port alone is on the loopback interface, and a host is an IP address, IPv6 in brackets like [::1]:3333. --gdb 0 takes any free port, and the line the run prints on standard error says which. Add --wait-for-debugger to hold the core before its first instruction until the debugger continues |
--semihosting |
Serve the firmware's semihosting calls. Its console is printed as console t=... n=... text=... lines, and shown in GDB when one's attached. Its clock reads virtual time, and its exit ends the run, with a last line exit code=N, and the process with that status's low byte. Its files are GDB's, on GDB's host, and with no debugger attached a file call fails. Without it, BKPT #0xAB is the breakpoint it is on a part with nothing serving host calls |
--trace [HOST:]PORT |
Stream the core's own trace at that address, its ITM's stimulus ports as the Instrumentation packets an SWO viewer reads. One client at a time, and with none connected the trace goes and the run never waits. The run's output is the same with it as without |
--rtt [HOST:]PORT |
Stream the firmware's RTT terminal, up-channel 0, at that address. The run finds the control block by the firmware's _SEGGER_RTT symbol, or by its ID in RAM when the firmware has no symbols, and drains it every virtual millisecond, writing its read offset back as a probe does. That's what makes room for more, so a firmware that waits for room runs as it would with a probe attached. One client at a time, and with none connected what's drained goes |
--coverage PATH |
Write what the run reached: every instance a model is bound to, every register of one that wasn't touched, and every instance it reached that has no model |
--outputs PERIOD |
Print each of the board's outputs when it changes, read every period of virtual time. The period has to be above zero |
--image PATH[@ADDRESS] |
Place another image in memory before the core's first instruction, such as a bootloader in front of the firmware. An ELF file goes where it stores its sections, and any other file whole at the address after its @. The firmware itself takes an @ADDRESS the same way. Two images that overlap, or one outside the memory map, are refused before anything runs |
--boot ADDRESS |
Boot from ADDRESS instead of where the core boots on its own, such as an application linked to sit behind a bootloader, run without one. An image has to place something there |
--host WIRE=PORT |
Listen for a host on that wire at that port instead of the one the board names. 0 takes any free port |
--link |
Be an engine's target on stdin and stdout, a step at a time in version 5 of the step protocol, and print nothing else there. The engine can have it write a snapshot of itself at any stop, and arm faults that fire at an exact point inside a step. It takes a machine with an edge, which here is pixracer, and it doesn't go with --until, --outputs, --coverage, --host, --semihosting, --trace or --rtt. An instant the run can't reach, behind where it stands or past where the virtual clock runs, is refused |
--seed SEED |
With --link, key every random draw the board makes by SEED, 0 when you leave it out: the noise a condition puts on a chip, and which words a garbled serial line errs on. The same seed, runtime and firmware under the same steps is the same run on any host |
--restore PATH |
With --link, start from the snapshot an engine had a target write to PATH instead of from reset. It has to be the same machine, target, firmware and runtime, and the run carries on from the stop it was taken at, under its seed, as the same run as one that never stopped. The core loads a snapshot as it stands, so only restore one you or your engine wrote |
Virtual time isn't wall-clock time. A firmware waiting for an interrupt jumps straight to its next event, so a deadline can pass in a blink, and one that keeps the core busy runs nearer the clock on the wall. A wire the board says a host may sit on gets a TCP socket on the loopback interface, and the run says so before it starts. Whatever connects there is the far end of that serial port.
rigless svd generate turns a vendor's CMSIS-SVD file into the register tables of an SoC. The
directory must hold exactly one .svd file, and the tables land beside it:
The trace and benchmark commands come later.
Building and testing
.gitlab-ci.yml is the source of truth. Every job's script is a command you can run yourself,
so this page doesn't copy them and can't tell you something the pipeline has stopped doing. Your
merge request runs them anyway.
The pipeline runs the suite under cargo nextest, and cargo test runs it just the same, since a
process runs as many cores as it likes. The tests that boot a firmware need one built under
firmware/, and they fail rather than skip without it, saying so.
Contributing
CONTRIBUTING.md covers how to get set up, how we work, and what a change is expected to carry. Models come with a conformance check, and determinism is the product: no wall-clock time, no unseeded randomness, and no iteration over unordered collections in the emulation path.
What it is not
The open runtime boots firmware, runs unit tests in CI, serves GDB, prints every peripheral access,
and carries the seam a plant is stepped against. Stepping a plant in lockstep, injecting faults and
replaying runs bit-for-bit is the Rigless engine, rigless-hil, a separate, closed, commercial
binary. The Plant trait it steps is in rigless-plant, in
rigless-labs/hil, under the Rigless License. rigless-plant
is never on crates.io, and it links no runtime. Nor is this an emulator of one board. A
part and a board are each a module behind a feature of its own, and the runtime's generic modules
know neither by name.
License
MIT OR Apache-2.0, at your option, for everything but the register tables rigless svd generate wrote under src/soc/, which carry the copyright and license of the vendor's SVD they
came from. The STM32F427's are Apache-2.0, so the package says (MIT OR Apache-2.0) AND Apache-2.0. The core is this crate's own. Its dependencies are permissive: Cranelift's crates and
rustc_apfloat under Apache-2.0 with the LLVM exception, and the rest under MIT, Apache-2.0 or
both, which deny.toml holds them to. See
LICENSE-MIT,
LICENSE-APACHE and
NOTICE. Unless you say otherwise, a
contribution you submit for inclusion here, as the Apache license defines one, is licensed under
both MIT and Apache-2.0, with no additional terms. You don't need a sign-off or any paperwork.
Rigless is a trademark of Rigless Labs.