Skip to content
SimanticDashboard

The sim guide: start, test, debug, profile

This is the one document to read before using sim, the Simantic MCU simulator, on real firmware. It covers getting a binary running, turning a run into a repeatable test, finding out why firmware misbehaves without halting it, and measuring where the guest spends its time. Flag-by-flag reference lives in sim --help and the README; this guide is about which flag to reach for and what its output looks like.

Every command and output format below is taken from the shipping code. Where an older document disagrees with this one, this one is right.

Contents

  1. Start
  2. Run a firmware image
  3. What a run can emit
  4. Test
  5. Scripted peers: the device on the other end of the wire
  6. Testing from Python
  7. Multi-machine scenarios
  8. Debug
  9. Profile
  10. Host-facing links
  11. Cheat sheet

1. Start

Install

Releases are a zip whose single entry is the sim binary. Platforms: linux-x64 and osx-arm64.

curl -fsSL https://drjdhqfvrttolueolzif.supabase.co/storage/v1/object/public/releases/cli/<version>/osx-arm64/sim-v<version>-osx-arm64.zip -o sim.zip
unzip sim.zip && chmod +x sim && mv sim ~/.local/bin/
sim --version

The zip also carries this guide as GUIDE.md.

Later: sim update downloads the newest release, checks its SHA-256 and replaces itself. sim update --check only reports. It never downgrades.

Sign in (only for --mcu)

MCU models are pulled from mcu-lib with your account. After the first pull, --use-cached runs offline.

sim login              # browser device-code flow; prints a one-time code and a URL
sim auth               # headless/CI: paste an smtc_ token; writes ~/.sim_id (0600)
sim models             # list the MCU names --mcu accepts

sim user shows the signed-in profile. sim telemetry shows or toggles usage stats.

Two ways to name the target

Flag What it is Network
--mcu STM32H753IITX A model from the Simantic backend (sim models). Validates your token, fetches the platform. --use-cached reuses ~/.sim_cache/<name>.json after the first fetch, so later runs are offline. yes, first run
--scenario run.yaml Several machines and the media between them. Each machine names an mcu: and can list parts:. yes, first run

--configuration project.ioc hands the platform your STM32CubeMX project so clock trees, prescalers and pin muxing come from your settings rather than defaults. Pass it whenever you have one; a missing key that the platform needs is an error rather than a silent default.

First run

sim --mcu STM32F401RE --elf build/zephyr/zephyr.elf --timeout 5 --ascii
cat output.txt

--timeout is virtual seconds. The sim free-runs as fast as the host allows, so 5 virtual seconds of a blinky finish in well under 5 wall seconds, while 5 virtual seconds of a firmware that busy-waits can take longer (see Profile). Keep timeouts at 2 to 5 s while iterating; raise them only for a behaviour that needs the time.

On a TTY the run shows a pinned status line, Runtime: <t>/<timeout>s (virtual) | Mem: .... At the end it prints Runtime, Elapsed (wall) and Resources.


2. Run a firmware image

ELF

--elf is the normal case: symbols are read from it, so --trace-symbol, --trace-memory and the profiler can use names.

Flash image (ESP32 ROM boot)

For an MCU that boots from mask ROM (ESP32-C3, C6, S3), pass the merged image from idf.py merge-bin as --flash. sim fetches the vendor ROM once (pinned, checksummed) into ~/.sim_cache/roms and boots through it. --rom supplies your own dump for offline use.

Extra binaries and media

  • --load file@target: a raw binary after the ELF, at a sysbus peripheral name or an address (an XIP flash image, a filesystem). Repeatable.
  • --sd image.img: a FAT image inserted into the SD/MMC host. Make one with mformat -i sd.img -F :: and mcopy -i sd.img file ::/.

Parts: what is on the board besides the MCU

A machine in a scenario (or a test.yaml) lists its board-level devices under parts:: a scripted sensor on I2C or SPI, a peer on a UART, a CAN node, an SD card. Each entry is a name, a type, where it attaches and (for scripted ones) the Python script that plays it; see §5 for the types and §7 for a full example. The MCU model itself is never edited. For a device parts: cannot express, such as an observer wired to a pin, overlay: sensors.board on the machine takes your own board file.


3. What a run can emit

Everything is stamped on the same virtual clock, so any two files from one run line up by timestamp. Nothing halts the guest.

UART: --output (default ./output.txt)

Default is hex, 16 bytes per row, with the record’s time and the peripheral:

[0.005538s] (UART6) B5 62 06 8A 09 00 00 01 00 00 07 00 91 20 01 53 48

