hilt 0.2.0

Renode-based hardware-in-the-loop test fixtures for embedded Rust projects
Documentation
  • Coverage
  • 100%
    88 out of 88 items documented1 out of 2 items with examples
  • Size
  • Source code size: 135.8 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 1.4 MB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 1s Average build duration of successful builds.
  • all releases: 2s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • comploplo/hilt
    1 0 0
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • comploplo

hilt

crates.io docs.rs license

Test your embedded Rust firmware with plain cargo test — no hardware on the desk, no flashing, no probe.

hilt boots your compiled firmware inside a Renode simulation running in a Podman/Docker container, captures everything it does, and hands the result back to your test to assert on. It works natively on both x86-64 and arm64 hosts, so the same test suite runs on the laptop where you develop — Apple Silicon included — and in CI, with no code changes. The whole point is making embedded development and testing a local, everyday cargo test loop instead of a board-farm ritual.

use hilt::{HilConfig, Platform, RenodeRunner, assert_hil_ok};

#[test]
fn firmware_self_check_passes() {
    let output = RenodeRunner::new(HilConfig::single(
        Platform::rp2040(),
        "target/thumbv6m-none-eabi/release/examples/hil_keyboard",
    ))
    .run();
    assert_hil_ok!(output);
}

Zero dependencies. The only thing it shells out to is a container runtime; firmware ELFs are read in pure Rust.

What you need

  • Podman or Docker — auto-detected, or pick one with HILT_CONTAINER_RUNTIME=docker. This is where Renode runs; you never install Renode itself.
  • A firmware ELF cross-compiled for your target (your normal embedded build).

On Apple Silicon the first run also builds a small native Renode image (one-time, needs network); see Image selection below.

Your first test

The simplest style is self-reporting firmware: the firmware checks itself and signals success, and the host test just watches for that signal.

1. In your firmware, add an empty marker function and call it when your checks pass:

#[no_mangle]
#[inline(never)]
pub extern "C" fn hil_marker() {}

// ... in your test firmware's main, after asserting whatever you care about:
hil_marker();

That's the whole firmware-side contract. hilt finds the hil_marker symbol in the ELF, sets a CPU hook on its address, and logs HIL OK the moment execution reaches it. No UART driver, no semihosting, no test framework in the firmware.

Failure is signalled the same way: reaching a hil_fail symbol (rename via HilConfig::fail_marker) or the firmware's panic handler logs HIL FAIL, output.failed() turns true, and assert_hil_ok! fails with a distinct "reported FAILURE" message.

Failure ends the simulation immediately. So does pass for a single machine with no UART/CAN bridge, so a passing self-reporting test takes seconds, not the full timeout. Bridged or multi-machine runs keep going after pass, since the host or another machine may still be mid-test.

If you define both markers, give them different bodies — the linker folds identical empty functions onto one address.

2. In your host test, point hilt at the ELF (build it yourself, or let GuestBuild run the cross-compile for you):

use hilt::{GuestBuild, HilConfig, Platform, RenodeRunner, assert_hil_ok};

#[test]
fn firmware_self_checks_pass() {
    let elf = GuestBuild::example("my-firmware", "hil_smoke")
        .target("thumbv6m-none-eabi")
        .build();
    let output = RenodeRunner::new(HilConfig::single(Platform::rp2040(), elf)).run();
    assert_hil_ok!(output);
}

3. Run cargo test. Behind the scenes, hilt generates a Renode script, stages it with your ELF in a work directory, runs Renode in a container, and returns a HilOutput. If the test fails, the full simulation log is printed so you can see what the firmware actually did. output.contains("...") asserts on any log line, not just the marker.

Whatever the firmware prints on its console UART (Platform::uart, e.g. sysbus.uart0 on rp2040) is captured too: output.uart() returns the text, and output.contains(..) searches it.

There are two timeouts. HilConfig::timeout is simulated time — how long the firmware gets to run. HilConfig::wall_timeout is the real time budget for the whole run, defaulting to 2 × timeout + 60 s because boards can simulate slower than realtime. If the wall-clock budget kills the run, output.timed_out() is true.

Talking to the firmware while it runs

