rvtime
A RISC-V compiler with a wasmtime-like interface.
Load a statically linked RV64IMAC ELF, compile it to native code with Cranelift, call its exported functions from Rust, and let it call back into the host.
let engine = Engine::new(&Config::default())?;
let module = Module::from_file(&engine, "guest.elf")?;
let mut store = Store::new(&engine, ());
let instance = Linker::new(&engine).instantiate(&mut store, &module)?;
let add = instance.get_typed_func::<(u64, u64), u64>("op_add")?;
assert_eq!(add.call(&mut store, (10, 3))?, 13);
What this is for
Running code you did not write, in the same process, without letting it reach anything you did not hand it. A guest gets its own address space, can only call host functions you registered, and can be stopped mid-run.
The compiler is eager and whole-program: Module::new decodes every function in
.text and generates native code for all of them. There is no interpreter and
no bytecode — a guest call is a native call.
What it is not
Not a WebAssembly runtime with different input. The two problems differ in ways that shape everything downstream. WebAssembly arrives with a type system, an import table, and structured control flow. An ELF arrives with none of that: no signatures to check calls against, no symbolic imports to resolve, and control flow that has to be recovered from the instruction stream. Several decisions in Design exist only because of that gap.
Not a policy. rvtime compiles, confines, and calls. Which host functions exist, what they mean, and what a guest may do are the embedder’s to decide. The runtime ships no standard function set — see Host Calls for why that separation is load-bearing rather than fastidious.
How to read this
The examples are real programs in the repository, compiled by CI, and included here by reference rather than pasted — so what you read is what runs. Start there if you want to use rvtime.
The design chapters explain why the compiler is shaped the way it is. Read those if you want to change it, or if you hit something surprising and want to know whether it is deliberate.
API documentation lives in rustdoc and is not duplicated here.
Getting Started
Adding rvtime
[dependencies]
rvtime = "0.0.1"
rvtime runs on Linux and macOS. Windows is not supported — guest memory and trap
handling are POSIX, and a port would mean VirtualAlloc/VirtualProtect and a
vectored exception handler.
Building a guest
The image must be a statically linked RV64IMAC ELF, linked with
--emit-relocs:
cargo build --release --target riscv64imac-unknown-none-elf \
--config 'target.riscv64imac-unknown-none-elf.rustflags=["-Clink-arg=--emit-relocs"]'
The relocations are not optional, and leaving them out is the first thing to check when a guest fails to load. They are what identify which functions have their address taken, and therefore where an indirect call may legally land. See Calls.
A guest is no_std. It gets alloc once you hand it a heap, which unlocks
Vec, String, and any crate that does not need std. It cannot use std
itself: every Rust RISC-V std target is riscv64gc, whose code contains F/D
instructions rvtime does not implement.
Floating point still works — on a target without hardware float, LLVM lowers it
to soft-float calls into compiler_builtins, which is ordinary integer code.
The guest SDK
rvtime-guest supplies the two things every guest needs: a way to reach the
host, and an allocator.
#![no_std]
#![no_main]
extern crate alloc;
use rvtime_guest::{call2, heap};
/// Traps instead of looping, so a guest bug reaches the host as a catchable
/// trap rather than hanging the thread that called in.
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
rvtime_guest::abort()
}
/// The embedder calls this first, passing the bounds of `Store::heap()`.
#[unsafe(no_mangle)]
pub extern "C" fn init_heap(start: u64, size: u64) -> u64 {
unsafe { heap::init(start as usize, size as usize) };
0
}
#[unsafe(no_mangle)]
pub extern "C" fn add(a: u64, b: u64) -> u64 {
unsafe { call2(1, a, b) } // whatever number the embedder registered
}
There are no standard host functions in the SDK. It knows how to make a call, never which calls exist.
Keeping exports alive
The linker garbage-collects anything unreachable from the entry point, which removes exported functions the host means to call. Anchor them:
#[unsafe(no_mangle)]
pub static EXPORTS: [extern "C" fn(u64, u64) -> u64; 2] = [add, multiply];
and reference that table from _start, or the table itself will be collected
along with everything it names. One array per signature, since a const
initialiser cannot cast a function pointer to a common type.
--no-gc-sections looks like the obvious shortcut and is not one. It retains
every section, including all of core, alloc and compiler_builtins.
Measured on this repository’s hosted fixture: 115 KB grows to 1.17 MB, and
2,441 instructions become 51,228. The cost is invisible on a toy guest with no
dependencies and severe on a real one.
--export-dynamic does not work either — it governs the dynamic symbol table,
which a static binary does not have. The one precise alternative is
-Clink-arg=--undefined=<symbol> per export, which trades the array for a list
of linker flags that has to be kept in step with the same functions.
Where to go next
Calling a Guest is the smallest complete program. Host Functions covers the other direction.
Calling a Guest
The smallest complete program: compile an ELF, instantiate it, call a function.
cargo run --example calling_a_guest
use rvtime::{Config, Engine, Linker, Module, Store};
/// A statically linked RV64IMAC ELF, built with `--emit-relocs`.
const GUEST: &[u8] = include_bytes!("../../../fixtures/basic.elf");
fn main() -> anyhow::Result<()> {
// An engine holds the target configuration. Compiled code is tied to it,
// so a module and the store that runs it must come from the same one.
let engine = Engine::new(&Config::default())?;
// Compiling reads the ELF, decodes every function, and generates native
// code for all of them up front.
let module = Module::new(&engine, GUEST)?;
// A store owns one guest's memory and registers, plus whatever data you
// want host functions to see. Here there is none, so `()`.
let mut store = Store::new(&engine, ());
// Instantiating maps the guest's memory and wires it to the compiled code.
let instance = Linker::new(&engine).instantiate(&mut store, &module)?;
// Exports are looked up by symbol name. The type parameters say how many
// argument registers to use and how many results to read back -- an ELF
// carries no signature to check them against.
let add = instance.get_typed_func::<(u64, u64), u64>("op_add")?;
assert_eq!(add.call(&mut store, (10, 3))?, 13);
// A handle is reusable, and calls are ordinary function calls.
let fib = instance.get_typed_func::<(u64,), u64>("fib")?;
for n in 0..10 {
print!("{} ", fib.call(&mut store, (n,))?);
}
println!();
Ok(())
}
What is happening
Engine holds the target configuration — optimisation level, address space
size, whether interruption checks are emitted. Compiled code is tied to it,
because some of those settings are baked into the generated instructions. A
module and the store that runs it must come from the same engine.
Module::new does all the work: it parses the ELF, recovers function
boundaries from the symbol table, decodes every instruction, analyses control
flow, and generates native code for the whole program. There is no lazy path
yet, so this is where the time goes — see Performance.
Store owns one guest: its memory, its registers, and whatever data you
want host functions to see. One store holds one instance, which is narrower
than wasmtime and matches what a program image needs — one address space, one
register file.
get_typed_func resolves a symbol and gives it a Rust signature. Nothing
checks that signature against the guest, because an ELF carries no type
information to check against. The type parameters choose how many argument
registers to write and how many results to read; they are not a contract the
guest declared.
At most eight arguments and two results — a0..a7 going in, a0 and
a1 coming back. Asking for more results is an error rather than a silent
misread, because the registers beyond those two hold whatever was there before
the call. See Registers.
Host Functions
Letting a guest call back into the host.
cargo run --example host_functions
use rvtime::{Caller, Config, Engine, Linker, Module, Store};
const GUEST: &[u8] = include_bytes!("../../../fixtures/hosted.elf");
/// Whatever the host wants to keep across calls. Host functions reach it
/// through `Caller::data`.
#[derive(Default)]
struct State {
ticks: u64,
}
fn main() -> anyhow::Result<()> {
let engine = Engine::new(&Config::default())?;
let module = Module::new(&engine, GUEST)?;
let mut store = Store::new(&engine, State::default());
let mut linker = Linker::new(&engine);
// A guest calls these with `ecall`, taking the number from `a7`. The key
// is a number rather than a name because an ELF has no import table to
// resolve names against -- you and the guest agree on the numbering.
linker.func_wrap(1, |_: Caller<'_, State>, a: u64, b: u64| {
Ok(a.wrapping_add(b))
})?;
linker.func_wrap(4, |mut caller: Caller<'_, State>| {
caller.data_mut().ticks += 1;
Ok(caller.data().ticks)
})?;
// Host functions can read and write guest memory. The guest passes a
// buffer the usual way, as a pointer and a length.
linker.func_wrap(2, |caller: Caller<'_, State>, ptr: u64, len: u64| {
let bytes = caller.read(ptr, len)?;
Ok(bytes.iter().map(|b| *b as u64).sum::<u64>())
})?;
let instance = linker.instantiate(&mut store, &module)?;
let add = instance.get_typed_func::<(u64, u64), u64>("call_add")?;
println!(
"guest asked the host to add: {}",
add.call(&mut store, (20, 22))?
);
let tick = instance.get_typed_func::<(), u64>("call_tick")?;
tick.call(&mut store, ())?;
tick.call(&mut store, ())?;
println!("host state after two ticks: {}", store.data().ticks);
// The guest fills a buffer and asks the host to sum it.
let round_trip = instance.get_typed_func::<(u64,), u64>("round_trip")?;
println!(
"sum of 1..=10 computed by the host: {}",
round_trip.call(&mut store, (10,))?
);
// Returning `Err` from a host function stops the guest rather than
// handing it a value. Use `Ok(code)` for failures the guest should handle.
Ok(())
}
Numbers, not names
A guest reaches a host function with ecall, taking the call number from a7
and arguments from a0 onwards — the standard RISC-V syscall convention. So
Linker is keyed by number.
That is not a simplification of wasmtime’s named imports; it is what the input
format allows. A WebAssembly module carries an import table naming what it needs,
which the host resolves. An ELF carries nothing of the sort. There is no name to
match, so the guest and the host agree on a numbering, and nothing checks that
they agree — a mismatch surfaces as Trap::UnknownHostCall at the moment the
guest calls it.
Reading and writing guest memory
Caller gives host functions access to the guest’s address space. Buffers are
passed the usual way, as a pointer and a length in two registers, and the host
validates the range before touching it.
There is no marshalling layer above that. A host function receives u64s and
decides what they mean, because rvtime has no way to know.
Failing
Two different failures, and the difference matters:
Ok(code)returns a value the guest handles. Use this for anything the guest should be able to recover from — a missing key, a closed connection.Err(..)stops the guest. The pending call fails with the error attached. Use this when continuing makes no sense.
This is the same split as a return value versus a trap in wasmtime. Reaching for
Err where the guest could have coped turns a recoverable condition into a dead
plugin.
Arity
func_wrap covers zero to six arguments. Beyond that, Linker::func hands you
the raw Caller and you read registers yourself — useful when the number of
arguments is not fixed.
The Guest Heap
Giving a guest a heap, so it can allocate.
cargo run --example guest_heap
use rvtime::{Config, Engine, Linker, Module, Store};
const GUEST: &[u8] = include_bytes!("../../../fixtures/hosted.elf");
fn main() -> anyhow::Result<()> {
let mut config = Config::default();
config.memory_size(16 << 20).stack_size(64 << 10);
let engine = Engine::new(&config)?;
let module = Module::new(&engine, GUEST)?;
let mut store = Store::new(&engine, ());
let instance = Linker::new(&engine).instantiate(&mut store, &module)?;
// rvtime commits a heap between the guest's image and its stack, but does
// not tell the guest where it is: the bounds travel through whatever
// interface you defined. Here the guest exports a function to receive them.
let heap = store.heap()?;
println!(
"heap: {:#x}..{:#x} ({} KiB)",
heap.start,
heap.end,
(heap.end - heap.start) / 1024
);
let init = instance.get_typed_func::<(u64, u64), u64>("init_heap")?;
init.call(&mut store, (heap.start, heap.end - heap.start))?;
// With the allocator fed, the guest can use `alloc` -- `Vec`, `String`,
// and any crate that does not need `std`.
let sum = instance.get_typed_func::<(u64,), u64>("alloc_sum")?;
println!(
"guest summed a heap-allocated vector: {}",
sum.call(&mut store, (1000,))?
);
// The allocator hands memory back, so a long-running guest does not leak.
let used = instance.get_typed_func::<(), u64>("heap_used")?;
println!("bytes still allocated: {}", used.call(&mut store, ())?);
Ok(())
}
Why the host has to hand it over
rvtime commits the heap but does not tell the guest where it is. That looks like an omission and is deliberate.
Every way of conveying the bounds automatically means rvtime inventing an ABI:
a header at a fixed guest address, a reserved call number, a value stashed in
tp. Each of those claims part of a space the embedder owns, and each becomes a
compatibility surface the moment anyone depends on it. So Store::heap() reports
the bounds and you pass them in however your interface already works — an init
export, as here, or a host function you registered.
What you get
The region sits between the guest’s image and its stack, committed read-write
and zeroed. Committing it up front costs address space rather than memory:
mprotect only changes protection, and pages fault in when first touched.
A guard page separates it from the stack, so running off the top of the heap faults instead of quietly landing in stack frames.
Size follows Config::memory_size — the heap is whatever is left after the
image and the stack. For many small guests, size them down; the default 64 MiB
is address space per store.
Before the handover
An allocation before heap::init returns null, and the guest writes through it.
Guest address 0 is never committed, so that faults rather than corrupting the
bottom of the address space. A null dereference in a guest is a trap you can
catch, not silent damage.
Interrupting a Guest
Stopping a guest that will not stop itself.
cargo run --example interrupting
use rvtime::{Config, Engine, Linker, Module, Store};
use std::{thread, time::Duration};
const GUEST: &[u8] = include_bytes!("../../../fixtures/hosted.elf");
fn main() -> anyhow::Result<()> {
// On by default, but spelled out here because it is the point of the
// example: it makes the translator emit a check on every backward edge.
let mut config = Config::default();
config.interruptible(true);
let engine = Engine::new(&config)?;
let module = Module::new(&engine, GUEST)?;
let mut store = Store::new(&engine, ());
let instance = Linker::new(&engine).instantiate(&mut store, &module)?;
// The handle is `Send + Sync`, so a watchdog can hold one while the guest
// runs somewhere else.
let handle = store.interrupt_handle()?;
// `spin` never returns on its own.
let spin = instance.get_typed_func::<(u64,), u64>("spin")?;
let guest = thread::spawn(move || spin.call(&mut store, (0,)));
thread::sleep(Duration::from_millis(100));
println!("asking the guest to stop");
handle.interrupt();
// The guest stops at its next loop iteration and the pending call fails.
match guest.join().expect("the guest thread panicked") {
Ok(value) => println!("returned {value} -- unexpected, `spin` should not return"),
Err(error) => println!("stopped: {error}"),
}
Ok(())
}
Why this exists
Without it, a guest that loops forever holds the thread that called into it forever. There is no way to take that thread back: you cannot safely kill a thread mid-execution, and the guest is running native code with no scheduler above it.
For a guest you wrote, that is a bug you fix. For anything installed at runtime,
it is a denial of service against the host — which is why the checks are on by
default, and why interrupt_handle() errors rather than returning a handle
that would silently do nothing on a module compiled without them.
What it costs
A load, a test and a branch on every backward edge. Measured at 0.2% on a 50-million-iteration tight loop — the flag stays in L1 and the branch predicts perfectly, so it fills slack the loop already had.
Straight-line code pays nothing: the flag pointer is loaded once per function, and only in functions that actually loop.
What it does not cover
Only loops. That is enough for non-termination — a guest can run forever by looping, and unbounded recursion exhausts the stack and traps — but it means a long-running straight-line computation cannot be interrupted mid-way.
In practice, optimisation decides. A counted loop that LLVM turns into a closed form has no backward edge and therefore no check; it also always terminates, so nothing is lost.
Using it
Interrupt is Send + Sync and cheap to clone, so a watchdog thread can hold
one while the guest runs elsewhere. interrupt() returns immediately; the guest
stops at its next loop iteration and its pending call fails with
Trap::Interrupted. clear() withdraws the request so the store can be used
again.
Overview
The pipeline
ELF ──▶ decode ──▶ analyse ──▶ CLIF ──▶ Cranelift ──▶ native code
Four crates, split so that decoding is settled before any code generation decision is made:
| crate | responsibility |
|---|---|
rvtime-core | registers, instructions, ELF loading. No codegen. |
rvtime-cranelift | control-flow analysis and CLIF emission. |
rvtime-compiler | codegen backends, guest memory, trap handling. |
rvtime | the public API. |
Backends depend on core; core depends on no backend. The seam is the Inst
enum, so a second backend could consume the same vocabulary without the first
knowing.
One CLIF function per RISC-V function
The central decision, and the one everything else follows from.
An ELF already carries function structure in its symbol table, so there is no
whole-program control-flow graph to rebuild. Each RISC-V function becomes one
Cranelift function: jal becomes a real call, ret becomes a native
return, and the host’s own stack carries return addresses.
The alternative — compiling the whole program into a single function with every basic block in one map, and a jump table for every transfer — is what you are forced into when the input has no function boundaries. It works, but it makes every function’s registers live in one enormous SSA graph, rules out compiling anything independently, and scales badly with program size.
Because functions are separate here, they can be compiled independently, cached independently, and eventually compiled lazily.
What the ELF has to provide
Three things are recovered at load time and cannot be recovered later:
- Function boundaries, from
STT_FUNCsymbols. Interior.L*labels share the address space of real functions and are filtered out; treating one as a function splits a body in half. - Indirect jump targets, from
R_RISCV_64relocations landing in.text. This is why--emit-relocsis required — see Calls. - Segment layout and permissions, from the program headers.
Guest code is never executed
The guest’s instructions are compiled, not interpreted and not run in place.
Guest memory is mapped read-only where the image is executable, and never
executable at all. The only reason to map .text is that programs read
constants out of it.
That also means a guest which corrupts its own code image achieves nothing: the compiled code was generated once and lives in the JIT’s pages.
Calls
A direct call looks indirect
The single most surprising thing about compiling RISC-V. LLVM materialises a call target as a pair:
auipc ra, 0x0 # ra = pc + 0
jalr ra, 0x4a(ra) # jump to ra + 0x4a
That is a direct call to a known address, encoded as a jump through a
register. Almost every call in a compiled binary looks like this; plain jal is
comparatively rare.
Compiling every jalr as an indirect dispatch would be correct and
catastrophically slow. So a per-block constant-propagation pass tracks known
register values and folds the pair back into the address it was always going to
compute.
Matching on adjacency would be simpler and wrong. LLVM may schedule other
instructions between the two, and linker relaxation can collapse the pair into a
bare jal. The pass tracks values, not patterns.
Classifying a transfer
Once a target is known, what it is depends on the program, not the encoding:
| shape | meaning |
|---|---|
jalr zero, 0(ra) | return |
| target is a known function entry | call (tail call if rd is zero) |
| target is inside this function | local jump |
| target unknown | indirect call |
The order matters. Asking “is rd equal to ra?” is not sufficient: a
jal ra, <local label> targets an address inside the current function and is
not a call, while recursion targets an address that is both a function entry and
inside the current function and is one. Checking the entry set first gets both
right.
This was a real bug, found by a fixture that spelled out encodings LLVM never emits.
Indirect calls
A computed target is checked against a dispatch table: one slot per two bytes of
.text, since RISC-V instructions are two-byte aligned, filled from the
relocation-derived entry set. A target with no slot traps.
That null check is what stops a corrupted function pointer from becoming an
arbitrary jump. It is also why --emit-relocs is mandatory: the relocations are
what say which addresses a function pointer may legitimately hold. The
alternative — scanning .rodata for values that look like code addresses — is a
heuristic, and a missed switch table would turn into a trap on correct code.
Tail calls
jr compiles as call-then-return. Semantically identical, but it consumes a
native frame, so unbounded tail recursion grows the host stack rather than
running in constant space. Switching the guest convention to CallConv::Tail
would fix it.
Registers
Guest registers live in Cranelift variables inside a function, so register
allocation is Cranelift’s problem and SSA construction comes free from the
function builder. x0 folds to a constant and writes to it are discarded.
The question is what happens at a call boundary.
The signature
fn(vmctx, sp, a0..a7) -> (a0, a1)
Only the registers the RISC-V ABI says are live get passed. Callee-saved registers stay in the caller’s variables and are never handed over.
That works because a callee which clobbers s0 spills it to the guest stack in
its own prologue and reloads it in its epilogue, exactly as the hardware would —
those are real guest memory accesses that rvtime honours. The value it spills is
its own zero-initialised s0 rather than the caller’s, which is unobservable:
nothing reads that slot except the epilogue that restores it.
The alternative — passing all thirty-two registers through the VM context — would be correct regardless of what the guest does, at the cost of roughly thirty loads and thirty stores wrapped around every call, including a two-instruction leaf function.
Why sp goes in but does not come out
sp is callee-saved. A conforming function restores it before returning, so the
caller’s own value is still correct and returning it would be redundant.
It also would not fit. Three results are fine on aarch64, which has three return
registers, but x86_64’s Fast convention has two — a three-result signature
fails to compile there outright. The design was arm64-shaped without anyone
noticing until CI ran on x86_64.
Two results is therefore both the correct number and the maximum, which is why
get_typed_func refuses a wider result type rather than reading registers the
callee never wrote.
gp and tp
The exception. They are set once at startup and read everywhere, so threading them through every signature would be wasteful and dropping them would be wrong. They live in the VM context and are loaded only by functions that reference them, and written back only by functions that modify them.
The assumption
All of this assumes the guest honours the LP64 ABI. Compiler output does.
Hand-written assembly that passes data in s0 or t0 across a call does not,
and will misbehave rather than be diagnosed — rvtime cannot detect it.
Memory
Layout
[ image ][ heap ][ guard ] ... [ stack ]
0 size
The whole address space is reserved in one PROT_NONE mapping and the pieces
are committed into it, so a guest address is an offset from a single base.
Everything not committed is a guard page.
Confinement, not bounds checking
A guest address becomes a host address by masking and adding:
host = base + (guest & (size - 1))
That mask is the sandbox. Guest addresses are 64-bit values a program can compute arbitrarily; masking keeps every access inside the reservation, where anything uncommitted faults. Without it a guest could compute an address past the end and read host memory.
It requires the size to be a power of two — any other size would leave part of the mask’s range pointing outside the reservation.
This confines rather than bounds-checks. An address past the end wraps and may land on a committed page instead of faulting. A guest can therefore corrupt itself with a wild pointer, but it cannot reach anything outside its own memory. Precise faulting would need explicit compare-and-branch on every access, which costs throughput.
Where the mask lives
Compiled into the generated code as an immediate. That makes a Module tied to
the size its Engine was configured with, and it is why a Store maps memory
at the module’s size rather than its own config — mapping at any other size
would let the compiled mask disagree with the reservation.
Faults
An out-of-bounds access hits a guard page and raises a signal inside JIT
compiled code. A handler records the faulting address, translates it back into
the guest address space, and unwinds via setjmp/longjmp to the frame that
entered the guest.
Both SIGSEGV and SIGBUS are handled: Linux reports these as SIGSEGV, macOS
on arm64 reports SIGBUS. Handling only one silently never fires on the other.
Page granularity
Permissions apply at host page granularity, which is not the guest’s 4 KiB page. macOS on arm64 uses 16 KiB pages, and a typical RISC-V image places its read-only, executable and writable segments 4 KiB apart — so all three land in one host page.
Where segments share a page it takes the union of their permissions. The alternative is for whichever segment is written last to silently strip rights from the others. On a 16 KiB-page host this lets a guest write to its own code; on a 4 KiB-page host it does not. Either way the sandbox boundary is unaffected, since that is the reservation, not the page bits.
The heap
Committed read-write and zeroed at instantiation, occupying everything between
the image and the stack less one guard page. Committing it up front costs
address space rather than memory, because mprotect only changes protection and
pages fault in when first touched.
rvtime does not carve it up or tell the guest where it is — see The Guest Heap.
Host Calls
Mechanism, not policy
rvtime ships no standard host functions, and this is the decision most likely to look like something missing.
wasmtime does the same: WASI is a separate crate, not part of the core runtime. The reason is not fastidiousness. The host-function set is policy — it encodes what a guest is permitted to do. Bake one into the runtime and every embedder either inherits that policy or fights it.
For a plugin host it is worse than that: capability control is the reason to use an in-process sandbox rather than a subprocess. A plugin can do only what you registered. Move that surface into the runtime and you have given away the thing that made it worth building.
So the guest crate knows how to make a call and never which calls exist, and the embedder defines the interface.
The mechanism
A guest executes ecall with the number in a7 and arguments in a0 onwards.
Compiled code:
- flushes
a0..a7to the VM context, since the handler reads them there; - calls a trampoline whose address the context carries;
- checks the returned status;
- reloads the argument registers.
The trampoline is monomorphised over the embedder’s data type, casts the opaque pointer back to the store, and dispatches on the number.
Failure
The trampoline returns a status. Zero means the call succeeded and the guest continues; anything else means it must stop, and compiled code returns immediately with the reason recorded in the store.
That gives host functions two distinct failures:
Ok(code)— a value the guest handles.Err(..)— the guest stops.
Without the status the guest would run on after a failed call with whatever
happened to be in a0.
Cost
Flushing and reloading eight registers around each call. That is the price of letting a handler read and write guest state through a stable structure rather than threading registers through a signature the host would have to know.
Only the argument registers move — callee-saved and temporary registers stay in Cranelift variables across the call, because the ABI says nothing may rely on temporaries surviving one.
Interruption
Backward edges are enough
A guest can only run forever by looping. Unbounded recursion exhausts the native stack and traps, because each guest call is a real native call. So a check on every backward edge catches every case of non-termination, and nothing else needs one.
The translator emits, before each backward transfer:
flag = load(interrupt_pointer)
if flag != 0 -> trap(Interrupted)
The pointer is loaded once per function, and only in functions that contain a backward edge — straight-line code pays nothing.
Not hoisting the check
The subtle part. If Cranelift treated the flag load as loop-invariant it would hoist it out, and the guest would never see a request raised after it entered the loop. Interruption would silently never work.
The load is therefore left able to trap and not marked can_move, which is what
prevents code motion. Since that is an argument about optimiser behaviour rather
than something the type system enforces, it is verified directly: a test runs a
guest in a genuine infinite loop, interrupts it from another thread, and fails
loudly on a timeout rather than hanging.
Where the flag lives
In an Arc<AtomicU64>, with the VM context holding a pointer to it — not inline
in the context. A Store is Send, so it can move; a pointer into it would
dangle. The Arc also lets a watchdog on another thread hold a handle.
Cost
A load, a test and a branch per iteration: 0.2% on a 50-million-iteration tight loop. The flag stays in L1 and the branch predicts perfectly, so it fills slack the loop already had.
At that price the checks are on by default. A thread that cannot be reclaimed is a far worse outcome than a fifth of a percent.
What it does not cover
Only loops. A long-running straight-line computation cannot be interrupted part-way through.
Optimisation decides more than the source does here: a counted loop that LLVM turns into a closed form has no backward edge and therefore no check. It also always terminates, so nothing is lost — but it means “this function loops in the source” does not imply “this function is interruptible”.
Caching
What is worth caching
Measured before deciding: for a 99 KiB guest, loading and decoding the ELF is 78 µs of 10.6 ms — under 1%. Code generation is essentially all of it.
So caching anything short of generated code would have been pointless, and that ruled out the obvious cheap options.
What was not chosen
Serialising the finished module — emitting an object file and relocating it back
in on load — would skip everything, not just codegen. It also means writing a
relocating loader, and relocations are architecture-specific: absolute and
relative 64-bit entries cover x86_64, while arm64 needs ADRP/ADD pairs and
CALL26. That is a platform-specific component to write and maintain.
Cranelift’s incremental cache captures most of the win without any of it.
How it works
Context::compile_with_cache hashes the CLIF function together with the ISA
settings and looks the result up before generating anything. On a hit it
deserialises the compiled code; on a miss it compiles and stores.
The key is what makes it safe to share. It covers the function’s contents, not its name or address, so two guests containing the same function reuse one entry. It covers the target settings, so changing the optimisation level produces misses rather than code built for different flags.
Entries are written to a temporary file and renamed, because a daemon may compile the same guest from several processes at once and a partially written entry would be indistinguishable from a complete one.
What it costs
| time | |
|---|---|
| no cache | 13.7 ms |
| cold, writing entries | 27.4 ms |
| warm | 5.3 ms |
A warm cache is 2.6× faster; a cold one is 2× slower, because it serialises and writes every function. This pays off when a guest is compiled once and loaded many times, and loses for a compile-and-discard workload.
The residual 5.3 ms is CLIF construction, key hashing and deserialisation — which also means actual code generation was about 8.4 ms of the original 13.7.
Damage
A cache is disk state that other things can corrupt or truncate. A damaged entry must cause a recompile, never a miscompile, so that is tested directly: every entry is overwritten with garbage and the module must still compile and compute the right answer.
Performance
Every figure here was measured on the repository’s own fixtures, on an Apple M series machine. They are recorded because each one changed a decision.
Compilation
For hosted.elf — 99 KiB, 55 functions, built with alloc:
| phase | time |
|---|---|
| ELF load and decode | 78 µs |
everything (Module::new) | 10.6 ms |
Code generation is ~99% of compile time. That is why caching targets codegen and nothing else, and why the ELF front end has never needed optimising.
Compilation is eager and whole-program: every function in .text is compiled
when the module is created, whether or not it is ever called.
Caching
| time | |
|---|---|
| no cache | 13.7 ms |
| cold, writing entries | 27.4 ms |
| warm | 5.3 ms |
A warm cache is 2.6× faster than none; a cold one is 2× slower. See Caching.
Interruption
0.2% on a 50-million-iteration tight loop — the worst case, since the check is proportionally largest where the loop body is smallest. Straight-line code pays nothing.
A first attempt measured this against a recursive fib and reported −20%,
which was noise: fib is call-dominated, so the loop check barely features.
Measuring the wrong workload is easier than it looks.
Optimisation level
OptLevel::None compiles faster; OptLevel::Speed generates better code. On
these fixtures the difference in compile time is small and in run time not
reliably measurable, because the fixtures are too short to say anything useful.
There is no benchmark suite yet, so treat any claim about guest execution speed as unmeasured. The figures above are all about the compiler, not the code it produces.
Limitations
Known gaps, and what each would take.
Platform
Windows is not supported, and the build says so rather than failing inside the C compiler.
Two thirds of the port are mechanical. Guest memory maps almost directly:
mmap/mprotect/munmap become VirtualAlloc/VirtualProtect/VirtualFree.
Catching the fault is a swap of sigaction for AddVectoredExceptionHandler.
Recovery is the part that does not translate.
rvtime recovers with _longjmp. On Windows that unwinds, which needs unwind
information for every frame it walks, and guests are compiled with unwind_info
off. Turning it on does not help: cranelift-jit never registers unwind tables
with the OS. It contains no call to RtlAddFunctionTable and does not even
depend on the Windows API that provides it, so the data would exist and nothing
would know about it.
What it would take
wasmtime solves this by not unwinding at all. Its vectored handler rewrites the thread context and resumes execution somewhere else:
context.Rip = handler.pc as _;
context.Rbp = handler.fp as _;
context.Rsp = handler.sp as _;
EXCEPTION_CONTINUE_EXECUTION
Those values are captured at the guest entry point, so the effect is a
setjmp/longjmp pair built out of the operating system’s context mechanism
instead of libc’s.
The instructive part is that wasmtime uses the same design on Unix. Windows is
not the odd platform here — rvtime’s C shim is. Adopting the saved-context
approach would delete the shim and the cc build dependency, give both
platforms one mechanism, and make the unwind-info problem disappear entirely,
because nothing would ever unwind.
What it costs is exactly what setjmp hides today. Restoring a context by hand
means touching architecture-specific fields — uc_mcontext.gregs[REG_RIP] on
Linux x86_64, __ss.__pc on macOS arm64, Rip/Rsp on Windows x64 — so four
variants replace one portable call.
That leaves a small assembly trampoline to capture the entry frame, a vectored handler, and the memory port. wasmtime is Apache-2.0 with LLVM exception, so adapting the approach is compatible with this project’s licence.
Why it waits
Not because the design is unknown; it is written down above. Because none of it can be executed on a POSIX machine.
This is the one component where being subtly wrong converts a catchable trap into a crash, and its characteristic failure is passing the test that was written while breaking on a different stack shape. It wants a Windows machine in the development loop, not a CI job at the end.
CI covers Linux/x86_64 and macOS/arm64.
Guests
No hardware floating point. RV64IMAC only. Guests can use floats via
soft-float, which is bit-exact, but an image built for riscv64gc contains real
F/D instructions and is rejected at load with the offending instruction
named.
No std. This follows from the above: every Rust RISC-V std target is gc.
Guests are no_std plus alloc, which covers crates that do not need an
operating system. Anything that does — files, sockets, threads — has to come
from host functions.
The LP64 ABI is assumed. Hand-written assembly that passes data in callee-saved or temporary registers across a call will misbehave, and rvtime cannot detect it. Compiler output is fine. See Registers.
--emit-relocs is required, so a stripped third-party binary will not load.
This is the accepted trade for knowing exactly where an indirect call may land.
Runtime
Tail calls grow the native stack. jr compiles as call-then-return, which is
semantically identical but consumes a frame. Unbounded tail recursion will
exhaust the host stack instead of running in constant space. Switching the guest
convention to CallConv::Tail fixes it.
Interruption only covers loops. A long-running straight-line computation cannot be stopped part-way. See Interruption.
Compilation is eager. Config::strategy exists with a single Eager
variant; nothing is compiled lazily, so a module pays for functions that are
never called.
Memory confinement is not a precise bounds check. An address past the end of the address space wraps rather than faulting, so a wild pointer can corrupt the guest’s own memory. It cannot escape it. See Memory.
No guest execution benchmarks. Compile-time figures are measured; claims about how fast compiled guest code runs are not.