--ascii rebuilds the firmware’s own lines: a line is emitted on \n, \r is dropped, TAB passes through, other non-printable bytes become .. The stamp is that of the record which completed the line; an unterminated line at the end of the run is stamped [end].

[1.500000s] (UART0) *** Booting nRF Connect SDK
[end] (UART0) no newline

--only-messages drops the [t] (label) prefix. In a multi-machine scenario every row gets a [machine] prefix, including with --only-messages.

Binary protocols (UBX, MAVLink, CRSF) stay in hex mode. --ascii mangles them.

Bus frames: --frames <file>

One line per bus transaction: CAN, I2C, SPI, BLE, 802.15.4, Ethernet. This is the right surface for testing anything on a bus. Line shape:

[<machine>] [<t>s] (<label>) <PROTO> <Tx|Rx|Dropped|Corrupted> <summary>
Protocol Summary Meaning of direction
CAN id=0x123 dlc=8 data=DE AD BE EF 00 11 22 33, plus ext and rtr when set Tx from the controller, Rx into it
I2C addr=0x17 write len=2 data=00 00 / addr=0x17 read len=2 data=FF 00 Rx write = master wrote to the slave; Tx read = master read
SPI cs=0 len=2 mosi=A5 00 miso=00 7F always Rx; MOSI is what the slave received
BLE, 802.15.4, Eth len=39 data=D6 BE 89 8E ... (data truncated at 32 bytes, … (+N B)) radio Tx/Rx

A real I2C capture (a Notecard on an L433):

[1.060054s] (notecard) I2C Rx addr=0x17 write len=46 data=2D 7B 22 72 65 71 22 3A 22 63 61 72 64 2E 76 65 ...
[1.086026s] (notecard) I2C Rx addr=0x17 write len=2 data=00 00
[1.088024s] (notecard) I2C Tx addr=0x17 read len=2 data=FF 00

Media impairments in a scenario add Dropped and Corrupted lines with a reason= prefix, e.g. BLE Dropped reason=receiver muted. Those are also echoed to stdout.

A frame is written when the controller model signals the end of a transaction. I2C is one line per addressed write or read; SPI is one line per chip-select window (STM32H7, LPSPI) or per completed transfer. A real SPI capture, an ICM-42688 on an STM32H753 (WHO_AM_I burst read, then two INT_STATUS polls):

[0.516903s] (icm) SPI Rx cs=0 len=13 mosi=9F 00 00 00 00 00 00 00 00 00 00 00 00 miso=00 00 00 11 22 33 44 55 66 77 88 99 AA
[0.518930s] (icm) SPI Rx cs=0 len=3 mosi=F5 00 00 miso=00 47 00

Where the boundary comes from, per controller: STM32H7 SPI at end of transfer (EOT); STM32F0/F1/F4/L4 SPI when the driver clears SPE, and on the slave’s own chip-select line when the part declares one (cs: PA4 on an spi-device; active low), which is the hardware boundary; STM32F4 and F7/H7 I2C on STOP or a repeated START; nRF52 TWI and NXP LPSPI on their own transaction end. A controller that uses neither path yields no lines; a transaction that never ends carries [TRUNCATED at 4096 B -- FinishTransmission never seen] after 4 KiB, so a gap is visible rather than silent. F4 I2C and SPI framing needs sim 0.5.17 or later.

Wireshark: --pcap <file>

CAN, BLE, 802.15.4, Ethernet and 802.11 frames as pcapng, one interface per (machine, protocol). Opens directly in Wireshark or tshark. Link types and caveats are in the README.

Everything, in order: --trace-out <file.jsonl>

One JSON object per line, virtual-time order, every record kind the run produced. Parse this from any language for a checker that needs more than a substring. Every record has t (seconds), kind and machine.

kind Fields
uart label, hex (lowercase, no spaces)
frame label, protocol (Can, I2c, Spi, Ble, Ieee802154, Eth, WiFi), direction (tx/rx/dropped/corrupted), summary, id, dlc, data_hex, miso_hex (null when not applicable)
interrupt direction (enter/exit), exception_index, name (best-effort ARMv7-M: SysTick, IRQ5)
gpio port, pin, level (0/1). Only recorded when --trace-out is set
symbol symbol, pc, args = [{register, value, symbol}]
final-state cpu, pc, pc_symbol, sp, lr, lr_symbol, halted, executed_instructions, spin_skipped_instructions, sleep_skipped_instructions
{"t":0.001234,"kind":"uart","machine":"dut","label":"usart2","hex":"48656c6c6f0d0a"}
{"t":0.045012,"kind":"interrupt","machine":"dut","direction":"enter","exception_index":15,"name":"SysTick"}
jq -r 'select(.kind=="frame" and .protocol=="I2c") | "\(.t) \(.summary)"' run.jsonl