Self-reporting covers a lot, but sometimes the host needs to be in the conversation — sending bytes or bus frames and asserting on the replies.

UART ↔ TCP (works on Linux, macOS, and Windows): expose any machine's UART as a TCP socket on localhost and speak raw bytes to it:

use std::io::{Read, Write};
use hilt::{HilConfig, MachineSpec, Platform, RenodeRunner};

let machine = MachineSpec::new("dev", "firmware.elf", Platform::rp2040())
    .with_uart_bridge(0, "sysbus.uart0"); // 0 = pick a free port
let run = RenodeRunner::new(HilConfig::multi(vec![machine])).spawn();
let mut uart = run.connect_uart("dev"); // retries until Renode accepts
uart.write_all(b"ping")?;
let mut reply = [0; 4];
uart.read_exact(&mut reply)?;
let output = run.kill(); // or run.wait() to let the simulation finish

spawn() returns a RunningHil handle: wait() for the run to end, kill() to stop it early, or just drop it — every path removes the container. Port 0 gives each run its own port (run.uart_port("dev")), so parallel tests never collide.

CAN ↔ SocketCAN (Linux only): wire multiple machines into a shared CAN bus and bridge it to a host vcan interface, so host code exchanges frames with the simulated network:

let machines = vec![
    MachineSpec::new("controller", "controller.elf", Platform::stm32h7())
        .with_socketcan_bridge(),
    MachineSpec::new("sensor", "sensor.elf", Platform::stm32h7()),
];
// hilt::setup_vcan("vcan0");  // once, needs privileges
let run = RenodeRunner::new(HilConfig::multi(machines)).spawn();
// open vcan0 with your favorite SocketCAN library and talk to the firmware
let output = run.wait();

All three styles mix freely in one run.

Supported boards

Constructor Chip Console UART Notes
Platform::rp2040() RP2040 (Cortex-M0+) sysbus.uart0 e.g. Raspberry Pi Pico
Platform::nrf52840() nRF52840 (Cortex-M4F) —
Platform::stm32f3() STM32F303xB (Cortex-M4F) — e.g. ZSA Voyager
Platform::stm32f4() STM32F411 (Cortex-M4F) —
Platform::stm32h7() STM32H753ZI Nucleo sysbus.usart3 full board model, CAN-ready

Not listed? Every Platform field is public, so describe any Renode platform (.repl) with a struct literal — embed your own string or reference one bundled in the Renode image:

const MY_BOARD: Platform = Platform {
    name: "myboard",
    repl: ReplSource::Embedded(include_str!("myboard.repl")),
    ..Platform::rp2040()
};

Adding a board is usually a dozen lines; the built-in .repls are good templates.

Beyond the basics

  • Simulated key matrices (feature matrix) — MatrixTestPlan generates a Python Renode peripheral that "presses" keys on a GPIO row/column matrix, plus parsers (parse_key_log, parse_tapdance_log) for asserting the firmware saw the right keys. Built for keyboard firmware, reusable for any scanned matrix.
  • Hand-rolled runs — the runner's building blocks (extract_elf_info, find_symbol_address, run_renode_container, run_with_timeout_and_cleanup) are public if you need a custom flow.
  • Environment probes — container_runtime_available(), renode_available(), interface_available() let tests skip gracefully on machines without the needed setup; detect_container_runtime() names the runtime that will be used.

Run cargo doc --open for the full API reference.

Renode image selection

The container image is picked by host architecture, so the same tests run on an Apple-Silicon laptop and amd64 CI unchanged:

  • x86-64 hosts use docker.io/antmicro/renode:latest, Antmicro's official (amd64-only) image, pulled on first use.
  • aarch64 hosts get localhost/hilt-renode:arm64, a native image hilt builds on demand the first time it's needed — the official amd64 image never finishes booting under qemu emulation. First run downloads the portable Renode release and builds; later runs reuse the cached image.

Either way, any missing image is pulled or built once, before the run's wall-clock timer starts, so a slow first pull/build doesn't eat into the timeout budget.

Override with HILT_RENODE_IMAGE=<ref> or per-config via HilConfig::image.

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.