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

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.