Interrupts: --trace-interrupts

Interleaved into --output, one line per entry and exit:

[0.045012s] (IRQ) SysTick enter
[0.045020s] (IRQ) SysTick exit
[0.100004s] (IRQ) IRQ37 enter

Function calls: --trace-symbol <name|0xADDR>

A non-halting hook that prints the argument registers each time the PC reaches the symbol. Takes a C name, a C++ qualified name with or without its parameter list (every overload is hooked), a mangled name, or a raw address for stripped images. Repeatable.

[0.001420s] (TRACE) HAL_SPI_TransmitReceive_DMA pc=0x8004F10 r0=0x24000A40 <hspi1> r1=0x24001000 r2=0x24001100 r3=0x10

stderr says how each name resolved: [trace-symbol] 'GPS::configureUBX' resolved to '...' at 0x..., or not found in dut's ELF; skipping.

Data watches: --trace-memory <symbol|0xADDR[:len]>

Every read or write of a variable or address range, with value and PC, plus a count at the end:

[0.230010s] (MEM) uwTick write addr=0x2400001C value=0xE7 pc=0x800A3C2
...
(MEMWATCH) uwTick: 5231 hits (12 read, 5219 write), first=0.001000s last=4.999000s

Peer logs

Scripted peers’ ctx.info(...) lines print on stdout next to the firmware’s UART output, stamped with virtual time.

Debug-probe streams without a probe

Flag Stream Read it with
--swo-tcp <port>, --swo-file <f> ITM/DWT packets, exactly what a probe’s SWO pin carries orbcat -s 127.0.0.1:<port> -p ITM -c '0,%c' (orbuculum), or scripts/swo_decode_pyocd.py on the file
--swo-udp host:port, --swo-udp-port N one ITM stimulus port, unframed any UDP listener
--dwt-pc <N>, --dwt-exc, --dwt-watch 0xADDR[:size] PC sampling, exception trace, up to four data comparators, into the SWO stream same decoders
--rtt-tcp <port>, --rtt-file <f>, `–rtt-channel <idx name>` SEGGER RTT up channel, found in RAM and polled like a probe does; works on stripped images
--etm-tcp <port>, --etm-file <f>, --etm-range lo-hi one 17-byte record per retired instruction: flags, PC, branch target, cycle stamp your own reader; the format is documented in the CoreSight README

4. Test

Make the firmware self-verifying

The cheapest test is one the firmware writes itself. Print a verdict line and assert on it:

printk("RESULT: %s\n", ok ? "PASS" : "FAIL");
sim --mcu NRF52840QIAA --elf app.elf --timeout 3 --ascii --only-messages
grep -q '^RESULT: PASS' output.txt && ! grep -q 'RESULT: FAIL' output.txt

Test what went over the wire, not what the firmware said

A firmware that prints “sensor OK” has told you about its own bookkeeping. The bus tells you what actually happened. Use --frames:

sim --scenario zp4.yaml --timeout 8 --frames frames.txt --output out.txt
grep -c 'I2C Rx addr=0x45 write' frames.txt          # INA228 configured?
grep 'SPI Rx cs=0 len=2 mosi=4F' frames.txt          # GYRO_CONFIG0 written with the expected ODR?
grep 'CAN Tx id=0x' frames.txt | head

Three layers, in order of how much they prove:

  1. --frames: the bytes the device saw, with timing. “The driver wrote GYRO_CONFIG0 = 0x06” is one grep.
  2. --trace-symbol: the call path and its arguments. “HAL_SPI_TransmitReceive_DMA was called with 16 bytes” proves the driver reached the HAL.
  3. --trace-interrupts: the completion path. “EOT and the DMA IRQ fired 6 µs after the frame” proves the round trip.

For anything with a checksum or a frame structure (MAVLink, UBX, CRSF, a CAN protocol), decode the hex from --output or --trace-out with the protocol’s own library in your language. A checker written in the firmware’s language, using the firmware’s own headers, needs no second implementation of the protocol.

Timing assertions

Every line is stamped in virtual time at microsecond resolution, so a rate is a subtraction. The NAV-PVT period a GPS driver programmed, the 1 Hz heartbeat, the 100 ms bus-thread cadence: measure them from consecutive stamps, not from what the peer or the firmware claims. Peers pace their replies at the declared baudRate, and bus models charge transfer time from their programmed clock dividers, so wire timing is the model’s, not a constant.

