Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 resultsa0..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:

crateresponsibility
rvtime-coreregisters, instructions, ELF loading. No codegen.
rvtime-craneliftcontrol-flow analysis and CLIF emission.
rvtime-compilercodegen backends, guest memory, trap handling.
rvtimethe 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_FUNC symbols. 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_64 relocations landing in .text. This is why --emit-relocs is 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:

shapemeaning
jalr zero, 0(ra)return
target is a known function entrycall (tail call if rd is zero)
target is inside this functionlocal jump
target unknownindirect 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:

  1. flushes a0..a7 to the VM context, since the handler reads them there;
  2. calls a trampoline whose address the context carries;
  3. checks the returned status;
  4. 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 cache13.7 ms
cold, writing entries27.4 ms
warm5.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:

phasetime
ELF load and decode78 µ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 cache13.7 ms
cold, writing entries27.4 ms
warm5.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.