hilt
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 ;
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:
pub extern "C"
// ... 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 ;
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 ;
use ;
let machine = new
.with_uart_bridge; // 0 = pick a free port
let run = new.spawn;
let mut uart = run.connect_uart; // retries until Renode accepts
uart.write_all?;
let mut reply = ;
uart.read_exact?;
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!;
// hilt::setup_vcan("vcan0"); // once, needs privileges
let run = new.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 ;
Adding a board is usually a dozen lines; the built-in .repls are good
templates.
Beyond the basics
- Simulated key matrices (feature
matrix) —MatrixTestPlangenerates 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 imagehiltbuilds 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.