Negative controls

A test that cannot fail proves nothing. Detach the peer (drop it from parts:) and require the absence of the result: no GPS_RAW_INT frames, but HEARTBEAT still flowing. sim-fixtures’ expect_absent and expect_frames_absent exist for this.

Package it as a fixture: test.yaml

sim-fixtures’ runner turns a directory with a test.yaml into a CI job. It resolves the MCU from mcu-lib, attaches the parts, runs sim --ascii --only-messages, and checks substrings.

mcu: STM32H753IITX
parts:                          # optional; devices on the board, see §5
  - { name: card, type: sd-card, bus: sdmmc1, size: 16MiB, image: card.img }
  - { name: baro, type: i2c-device, bus: i2c2, address: 0x63, script: icp20100_peer.py }
elf: zp4-h753.elf
elf_external: true              # third-party build; missing ELF is SKIP, not FAIL
timeout: 8                      # virtual seconds
wall: 90                        # wall-clock ceiling; default 30 s wired, 100 s wireless
sim_args: ["--trace-interrupts"]
expect:        ["HEARTBEAT", "GPS fix: 3"]
expect_absent: ["Guru Meditation", "RESULT: FAIL"]
expect_frames:        ["I2C Rx addr=0x45 write", "SPI Rx cs=0"]
expect_frames_absent: ["CAN Dropped"]
ci: [simantic]

Multi-machine fixtures declare machines: and media: instead of mcu:, and run through --scenario; expect strings then carry the [machine] prefix. A fixture’s media: and nets: are part of its wiring: a peer on a UART or CAN medium, or a GPIO net between two pins, does nothing without them. When you turn a fixture into a hand-written scenario, carry those keys across.

./run-smoke.sh /path/to/sim tests/warg/zp4-h753      # one fixture
./run-smoke.sh /path/to/sim                           # everything

Other keys: pad and pad_route (GPIO waveform checks through --trace-out), quantum, seed, serialExecution, nets, xfail (per engine, with a reason), hardware (skip, needs a bench), negative and fidelity_gap (labels). The runner refuses a fixture with no expect.

The wall-clock cap is deliberate. A fixture that takes 60 s of host time for 2 s of virtual time is telling you about a model gap or a firmware busy-wait, and the runner treats it as a failure rather than something to wait out.

In GitHub Actions

- run: curl -fsSL <release zip url> -o sim.zip && unzip sim.zip && chmod +x sim
- run: ./sim auth <<< "$SIM_EMAIL"$'\n'"$SIM_TOKEN"
- run: ./sim --mcu STM32H753IITX --elf build/app.elf --timeout 5 --ascii --frames frames.txt
- run: grep -q 'RESULT: PASS' output.txt && grep -q 'I2C Tx addr=0x63 read' frames.txt

5. Scripted peers

Firmware talks to things. A scripted peer is the thing on the other end: a GPS that ACKs configuration and streams fixes, an IMU that fills a FIFO at the ODR the driver programmed, a CAN node, a GCS. Peers are short Python scripts (typically 40 to 130 lines) loaded by the platform, running inside the sim process on virtual time. Using a shipped peer needs no code; shaping the scenario your test needs (a sensor that fails at t = 3 s, a GPS that loses fix, a CAN node that stops answering) means editing or writing one, and that is the point of them. Chip-decided figures belong at the top of the script as named, datasheet-cited constants.

Wire a peer in as a parts: entry on the machine (sim 0.5.17+). Script paths are relative to the scenario or test.yaml file.

