Expand description
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. The traits each of those implements are this crate’s public surface, documented in their modules and walked in the sections below. The STM32F427 and the Pixracer are the first part and the first board it carries, each behind a feature of its own, so wherever a name of either appears below it’s an example and not the subject.
rigless run --machine pixracer --until 2ms firmware/stm32f427/bear-bones/target/thumbv7em-none-eabihf/release/bear-bonesThe core is the crate’s own, in Rust: a decoder and translator per CPU family that lowers blocks of the firmware’s instructions to host code with Cranelift. What to install and how to build it is in the repository’s README, which is where setup is written down once rather than here as well.
§What is here
One crate, one tree, one version, under MIT or Apache-2.0 at your option. The register tables rigless svd generate wrote under src/soc/ are the exception, and each carries the license of the vendor file it came from. The library is the runtime and the binary is how you drive it. A change to either is one merge request against this repository.
src/
├── lib.rs
├── lib.md this page: the crate's documentation, which `lib.rs` includes
├── runtime.rs
├── runtime/ what knows no SoC and no processor family, re-exported at the root
│ ├── device.rs
│ ├── mcu.rs
│ ├── time.rs
│ ├── session.rs
│ ├── snapshot.rs
│ ├── image.rs the images a run places, and where the core boots
│ ├── elf.rs
│ ├── symbols.rs the firmware's own names, which a fault can name an instruction by
│ ├── coverage.rs
│ ├── emulator.rs
│ ├── emulator/ the runner, the code generator and memory, which every family shares
│ ├── link.rs
│ ├── link/ the target's side of the link
│ ├── version.rs what the runtime calls itself
│ └── version/ the rule `build.rs` writes it by
├── 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 peripheral: adc, dbg, dma, flash, gpio, i2c, iwdg, otg, pwr, rcc, rtc, sdio, spi, tim, usart
│ ├── stm32h743.rs
│ └── stm32h743/ the vendor's file, the generated tables, the map and its corrections, and the MCU with one model per peripheral
├── cpu.rs
├── cpu/ one module per core family, the whole core: decode, translation, exceptions
├── chip.rs
├── chip/ the chips a board puts on its buses, and the seam each kind attaches through
├── edge.rs the board's edge: what crosses it, and the `Edge` trait a board with a world implements
├── machine.rs
├── machine/
│ ├── armv7m.rs one per core family
│ ├── flight.rs what every flight controller board senses and drives, so one plant flies any of them
│ ├── matekh743.rs, pixracer.rs one per board, behind a feature of its own: 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.rs the `rigless` command, over any table of machines
└── main.rs the `rigless` binary: the command over `machine::all()`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. That’s the whole MCU, holding a model per instance as a field |
soc::stm32h743 | The STM32H743, behind the feature of that name: ST’s own SVD, the tables generated from it, the memory map and the corrections from RM0433 rev 9 in memory_map, and Stm32h743 itself in peripherals. That’s the whole MCU, holding a model per instance as a field. A firmware’s access to undefined space inside a peripheral reads zero and drops a write, as RM0433 says the part does |
cpu | One module per core family, each the whole core: its decoder and translator, its exception model and interrupt controller, the address space the processor answers itself, its floating-point unit and MPU, how it boots and what a debugger sees, and the blocks the architecture fixes at an address, each a Device like any peripheral |
chip | The chips a board puts on a part’s buses, each written against its own datasheet and naming no SoC, the seam each kind attaches through: spi::Peer, i2c::Peer, uart::Peer and sd::Card, and Place, where a controller holds a chip, a board silences it, and a trigger counts its transactions and fires a flip or a drop. uart::Errors is how often words arrive garbled on a line, which a board sets and a serial port’s model asks about each word |
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 and says what the part does at a reset and a stop, and Operate, with read, write and advance_to written once against it for every Mcu. Single is one model on a bus of its own, and Bare is a bus with none |
time | VirtualNanos, the virtual clock every access and every event is stamped with |
emulator | The seam between the emulator and the models: Access and Stop as the emulator reports them, the Host it lends the models, the Driver a process lends the run, the CoreSpec a machine module fills in to say what one target’s core is, the Processor a core family implements and the CoreBus it’s lent, and run, which runs the target’s core |
link | The step protocol between an engine and a target it spawned, version 5: the target says what it carries in Manifest, its conditions among it, then the two meet once per plant step on stdin and stdout, a fault the engine arms fires at an exact point the target counts to, and at any stop the engine can have the target write a snapshot of itself. serve is the target’s side, for any board with an edge |
snapshot | A target’s whole state at a stop, as a file a new process starts from: the core’s own state, every writable region of memory, and every model, chip and board through the Store trait each implements. take writes one and Snapshot reads one back |
machine | The Board trait a board implements, its Targets borrowed one per MCU, its edge, and every machine this build carries |
edge | Edge, the trait a board that drives or senses something implements with its own outputs and signals types, the two halves of the seam a plant is stepped against, and its own conditions, what can be wrong with the board itself. Coupled is the same edge as bytes, Condition, Sensed and Reading are what a stuck, biased or noisy chip reads through, draw and normal are every random draw a board makes, keyed by the run’s seed, and Pulse is what a timer’s pin carries |
machine::armv7m | The ARMv7-M machine: one typed field per property of a target’s, the Config the family makes its core from, and the vector table a run boots from |
machine::pixracer | The Pixracer, behind the feature of that name: one STM32F427, its two crystals, its rail, the chips on its buses, and the six outputs and the mountings its description names |
machine::matekh743 | The Matek H743, behind the feature of that name: one STM32H743, its crystal and rail, the two inertial units, the barometer and the external compass on its buses, the GPS receiver, the battery’s senses, and the thirteen outputs and the mountings its description names |
machine::flight | What every flight controller board senses of its vehicle and drives on it: the Signals both boards take, AT_REST, and the Actuators their outputs are, through Actuate, so one plant flies either |
machine::stm32f427_bench | The board that proves the STM32F427 rather than being a board: the whole MCU, a rail, one crystal, a peer on every serial interface, serial port and the first I2C interface, known voltages on the converters’ pins, and a memory card |
coverage | Which of an MCU’s registers a run reached, resolved from the accesses a session already reports |
session | One firmware on one target: what the emulator calls at every access and every stop, and what the run records |
image | The images a run places in memory, where each section goes, where the core boots from, and every refusal before the core runs |
elf | The smallest ELF reader the runtime needs: the symbols, and each section’s bytes with where the file stores them |
symbols | The firmware’s own names for its code and data, read from its ELF symbol table, which a trigger names an instruction by |
version | What this runtime calls itself: its version alone for an official release, and X.Y.Z+<commit>, X.Y.Z+<commit>.dirty or X.Y.Z+unknown for any other build, so a result from one can’t pass for a release |
svd | The generator, behind the svd feature: a vendor’s CMSIS-SVD file becomes the register tables an SoC is built from |
cli | The rigless command, behind the cli feature. cli::main runs it over a table of machines, so a binary of your own gives your boards everything rigless run has |
| Feature | Default | What it adds |
|---|---|---|
cli | yes | The rigless binary, cli::main for a binary of your own, and the argument parsing both need, with the kernels rigless-rtos walks for its debugger. It turns on svd too |
svd | through cli | rigless::svd, so a build can generate tables as a library |
stm32f427 | through pixracer | The STM32F427 SoC, its models, and the stm32f427-bench machine |
stm32h743 | no | The STM32H743 SoC, its tables, its memory map, its corrections and its models |
pixracer | yes | The pixracer machine, and the STM32F427 it’s built on |
matekh743 | no | The matekh743 machine, and the STM32H743 it’s built on |
The bare-metal test firmware lives under firmware/, one directory per SoC. The plant interface and a reference plant for the commercial engine live in rigless-labs/hil, under the Rigless License. The API documentation of main is published at rigless-labs.gitlab.io/rigless.
§What you implement
Every seam is a public trait with the contract in its documentation. No model behind one is looked up by name, so what you write is a type the compiler checks against the runtime. The one downcast is a chip’s: a controller holds its chip as the seam’s trait object in a chip::Place, and its chip_mut hands the board back the type it hung, through Any.
| You are writing | Implement | Walked in |
|---|---|---|
| A peripheral of an SoC | device::Device against a soc::Layout the generator emits. The DeviceContext it’s handed is its time, its events, its interrupt lines, its request lines, its clocks, the accesses it asks the runtime to make, a reset of the whole part, and what it tells the run about its own instance | Modeling a peripheral, Adding a model |
| A block the processor architecture fixes | the same Device, beside its core family under cpu | Adding a core family |
| An SoC | mcu::Mcu: a struct holding one model per instance as a field, bus, bus_mut and split, over a soc::Bus built from the generated tables with the manual’s corrections | Adding an SoC |
| A chip on a bus | chip::spi::Peer, chip::i2c::Peer, chip::uart::Peer, chip::parallel::Peer or chip::sd::Card, and setters in its datasheet’s terms where the chip measures something | A chip on a line of your own, Adding a chip |
| A memory-mapped chip on a part you didn’t write | chip::parallel::Peer in a soc::Window the part defines, attached with mcu::Wired. Add memory with soc::Bus::add_region | Extending the STM32F427 |
| A host on a wire | chip::uart::Peer too: chip::uart::TcpPeer is one over a socket, and a board names its wires through machine::HostWire | The machine and the session |
| A board | machine::Board: name, targets, target_names and mcu, then hosts, attach_host and host_mut where a host may sit on one of its wires, and edge where it has one. edge::Edge with the board’s own Outputs, Signals and Conditions where it drives or senses something | The board, The edge, Adding a machine |
| What a snapshot keeps | snapshot::Store, on a board and on every chip, which Board and each chip seam require. Saving the MCU is one call, since every part and model this crate ships implements it already. store! implements it for your own part, model, chip or board, naming each field once as a value, a part or what construction built. impl Store for MyBoard {} says the board can’t be kept, and its runs start from reset | the snapshot module |
| A core family | emulator::Processor, a function that makes one from the family’s typed configuration, and a machine module whose CoreSpec carries that function, as cpu::armv7m and machine::armv7m do | Adding a core family |
| What a run does at every access, and at each instant it was asked to reach | emulator::Driver. rigless run prints, the engine’s target answers the link, and a test records | the emulator module |
| What a plant reads and writes | nothing. The board’s own Edge::Outputs and Edge::Signals are the two halves of the seam, and the board is what turns its pins and chips into them | The machine and the session |
A board written outside this crate runs from a binary of your own. cli::main is the whole rigless command over a table of machines you hand it, --link and --gdb included. emulator::run_printed prints what rigless run prints, link::serve runs a target for an engine, and emulator::run with a Driver of your own does anything else. rigless run itself carries the machines in the table machine::all() returns and no others, which is what keeps the runtime free of any registry. Building your own device walks the whole path, from cargo add to a firmware running on your own part.
§The command line
run executes a firmware image on one MCU of a machine. It prints every peripheral access, then its stop, any resets with what caused each, the addresses no register covers, the accesses the bus refused for a reason other than a shut gate, the registers no model implements, the interrupts it has no line for, and what a model told the run about its own instance:
rigless run --machine pixracer [--target MCU] [--until 10ms] [--gdb 3333] [--wait-for-debugger] [--coverage PATH] [--outputs 20ms] [--quiet] [--link [--seed N | --restore PATH]] [--host WIRE=PORT] [--image PATH[@ADDRESS]]... [--boot ADDRESS] firmware.elf
serial1 on :5760--until is virtual time with a unit, ns, us, ms, or s. Without it the run has no deadline and ends when you press Ctrl-C. 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. --gdb serves GDB at that address, the loopback interface for a port alone, while the target runs, and --wait-for-debugger holds it before its first instruction instead.
The firmware and every --image go into memory before the core’s first instruction, the way a probe writes them. An ELF file goes where it stores its sections: each section’s bytes at the load address of the segment that holds it, as objcopy writes them into a binary. Nothing it only zeroes is written, and neither are bytes outside every section, like the file’s own headers a linker can map in front of the first one. Any other file goes whole at the address after its @, like app.bin@0x08004000. The core boots where its machine says it does on its own, and --boot ADDRESS boots it from somewhere else. A fault names an instruction by the firmware’s symbols, not an --image’s.
That gives you two ways to run an application linked to sit behind a bootloader. Put the bootloader in front of it, and the run boots the way the board does, bootloader first:
rigless run --machine pixracer --image bootloader.elf app.elfOr run the application alone, from its own vector table:
rigless run --machine pixracer --boot 0x08004000 app.elfA run is refused before anything runs when an ELF file is built for another machine than the core runs or keeps the other byte order from the part’s, two images overlap, a segment is outside the memory map, the core can’t boot from the address, or nothing is placed there. The refusal names what it hit, so you don’t get a core running from empty flash.
--quiet drops the line per access and prints the rest: the console, the outputs, the stop and the gaps the run ends with. A large firmware can make millions of accesses a second, and that’s too many lines to read. The coverage is the same either way.
--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 wasn’t. After those it names every instance the run reached with no model behind it, and every address it reached outside every instance. It’s what a bench run is read for, and rigless run --machine stm32f427-bench is that run.
A wire the board says a host may sit on gets a TCP socket on the loopback interface at the port the board names, and the run says so before it starts, serial1 on :5760 above. Whatever connects there is the far end of that serial port: bytes it writes reach the firmware as frames at the port’s baud, from the stop the runtime noticed them at, and bytes the firmware sends reach it as each frame ends. Nothing waits for it, and a run with no one connected is a run whose wire hears nothing. --host WIRE=PORT moves that socket to another port, and a port of 0 takes any free one and says which.
--outputs PERIOD reads the board’s outputs every period of virtual time and prints each field that changed since the last read, in the unit the board declares it in. The period has to be above zero. The first read prints every field. Here’s how the Pixracer’s first read starts, with one pin driven:
output t=20000000 PWM1.width=1000000
output t=20000000 PWM1.period=20000000
output t=20000000 PWM1.command=0
output t=20000000 PWM2.width=0A run is one MCU. A machine with more than one names the one to run with --target. A machine with one needs no name. Every other MCU of such a machine is another run, in this process or another, since each run’s core is a thread of its own.
--link makes the run an engine’s target. The engine that spawned it speaks the link module’s frames on its stdin and stdout, and nothing else is printed there. The target answers the engine’s Open with Manifest, which names the board’s inputs with their units and widths, its outputs, its ports, and its conditions, each resting at zero. Then once per plant step the engine sends Advance, asking it to reach an instant with some inputs and conditions changed and some bytes on its ports. The target answers Reached with every output, what the firmware sent, any resets, and each gap and each thing a model told, the first time the run meets it. Shutdown ends the run, and the target answers it with a Report. --link doesn’t take --until, --outputs, --coverage, --host, --semihosting, --trace or --rtt. The target refuses an Advance for an instant behind where the run stands or past where the virtual clock runs, since it could never answer it. --seed N keys every random draw the board makes, 0 when it’s left out. The target hands the board the seed and the Advance’s index once per Advance, after the conditions and the inputs, so the same seed under the same steps is the same run. The engine picks the instants and reads the outputs, and the board’s ports cross the link and open no socket. The engine is the commercial rigless-hil, and rigless-hil run is the command that spawns this one.
Between a Reached and the next Advance the engine can send TakeSnapshot with a path. The target writes a snapshot of itself there, the core’s state, its memory, every model and chip, and what the link holds, and answers Snapshotted with its SHA-256. When it can’t, at a stop where a reset the core or a model asked for hasn’t happened yet or with a chip that can’t be kept, it answers Refused with why, and the run goes on as it stood. --restore PATH starts a target from one instead of from reset. It has to be the same machine, target, images, boot address and runtime, and it names the snapshot in its Manifest. The engine’s first Advance then carries on from the stop the snapshot was taken at, under the seed it was taken with, and from there it’s 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. That’s what lets cases that start the same way run the start once. The file is the target’s part, and an engine appends its own after it: rigless snapshot, a u16 format, then each part as a one-byte name length, the name, a u64 length, the part’s SHA-256 and its bytes.
An Advance can also arm faults, each with an id the engine picks, a trigger, and what fires. A trigger is a chain of links, each counting from where the one before it fired: After a length of time, the Nth Access to a register the Manifest lists, the Nth Transaction with a chip it lists, the Nth arrival at an instruction to Execute it, by its address or the firmware’s own symbol for it, or the Nth instruction that makes a Write into a range of RAM. An access link counts the core’s own reads or writes, optionally of a value under a mask, and fires right after the one before its count, so the one it counts to is the first to see the fault. A transaction link does the same with the transactions a chip’s place counts: a select to its deselect, a card’s command, or a word on a serial line. An After instant becomes a pause of the run’s, between two instructions, which the models are never told of, and the step still ends where the engine asked. An instruction or a write is watched by the core only while a link counts it, and the core runs on through a hit short of the count as it would without the watch, so neither changes the run. An interrupt taken at a watched instruction before it runs brings the core back to it, and that’s another arrival. A push of several registers into a watched range is one write. Write links of different faults watch one range between them, since the core reports a store to one range only. An instruction outside the part’s memory, and a write range outside its RAM, are refused. What fires is the board’s conditions, set right there, or a one-shot fault: the next word a chip answers flipped under a mask, its next transaction dropped, an interrupt line raised for an instant, a reset from the part’s pin, or the core’s next access to a register failed, which it takes as a bus error. Reached lists every fault that fired since the last answer, with its instant, its instruction count and the instruction the core was at, for one fired right after an access or on arriving at an instruction. A fault that never fires stays armed, and a snapshot keeps it with how far it has counted.
The crate is MIT or Apache-2.0, at your option, the core included. Cranelift and rustc_apfloat, which the core builds on, are Apache-2.0 with the LLVM exception. The register tables rigless svd generate wrote under src/soc/ are the exception, and carry their vendor’s copyright and license lines in their headers.
svd generate turns a vendor’s SVD into the register tables of an SoC:
rigless svd generate src/soc/stm32f427§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 rigless::device::{Device, DeviceContext, RegisterFile};
use rigless::mcu::{Mcu, Operate, Single};
use rigless::soc::{Access, Bus, Cpu, Endian, Layout, Peripheral, ReadAction, Region, RegisterSpec, Width, WriteModify};
use rigless::time::VirtualNanos;
// What the generator emits for one register layout.
static REGISTERS: [RegisterSpec; 2] = [
RegisterSpec { name: "CTRL", offset: 0x0, width: Width::W32, reset: 0, access: Access::ReadWrite, read: ReadAction::None, write: WriteModify::None, fields: &[] },
RegisterSpec { name: "DATA", offset: 0x4, width: Width::W32, reset: 0, access: Access::ReadWrite, read: ReadAction::None, write: WriteModify::None, fields: &[] },
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Register { Ctrl, Data }
impl Layout for Register {
const REGISTERS: &'static [RegisterSpec] = ®ISTERS;
fn from_index(index: usize) -> Option<Self> { [Self::Ctrl, Self::Data].get(index).copied() }
fn index(self) -> usize { self as usize }
}
const SERIAL: Peripheral = Peripheral { name: "SERIAL", base: 0x4000_0000, size: 0x100, interrupts: &[5], registers: ®ISTERS };
static MEMORY: [Region; 1] = [Region::ram("sram", 0x2000_0000, 64 << 10)];
// What a person writes.
#[derive(Default)]
struct Serial { sent: Vec<u8> }
impl Device for Serial {
type Register = Register;
fn write(&mut self, register: Register, value: u64, _registers: &mut RegisterFile, _context: &mut DeviceContext<'_>) {
if register == Register::Data { self.sent.push(value as u8); }
}
}
// A bus describes where things answer, and 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 = Bus::builder(Cpu::new("a-core", true, 16, Endian::Little), &MEMORY, [SERIAL]);
let mut soc = Single::new(bus, "SERIAL", Serial::default())?;
let data_register = SERIAL.base + u64::from(Register::Data.spec().offset);
let now = VirtualNanos(0);
soc.write(now, data_register, Width::W32, 0x5A)?;
assert_eq!(soc.read(now, data_register, Width::W32)?, 0x5A);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, an interrupt count and a byte order, 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, write_modify for a write rule, field for a field’s shape, and declare for a field the file leaves out. A write rule 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’s nothing to look up and nothing to downcast. An input the bus doesn’t 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. Your own MCU writes one with Bus::drive, which hands over the registers and the context for a slot, as an access does.
§Time and events
Every access reaches the bus at an instant of the virtual clock, which the core’s instruction count runs, and a model sees that instant and nothing else about time through the DeviceContext every method receives. An event is a timer on that clock: nothing ticks and nothing polls. The time and soc modules document how events are ordered and how a model raises an interrupt it can’t know the number of.
§The core
The core is the crate’s own. A family’s module under cpu decodes the firmware’s instructions and translates a block of them at a time to host code with Cranelift, quickly the first time and again with better code once the block turns out to run often. What knows no instruction set, the page table the translated code reads memory through, the cache of blocks and the code generator, is private to the crate. A family meets the runner through emulator::Processor and the emulator::CoreBus it’s lent, whose documentation is the contract. emulator::run makes the target’s processor with the constructor its CoreSpec carries, and runs it on a thread of its own. A processor is a plain value, so a process runs as many as it likes, one after another or at once. The run maps the target’s memory, places the images and boots, then runs the core in stretches to the next instant the models asked for. A load or store to plain memory runs inline in the translated code. Every other access, a peripheral’s or one the memory map doesn’t answer, is a call into Session::access at its instant, with its instruction count and its program counter, before the next instruction, and the core carries on afterwards by itself when the run has nothing to do between. Every instruction counts exactly, so an access lands at the same instruction count on every host. A watched instruction is a breakpoint the core stops before, a watched range has its pages sent through the same calls, and a debugger is served between stretches. What the processor answers itself, its interrupt controller, its system control space and how it boots, is the family’s module under cpu. The core maps memory a page at a time, so every region and alias starts and ends on one of its pages, and a run whose map doesn’t is refused before the first instruction. A model’s write to memory the guest can’t write is refused as the guest’s would be. A store to a region a model programs, flash behind its flash interface, goes to that model instead. The core’s half of a snapshot is the processor’s architectural state, with nothing of the process or of how the run was split into stretches in it, so a snapshot is the same bytes from every process and every host, and loads into a new core before its first instruction. A saved state the core couldn’t have been in, with an active exception its table doesn’t have say, is refused. Nothing crosses a process boundary.
§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 CoreSpec that describes its core, and mcu, the same MCU reached without building anything, which is how the session reaches it on every access. The board keeps ownership throughout, so once the run has ended a caller holding it reaches any model on it by field. A Session is one firmware on one of those targets: the emulator calls Session::access for every peripheral access and Session::stop at every instant the models asked for, and the answer to each names the next. The machine and session modules document the rest.
A board may also have wires a host sits on. Board::hosts names each with the port rigless run listens on for it, and Board::attach_host hangs whatever the caller puts there on the serial port the board’s description wires it to: a chip::uart::TcpPeer over a socket from the command line, a test’s stand-in from a test, or bytes at chosen instants from a test framework. A serial port’s far end is a chip::uart::Peer whichever it is, the port keeps its own time against its baud whatever is behind it, and the session asks every model to look outside at every stop of its target, so a byte a host wrote lands at the stop it was noticed at and wakes a firmware asleep on the line. That is the whole of the runtime’s part in it: what feeds a wire decides whether a run is reproducible, and the runtime makes no mode of it.
The board’s other edge faces the world. A board that drives or senses something implements Edge with two types of its own. Outputs is what the firmware drives, which Edge::drive reads off the pins, and Signals is what the world does to the board’s transducers, which Edge::sense hands to each chip through the chip’s own setters on the chip’s own axes. A flight controller’s outputs are pulses off its timer pins and what each commands on its speed controllers’ curve. A pump controller’s could be a valve’s position and a relay. Edge::OUTPUT_FIELDS and Edge::FIELDS say how each crosses the link, field by field. Conditions is the third type, what can be wrong with the board itself: a chip that’s stopped answering, one that senses a stuck, biased or noisy value while the chip beside it senses the truth, or a serial line that garbles what arrives. Edge::condition takes it and it takes effect at once, Edge::CONDITIONS says how it crosses, and every condition rests at zero, the board as built. A silence goes on the chip’s chip::Place through its controller, so the controller takes the path it takes with nothing there, and a wrong reading is sense handing the chip what an edge::Sensed answers. The chip never learns of either. Anything random comes from edge::draw, a function of the run’s seed, the name of what it’s for and an index. No generator holds state, so setting one condition never moves another’s noise. Edge::draw is where a board draws for the step about to run, and a board with nothing random leaves it out. Whatever steps a plant, in lockstep or otherwise, holds the board as its own type, runs the session to an instant, reads the one and writes the other, and runs on. The runtime itself steps no plant and names none. The session and rigless run reach a board’s edge only as bytes, through edge::Coupled. Board::edge answers it, and a board that drives and senses nothing has no edge.
rigless run is the session as a command. It builds the machine by name, starts the emulator on one of its MCUs, runs to --until of virtual time, or until you press Ctrl-C when --until isn’t given, and prints one line per access, then the stop, the resets, and the unmapped, refused, unimplemented, unrouted and told 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
unmapped at=0x4002381c pc=0x080001e0t is the virtual time in nanoseconds and n the instruction count, both as the core reported them. pc is the instruction that made the access, or debugger for a debugger’s. The reports come from the session, and your own binary or test gets them the same way. Session::unmapped is a list of Unmapped entries that print as the line above, in hex, so an address no model claims reads as an address. A reset prints as reset t=<ns> n=<count> cause=core when the core asked for it, and names the instance when a model did, like cause=IWDG for the watchdog. A told line names the instance, the register, the instant and the manual’s reason, once for each.
Ctrl-C ends a run with no --until where it stands, between two instructions, and its stop line says where that is: stop t=<ns> n=<count> reason=interrupted. Run it again with --until <ns>ns and it prints the same accesses and stops after the same instruction, so you can replay the run you cut. Ctrl-C ends a run that’s waiting for a debugger, or whose core a debugger holds, the same way, and the debugger hears the connection close. A second Ctrl-C ends the process at once.
§The debugger
--gdb [HOST:]PORT serves GDB at that address and the target runs from reset, so a debugger attaches whenever it likes and detaches without ending the run. A port alone is on the loopback interface, and a host is an IP address, IPv6 in brackets like [::1]:3333. --gdb 0.0.0.0:3333 serves every interface, and then anyone who can reach the machine can read and write the emulated core, so an SSH tunnel to the loopback is usually the better way in from another machine. --wait-for-debugger holds the core before its first instruction instead, until a debugger continues, steps, detaches or kills it. A connection that closes without a word, like a port probe’s, leaves the core as it found it. The command says which it did and where once it’s listening there, serving GDB on 127.0.0.1:3333 or waiting for a debugger on 127.0.0.1:3333, so --gdb 0 tells you the port the system picked.
rigless run --machine <name> --gdb 3333 firmware.elf
gdb-multiarch firmware.elf
(gdb) target remote :3333
(gdb) break bear_bones::tim2_update
(gdb) continueSet a breakpoint before the first continue. Virtual time isn’t wall-clock time: the idle warp skips every idle wait 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 doesn’t resolve isn’t one: GDB prints Function ... not defined. and continues anyway. The bear-bones firmware’s symbols are reset, bear_bones::tim2_update, and bear_bones::default_handler. There’s no main.
§Connecting and detaching
The runtime serves the debugger itself, on the core’s thread between stretches, so virtual time stands still while the debugger holds the core, and a debugger that attaches finds the core held where it was. It serves one debugger at a time, so one that connects while another is attached is refused and closed at once. Once that one goes, the next can connect straight away. detach lets the run go on. Under target remote that closes the connection, and under target extended-remote GDB stays connected, as it expects to, and attach 1 holds the core again wherever it ran to. Until then the server answers nothing that asks about the core, so monitor reset asks you to attach first, while monitor help, monitor halt, monitor peek on and monitor peek off still answer. disconnect leaves the core held where it was for the next target remote to find, with your breakpoints and watchpoints taken out and its halting debug still on, as a probe that lost its host leaves it. A GDB that crashes leaves the core as it was too: held if it was stopped, and running on with its halting debug still on if it was running, so a BKPT it reaches then holds it for the next debugger. kill under target remote lets the run go on. Under target extended-remote, GDB’s own run and starti reset the machine as monitor reset does and read everything again, and kill leaves the core held for the next run or attach, or lets it run on once GDB detaches or goes.
§Breakpoints, watchpoints and stepping
Breakpoints, watchpoints on writes, reads and both (watch, rwatch and awatch), stepi, finish, info registers and load work as they would against a probe. The registers are the family’s, its system registers among them and its floating-point registers when the core has them, and a step runs one instruction with interrupts held off, so one that comes due while you step is taken only once you continue. GDB’s own record full works over the server, so reverse-stepi and reverse-continue go back through what GDB recorded.
next over an idle loop never returns. A loop around an idle wait is one source line, so next has no other line to reach and steps the CPU around it forever, and a step holds interrupts off, so the one that would take it somewhere else waits until the run goes on. stepi returns, and next doesn’t until you press Ctrl-C, which stops it at its next step. Step into the handler with a breakpoint instead.
§Halting debug
While a debugger is attached the core’s halting debug is on, as a probe turns it on. The firmware reads DHCSR.C_DEBUGEN set, and a BKPT stops the core at itself, which GDB reports as SIGTRAP. Continue and it stops there again, so step past it with set $pc = $pc + 2. Once the debugger detaches or kills the run, a BKPT does what it does with no debugger, which is HardFault unless the firmware turned DebugMonitor on. One that disconnects leaves halting debug on, so a BKPT the core reaches after that holds it for the next debugger. Since that changes what the firmware does, the run prints debugger attached t=<ns> n=<count> where a debugger takes the core and debugger detached t=<ns> n=<count> where it lets it go or goes, and a run nobody debugs prints neither. In non-stop mode, a detach or a kill while the core runs holds it first, so it’s let go at a stop, as it is in all-stop mode. A driver of your own hears both in Driver::on_debugger_attached.
§Memory and registers
A debugger that stops the core and reads memory or the core’s registers leaves the run the same run, a peripheral’s registers included, since your reads are peeks. Writing a register or memory changes the run from that stop on, and the run’s output doesn’t show it, unless it reaches a peripheral, below. A run you can take back keeps it, to put it back where you made it. load writes the flash all the same. Where a model programs the flash, a snapshot keeps it as it keeps RAM. Anywhere else the run refuses a snapshot from then on, since a snapshot keeps that flash only as the images placed it. compare-sections checks what’s in memory against the ELF with a CRC the server works out, which takes milliseconds where reading it all back took seconds.
A peripheral write the debugger makes itself, through set, reaches the models like any other access but with no program counter, since no instruction made it. It appears in the run’s output with pc=debugger. An address nothing covers is an error to GDB, Cannot access memory, and no gap in the run’s report, since the run never went there.
§Monitor commands
monitor reset resets the machine as the firmware’s own request does and holds the core at its reset handler. GDB keeps the registers it had already read, so ask again with maintenance flush register-cache. monitor reset halt and monitor reset init, which IDEs’ launch templates send, and monitor system_reset, which other emulators’ scripts send, do the same. monitor halt says whether the core is held, which it is whenever GDB has it, and monitor help lists every command. None of them writes a file on your machine or starts a server, so the debugger’s port reaches the emulated core and nothing else.
§Watchpoints and instruction counts
Set as many watchpoints as you like, up to 64 MiB of memory between them, where silicon gives you four comparators. Every access to each page of memory a watchpoint touches is checked while it’s in, so one over a lot of memory slows the run, as monitor help says.
A watchpoint stops the core before the access it watches happens, with the instruction that makes it not run yet. GDB then takes the watchpoint out, steps that instruction and tells you, so you land right after the instruction that made the access, as on any target that stops before it. Some instructions make more than one access: a load or store multiple, an LDRD or STRD, an unaligned access split into bytes, and a store through a bit-band alias, which reads the word before it writes it back. One whose later access is watched runs again from its first access when you go on, as the architecture lets a core restart a load or store multiple, so its earlier accesses to plain memory happen twice, with the same values, and nothing sees it. Once one of its earlier accesses has reached a peripheral, though, the core doesn’t stop before the watched one. The instruction finishes and the core stops right after it, so a peripheral never sees an access twice and the run’s output never gains a line. GDB steps once more from there, as it does after every watch stop, so you land one instruction further on. A core family that doesn’t ask before an access stops after it the same way, as Adding a core family says.
monitor icount prints how many instructions the core has retired and the virtual time it’s at, like icount 57594 at 2230340 ns. GDB reads the same count as $icount, a register of the run’s own after the family’s, so p $icount, display $icount and break bear_bones::tim2_update if $icount > 57600 work. You can’t write it. The run is deterministic, so a count is a place you can get back to. monitor until-icount 57594 and then continue stops the core once it has retired exactly that many instructions, and every access in the run’s output carries its count as n, so take one from there. monitor until-time 5300us stops it at that instant of virtual time, waiting or not. GDB hears either stop as SIGTRAP, and the run with the stop is the run without it. A count or an instant the run has passed is refused.
§Peeks
A read you make, through x, print or an IDE’s register view, is a peek. It answers what the firmware’s read would answer at that instant and changes nothing. A flag a read clears stays set, the byte waiting in a serial port’s data register stays there for the firmware, and no model and no driver hears of it, so the run is the one it would be with nobody looking. A peripheral whose clock is off shows you what its registers hold, where the firmware’s own read of it answers zero, and a write you make to one is dropped, as the firmware’s is. monitor peek off makes your reads the firmware’s for the rest of the run, side effects and all, each in the run’s output with pc=debugger, for when you want a read to do what it does on silicon. monitor peek on makes them peeks again. The server’s own reads, like the CRC compare-sections asks for, always peek.
§Vector catch and the memory map
monitor vector-catch hard bus has the core halt when it takes a HardFault or a BusFault, before the handler’s first instruction, with the faulting context on the stack where the handler would find it, rather than you finding the core spinning in the handler later. The words are DEMCR’s VC_ bits: hard, int for a fault on exception entry or return or on the vector read, bus, state for an undefined instruction or a bad state, check for an unaligned access or a divide by zero, nocp, mm, and reset, which halts the core at the reset handler after the firmware’s own reset. all or none stand alone and do what they say, and with no words it says what’s caught. Writing DEMCR yourself does the same, and GDB hears the halt as SIGTRAP. DFSR says what halted the core: BKPT for a BKPT or a breakpoint, DWTTRAP for a watchpoint, VCATCH for a vector catch and HALTED for your own halt or step. Write one to a bit to clear it.
GDB reads a memory map from the run: the flash in the blocks it erases in, the RAM, the system memory as read-only, and every peripheral and the core’s own space, so info mem lists them all. load then erases the flash a block at a time and writes it with vFlashErase and vFlashWrite, as it would through a probe, and a run whose flash no model programs refuses a snapshot after it, as it does after any write there. With the map GDB refuses an address it doesn’t list, which is one nothing answers anyway, and writes the flash only through load, so a set there says writing to flash is forbidden.
You reach the bit-band aliases as the core does. A word at an alias reads as the one bit it stands for, and writing one sets or clears that bit. A write to a peripheral’s alias peeks the register and writes it back once with the bit changed, so the model hears one write of the register, as it would from the firmware.
§Host calls
rigless run --semihosting serves the firmware’s semihosting calls, the ones of Arm’s “Semihosting for AArch32 and AArch64” a firmware’s console, clock, exit and files take: SYS_OPEN, SYS_CLOSE, SYS_WRITEC, SYS_WRITE0, SYS_WRITE, SYS_READ, SYS_ISTTY, SYS_SEEK, SYS_FLEN, SYS_CLOCK, SYS_TIME, SYS_ERRNO, SYS_EXIT, SYS_EXIT_EXTENDED, SYS_ELAPSED and SYS_TICKFREQ. Every other call, SYS_READC, SYS_ISERROR, SYS_TMPNAM, SYS_REMOVE, SYS_RENAME, SYS_SYSTEM, SYS_GET_CMDLINE and SYS_HEAPINFO among them, answers -1, as a call that failed does. What it writes to the console is printed as it runs, as console t=<ns> n=<count> text=<bytes> lines among the accesses, with a line break in what it wrote shown as \n, and with GDB attached GDB prints it too. Its clock and its time read virtual time, so two runs read the same, and its exit ends the run with its status, which the last line says as exit code=N and the process exits with. An attached GDB hears the firmware exit with that status. Its files are GDB’s: with GDB attached, an open, read, write, seek or close happens on GDB’s host, in GDB’s working directory, and with no debugger attached it fails, so a firmware never reaches the files of the machine the emulator runs on. Without the flag a host call is the breakpoint instruction it is, which halts the core under a debugger and faults without one, as it does on a part with nothing serving it.
Driver::on_console hands a driver of your own each write to the console, and Finished::exit_status the status the firmware ended the run with. Start::new makes a run’s start, and Start::semihosting turns host calls on.
§Output ports
--trace [HOST:]PORT streams the core’s own trace there, which on an ARMv7-M core is its ITM: what the firmware writes to a stimulus port it enabled goes out as the Instrumentation packet a viewer of the ITM’s packet protocol reads, as it would from a probe on the trace pin. The port takes one client at a time, and with none connected the trace goes, so the run is the same run whether anyone’s listening, and never waits on a slow client. A stimulus port reads ready whenever it’s enabled, since the port takes every packet at once. Start::trace is the listener a run of your own streams it to. The ITM is the part’s with or without the flag, so a firmware that turns it on and writes to it runs as it does on silicon, and the packets go nowhere.
--rtt [HOST:]PORT streams the firmware’s RTT terminal, its up-channel 0, there. The run finds the control block by the firmware’s symbol for it, or by its ID in RAM when the firmware has no symbols, and drains it every virtual millisecond, at whole milliseconds, writing the read offset back as a probe does. Writing the read offset is what makes room in the buffer, so a firmware that waits for room runs differently with the flag than without, as it does on a board with a probe attached and one without, and two runs with it are the same run. Start::rtt is the listener for a run of your own.
§Conditions and dprintf
GDB hands the run a breakpoint’s condition and a dprintf’s print to work out itself, as agent expressions, so the core never comes back to GDB for a pass that doesn’t stop. break bear_bones::tim2_update if (*(int *)0x40020014 & 0x20) == 0x20 stops only at the passes where the pin is high, and info breakpoints says (target evals). Over 20 virtual seconds of bear-bones, about 20,000 passes, that took 1.6 s where GDB testing it at every pass took 4.8 s, and the run with no breakpoint at all 1.5 s. set breakpoint condition-evaluation host has GDB test it again. A condition reads registers, $icount among them, and memory through peeks, so it leaves the run the same run. One that can’t be worked out, a read where nothing answers say, stops the core at the breakpoint and says why.
set dprintf-style agent then dprintf bear_bones::tim2_update,"odr %#x\n", *(int *)0x40020014 prints at every pass and never stops the core. It takes %d, %i, %u, %x, %X, %o, %c, %s, %p and %%, with flags, a width, a precision and a length, and an int and a long are 32 bits, as on the targets that run here.
§Non-stop and live reads
set non-stop on before target remote has GDB leave the core running while you look. continue & runs it in the background, and x, print and an IDE’s live watch read memory while it runs, as peeks, so watching a value change leaves the run the same run. interrupt stops it, and a stop comes to GDB as it happens, without GDB waiting on the core. While the core runs, its registers, writes to memory and monitor wait for a stop, and a breakpoint you set goes in at once.
In non-stop mode the core is one thread, whatever kernel it runs, since the run can’t stop one of a kernel’s threads and leave the others going. The thread list comes back in all-stop mode. GDB doesn’t wait on the core in non-stop mode, so it hears nothing of the firmware’s host calls: the console still prints in the run’s output, but not in GDB’s, and a file call fails as it does with no debugger. What a dprintf prints, and why a breakpoint’s condition couldn’t be worked out, go to the run’s output as dprintf t=<ns> n=<count> text=<bytes> lines, each once however often you take the run back over it. A driver of your own hears them in Driver::on_debugger_print, never in Driver::on_console.
§More than one target
A run steps one target of a board, so each target of a board with more than one gets a run and a port of its own, rigless run --target <name> --gdb <port>, and one GDB holds them all as inferiors, each over its own connection:
(gdb) target extended-remote :3333
(gdb) add-inferior -exec firmware.elf
(gdb) inferior 2
(gdb) target extended-remote :3334
(gdb) info inferiorsA breakpoint goes in every inferior whose program has it, and continue runs the inferior you’re in. Under an engine’s lockstep, with --link, a target you hold doesn’t answer its step until you let it go, so the engine holds the others with it. One server that stops every core at the same instant needs a run of several targets, which the runtime doesn’t do yet.
§Going back
monitor save NAME keeps the run where it stands, in memory, for as long as your debugger’s attached, and monitor restore NAME takes the run back there, or forward to it. monitor snapshots lists what you kept. GDB keeps the registers and memory it read across a monitor command, so after a restore ask again with maintenance flush register-cache and maintenance flush dcache.
reverse-stepi, reverse-step, reverse-next, reverse-finish and reverse-continue work with no record in GDB. The run keeps a checkpoint when you attach and every 4,194,304 instructions after, 64 at most, the older ones thinned out as it goes, and going back is putting the run back to one and running forward from there. reverse-stepi lands right before the instruction that came next, an exception the core took before it included. reverse-continue stops where going forward would have stopped: a breakpoint, a watchpoint, or a halt point, so a monitor until-icount N with an N the run has passed is a place it stops at. A watchpoint lands you on the store that last wrote the word, with its old value in it. With nothing to stop at, it goes back to where you attached, and GDB says it reached the end of the recorded history.
The run is deterministic, so going forward again is the same run, as long as what you did comes back where you did it: your writes, the registers you set, your steps, which hold interrupts off for their instruction, a reset, and what GDB answered the firmware’s file calls with all do. So do the bytes a host wrote to a serial port, at the same instants. The run’s output prints each instant once, so a session you went back and forth in prints what one that never went back prints, and the trace and RTT ports send nothing twice. Something the history doesn’t have, a write or a step it never saw there, takes the run a new way from there: what came after it goes, the snapshots you saved after it with it, and the run’s output carries on from there, its counts going back.
A run under --link can’t go back, since the engine’s inputs aren’t kept, and the monitor says so. A driver of your own lets a debugger take the run back with Driver::may_rewind, which says so only when its calls change nothing in the run but which instant on_stop answers. A snapshot, the file an engine asks for, is still refused once a debugger has written memory the guest can’t. A checkpoint keeps it as you wrote it, since it’s yours and never a file.
§Threads
rigless run --gdb shows GDB every thread of a firmware whose kernel rigless-rtos knows, ChibiOS/RT or FreeRTOS. GDB looks up the kernel’s symbols for the run when it loads the firmware’s, so there’s nothing to set: info threads lists each thread with its name and what it’s doing, like (UART1, WTOREVT) under ChibiOS or (delay, Blocked) under FreeRTOS, and thread 3 then bt gives you that thread’s stack. The thread that runs has the core’s registers. Every other one has what its kernel saved when it switched away from it, and you can’t write those, so a register the save doesn’t keep reads as <unavailable> and set $r4 = 1 there is refused. The core runs whichever thread the kernel runs, so stepi, next or continue in another thread steps or runs the one that runs, and the stop names it. The run walks the kernel’s lists once a stop, through peeks, so looking at threads leaves the run the same run. Before the kernel starts, or when its memory doesn’t check out, the core is the one thread, as it is with no kernel at all. GDB first meets the core as thread 1, before it’s looked anything up, so it can show thread 1 beside the thread that runs until the next stop, when it says thread 1 exited.
The kernels are the binary’s, not the runtime’s, and rigless-rtos’s README says what each one needs of a build. A run of your own shows the threads of the kernels you put in Start::kernels, each a WalkThreads. A kernel names the symbols it asks GDB for, and from where GDB found them, the core’s registers, the target’s byte order and a way to peek at memory, it walks its threads: each one’s id, name and state, and what its kernel saved of its registers, by the names the target description gives them. A register is its bytes in the target’s order, both ways, so one wider than 64 bits crosses whole. The run asks for every kernel’s symbols in order and walks the first whose required symbols GDB found. With no kernels the core is the one thread.
§Building your own device
Here’s how to put your own part on rigless with nothing but the crate as a dependency: a peripheral, an SoC that holds it, a board, and a firmware that runs on it. The part is made up on purpose, so nothing here is specific to any real silicon. Its core is from the one family the crate carries today, ARMv7-M, so the board fills in machine::armv7m::Core and you build the firmware for that family’s targets. Another family can live in a crate of your own, as Adding a core family says. Every Rust block for the host side is a doctest, so it compiles against this crate on every test run. The two that boot the firmware are no_run, so build the image first. The firmware blocks are ignore, since they’re built for the core, not the host.
cargo add rigless --no-default-featuresTurn the default features off. That drops the CLI, the SVD generator and the SoCs and boards we ship, and leaves you the runtime. The core is always built in, and it’s Rust, so there’s nothing else to install.
§The peripheral
A peripheral is a Device written against a register layout. The register file already stores what the firmware wrote and applies the access rules, so your model only does the parts storage can’t: what a write to the data register means, a status bit the firmware isn’t allowed to write, and something happening later.
The register table here is written by hand, the way the generator would emit it, which is fine for a register or three. For a part with more, write an SVD for it, a made-up part included, and rigless svd generate writes the tables, the Register enums and the field constants for you, as the generator says.
use rigless::device::{Device, DeviceContext, Level, RegisterFile};
use rigless::mcu::{Mcu, Operate, Single};
use rigless::soc::{Access, Bus, Cpu, Endian, Layout, Peripheral, ReadAction, Region, RegisterSpec, Width, WriteModify};
use rigless::time::{EventId, VirtualNanos};
// The register table, the way the generator would emit it from a vendor's SVD. Written by hand here
// because there's no vendor. `SR` is read-only to the firmware: the model sets and clears it.
static REGISTERS: [RegisterSpec; 3] = [
RegisterSpec { name: "CR", offset: 0x0, width: Width::W32, reset: 0, access: Access::ReadWrite, read: ReadAction::None, write: WriteModify::None, fields: &[] },
RegisterSpec { name: "SR", offset: 0x4, width: Width::W32, reset: 0, access: Access::ReadOnly, read: ReadAction::None, write: WriteModify::None, fields: &[] },
RegisterSpec { name: "DR", offset: 0x8, width: Width::W32, reset: 0, access: Access::ReadWrite, read: ReadAction::None, write: WriteModify::None, fields: &[] },
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Register { Cr, Sr, Dr }
impl Layout for Register {
const REGISTERS: &'static [RegisterSpec] = ®ISTERS;
fn from_index(index: usize) -> Option<Self> { [Self::Cr, Self::Sr, Self::Dr].get(index).copied() }
fn index(self) -> usize { self as usize }
}
const EN: u64 = 1;
const IE: u64 = 2;
const BUSY: u64 = 1;
const DONE: u64 = 2;
/// The beacon's one interrupt, by its position in the instance's list.
const FRAME_DONE: usize = 0;
/// The beacon's one request output, in its own numbering: high while a byte is on the wire.
const SENT: usize = 0;
/// One byte occupies the beacon for a millisecond of virtual time.
const FRAME: VirtualNanos = VirtualNanos(1_000_000);
/// The model keeps what the registers can't: the bytes it sent, and the event that ends the frame.
#[derive(Debug, Default)]
struct Beacon { sent: Vec<u8>, dropped: u32, frame_end: Option<EventId> }
impl Device for Beacon {
type Register = Register;
fn read(&mut self, register: Register, registers: &mut RegisterFile, context: &mut DeviceContext<'_>) -> Option<u64> {
if register != Register::Sr { return None; }
let status = registers.get(Register::Sr);
if status & DONE != 0 { registers.set(Register::Sr, status & !DONE); context.lower(FRAME_DONE); }
Some(status)
}
fn write(&mut self, register: Register, value: u64, registers: &mut RegisterFile, context: &mut DeviceContext<'_>) {
if register != Register::Dr { return; }
let enabled = registers.get(Register::Cr) & EN != 0;
let busy = registers.get(Register::Sr) & BUSY != 0;
if !enabled || busy { self.dropped += 1; return; }
self.sent.push(value as u8);
registers.set(Register::Sr, BUSY);
context.request(SENT, Level::High);
self.frame_end = Some(context.schedule(VirtualNanos(context.now().0 + FRAME.0)));
}
fn on_event(&mut self, event: EventId, registers: &mut RegisterFile, context: &mut DeviceContext<'_>) {
if self.frame_end != Some(event) { return; }
self.frame_end = None;
registers.set(Register::Sr, DONE);
context.request(SENT, Level::Low);
if registers.get(Register::Cr) & IE != 0 { context.raise(FRAME_DONE); }
}
fn reset(&mut self) {
self.frame_end = None;
}
}
/// Where the one instance lives. A bus is a core, a memory map, and the instances at their addresses.
const BEACON: Peripheral = Peripheral { name: "BEACON", base: 0x4000_0000, size: 0x100, interrupts: &[3], registers: ®ISTERS };
const CPU: Cpu = Cpu::new("lantern-core", false, 16, Endian::Little);
static MEMORY: [Region; 2] = [Region::flash("flash", 0x0800_0000, 64 << 10), Region::ram("sram", 0x2000_0000, 16 << 10)];
// Drive it directly, like a unit test of one model would: a byte goes out, the beacon is busy for a
// frame, and a second byte written in the meantime gets dropped.
let mut soc = Single::new(Bus::builder(CPU, &MEMORY, [BEACON]), "BEACON", Beacon::default())?;
let at = |register: Register| BEACON.base + u64::from(register.spec().offset);
let start = VirtualNanos(0);
soc.write(start, at(Register::Cr), Width::W32, EN)?;
soc.write(start, at(Register::Dr), Width::W32, u64::from(b'h'))?;
assert_eq!(soc.read(start, at(Register::Sr), Width::W32)?, BUSY);
soc.write(start, at(Register::Dr), Width::W32, u64::from(b'i'))?;
soc.advance_to(FRAME);
// A read can have side effects too: `DONE` is set when the frame ends and cleared by reading `SR`.
assert_eq!(soc.read(FRAME, at(Register::Sr), Width::W32)?, DONE, "the frame is over");
assert_eq!(soc.read(FRAME, at(Register::Sr), Width::W32)?, 0, "reading SR cleared DONE");
{
let (beacon, _registers) = soc.pair();
assert_eq!((beacon.sent.as_slice(), beacon.dropped), (b"h".as_slice(), 1));
}
// With `IE` set, the end of a frame raises the beacon's interrupt, and reading `SR` lowers it. The
// read above already drove the line low once, and the runtime reports every level a model drives.
soc.drain_interrupts();
soc.write(FRAME, at(Register::Cr), Width::W32, EN | IE)?;
soc.write(FRAME, at(Register::Dr), Width::W32, u64::from(b'i'))?;
let second = VirtualNanos(2 * FRAME.0);
soc.advance_to(second);
let raised: Vec<(u16, Level)> = soc.drain_interrupts().into_iter().map(|change| (change.interrupt, change.level)).collect();
assert_eq!(raised, [(3, Level::High)], "line 3 of the part, as BEACON declares it");
soc.read(second, at(Register::Sr), Width::W32)?;
let lowered: Vec<Level> = soc.drain_interrupts().into_iter().map(|change| change.level).collect();
assert_eq!(lowered, [Level::Low]);Three things to notice. SR is Access::ReadOnly, so the register file rejects the firmware’s writes and only registers.set can move the flag. context.schedule takes an absolute instant and gives you an EventId. on_event fires at exactly that instant with the same id, whether or not the firmware touched the peripheral in the meantime. There’s no cancel. Keep the last id you scheduled and ignore anything else. And asking for an instant you already have an event at gives you the same id back, not a second event, so re-scheduling on every access is free. When the part resets, the reset drops every event, so reset forgets the frame the beacon was sending. Leave reset out of a model that keeps nothing a reset clears.
Reads work the same way. Device::read returns Some(value) to answer a read yourself, or None to let the register file serve what it holds. Return None for anything the file already has right, which is most registers. Where a read has a side effect on the whole register, say so in the layout instead: ReadAction::Clear or ReadAction::Set, and the file does it when the model returns None. Anything narrower is the model’s job, like the DONE bit above, which clears on a read of SR while BUSY stays: read the file, change what changed, and hand back what the firmware should see.
A debugger’s read is a peek instead, Device::peek, which answers what your read would with nothing changed. It takes &self and the register file shared, so it can’t change a thing, and None serves what the file holds with no read action taken. The beacon needs none, since its read answers what SR holds. Write one only where your read answers something the file doesn’t hold, like the byte at the front of a FIFO or a counter that runs on time.
§The SoC
An SoC is a struct with your models as fields, plus a Bus that knows which address belongs to which instance and holds the registers. The bus doesn’t own models. split is where a slot number turns into a field, and check makes sure each field’s layout matches its slot. If you wire a slot to the wrong field it fails when you build the part, not on the first access.
use rigless::device::{ErasedDevice, RequestChange};
use rigless::soc::Error;
/// A second peripheral: one read-only register that counts the bytes the beacon has sent. It never
/// touches the beacon's registers. It listens to the beacon's request line, in the next sections.
static TALLY_REGISTERS: [RegisterSpec; 1] = [
RegisterSpec { name: "COUNT", offset: 0x0, width: Width::W32, reset: 0, access: Access::ReadOnly, read: ReadAction::None, write: WriteModify::None, fields: &[] },
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TallyRegister { Count }
impl Layout for TallyRegister {
const REGISTERS: &'static [RegisterSpec] = &TALLY_REGISTERS;
fn from_index(index: usize) -> Option<Self> { (index == 0).then_some(Self::Count) }
fn index(self) -> usize { 0 }
}
const TALLY: Peripheral = Peripheral { name: "TALLY", base: 0x4000_0100, size: 0x100, interrupts: &[], registers: &TALLY_REGISTERS };
#[derive(Debug, Default)]
struct Tally;
impl Device for Tally {
type Register = TallyRegister;
fn on_request(&mut self, change: RequestChange, registers: &mut RegisterFile, _context: &mut DeviceContext<'_>) {
// A sink hears every request on the part. This one wants the beacon's, rising.
if change.instance == BEACON.name && change.request == SENT && change.level == Level::High {
registers.set(TallyRegister::Count, registers.get(TallyRegister::Count) + 1);
}
}
}
/// The part, complete: the bus, and the models on it as fields.
#[derive(Debug)]
struct Lantern { bus: Bus, beacon: Beacon, beacon_slot: usize, tally: Tally, tally_slot: usize }
impl Lantern {
fn new() -> Result<Self, Error> {
// `reads_requests` is the wiring: the tally hears what the beacon drives.
let bus = Bus::builder(CPU, &MEMORY, [BEACON, TALLY]).reads_requests(TALLY.name).build()?;
let beacon_slot = bus.slot(BEACON.name)?;
let tally_slot = bus.slot(TALLY.name)?;
let mut chip = Self { bus, beacon: Beacon::default(), beacon_slot, tally: Tally, tally_slot };
chip.check()?;
Ok(chip)
}
}
impl Mcu for Lantern {
fn bus(&self) -> &Bus { &self.bus }
fn bus_mut(&mut self) -> &mut Bus { &mut self.bus }
fn reset(&mut self, _at: VirtualNanos, _cause: rigless::device::ResetCause) { self.reset_every_model(); }
fn poll(&mut self, now: VirtualNanos) { self.poll_every_model(now); }
fn split(&mut self, slot: usize) -> (&mut Bus, Option<&mut dyn ErasedDevice>) {
let Self { bus, beacon, beacon_slot, tally, tally_slot } = self;
let model: Option<&mut dyn ErasedDevice> = if slot == *beacon_slot { Some(beacon) } else if slot == *tally_slot { Some(tally) } else { None };
(bus, model)
}
}
let mut chip = Lantern::new()?;
let at = |register: Register| BEACON.base + u64::from(register.spec().offset);
chip.write(VirtualNanos(0), at(Register::Cr), Width::W32, EN)?;
chip.write(VirtualNanos(0), at(Register::Dr), Width::W32, u64::from(b'h'))?;
chip.advance_to(FRAME);
assert_eq!(chip.read(FRAME, at(Register::Sr), Width::W32)?, DONE);
assert_eq!(chip.beacon.sent, b"h");Mcu asks for five methods: the bus both ways, split, and what the part does at a reset and at a stop. reset_every_model and poll_every_model are what a part with nothing of its own to keep there says. Operate then gives you read, write and advance_to at whatever instant you pick, which is all a unit test of the part needs. The tally is what a second peripheral costs: another field, another slot, another arm in split. That’s it.
A flash Region is erased whole unless you say how it erases. Region::flash(...).erased_in(&[...]) gives its erase blocks, which add up to its size, and GDB’s load then erases a block at a time, as it does through a probe. Without them the memory map a debugger reads has the flash as one block, so a load of a few bytes erases all of it.
A flash Region reads 0 wherever no image fills it unless you say what it erases to. Most flash erases to all ones, so give it .erased_to(0xFF). It then starts that way past the image, and vFlashErase writes the same.
Firmware that writes its own flash, to keep its parameters say, does it through the part’s flash interface. .programmed_by(name) on the region names that instance, and a store the firmware makes into the region then reaches its model at Device::write_region. The model decides what the store means and changes the flash only through DeviceContext::program, so what it programs lands at the next settle. A store the part ignores, or only flags as an error, the model still takes. One it doesn’t take shows up in the run’s output as a store nothing answered. A snapshot keeps a programmed region as it keeps RAM.
§The board
A board is what a Session runs. The chip is complete on its own. The board adds whatever sits around it: crystals, a supply rail, chips on the buses. This one adds nothing. The one thing every board has to provide is the description of its core, and that comes from the core family module, armv7m::Core: one typed field for each thing the family needs to make a processor, so a board can’t leave one out, and the family checks each value when the run starts.
use std::num::{NonZeroU32, NonZeroU64};
use rigless::cpu;
use rigless::machine::{Board, Target, armv7m};
use rigless::session::Session;
use rigless::snapshot::Store;
/// The board: one Lantern and nothing else.
#[derive(Debug)]
struct LanternBoard { chip: Lantern }
// A board says how a snapshot keeps it. This one doesn't, so a snapshot of it is refused and its
// runs start from reset.
impl Store for LanternBoard {}
impl Board for LanternBoard {
fn name(&self) -> &'static str { "lantern" }
fn target_names(&self) -> Vec<&'static str> { vec!["lantern-core"] }
fn mcu(&mut self, target: usize) -> Option<&mut dyn Mcu> { (target == 0).then_some(&mut self.chip) }
fn targets(&mut self) -> Vec<Target<'_>> {
let core = armv7m::Core {
variant: armv7m::Variant::M3, // the part declares no FPU
clock_hz: NonZeroU64::new(16_000_000).unwrap(), // drives the core's own timers
interrupt_lines: NonZeroU32::new(u32::from(Operate::cpu(&self.chip).interrupt_lines)).unwrap(),
priority_bits: 0, // zero leaves it to the core
refclk_divider: NonZeroU32::new(8).unwrap(),
instructions_per_second: NonZeroU64::new(250_000_000).unwrap(), // four nanoseconds each
boot: "flash", // the region at the reset vector
}
.spec(&cpu::armv7m::windows(Mcu::bus(&self.chip)));
vec![Target::new("lantern-core", &mut self.chip, core)]
}
}
// A session on the board's one target. Nothing runs yet, but the core's description is built and
// checked against the part: a `boot` that isn't in `MEMORY` gets rejected right here.
let board = LanternBoard { chip: Lantern::new()? };
let session = Session::new(Box::new(board), 0, VirtualNanos(3 * FRAME.0))?;
assert_eq!(session.until(), VirtualNanos(3 * FRAME.0));boot is the region the emulator also maps at address zero, where the core fetches its reset vector. That’s why the firmware below links its vector table at the start of flash. Name a region that isn’t in MEMORY and Session::new rejects it and tells you which names exist. The interrupt count comes from the Cpu, so you write it once. instructions_per_second is how fast the core runs against the run’s virtual clock: 250 million is four nanoseconds an instruction, which every target we ship uses. The part’s own clock, one instruction a cycle, is the other natural pick, and the virtual clock counts whole nanoseconds, rounding each instant down. spec gives the core an MPU of eight regions and the floating-point unit its variant has by default. When the part’s manual says otherwise, call spec_with_parts with what it says. The core runs only what the variant and the part have, so a variant without the DSP extension takes its instructions as undefined, and a part without an FPU faults on every floating-point instruction. A unit or an MPU the variant can’t have is refused when the run starts. What the architecture leaves to the implementation goes on armv7m::Config: the CPUID the part reports, its caches as their identification registers describe them, whether it predicts branches, the registers it has in the system control space’s IMPLEMENTATION DEFINED range, and where its vector table is at reset when that isn’t zero, in which case nothing answers at zero but what MEMORY puts there. The core caches nothing, so a firmware’s cache maintenance is taken and does nothing. Leave them unset and the core reports the Cortex-M4’s CPUID, has no caches, and boots from zero.
§Interrupts
A peripheral’s interrupt lines are declared on the instance, not on the model: interrupts: &[3] on BEACON says the beacon’s one interrupt is line 3 of the part. The model raises it by position in that list, context.raise(FRAME_DONE) with FRAME_DONE at 0, so a model written once serves every instance of its layout whatever lines those instances have. Two beacons at two addresses are two Peripherals with two interrupts tables and one Beacon type.
An interrupt is a level, not a pulse. The model raises it when the flag goes up and lowers it when the firmware clears the flag, which for the beacon is a read of SR. Drive the same level twice and the runtime reports it twice, so a model that should only report changes keeps the last level it drove. Operate::drain_interrupts is how a unit test sees what a model drove, as the peripheral block above does.
The part decides how its interrupts reach the core. With no interrupt controller in its description, an interrupt drives the emulator’s line of its own number, which is right when the core’s interrupt controller numbers its inputs the way the part does. A part with a controller on its own side, an interrupt matrix the firmware programs say, names it with routes_interrupts, and that model hears every interrupt at Device::on_interrupt and drives the core’s lines with DeviceContext::drive_line. An interrupt with no line isn’t dropped silently: Session::unrouted lists it. In the firmware, line 3 is row 3 of the interrupt table, right after the sixteen exceptions. To take the beacon’s interrupt instead of polling for it, enable the line in the core’s interrupt controller, set IE, and put a handler in that row:
const NVIC_ISER0: *mut u32 = 0xE000_E100 as *mut u32;
const BEACON_LINE: u32 = 3;
extern "C" fn beacon_done() {
read(BEACON_SR); // clears DONE, which lowers the line
}
#[unsafe(link_section = ".vector_table.interrupts")]
#[used]
static INTERRUPTS: [extern "C" fn(); INTERRUPT_POSITIONS] = {
let mut table = [default_handler as extern "C" fn(); INTERRUPT_POSITIONS];
table[BEACON_LINE as usize] = beacon_done;
table
};
// In `reset`, before the first byte:
write(NVIC_ISER0, 1 << BEACON_LINE);
write(BEACON_CR, EN | IE);Interrupts go from a peripheral to the core. When one peripheral needs to tell another something, that’s a request line, which is the next section.
§Request lines
A request line is how one peripheral tells another something. It’s a level the driving model owns, like an interrupt, but it goes to other instances on the part instead of to the core. The beacon drives its request 0 high while a byte is on the wire and low when the frame ends: context.request(SENT, Level::High) in write, Level::Low in on_event, in the beacon’s own numbering.
Who hears it is the bus description’s business, not the beacon’s. reads_requests(TALLY.name) in Lantern::new makes the tally a sink, and a sink gets every request on the part in Device::on_request as a RequestChange: the driving instance’s name, its request number, and the level. The tally checks for the beacon’s request 0 going high, counts it, and ignores everything else. That’s how a DMA controller on a real part knows which of its channels fired: it knows its own wiring and reads the instance name off the change. Nothing reaches into another model’s registers.
Requests are delivered by Operate::settle, which carries out everything the models asked for after an access or an event: requests to their sinks, clocks to their readers, and any bus access a model asked for, which is why it takes the emulator’s memory. A session settles after every access and every stop. In a unit test you call it yourself:
use rigless::emulator::{Error as EmulatorError, Memory};
// Nothing here asks for a memory access, so the memory a settle is handed can refuse everything.
struct NoMemory;
impl Memory for NoMemory {
fn read(&mut self, address: u64, width: Width) -> Result<u64, EmulatorError> { Err(EmulatorError::Memory { address, width }) }
fn write(&mut self, address: u64, width: Width, _value: u64) -> Result<(), EmulatorError> { Err(EmulatorError::Memory { address, width }) }
}
let mut chip = Lantern::new()?;
let at = |register: Register| BEACON.base + u64::from(register.spec().offset);
let count = TALLY.base + u64::from(TallyRegister::Count.spec().offset);
chip.write(VirtualNanos(0), at(Register::Cr), Width::W32, EN)?;
chip.write(VirtualNanos(0), at(Register::Dr), Width::W32, u64::from(b'h'))?;
let _refused = chip.settle(&mut NoMemory)?;
assert_eq!(chip.read(VirtualNanos(0), count, Width::W32)?, 1, "the tally saw the beacon's request go high");
chip.advance_to(FRAME);
let _refused = chip.settle(&mut NoMemory)?;
chip.write(FRAME, at(Register::Dr), Width::W32, u64::from(b'i'))?;
let _refused = chip.settle(&mut NoMemory)?;
assert_eq!(chip.read(FRAME, count, Width::W32)?, 2, "and go high again for the second byte");Clocks work the same way: Builder::clocked_by says which instance runs on which output of another, context.clock drives a rate, and Device::on_clock receives it. A bit of one instance that another answers behind is Builder::gate, or Builder::gate_writes for writes to named registers. The soc module docs cover all three.
§The firmware
The firmware is a normal bare-metal image: no_std, your own vector table, your own linker script, built for the core’s target. It’s in the repository as firmware/template/, ready to copy. This one sends “hi” on the beacon the way a real driver would: wait until the last byte is done, write the next. Then it sleeps. That wait is the whole point of the test. A model that just stored registers would never clear BUSY, and the firmware would spin forever.
#![no_std]
#![no_main]
use core::arch::asm;
use core::ptr::{read_volatile, write_volatile};
// The beacon's registers, at the address the SoC put them.
const BEACON_CR: *mut u32 = 0x4000_0000 as *mut u32;
const BEACON_SR: *mut u32 = 0x4000_0004 as *mut u32;
const BEACON_DR: *mut u32 = 0x4000_0008 as *mut u32;
const EN: u32 = 1;
const BUSY: u32 = 1;
/// One row per interrupt line the core declares. None is used, but the table has them.
const INTERRUPT_POSITIONS: usize = 16;
fn read(register: *mut u32) -> u32 {
// SAFETY: a word-aligned register on the part's map, and this is the only code running.
unsafe { read_volatile(register) }
}
fn write(register: *mut u32, value: u32) {
// SAFETY: as for the read.
unsafe { write_volatile(register, value) };
}
/// Sends "hi" the way a driver would: wait until the last byte is done, write the next. Then sleeps.
#[unsafe(no_mangle)]
pub extern "C" fn reset() -> ! {
write(BEACON_CR, EN);
for &byte in b"hi" {
while read(BEACON_SR) & BUSY != 0 {
core::hint::spin_loop();
}
write(BEACON_DR, u32::from(byte));
}
loop {
// SAFETY: WFI sleeps until an interrupt. It touches no memory or register.
unsafe { asm!("wfi", options(nomem, nostack, preserves_flags)) };
}
}
extern "C" fn default_handler() {
loop { core::hint::spin_loop(); }
}
#[unsafe(link_section = ".vector_table.reset")]
#[used]
static RESET_VECTOR: extern "C" fn() -> ! = reset;
/// The fourteen exception vectors after the reset vector.
#[unsafe(link_section = ".vector_table.exceptions")]
#[used]
static EXCEPTIONS: [extern "C" fn(); 14] = [default_handler; 14];
#[unsafe(link_section = ".vector_table.interrupts")]
#[used]
static INTERRUPTS: [extern "C" fn(); INTERRUPT_POSITIONS] = [default_handler; INTERRUPT_POSITIONS];
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo<'_>) -> ! {
loop { core::hint::spin_loop(); }
}The interrupt table needs one entry per line the core declares (CPU.interrupt_lines). The linker script puts the vector table at the start of flash, and its MEMORY has to use the same origins and lengths as the SoC’s, because the emulator loads the image by the SoC’s map:
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 64K /* as MEMORY declares them, or the loader and the */
SRAM : ORIGIN = 0x20000000, LENGTH = 16K /* linker disagree about where the code is */
}
ENTRY(reset);
_stack_start = ORIGIN(SRAM) + LENGTH(SRAM);
SECTIONS
{
.vector_table ORIGIN(FLASH) :
{
LONG(_stack_start);
KEEP(*(.vector_table.reset));
KEEP(*(.vector_table.exceptions));
KEEP(*(.vector_table.interrupts));
} > FLASH
.text : { *(.text .text.*); } > FLASH
.rodata : { *(.rodata .rodata.*); } > FLASH
.bss (NOLOAD) : { *(.bss .bss.*); } > SRAM
/DISCARD/ : { *(.ARM.exidx .ARM.exidx.*); }
}The firmware is its own Cargo project, not a workspace member, because it’s built for a different target. Cargo.toml gets an empty [workspace] and panic = "abort" in both profiles. .cargo/config.toml sets the target and the linker script:
[build]
target = "thumbv7m-none-eabi" # a core without an FPU. thumbv7em-none-eabihf for one with
[target.thumbv7m-none-eabi]
rustflags = ["-C", "link-arg=-Tlink.x", "-C", "link-arg=--nmagic"]rustup target add thumbv7m-none-eabi
cargo build --release§Running it
rigless run only knows the machines in machine::all(), so for your own board you run it from a binary of your own. The shortest is the whole command over a table of your own machines. Turn on the cli feature, cargo add rigless --no-default-features --features cli, and the binary is one line:
use std::process::ExitCode;
use rigless::machine::{Board, Constructor};
use rigless::soc::Error;
fn lantern() -> Result<Box<dyn Board>, Error> {
Ok(Box::new(LanternBoard { chip: Lantern::new()? }))
}
/// Each board by the name `--machine` takes, which is the name it calls itself.
const MACHINES: &[(&str, Constructor)] = &[("lantern", lantern)];
fn main() -> ExitCode {
rigless::cli::main(MACHINES)
}That binary is rigless for your boards: run --machine lantern --until 3ms firmware.elf prints what rigless run prints, --gdb 3333 serves a debugger, and --coverage writes the report. Once the board implements Edge, --link makes it an engine’s target, and an engine spawns this binary wherever it would spawn rigless. link::serve is that target as a function, for a program that isn’t a command.
To keep your own main, emulator::run_printed does what the command does: it takes a Session on one of the board’s targets and a Start naming the image, and prints every access, the stop and the reports, the same lines rigless run prints, to whatever you hand it. Accesses::Counted prints what --quiet prints:
use std::io;
use std::path::Path;
use rigless::emulator::{self, Start};
use rigless::image::Image;
let session = Session::new(Box::new(LanternBoard { chip: Lantern::new()? }), 0, VirtualNanos(3 * FRAME.0))?;
let firmware = Image::read(Path::new("firmware/template/target/thumbv7m-none-eabi/release/template"), None)?;
let images = [firmware];
let start = Start::new(&images);
let printed = emulator::run_printed(session, &start, None, emulator::Accesses::Printed, io::stdout())?;
assert_eq!(printed.session.board().chip.beacon.sent, b"hi");That’s a whole main. The flags map straight across: the session’s until is --until, and time::NO_DEADLINE means run until interrupted. debugger: Some(&listener) in Start is --gdb, with a TcpListener you bound where the debugger connects, 127.0.0.1 and the port for a port alone. The third argument is --outputs, the period to read the board’s outputs at. printed.coverage is what --coverage writes, so fs::write(path, printed.coverage.to_string()) gives you the same file. A host on one of the board’s wires is board.attach_host(...) before you make the session, which rigless run does for every wire Board::hosts names, at the board’s port or the one --host gives. printed.session hands the board back, so you can look at your models once the run is over.
To do something other than print, write your own Driver and call emulator::run. A Driver gets every access as it happens, and every stop at an instant the run was asked to reach. That’s what a test wants to assert on:
use std::path::Path;
use rigless::emulator::{self, Driver, Finished, Host, Settled, Start, Stop, StopReason};
use rigless::image::Image;
/// What the run does on every access and every stop. This one records accesses and lets the run
/// reach its deadline. `rigless run` prints them instead, a test asserts on them, and the link's
/// target counts toward the faults it armed after each one and takes a snapshot at a stop when the
/// engine asks, with the host it's lent there.
struct Recording(Vec<(emulator::Access, u64)>);
impl<D: Board + ?Sized> Driver<D> for Recording {
fn on_settled(&mut self, _session: &mut Session<D>, _host: &mut dyn Host, settled: Settled) {
if let Settled::Access { access, value } = settled { self.0.push((access, value)); }
}
fn on_stop(&mut self, _session: &mut Session<D>, _stop: Stop, _host: &mut dyn Host) -> Option<VirtualNanos> { None }
}
let session = Session::new(Box::new(LanternBoard { chip: Lantern::new()? }), 0, VirtualNanos(3 * FRAME.0))?;
let firmware = Image::read(Path::new("firmware/template/target/thumbv7m-none-eabi/release/template"), None)?;
let images = [firmware];
let start = Start::new(&images);
let Finished { session, driver, stop, .. } = emulator::run(session, &start, Recording(Vec::new()))?;
// What the beacon saw, and what the core did.
assert!(session.unmapped().is_empty(), "an address no model claims: {:?}", session.unmapped());
assert_eq!(session.board().chip.beacon.sent, b"hi");
let writes = driver.0.iter().filter(|(access, _)| access.address == BEACON.base + 0x8).count();
assert_eq!(writes, 2, "one write per byte, the second only once the first's frame had passed");
let stop = stop.expect("the driver let the run reach its deadline");
assert_eq!(stop.reason, StopReason::Idle, "the firmware sleeps once it has said hi");Driver::on_settled sees every access, every hit on what the emulator watches and every stop once the models have settled after it. Driver::on_stop returns the next instant to run to, or None to stop. Finished gives you back the session (board included), the driver (with whatever it recorded), and the stop that ended the run. Set Start::interrupt to a flag of your own and the run ends once you set it, with Finished::interrupted saying where it stood. StopReason::Idle means the core is halted waiting for an interrupt with nothing pending, and the emulator skips ahead instead of waiting.
Runs share nothing. Each emulator::run makes a core of its own on a thread of its own, so a process runs as many as you like, one after another or at once, and a test that boots a firmware runs under cargo test as well as cargo nextest.
§The edge
An engine steps a board in lockstep through its edge: what the firmware drives, read off the board, and what the world does to the board’s transducers, handed to them. If your board drives or senses something a plant cares about, implement edge::Edge for it and answer Board::edge with it. The lantern drives nothing a plant reads, so here’s a board that does: a made-up heater, whose firmware reads the water’s temperature and drives a heating element. It’s the heater the engine’s own docs step against a tank of water, field for field.
use std::num::{NonZeroU32, NonZeroU64};
use rigless::cpu;
use rigless::device::{Device, DeviceContext, ErasedDevice, RegisterFile};
use rigless::edge::{Coupled, Edge, Field};
use rigless::machine::{Board, Target, armv7m};
use rigless::mcu::{Mcu, Operate};
use rigless::session::Session;
use rigless::snapshot::Store;
use rigless::soc::{Access, Bus, Cpu, Endian, Error, Layout, Peripheral, ReadAction, Region, RegisterSpec, Width, WriteModify};
use rigless::time::VirtualNanos;
// Two instances of one register each: the water the firmware reads, in thousandths of a degree, and
// the element's duty it writes, in thousandths of full power.
static THERMOMETER_REGISTERS: [RegisterSpec; 1] = [
RegisterSpec { name: "DATA", offset: 0x0, width: Width::W32, reset: 0, access: Access::ReadOnly, read: ReadAction::None, write: WriteModify::None, fields: &[] },
];
static ELEMENT_REGISTERS: [RegisterSpec; 1] = [
RegisterSpec { name: "DUTY", offset: 0x0, width: Width::W32, reset: 0, access: Access::ReadWrite, read: ReadAction::None, write: WriteModify::None, fields: &[] },
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Thermometer { Data }
impl Layout for Thermometer {
const REGISTERS: &'static [RegisterSpec] = &THERMOMETER_REGISTERS;
fn from_index(index: usize) -> Option<Self> { (index == 0).then_some(Self::Data) }
fn index(self) -> usize { 0 }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Element { Duty }
impl Layout for Element {
const REGISTERS: &'static [RegisterSpec] = &ELEMENT_REGISTERS;
fn from_index(index: usize) -> Option<Self> { (index == 0).then_some(Self::Duty) }
fn index(self) -> usize { 0 }
}
const THERMOMETER: Peripheral = Peripheral { name: "THERMOMETER", base: 0x4000_0000, size: 0x100, interrupts: &[], registers: &THERMOMETER_REGISTERS };
const ELEMENT: Peripheral = Peripheral { name: "ELEMENT", base: 0x4000_0100, size: 0x100, interrupts: &[], registers: &ELEMENT_REGISTERS };
const CPU: Cpu = Cpu::new("heater-core", false, 16, Endian::Little);
static MEMORY: [Region; 2] = [Region::flash("flash", 0x0800_0000, 64 << 10), Region::ram("sram", 0x2000_0000, 16 << 10)];
/// The probe: a read of `DATA` answers the water the board last handed it.
#[derive(Debug, Default)]
struct Probe { water: i32 }
impl Device for Probe {
type Register = Thermometer;
fn read(&mut self, _register: Thermometer, _registers: &mut RegisterFile, _context: &mut DeviceContext<'_>) -> Option<u64> {
Some(u64::from(self.water.cast_unsigned()))
}
}
/// The part: the probe on its bus, and the element, which is its register and nothing more.
#[derive(Debug)]
struct Boiler { bus: Bus, probe: Probe, probe_slot: usize, element_slot: usize }
impl Boiler {
fn new() -> Result<Self, Error> {
let bus = Bus::builder(CPU, &MEMORY, [THERMOMETER, ELEMENT]).build()?;
let (probe_slot, element_slot) = (bus.slot(THERMOMETER.name)?, bus.slot(ELEMENT.name)?);
let mut part = Self { bus, probe: Probe::default(), probe_slot, element_slot };
part.check()?;
Ok(part)
}
}
impl Mcu for Boiler {
fn bus(&self) -> &Bus { &self.bus }
fn bus_mut(&mut self) -> &mut Bus { &mut self.bus }
fn reset(&mut self, _at: VirtualNanos, _cause: rigless::device::ResetCause) { self.reset_every_model(); }
fn poll(&mut self, now: VirtualNanos) { self.poll_every_model(now); }
fn split(&mut self, slot: usize) -> (&mut Bus, Option<&mut dyn ErasedDevice>) {
let model: Option<&mut dyn ErasedDevice> = if slot == self.probe_slot { Some(&mut self.probe) } else { None };
(&mut self.bus, model)
}
}
/// The board: the part and nothing else.
#[derive(Debug)]
struct Heater { part: Boiler }
impl Heater {
/// The heater as built, its probe in water at rest.
fn new() -> Result<Self, Error> {
let mut board = Self { part: Boiler::new()? };
board.sense(&Self::REST);
Ok(board)
}
}
impl Store for Heater {}
impl Board for Heater {
fn name(&self) -> &'static str { "heater" }
fn target_names(&self) -> Vec<&'static str> { vec!["heater-core"] }
fn mcu(&mut self, target: usize) -> Option<&mut dyn Mcu> { (target == 0).then_some(&mut self.part) }
fn targets(&mut self) -> Vec<Target<'_>> {
let core = armv7m::Core {
variant: armv7m::Variant::M3,
clock_hz: NonZeroU64::new(16_000_000).unwrap(),
interrupt_lines: NonZeroU32::new(u32::from(CPU.interrupt_lines)).unwrap(),
priority_bits: 0,
refclk_divider: NonZeroU32::new(8).unwrap(),
instructions_per_second: NonZeroU64::new(250_000_000).unwrap(),
boot: "flash",
}
.spec(&cpu::armv7m::windows(Mcu::bus(&self.part)));
vec![Target::new("heater-core", &mut self.part, core)]
}
fn edge(&mut self) -> Option<&mut dyn Coupled> { Some(self) }
}
impl Edge for Heater {
/// The water, in thousandths of a degree.
type Signals = i32;
/// The element's duty, in thousandths of full power.
type Outputs = u16;
/// Nothing about the heater itself can go wrong.
type Conditions = ();
const FIELDS: &'static [Field] = &[Field::new("water", "m°C", 4, true)];
const OUTPUT_FIELDS: &'static [Field] = &[Field::new("ELEMENT.duty", "‰", 2, false)];
const REST: i32 = 20_000;
fn sense(&mut self, water: &i32) {
self.part.probe.water = *water;
}
fn condition(&mut self, _conditions: &()) {}
fn drive(&mut self) -> u16 {
let duty = self.part.bus.slots()[self.part.element_slot].registers().get(Element::Duty);
u16::try_from(duty.min(1_000)).unwrap_or(1_000)
}
}
// The session holds each declaration to its type before anything runs.
let session = Session::new(Box::new(Heater::new()?), 0, VirtualNanos(1_000_000))?;
assert_eq!(session.board().part.probe.water, 20_000);Five things to get right:
- The types cross the link as bytes, so each implements
link::Wire. The integers and()already do. A struct of your own writes its fields in order, each as its integer, andwire!writes that for you from the fields’ names. - Build the board sensing
REST, asHeater::newdoes. Nothing senses it for you, and an engine only sends what changes from it. - The fields describe those bytes.
FIELDS,OUTPUT_FIELDSandCONDITIONSlist each value in the order its type writes it, with its unit, its width in bytes and its sign.Session::newholds every list to its type and refuses one that doesn’t add up, naming the list and both numbers. An engine holdsFIELDSandOUTPUT_FIELDSagainst its own declaration of the board, so a field renamed on one side is a run refused rather than a wrong result. It offersCONDITIONSas they’re listed. sensehands each value to what measures it, a model or a chip, on the part’s own field, and the firmware finds it there the next time it reads. A model that has to act at the instant an input changes, like a pin raising an interrupt, takes it through adrive_method of the part’s, whichBus::drivewrites, from a test or a driver that knows the instant.drivereads what the firmware drives off the registers or the models that hold it, as the board would read its pins.
If your board’s chips can go wrong, list what can as its Conditions, each resting at zero: a chip that stops answering is a chip::Place its controller silences, and a reading that’s stuck, biased or noisy is the board handing the chip what an edge::Sensed answers under an edge::Condition. Name those chips in CHIPS and hand out each one’s place in place, so a fault can reach it. A session refuses a board that names a chip and has no place for it. The Pixracer’s module, machine::pixracer, is a whole board that does all of it.
To run it, put the heater in the table of the binary from Running it, and --link makes it an engine’s target. A plant on the engine’s side declares the same machine and the same fields from its own types.
§A chip on a line of your own
The chips this crate ships hang on the controllers its parts ship. A part of your own hangs a chip on a controller of its own, and the controller holds it in a chip::Place, which knows whether the chip answers, counts its transactions for a trigger, and takes a flipped word or a lost one. Here’s a serial line’s controller with a made-up gauge on its far end, which says its reading every 10 ms:
use std::any::Any;
use rigless::chip::{Place, Timeline};
use rigless::chip::uart::Peer;
use rigless::device::{Device, DeviceContext, RegisterFile};
use rigless::mcu::{Mcu, Operate, Single};
use rigless::snapshot::Store;
use rigless::soc::{Access, Bus, Cpu, Endian, Layout, Peripheral, ReadAction, Region, RegisterSpec, Width, WriteModify};
use rigless::time::{EventId, VirtualNanos};
// `DR` takes a word to send and answers the last word received, which the controller holds apart.
// `SR` says one is waiting.
static LINE_REGISTERS: [RegisterSpec; 2] = [
RegisterSpec { name: "DR", offset: 0x0, width: Width::W32, reset: 0, access: Access::ReadWrite, read: ReadAction::None, write: WriteModify::None, fields: &[] },
RegisterSpec { name: "SR", offset: 0x4, width: Width::W32, reset: 0, access: Access::ReadOnly, read: ReadAction::None, write: WriteModify::None, fields: &[] },
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Line { Dr, Sr }
impl Layout for Line {
const REGISTERS: &'static [RegisterSpec] = &LINE_REGISTERS;
fn from_index(index: usize) -> Option<Self> { [Self::Dr, Self::Sr].get(index).copied() }
fn index(self) -> usize { self as usize }
}
const WAITING: u64 = 1;
/// The controller's one interrupt, by its position in the instance's list.
const RECEIVED: usize = 0;
/// A word on the line takes a millisecond.
const FRAME: VirtualNanos = VirtualNanos(1_000_000);
/// The controller, what's on the far end of its line, the word it's sending and the last it received.
#[derive(Debug, Default)]
struct Uart { far_end: Option<Place<dyn Peer>>, sending: Option<(EventId, u16)>, received: u16 }
impl Uart {
/// The chip on the far end as the type the board hung there, for the board to hand it what it
/// measures. This is the one downcast.
fn chip_mut<C: Peer>(&mut self) -> Option<&mut C> {
let chip: &mut dyn Any = self.far_end.as_mut()?.chip_mut();
chip.downcast_mut()
}
}
impl Device for Uart {
type Register = Line;
fn read(&mut self, register: Line, registers: &mut RegisterFile, context: &mut DeviceContext<'_>) -> Option<u64> {
if register != Line::Dr { return None; }
registers.set(Line::Sr, 0);
context.lower(RECEIVED);
Some(u64::from(self.received))
}
fn write(&mut self, register: Line, value: u64, _registers: &mut RegisterFile, context: &mut DeviceContext<'_>) {
if register == Line::Dr {
let end = context.schedule(VirtualNanos(context.now().0 + FRAME.0));
self.sending = Some((end, u16::from(value.to_le_bytes()[0])));
}
}
fn on_event(&mut self, event: EventId, _registers: &mut RegisterFile, context: &mut DeviceContext<'_>) {
let Some((_, word)) = self.sending.filter(|(end, _)| *end == event) else { return };
self.sending = None;
// The far end hears the word as its stop bit ends, unless it's silent.
if let Some(chip) = self.far_end.as_mut().and_then(Place::answering) {
chip.receive(word, &mut Timeline::new(context));
}
}
fn poll(&mut self, registers: &mut RegisterFile, context: &mut DeviceContext<'_>) {
let Some(place) = &mut self.far_end else { return };
let now = context.now();
// A silent far end's words are lost, as down a cut line.
place.lose_while_silent(now);
while let Some(word) = place.answering().and_then(|chip| chip.transmit(now)) {
// Each word is a transaction: a trigger counts it, and a flip or a drop lands on it.
if let Some(word) = place.sent(u32::from(word)) {
self.received = u16::try_from(word & 0xFFFF).unwrap_or_default();
registers.set(Line::Sr, WAITING);
context.raise(RECEIVED);
}
}
}
fn reset(&mut self) {
self.sending = None;
}
}
/// The gauge: it says its reading once every 10 ms, and keeps what it hears.
#[derive(Debug, Default)]
struct Gauge { reading: u16, due: u64, heard: Vec<u16> }
impl Store for Gauge {}
impl Peer for Gauge {
fn receive(&mut self, word: u16, _context: &mut Timeline<'_, '_>) {
self.heard.push(word);
}
fn transmit(&mut self, now: VirtualNanos) -> Option<u16> {
(now.0 >= self.due).then(|| {
self.due += 10_000_000;
self.reading
})
}
fn received(&self) -> &[u16] {
&self.heard
}
}
const LINE: Peripheral = Peripheral { name: "LINE", base: 0x4000_0000, size: 0x100, interrupts: &[7], registers: &LINE_REGISTERS };
static MEMORY: [Region; 1] = [Region::ram("sram", 0x2000_0000, 16 << 10)];
let cpu = Cpu::new("a-core", false, 16, Endian::Little);
let mut part = Single::new(Bus::builder(cpu, &MEMORY, [LINE]), "LINE", Uart::default())?;
let data = LINE.base + u64::from(Line::Dr.spec().offset);
// The board hangs the gauge, and reaches it again as a gauge to hand it its reading.
part.pair().0.far_end = Some(Place::new(Box::new(Gauge::default())));
part.pair().0.chip_mut::<Gauge>().unwrap().reading = 43;
// At a stop the controller asks the far end for what it has said by then.
part.poll(VirtualNanos(0));
assert_eq!(part.read(VirtualNanos(0), data, Width::W32)?, 43);
// The firmware's word reaches the gauge as its frame ends.
part.write(VirtualNanos(0), data, Width::W32, u64::from(b'?'))?;
part.advance_to(FRAME);
assert_eq!(part.pair().0.chip_mut::<Gauge>().unwrap().heard, [u16::from(b'?')]);
// Silenced, as a board condition does, the gauge's next word is lost on the line.
part.pair().0.far_end.as_mut().unwrap().silence(true);
part.pair().0.chip_mut::<Gauge>().unwrap().reading = 44;
part.poll(VirtualNanos(10_000_000));
assert_eq!(part.read(VirtualNanos(10_000_000), data, Width::W32)?, 43);The controller asks the far end at every stop of the session, through Device::poll, and the far end hands over what it has said by then. So a word a chip says lands at the first stop at or after its instant: a host on a socket at the stop its bytes are noticed at, and a chip’s word at the next event of the part or the next step of an engine. A run with more stops, like one reading --outputs often, hands the firmware a chip’s words sooner. Keep a chip’s words on instants the run stops at anyway, or give its controller an event of its own to ask at.
Your board does the rest. Its condition silences the place with Place::silence, its place hands it out as a chip::Placed, so a trigger counts its words and a fault flips or drops one, and its sense reaches the chip through chip_mut to hand it what it measures. A snapshot keeps the place and the chip through their own Store, which a chip that can’t be kept leaves empty, as the gauge does.
§Extending the STM32F427
Everything above is for writing a part from scratch. Most boards don’t need that. They use the STM32F427 this crate ships and add what’s on the PCB around it: memory and a display on the FMC, a chip on SPI, a file in flash. You can do all of that without writing an Mcu or forwarding any methods.
- Memory: call
Bus::add_region. The emulator maps it like the part’s own memory, you can load an image into it, DMA reaches it, and it survives a reset. - A memory-mapped chip, like a display on an FMC bank, implements
chip::parallel::Peer. It gets each access as an offset into its window, plus aDeviceContextfor time, events and interrupt lines. It doesn’t see the part’s registers. The part’s module lists its windows and what enables each one.stm32f427::NOR_PSRAMis NE1 to NE4, and each bank answers only while itsFMC_BCRx.MBKENis set.mcu::Wiredputs the chip in a window and passes every other access to the part.Wiredis anMcutoo, so your board holds it in place of the part. The part ismcu.partand the chip ismcu.chip()andmcu.chip_mut(). For two chips, wrap oneWiredin another. - A chip on SPI or a UART attaches to the part’s controller with
part.spi[0].attach(chip), orattach_selectedwith a chip select, like the chips in A chip on a line of your own. - A raw file loads at an address with
Image::read(path, Some(address)), like a data file in the NOR flash you added.
Here’s a board with 16 MB of SDRAM on the first SDRAM bank, 16 MB of NOR flash on NE1, a made-up display on NE4 (A16 low is a command, A16 high is data), and an FRAM on SPI1:
use std::num::{NonZeroU32, NonZeroU64};
use std::path::Path;
use std::sync::{Arc, Mutex};
use rigless::chip::fram::Fm25v02;
use rigless::chip::parallel::Peer;
use rigless::device::DeviceContext;
use rigless::emulator::{self, Driver, Host, Start, Stop};
use rigless::image::Image;
use rigless::machine::{Board, Target, armv7m};
use rigless::mcu::{Mcu, Operate, Wired};
use rigless::session::Session;
use rigless::snapshot::{Store, store};
use rigless::soc::stm32f427::{NOR_PSRAM, Stm32f427};
use rigless::soc::stm32f427::generated::FMC;
use rigless::soc::stm32f427::generated::fmc::{Register as Fmc, bcr4};
use rigless::soc::{Error, Layout, Region, Width};
use rigless::time::{NO_DEADLINE, VirtualNanos};
const SDRAM: Region = Region::ram("sdram", 0xC000_0000, 16 << 20);
const NOR: Region = Region::flash("nor", 0x6000_0000, 16 << 20).erased_to(0xFF);
/// A16 of the bank, which is byte address bit 17 on a 16-bit bus.
const DATA: u64 = 1 << 17;
/// The display keeps the last command and the data words sent after it.
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
struct Display { command: u64, data: Vec<u64> }
store!(Display);
impl Peer for Display {
fn read(&mut self, _offset: u64, _width: Width, _context: &mut DeviceContext<'_>) -> u64 {
0
}
fn write(&mut self, offset: u64, _width: Width, value: u64, _context: &mut DeviceContext<'_>) {
if offset & DATA == 0 {
self.command = value;
self.data.clear();
} else {
self.data.push(value);
}
}
}
#[derive(Debug)]
struct MyBoard { mcu: Wired<Stm32f427, Display> }
impl Store for MyBoard {
fn store(&self, to: &mut rigless::snapshot::Writer) -> Result<(), rigless::snapshot::Error> {
self.mcu.store(to)
}
fn load(&mut self, from: &mut rigless::snapshot::Reader) -> Result<(), rigless::snapshot::Error> {
self.mcu.load(from)
}
}
impl MyBoard {
fn new() -> Result<Self, Error> {
let mut part = Stm32f427::new()?;
part.bus_mut().add_region(SDRAM)?;
part.bus_mut().add_region(NOR)?;
part.spi[0].attach(Fm25v02::new());
let mcu = Wired::new(part, NOR_PSRAM[3], Display::default())?;
Ok(Self { mcu })
}
}
impl Board for MyBoard {
fn name(&self) -> &'static str { "my-board" }
fn target_names(&self) -> Vec<&'static str> { vec!["stm32f427"] }
fn mcu(&mut self, target: usize) -> Option<&mut dyn Mcu> { (target == 0).then_some(&mut self.mcu) }
fn targets(&mut self) -> Vec<Target<'_>> {
// Same core setup as in "The board" above, using the part's numbers.
vec![Target::new("stm32f427", &mut self.mcu, core)]
}
}
/// Copies the display's data to the window thread at each stop, and stops every 16.7 ms of
/// virtual time, about once per frame.
struct Presenter { frame: Arc<Mutex<Vec<u64>>> }
impl Driver<MyBoard> for Presenter {
fn on_stop(&mut self, session: &mut Session<MyBoard>, stop: Stop, _host: &mut dyn Host) -> Option<VirtualNanos> {
if let Ok(mut frame) = self.frame.lock() {
frame.clone_from(&session.board().mcu.chip().data);
}
Some(VirtualNanos(stop.at.0 + 16_666_667))
}
}
/// Runs the firmware on its own thread, with the data file in NOR flash. Your window loop reads
/// `frame` until the thread finishes.
fn start(firmware: &Path, data: &Path, frame: Arc<Mutex<Vec<u64>>>) -> Result<std::thread::JoinHandle<()>, Box<dyn std::error::Error>> {
let images = vec![Image::read(firmware, None)?, Image::read(data, Some(NOR.base))?];
let session = Session::new(Box::new(MyBoard::new()?), 0, NO_DEADLINE)?;
Ok(std::thread::spawn(move || {
let _finished = emulator::run(session, &Start::new(&images), Presenter { frame });
}))
}
let mut board = MyBoard::new()?;
let display = NOR_PSRAM[3].base;
let at = VirtualNanos(0);
// NE4 refuses accesses until the firmware enables it. Then the display takes a command and a data word.
assert!(board.mcu.write(at, display, Width::W16, 0x2C).is_err());
let bcr4 = FMC.base + u64::from(Fmc::Bcr4.spec().offset);
board.mcu.write(at, bcr4, Width::W32, bcr4::MBKEN.mask())?;
board.mcu.write(at, display, Width::W16, 0x2C)?;
board.mcu.write(at, display | DATA, Width::W16, 0xF800)?;
assert_eq!((board.mcu.chip().command, board.mcu.chip().data.as_slice()), (0x2C, [0xF800].as_slice()));
assert!(board.mcu.bus().memory().contains(&SDRAM));A Driver runs on the core’s thread during the run, so it and everything it holds must be Send. Share what the display shows through an Arc<Mutex<..>> like Presenter does, and keep your window on the main thread, where most windowing libraries want it.
§What goes wrong, and what it says
| What happened | What you see | What it means |
|---|---|---|
boot names a region that isn’t in MEMORY | lantern puts a region called rom at 0x0, but lantern-core has no such region (it has flash, sram) from Session::new, or lantern boots lantern-core from a region called rom, but lantern-core has no such region (it has flash, sram) when armv7m::Config::vector_table is set | armv7m::Core::boot has to be one of the MEMORY names |
| The firmware hit an address no model claims | unmapped at=0x40000200 pc=0x08000098 in Session::unmapped | Wrong address in the firmware, or the instance isn’t on the bus |
link.x and MEMORY disagree | The core fetches something that isn’t the vector table | link.x has to use the SoC’s origins and lengths |
| A table names a board by something other than what the board calls itself | the machine built for lantern calls itself lamp, and a run names a machine by what it calls itself from cli::main | The name in the table has to be what Board::name answers |
--link on a board with no edge | lantern has no edge a plant couples to, so it can't be an engine's target | An engine steps a board through its Edge. Implement it, or run the board without --link |
--outputs on a board with no edge | lantern has no edge, so there are no outputs to read. ... from emulator::run_printed | The outputs are the edge’s. Implement Edge, or leave --outputs out |
| An edge’s fields don’t add up to its types | heater's FIELDS add up to 2 bytes, and its Signals type takes 4 on the wire from Session::new or link::serve | Each Field::width has to be the bytes its value takes on the wire, in the order the type writes them |
A chip in CHIPS has no place | heater's CHIPS name gauge, and its `place` gives none for it from Session::new or link::serve | Edge::place answers the place of every chip CHIPS names, in that order |
§The SoCs and the machines
Everything a firmware runs on: the SoCs, the models of their peripherals, the machines built from them, and each core family.
| What | Where | Now |
|---|---|---|
| An SoC | one module under soc, behind a Cargo feature its boards turn on | 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 is the GPIO port of chapter 8. pwr is the power controller of section 5.5, and rcc the reset and clock controller of chapter 6. spi is the serial peripheral interface of chapter 28. tim is the general-purpose timer of chapter 18 against the tim2 layout, answering for all four of them, and through its Advanced family it’s the advanced-control timer of chapter 17, answering for TIM1 and TIM8. dma is the direct memory access controller of chapter 10, answering for both of them. sdio is the card interface of chapter 31. usart is the serial port of chapter 30, answering for all eight from the chapter’s two tables. otg is the reset register of the USB core of chapter 34 and nothing else, which is a partial and says so. adc is the analog-to-digital converter of chapter 13, answering for all three of them and for the common block they share. rtc is the backup domain of chapter 26 as far as the reset that clears it, a partial for the same reason and saying so. i2c is the inter-integrated circuit interface of chapter 27 as a controller, answering for all three, with target mode left out and saying so. flash is the flash memory interface of chapter 3, its registers and not the array behind them. iwdg is the independent watchdog of chapter 21, whose end of count restarts the part. dbg is the MCU debug component of section 38.16 |
| A chip | one module under chip, naming no SoC | chip::sd is the card end of a secure digital bus, and chip::memory_card is a card that answers it. chip::fram is a ferroelectric memory. chip::mpu9250 with the chip::ak8963 compass die inside it, chip::icm20608, chip::lis3mdl and chip::ms5611 are the inertial units, the magnetometer and the barometer of the first board, each written against its own datasheet. The LIS3MDL speaks SPI and, strapped by its SA1 pin, I2C too. chip::icm42688p and chip::icm42605 are two more inertial units, on a serial interface with a FIFO of packets, and chip::dps310 is a barometer on an I2C bus that hands a driver two raw words and the coefficients to turn them into a pressure. chip::qmc5883l is a three-axis magnetometer on an I2C bus that a driver polls for a ready flag and six bytes. chip::nmea is a receiver that reports a position as NMEA sentences on a serial port |
| A core family | one module under cpu and one under machine, compiled always, naming no SoC | cpu::armv7m is the ARMv7-M core and the blocks the architecture fixes at an address, and machine::armv7m the typed fields a target of it fills in |
| A machine | one module under machine declaring a board struct that implements Board. A board is behind a feature of its own that turns on its SoC’s, and an SoC’s bench is behind the SoC’s | machine::pixracer is one STM32F427 with its crystals, its rail, the chips on its buses and its six outputs. machine::stm32f427_bench is the part alone, for proving it |
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:
use rigless::mcu::{Mcu, Operate};
use rigless::soc::{Layout, Width, stm32f427};
use rigless::soc::stm32f427::Stm32f427;
use rigless::time::VirtualNanos;
let mut mcu = Stm32f427::new()?;
assert_eq!(mcu.cpu(), stm32f427::CPU);
// An instance answers behind whatever the part says has to be true elsewhere. Here that is the port's
// clock enable, RM0090 rev 22 section 2.1.11, so a firmware turns it on first and so does this.
use stm32f427::generated::{GPIOA, RCC, rcc};
let enables = RCC.base + u64::from(rcc::Register::Ahb1enr.spec().offset);
mcu.write(VirtualNanos(0), enables, Width::W32, rcc::ahb1enr::GPIOAEN.mask())?;
mcu.write(VirtualNanos(0), GPIOA.base, Width::W32, 0)?;
// The models are fields. No name, no lookup, nothing that can be absent.
let (clocks, registers) = mcu.clocks();
assert_eq!(clocks.sysclk(registers), 16_000_000, "the part wakes on its own oscillator");
assert_eq!(mcu.spi.len(), stm32f427::SERIAL_INTERFACES.len());§The models
Each model’s module opens with two tables. The first is what it covers: Register or section | Behavior | 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 doesn’t: Behavior | Source | Reason, one row for each clause of the chapter and each applicable erratum the model leaves out. Each row says why. That’s a wire the runtime doesn’t have, an analog effect with no register behavior, 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 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.
Two instances of one part are joined by four things and nothing else, because a model may not reach another model. A gate is a bit of one instance that another answers behind, which is how a peripheral whose clock is off stops answering its registers. A request line is a level one drives and another reads, which is how a serial interface tells a DMA controller it has a byte. A clock is a rate one drives and another runs on, which is how the tree tells a timer what it counts, and the timer keeps its count across the change. A requested access is a model asking the runtime to make a bus or memory access on its behalf and being handed the answer, which is how that controller then moves the byte. The first three are declared in the part’s own description beside its corrections, and rigless::soc’s documentation is where all four are written down. A model that needs none of them declares none.
A model’s boundary to the world outside the part is still methods, because the runtime carries no pin levels. 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 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<General> and Tim<Advanced> take with_clock(hz) from the part for the rate they count until the tree drives another, drive_input, drive_etr, drive_internal_trigger, and on the advanced family drive_break for the BKIN pin, and answer output(channel), complementary_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. It answers the whole clock tree: sysclk, hclk, pclk1, pclk2, timer_clock, pll48, rtc_clock, mco1, mco2, plli2s_clock and sai_clock. It drives the seven outputs something on the part runs on as clocks, so a timer, a serial interface or port, an I2C interface, the converters, the card interface, the flash interface, the watchdog and the processor’s counter follow a firmware’s retuning without naming the tree. The board’s rail goes in through the MCU’s own set_supply(millivolts), which is also how it moves, and feeds both the regulator, which answers scale, and the flash interface’s latency check. 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, like mcu.rcc or 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. A target is the MCU borrowed and the CoreSpec its emulator takes. Board::mcu is the same MCU by index, which is how the session reaches it on every access.
A machine doesn’t choose what the part has. Which of a part’s own peripherals are modeled 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’s the MCU, complete and runnable on its own. A board adds the crystals on the oscillator pins, the rail the regulator sees, and the integrated circuits on its buses, and puts them on the part’s fields when the board is built. A board that fits no crystal has no crystal. The part still has its whole clock tree.
The whole of it is a tree of concrete types, so every step of reaching anything is a field, up to a chip on a wire, which the interface’s chip_mut hands back as the type that was hung:
Pixracer ── mcu ──▶ Stm32f427 ── rcc ──▶ Rcc
├─ hse ──▶ Crystal ├─ pwr ──▶ Pwr
├─ lse ──▶ Crystal ├─ spi ──▶ [Spi; 6] ── chip_mut::<P>(n) ──▶ P
└─ rail ─▶ Supply └─ tim ──▶ [Tim<General>; 4]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 core’s description comes from the module of the MCU’s core family. armv7m is the first: one typed field for each thing the family needs to make a processor, with NonZero where a zero makes no core, 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 once, 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 FRAM and its barometer hang on the second serial interface and its two inertial units and its magnetometer on the first, each behind the select the board description names. Its serial1, the telemetry port a ground station talks to, is USART2, and rigless run listens for one on port 5760. Its serial3, UART4, has a satellite receiver on the far end that reports where the world says the board is, in NMEA sentences. Its six outputs are the description’s PWM(1) to PWM(6), four channels of the first advanced-control timer and two of a general-purpose one, read on the speed controllers’ curve. Its chips read the body’s axes as their own, since the firmware’s driver undoes the description’s rotation on the way in, and it’s built sensing a level vehicle at rest.
Stm32f427Bench is the same MCU with as little fitted as still lets something happen: a rail, one crystal, a peer on each serial interface and each serial port, and a seven-bit and a ten-bit one on the first I2C interface, each answering a known pattern and recording every word it was sent, known voltages on the converters’ pins, and a memory card on the card interface. It’s the part run standalone, which is what a conformance run wants. A board’s crystal and its sensors are things that run shouldn’t depend on, and a part has to be provable before there’s 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, each a call on the builder or a struct update over a generated constant, with the manual’s section named, and exposesCPU,MEMORY, andbuilder(). -
Add the feature to
Cargo.toml,<name> = ["tables"], indefaultonly when no board there turns it on, and the module tosrc/soc.rsunder#[cfg(feature = "<name>")].tablesis what the generated tables import the crate through, sosrc/lib.rsneeds nothing. -
Add the feature to the checks in the
lintjob of.gitlab-ci.yml, which build 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 for the peripheral rather than for that layout:tim.rsis written againsttim2,gpio.rsagainstgpioa. - 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 can’t 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, a slot inSlots, a row inField::all, and an arm inField::instance, in the slot-resolving match, and insplit. OnlyField::allisn’t a match the compiler checks, so it’s the one to remember.Operate::checkcatches a field wired to the wrong slot when the part is built. - Where the vendor gives one instance a shorter table than the layout the model is written against, and that shorter table is a prefix of it, override
Device::layoutto return the instance’s own. That is what letstimanswer for all four of chapter 18’s timers across two tables, and it is what the wiring check holds each slot to. - Add the model and its test file to
every_test_a_models_table_names_is_a_test_that_exists, which is what holds the tables of step 2 to naming tests that exist. A model missing from that list is a model whose tables nothing checks. - Add the instance’s register offsets to the bench firmware’s walk. Binding a model claims the whole instance, so the coverage gate wants every register of it reached, not only the ones the model answers for. A partial model is still walked whole and accounts for the rest in its second table.
- Expect the part’s own checks to fire, and answer each with a reason rather than by loosening it. A model with no clock enable behind it, or an instance the manual gives no interrupt line, is a real thing the part does. Each has an exception table next to the rule it breaks, so the reason is found where the rule is.
- A controller that chips hang on marks what a trigger counts on each chip’s
chip::Place:beginbefore it reaches the chip for a transaction,releasewhen the transaction ends, and every word the chip answers throughanswered, or throughsenton a line where each word is a transaction of its own. Hand the place out with aplace_mut. A controller that marks nothing has chips no trigger counts and no flip reaches.
§Adding a chip
Chips live under src/chip. Each one is written from its own datasheet and doesn’t name an SoC, so any board can use it. A chip implements the trait its controller calls: chip::spi::Peer on SPI, chip::uart::Peer on a UART, chip::sd::Card on the SD interface, chip::i2c::Peer on I2C (the part’s bus or one inside another chip), and chip::parallel::Peer in a window of the part’s addresses. Those traits live next to the chips for the same reason. Every one but parallel::Peer hands the chip a chip::Timeline with each word, the controller’s context narrowed to the time and its event queue, so a chip reads the time and schedules an event it hears at its own on_event, which the controller hands every chip it carries, silenced or not. An event a chip scheduled doesn’t survive a reset of its part, though the chip does, so the controller calls its restarted at the reset’s instant and a chip that keeps time schedules again there. A chip in a window gets the whole DeviceContext, since the window has interrupt lines of its own. An SPI word is a u32 with the frame’s width beside it, so a part with wider frames needs no other seam. A chip’s state is what it was sent. A stand-in for a chip has no real state, so it keeps a record of what it saw. A chip’s docs open with the same two tables as a peripheral’s, with the same four columns in the first, so add the chip to the check that holds those tables to real tests. The board attaches the chip to its controller when the board is built. On SPI it goes behind the chip select the board names (a port, a pin and the active level), and that pin’s level reaches the controller as its request line. On I2C its address selects it. Unit tests drive the chip directly through its trait, with a controller’s context wrapped in a Timeline. The firmware the board runs tests the chip and its select together. A chip that measures something has setters named after its datasheet, like set_rate, set_field and set_pressure, each in the chip’s own axes, and reports that value until it’s set again. The board gets to them through the controller’s chip_mut, or a card through Sdio::card_mut, which hands the chip back as the type it attached, and the board converts body axes to the chip’s axes.
§Adding a machine
Declare a struct whose fields are what’s on the board: its MCU or MCUs, its crystals, its rail and the chips on its buses. 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 CoreSpec from its core family’s module, and mcu hands the same MCU back by index. Give it a feature of its own in Cargo.toml that turns on its SoC’s, and name it in default if the default build should carry it. Gate its module, its row and every test that builds it on that feature, add the feature to the checks in the lint job of .gitlab-ci.yml, and add the row to the table machine::all() returns, which is how rigless run and rigless run --link both find it. An SoC’s bench rides on the SoC’s own feature instead. You hand a board outside this crate to cli::main in a table of your own instead. A board with two MCUs is two targets. A process runs one of them, --target says which, and targets answers with the same MCUs in the same order every time. A board that drives or senses something implements Edge with its own Outputs, Signals and Conditions. Its REST is what the board senses when it’s built, and all three types implement link::Wire, which is what lets edge answer with the board itself. condition turns each silence onto the chip’s place with its controller’s silence, which answers whether a chip hangs there, and senses what the board last sensed again, so a condition takes effect at once. sense hands each chip what its Sensed answers under its Condition, so one chip can read wrong while the one beside it reads the truth. draw has each Sensed draw its noise for the step, keyed by the noise condition’s own name, and senses again when any moved. A serial line’s error rates go on the port’s chip::uart::Errors in condition, and its seed in draw. drive reads each output off the pin its description wires it to, as whatever that pin carries means to the thing on it, and sense turns the body’s axes into each chip’s own before handing the signals over. CHIPS names every chip a trigger or a one-shot fault can reach, and place hands out each one’s place by that name, through its controller’s place_mut. Its edge answers itself.
§Adding a core family
A family is a core and the spec that makes it, and neither has to live in this crate. The core implements emulator::Processor: it runs a stretch of the family’s instructions on the emulator::CoreBus it’s lent, and between stretches it does what the runner, the models and a debugger ask of it. The trait’s documentation says what the runner relies on, from how instructions are counted to when the core hands control back. A function makes the core from the family’s own typed configuration, and refuses one the core can’t be. A machine module renders the spec: a closure in processor that hands that configuration to the function, an enum of the variants it accepts, and the rate the core retires instructions at. emulator::run calls whatever constructor the spec carries, so no generic module changes and nothing in the runtime names your family. cpu::armv7m::processor and machine::armv7m are the family this crate carries. Its code generator, page table and block cache stay private, so a family of your own brings its own way to run instructions, an interpreter or a translator. A family names the ELF machine its core runs in CoreSpec::elf, so a firmware built for another is refused before it runs, and says in CoreSpec::code_address where a function’s first instruction is from its symbol’s value. Its processor answers Processor::data_model, how wide a pointer and a long are, which the run reads the firmware’s RTT block, a dprintf’s long, a host call’s elapsed time and a debugger’s widest access by. A family that maps memory into its core says how big its pages are, and one that maps nothing takes regions of any size, every access reaching it through the bus. A family answers, address by address, whether it can stop before an instruction there, and the run refuses a debugger’s breakpoint or a watch on an instruction it can’t keep. One that never stops at an instruction writes none of the breakpoint methods. A family that asks CoreBus::stops_before before each of an instruction’s accesses has a debugger’s watchpoint stop the core before the access, and one that doesn’t still stops for it, right after the instruction that made the access. A kernel’s walk reads its switched-out threads back by the names the family’s target description gives its registers, so a family writes nothing for GDB to see them. A board whose MCUs are of two families runs each as its own target.
cpu::armv7m::processor also runs the ARMv7-M core without a session: make a machine::armv7m::Config from a machine::armv7m::Core, map pages of your own memory into the processor, its page_size bytes each, boot it and run it on a CoreBus of your own.
§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, where a variant's index is its row in [`REGISTERS`].
pub enum Register {
/// `CTRL` at offset `0x0`, resets to `0x0000_0000`, read-write.
Ctrl = 0,
/// `STATUS` at offset `0x8`, resets to `0x0000_0002`, read-write.
Status = 2,
}
/// The fields of `CTRL`.
pub mod ctrl {
/// `EN`, bit 6.
pub const EN: &FieldSpec = &super::REGISTERS[0].fields[4];
}
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:
rigless svd generate tests/fixture§Building and testing
.gitlab-ci.yml is the source of truth for how this is built and tested. Every job’s script is a command you can run yourself, so neither this page nor the README copies them, and neither can then tell you something the pipeline has stopped doing. What belongs here is what the suite proves.
Sixteen test binaries boot a firmware on the core built in, and they need one built under firmware/, bear-bones, conformance or the template, failing rather than skipping without it and saying so. 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 moved to another line that proves the first would notice, two runs of the binary that print the same thing to the byte, three runs at once in one process that are the same run, a debugger that stops it at a breakpoint and a watchpoint without changing it, and one that steps it and writes its flash, after which the run takes no snapshot. conformance is the bench, every check the conformance firmware declares, held to a budget the job enforces. debugger holds bear-bones on its read and access watchpoints and restarts it with extended mode’s run, and runs a program a few instructions long to show a BKPT halting the core for a debugger, a step over a faulting load stopping at its handler, memory nothing answers refused, monitor reset, the interrupt reaching a core that waits, the registers against the target description with and without a floating-point unit, the protocol’s framing, and one debugger at a time. It also runs GDB itself, gdb-multiarch, against the binary on bear-bones, and fails rather than skipping when that isn’t installed. debugger_protocol holds the server to “Debugging with GDB” for GDB 18.1, appendix E where it’s particular, thread-ids in both forms among it, and runs GDB itself for what only GDB’s use of the protocol shows, like its own record full. debugger_stops holds where a debugger stops the core and what that costs the run: a run serving GDB with nobody attached printing what a run without it prints, the interrupt reaching a core that has waited or slept a long way, watchpoints well past silicon’s four that stop the core before the access they watch, or right after the instruction once a model has seen part of it, and halt points at an exact instruction count or instant, with GDB itself reading and breaking on $icount. debugger_peeks holds a debugger’s reads to changing nothing: every model peeked on a busy part, bear-bones read in its TIM2 handler with the run it makes and the run it prints unchanged, monitor peek off bringing the firmware’s read back, a peripheral whose clock is off, and a stop by the interrupt, a step or a halt point finding every model settled at its instant. debugger_memory holds what a debugger reads and writes of memory and flash, and what DFSR says halted the core. debugger_live holds conditions and dprintf worked out in the target, and non-stop mode reading memory while the firmware runs. debugger_reverse holds monitor save, monitor restore, reverse-stepi and reverse-continue to the run that never went back, by what it hears and what it prints. two_targets debugs both MCUs of a board with two, one connection each. gdb_sessions runs GDB’s whole sessions under tests/gdb against bear-bones and its other programs and holds each to the transcript beside it. semihosting runs bear-bones’ programs that print, exit, and write and read files on GDB’s host, and output_ports the ones that write to the ITM and to RTT’s terminal. link spawns the binary as an engine would and steps it across the link. serve serves a board from outside the crate’s table of machines as an engine’s target, on the template firmware. snapshot stops bear-bones and the conformance bench, saves each, and restores it into a new core that runs on as the run that never stopped did. core needs no firmware: it runs programs a few instructions long, to show a core that locked up resets and runs on, a floating-point exception pends no interrupt line, an exception’s entry stores at the instruction it interrupts, and a stop saves the same bytes however the run was split into stretches on the way there. outside_family and core_alone need none either, and see only the public API: a toy family the test writes runs on the runner as this crate’s own does, a debugger’s watchpoint stopping it though it never asks before an access, and the ARMv7-M core runs on a bus of the test’s own with no session around it. cortex_m7 needs none either: a Cortex-M7 on a board of the test’s own boots from a vector table away from zero and runs a start-up’s cache enables and maintenance, its writes to registers of the part’s own, and a double-precision divide.
Everything else runs with no core: the session driven by hand as the core drives it, the link’s frames read back as written and refused when they aren’t, and every model against its manual. The runtime alone, with no SoC and no command line, is what the --no-default-features checks build. Miri runs it too, with the core absent, since it can’t execute the code the core generates.
The fuzz targets under fuzz/ take what comes from outside a run and the core itself: arbitrary code on the core, a processor’s saved state, a snapshot file, what a debugger sends, the link’s frames, a firmware image and its symbols, and an SVD for the generator. Each has to answer or refuse its input, and never panic, hang or touch memory it doesn’t own. The fuzz job runs them on every merge request with cargo fuzz and its address sanitizer. To run one yourself, on the nightly toolchain cargo fuzz needs and naming your host’s target, since the cargo fuzz you install may default to another:
cargo +nightly fuzz run --target x86_64-unknown-linux-gnu core§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. 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.
Modules§
- chip
- The chips a board puts on a part’s buses, and the seams they attach through.
- cli
- The
riglesscommand, over any table of machines. - coverage
- Which of an SoC’s registers a run reached, taken from the accesses a session already reports.
- cpu
- Each processor family: its core, and the blocks its architecture puts at a fixed address rather than the part.
- device
- What a peripheral model implements, and the register file the runtime keeps for it.
- edge
- The board’s edge: what crosses it, and the trait a board with a world implements.
- elf
- The smallest reader of an ELF file that the runtime needs: the file’s machine, class and byte order, its section headers, which the firmware’s symbols and its stored bytes come from, and its program headers, which say where those bytes are stored. It reads 32-bit and 64-bit files of either byte order and nothing else of the file.
- emulator
- The emulator, and the seam between it and the models.
- image
- The images a run places in memory, and where the core boots from.
- link
- The link between an engine and a target: one process steps a plant, the other runs a firmware, and they meet once per plant step.
- machine
- A machine: the board, which has its MCUs and the parts wired to them.
- mcu
- What joins a
Busto the models that give its peripherals behavior. - session
- One firmware on one target of one machine: the bus, the events, and what the run records.
- snapshot
- A target’s whole state at a stop, written to a file that a new process starts from.
- soc
- The static description of a system on chip, and the
Busthat gives its addresses meaning. - svd
- A vendor’s CMSIS-SVD file as the register tables an SoC is built from.
- symbols
- The firmware’s own names for its code and data, read from its ELF symbol table.
- time
- The virtual clock every access and every event is stamped with, and the events waiting on it.
- version
- What this runtime calls itself.
Macros§
- store
- Implements
Storefor a type.store!(Type)saves the whole value through serde. With fields,valuesare saved through serde,partssave themselves, andbuiltare kept as construction left them, and every field is named, so one added later doesn’t compile until it’s placed. A value derivesserde::Serializeandserde::Deserialize, and a part implementsStoreitself, often with this. - wire
- Implements
Wirefor a struct or a tagged enum, naming each field once in the order its bytes cross. A struct crosses as its fields, each as its own type writes it, and a field namedwitha function is read by that function. An enum crosses as its variant’s tag, one byte, then the variant’s fields, and a tag that names no variant is refused by the text afteras, like3 is no answer. Every field and every variant is named, so one added later doesn’t compile until it’s placed.