type Keys Script class and hooks ctx
uart-device script, baud (default 115200); connect through a uart medium class Peer: on_byte(b), optional on_break(), on_timer() `send(bytes
i2c-device bus: i2c1, address: 0x45, script class Slave: on_write(data), on_read(count) -> bytes, optional on_address(addr, is_read) -> bool (False NACKs), on_finish(), on_timer() now_us, gpio(line).set(level), timers, busy_until(us), busy, log
spi-device bus: spi1, script, cs: a chip-select pin such as PC4 when several devices share the bus (an index on controllers that take one) class Slave: on_transmit(b) -> miso byte, optional on_select(), on_deselect(), on_finish(), on_timer() same as I2C
can-node script; connect through a can medium class Node: on_frame(frame) with frame = {'id','data','extended','remote','fd'}, optional on_timer() send(id, data, extended=False, remote=False, fd=False, brs=False), timers, log
ble-peer, wifi-peer, eth-peer script; wifi-peer takes ssid, passphrase, eth-peer takes mac; connect through a ble/wifi/eth medium class Peer with the radio hooks (see tests/nrf/nrf52/ble-scripted-peer, tests/esp/esp32c3/wifi-peer, tests/stm/nucleo-f767zi/eth-counterpart) send, timers, log
sd-card bus: sdmmc1, size: 16MiB, image: card.img (a formatted image; a blank card has no filesystem to mount, so make one once: mformat -C -i card.img -T 32768 -h 16 -s 32 ::) none
model a stock part from the catalogue: class: Sensors.BMP388, bus: i2c4, address: 0x77, properties: { Temperature: 25 } (also EEPROMs, FRAM, Ethernet PHY, buttons, cellular modems) none
peripheral address, size, script, optional irq: [nvic@40] class Peripheral: on_access(req), optional on_timer(), on_uart_rx(b), on_gpio(line, value) bus_read(addr, len), bus_write(...), bus_read_bytes, cpu_reg(name), irq(n).set()/clear()/pulse(), watch(addr), uart_write, config (JSON), timers, log

Timers on every class: schedule_periodic(period_us), schedule_oneshot(delay_us), cancel_timer(). Logging: ctx.log(level, msg) or ctx.info(msg) etc., printed on stdout.

The smallest useful peer, a UART echo:

class Peer:
    def __init__(self, ctx):
        self.ctx = ctx

    def on_byte(self, b):
        self.ctx.send(bytes([b]))
# scenario or test.yaml
machines:
  dut:
    mcu: STM32F401RE
    elf: fw.elf
    parts:
      - { name: peer, type: uart-device, script: uart_peer.py }
media:
  - type: uart
    connect: [dut.usart1, dut.peer]

A peripheral part is also the way to close a loop with no external process: a tiny peripheral that polls the timer’s CCR registers with ctx.bus_read on a periodic timer, steps a plant model, and shares the result with the IMU slave through a plain Python module import. All scripts in one run share one interpreter.

Peers are the seam for fault injection, too: an I2C slave that returns False from on_address NACKs; busy_until makes it NACK for a while; a UART peer that sends a break; a CAN node that never answers. What a slave cannot yet do is stretch SCL or NACK a single data byte mid-transfer.

Examples worth copying: sim-fixtures tests/warg/zp4-h753/*_peer.py (GPS, IMU, baro, power monitor, rangefinder, CRSF, DroneCAN, TIM1 actuator), tests/stm/nucleo-g474re/can-counterpart/can_peer.py, tests/stm/nucleo-f401re/i2c-counterpart-stale/i2c_slave.py, tests/SCRIPTED-COUNTERPARTS.md.


6. Testing from Python

Grep and test.yaml cover “did it happen”. Anything with a protocol, a timing budget or a sequence (a GPS fix reaching telemetry, a rate matching what the driver programmed, an allocator granting a node id) is a pytest. Use the simantic package: the same engine, hosted inside your Python process, with the scenario under the test’s control.

pip install simantic
simantic auth          # once, to name MCUs by part number
simantic status        # shows which sim and engine resolved

The package downloads the engine matching the current sim release into ~/.simantic/ on first use. simantic install engine refreshes it after a sim update.

One test

from simantic import Sim

SCENARIO = {
    "machines": {"dut": {
        "mcu": "STM32H753IITX", "elf": "zp4-h753.elf",
        "parts": [
            {"name": "imu0",     "type": "spi-device",  "bus": "spi1", "cs": "PC4", "script": "icm42688_peer.py"},
            {"name": "gpspeer1", "type": "uart-device", "baud": 230400, "script": "gps_peer.py"},
            {"name": "canpeer",  "type": "can-node",    "script": "dronecan_peer.py"},
        ],
    }},
    "media": [
        {"type": "uart", "connect": ["dut.usart6", "dut.gpspeer1"]},
        {"type": "can",  "connect": ["dut.fdcan1", "dut.canpeer"]},
    ],
}

def test_imu_is_configured_then_streams():
    with Sim(scenario=SCENARIO, machine="dut", uart="usart1", trace_interrupts=True) as sim:
        sim.run_for(3.0)                                   # virtual seconds, deterministic
        spi = [f for f in sim.frames() if f["label"] == "imu0"]
        cfg = [f for f in spi if f["data"][0] == 0x4F]     # GYRO_CONFIG0 write
        assert cfg and cfg[0]["data"][1] & 0x0F == 0x06    # ODR = 1 kHz
        fifo = [f for f in spi if f["t"] > cfg[0]["t"] and len(f["data"]) > 16]
        assert len(fifo) > 100                             # FIFO bursts after configuration

The scenario dict is the same shape as the YAML file in Multi-machine scenarios. Paths resolve relative to the current directory; peer file: paths inside a fragment do too.

What Sim gives you

Call Returns
expect(pattern, timeout=) Match(t, text) when the UART text matches; raises ExpectTimeout with everything collected so far
run_for(seconds) advances exactly that much virtual time, then holds
read_uart(), uart_records() text so far; [{t, machine, label, text}] per burst
frames(from_start=) [{t, machine, label, protocol, direction, summary, id, data}], one per bus transaction (CAN, I2C, SPI, BLE, Ethernet)
interrupts() [{t, machine, direction, exception, name}] (needs trace_interrupts=True)
symbol_trace() hits on trace_symbols=[...] with argument registers
read_u32(symbol), read_memory(addr_or_symbol, n) a variable straight out of RAM, non-halting
threads(), heap() RTOS thread states and stack margins; heap arena use
send(text), send_bytes(b) host-side UART input
inject_gpio(port, pin, level), inject_can(peripheral, id, data), inject_radio(...) stimulus from the test
logs() simulator-side warnings: unhandled registers, model gaps
time current virtual time

Everything carries the virtual-time stamp, so a rate is a subtraction and an ordering is a comparison. run_for makes runs repeatable: the firmware sees the same instants every time, on your laptop and in CI.

Assertions worth writing

  • Rate from stamps, not from the peer. The NAV-PVT period is the difference between consecutive frame stamps. Compare it with the rate the driver itself programmed, read back from the CFG-VALSET bytes it sent. A peer that computes its own iTOW from the same constant can only confirm itself.
  • Ordering. The first NAV-PVT comes strictly after the last CFG ACK. The first handleNodeStatus hit in symbol_trace() comes after the allocation grant seen in frames().
  • End to end. The fix the peer injected comes back out as GPS_RAW_INT on USART1 with the same position. Decode the MAVLink bytes with the firmware’s own CRC rules; a frame your parser accepts with a correct checksum is one the ground station would accept.
  • Negative control. A second scenario with the GPS peer dropped from media, asserting zero GPS_RAW_INT while HEARTBEAT still flows. A test that cannot fail proves nothing.
  • Tolerance is the quantum. Timing matches to the scheduling quantum (100 µs default, quantum in the scenario), not to a hand-picked window.
  • Nothing silently unmodelled. sim.logs() empty of “unhandled” after init, or an explicit allowlist.

Peers are part of the test

Changing what the device does is a peer edit, in Python, and it is what makes a scenario yours: an IMU that reports a saturated axis, a baro that returns a stale frame, an I2C slave that NACKs for 50 ms, a CAN node that stops heartbeating at t = 5 s. The hooks and ctx methods are in Scripted peers; each peer in tests/warg/zp4-h753/ is a worked example with its chip’s figures named at the top. Peers run on the virtual clock, so the test stays deterministic.

Templates

sim-fixtures pytest/ has complete, passing examples on the ZeroPilot firmware: test_warg_zeropilot_gps_fix.py (UBX and MAVLink parsing, rate from stamps, negative control), test_warg_zeropilot_servo_pwm.py (TIM1 register math against the configured kernel clock), test_warg_zeropilot_closed_loop.py (actuator CCR through a plant back into the IMU and out as ATTITUDE). They predate frames() and drive sim as a subprocess, parsing --output; the assertions are the part to copy.

Without the package

Any language can do the same by running sim --scenario ... --frames --trace-out as a subprocess and parsing the files from What a run can emit. The JSONL trace is the easiest to consume. Byte-exact UART comes from the default hex --output:

LINE = re.compile(r"^\[([0-9.]+)s\] \((\w+)\) ([0-9A-F ]+)$")

7. Multi-machine scenarios

A --scenario YAML runs several machines on one virtual clock with media between them. It is also how a single machine gets a UART or CAN peer, since media are where connections live.

machines:
  dut:
    mcu: STM32H753IITX
    elf: ./zp4-h753.elf
    parts:                      # devices on the board (§5); sim 0.5.17+
      - { name: card,     type: sd-card,     bus: sdmmc1, size: 16MiB, image: card.img }
      - { name: imu0,     type: spi-device,  bus: spi1, cs: PC4, script: icm42688_peer.py }
      - { name: imu1,     type: spi-device,  bus: spi1, cs: PC5, script: icm42688_peer.py }
      - { name: baro,     type: i2c-device,  bus: i2c2, address: 0x63, script: icp20100_peer.py }
      - { name: gpspeer1, type: uart-device, baud: 230400, script: gps_peer.py }
      - { name: canpeer,  type: can-node,    script: dronecan_peer.py }
    # overlay: ./sensors.board  # your own board file, only for what parts cannot express
    gdbPort: 3333               # optional, distinct per machine
    clockPpm: 20                # optional crystal error
  gcs:
    mcu: ESP32C3
    elf: ./gcs.elf
media:
  - type: uart
    connect: [dut.usart6, dut.gpspeer1]
  - type: uart
    connect: [dut.usart1, gcs.uart0]
  - type: uart
    connect: [dut.uart7]
    bridge: "tcp:14550"         # one endpoint + a host program (QGroundControl, a socket)
  - type: can
    connect: [dut.fdcan1, dut.canpeer]
  - type: ble
    connect: [central.radio, peripheral.radio]
    impairment: { loss: 0.1 }
quantum: 0.0001                 # seconds; finest is 1 µs
seed: 7                         # required for any probability strictly between 0 and 1
serialExecution: false

Media types: uart, can, ble, wifi, ieee802154, eth. connect needs at least two endpoints except a bridged UART. bridge on uart takes tcp:<port>; on can it takes a host SocketCAN interface name. BLE media accept collisions, impairment (loss, corrupt, per-direction rules, mute). Paths resolve relative to the scenario file. --timeout and --seed on the command line override the file.

Output rows carry [machine] prefixes. --frames, --trace-out, --pcap, --trace-interrupts, --trace-symbol and --real-time all work under a scenario.

Never put a breakpoint on one machine of a scenario to observe timing. Halting one machine stops virtual time for all of them. Use the non-halting traces above; that is what they are for.

Multi-core machines (ESP32-S3, RP2040) run their cores on parallel host threads that only meet at quantum boundaries, so cross-core counts vary between identical runs and contention is rarer than on silicon. serialExecution: true makes runs repeatable but removes contention almost entirely, so it cannot be used to judge races.


8. Debug

Work down this ladder. Each rung is non-halting until the last.

  1. Nothing on the UART. Usually a peripheral clock never enabled, a register the model does not support, or a peer that failed to load. Check --configuration is passed if the project has an .ioc, then go to rung 2.
  2. Where is it? --trace-out and read the final-state record: pc, pc_symbol, lr_symbol, halted. A PC in HAL_Delay or an idle loop with a huge executed_instructions is a busy-wait; a PC in a fault handler is a fault; halted: true with a PC in Error_Handler is a HAL assert.
  3. Did it get there? --trace-symbol on the function you expect and the ones upstream of it. Arguments are printed, so a null handle or a wrong length is visible in the trace line.
  4. Is the data right? --frames for what the bus carried; --trace-memory on the buffer or flag the code polls.
  5. Is the interrupt arriving? --trace-interrupts. No IRQ<n> enter after a transfer means a NVIC enable, a DMAMUX request line or an EXTI mapping is missing, in that order of likelihood.
  6. Watch a variable over time. --trace-memory uwTick or any symbol; the (MEMWATCH) summary tells you how often and when.
  7. Only then halt. --gdb-port 3333 and attach. The run waits for the client; --timeout is ignored.
sim --mcu STM32F401RE --elf app.elf --gdb-port 3333
# other shell
arm-none-eabi-gdb app.elf -ex "target remote :3333"      # riscv64-unknown-elf-gdb or gdb-multiarch for RISC-V

--gdb-reverse snapshots every 10 ms so reverse-stepi and reverse-continue work. It is single-machine and single-core. Under a scenario, put gdbPort: on the machine instead of using the flag. Ctrl-C once stops the session gracefully; twice force-quits.

Reading a slow run

Wall time mixes host cost with guest work, so it cannot localise a slowdown. Read the guest-side numbers from final-state: executed_instructions, spin_skipped_instructions, sleep_skipped_instructions. Their sum is the run length at the core’s instruction rate. A large executed with tiny skipped means the core is burning instructions in a loop the spin-skip cannot see (a RAM poll such as HAL_Delay on uwTick, an idle task with no WFI). Then profile.

Slow simulation is a model gap or a firmware busy-wait, never a fact of life.


9. Profile

Where the instructions go: --profile-dir

sim --mcu STM32H753IITX --elf zp4.elf --timeout 10 \
    --profile-dir prof --profile-start-symbol main

Writes one file per CPU, <machine>-<cpu>.collapsedstack by default, weighted by retired instructions and attributed to RTOS threads where the RTOS is recognised. View with flamegraph.pl or speedscope. --profile-format perfetto writes .perfetto with a time axis, an IRQ track, WFI sleep slices and per-thread tracks; open at ui.perfetto.dev.

Scope it: --profile-start-symbol skips the bootloader or ROM, --profile-stop-symbol ends at a function, --profile-window-ms ends after a virtual duration. Whichever comes first wins.

What you are looking for: one PC or one small function holding a large share of the run. On a flight controller that turned out to be gyro calibration (1000 × HAL_Delay(1) per IMU, before the scheduler), a 1 s HAL_Delay before f_mount, HAL_Delay(10) completion polls in the SD driver, and an idle task that never executed WFI. Each is real CPU time and power on the aircraft too. The collapsed profiler can misattribute across RTOS context switches; the perfetto view with thread tracks is the tie-breaker.

Heap and stacks: --memory-stats <interval>

sim ... --memory-stats 100ms

Samples the heap and every RTOS thread’s stack high-water mark on the virtual clock, interleaved in --output:

[0.400000s] (MEM) heap_free=41232 heap_largest_free=38912 heap_frag=0.056 telemetry:stack_remaining=612 attitude:stack_remaining=1288
...
(MEM SUMMARY) min_heap_free=39104 max_fragmentation=0.081
(MEM SUMMARY)   telemetry: min_stack_remaining=228 at [7.556000s]

The summary is the number to put in a PR: the smallest stack margin per thread and when it happened.

Instruction-level: --execution-trace-dir

Every retired instruction of every CPU, one file per CPU, in pc, opcode, pcandopcode or disassembly format. Tens of MB per virtual second, so scope it with --execution-trace-start-symbol, --execution-trace-stop-symbol or --execution-trace-window-ms. Use it to diff two runs at instruction granularity, not for a first look.

Timing fidelity

Bus transfers take the time their programmed clock dividers imply. UART, SPI and I2C on STM32 pace from RCC and the prescaler registers; timers count from the RCC’s timer kernel clock; CAN wire time comes from the bit-timing registers. Measured wire timing lands within about 1 % of a NUCLEO under a logic analyser for the validated peripherals. Things that are not modelled today: cache effects on DMA (the sim reads what the CPU wrote, silicon reads what the D-cache left), flash wait states, and bus contention. If a timing assertion only passes at one hand-picked constant, the model is wrong, not the constant.


Flag Use
--uart-tcp uart7:5760 A UART on a raw TCP port for a host program (a GCS, a terminal, your test harness). Host side is wall-clock. Under a scenario use bridge: tcp:<port> on the medium.
--usb-cdc-console usb_otg_fs[=<text>] A CDC-ACM host attached to the USB device; its output lands in --output, optional text is sent as host input.
--control-port 21777 --control-map "ok=keys@2:low;up=keys@3:low" WebSocket in/out: button presses in, display frames out. Per-machine endpoints /control/<machine> under a scenario.
--real-time Pin to about 1× wall clock, only when a host program needs real pacing.
--jtag <port> RISC-V Debug Module over remote_bitbang, for a stock OpenOCD.

11. Cheat sheet

# run and read
sim --mcu <MODEL> --elf app.elf --timeout 5 --ascii
sim --mcu <MODEL> --elf app.elf --configuration project.ioc

# test a bus
sim --scenario run.yaml --timeout 8 --frames frames.txt --trace-out run.jsonl
grep 'I2C Tx addr=0x63 read' frames.txt
jq -r 'select(.kind=="frame")|.summary' run.jsonl

# debug without halting
sim ... --trace-interrupts --trace-symbol HAL_SPI_TransmitReceive_DMA --trace-memory uwTick
sim ... --trace-out run.jsonl && tail -1 run.jsonl      # final-state: pc, halted, executed_instructions

# debug halting
sim ... --gdb-port 3333 [--gdb-reverse]

# profile
sim ... --profile-dir prof --profile-start-symbol main [--profile-format perfetto]
sim ... --memory-stats 100ms

# fixture
./run-smoke.sh /path/to/sim tests/<vendor>/<board>/<name>

Output shapes at a glance:

Flag A line of output
--output (hex, default) [0.001234s] (usart2) 48 65 6C 6C 6F
--output --ascii [0.001234s] (usart2) Hello
--frames [1.088024s] (notecard) I2C Tx addr=0x17 read len=2 data=FF 00
--trace-interrupts [0.045012s] (IRQ) SysTick enter
--trace-symbol [0.001420s] (TRACE) foo pc=0x8004F10 r0=0x24000A40 <hspi1>
--trace-memory [0.230010s] (MEM) uwTick write addr=0x2400001C value=0xE7 pc=0x800A3C2
--trace-out {"t":0.045012,"kind":"interrupt","machine":"dut",...}