userland.rundocs70Try the demo
Internals

RISC-V runner

The RISC-V runner is a #![no_std] Rust RV64GC interpreter compiled to WebAssembly. It loads unmodified Linux ELF binaries and runs them instruction by instruction against a Linux syscall layer.

Design

  • One interpreter function. A dense match in exec() compiles to a WebAssembly br_table; the source is split across files with #[inline(always)] and fat LTO fuses it back into one function.
  • Hot state in locals. Registers and pc live in local variables inside exec().
  • No allocation in the hot path; one dependency, vte (no_std), for the terminal parser.
  • Block cache. Decoded basic blocks (16,384 entries) are entered on every control transfer and chained; stores to a page with cached code invalidate it (self-modifying code safety).
  • Cooperative yielding. An instruction budget returns control to the JS host so the event loop keeps running.
  • Small host boundary. Six imports; file I/O through a shared-memory protocol. See Host API.

ISA

RV64IMAFDC: integer, multiply/divide, atomics, single and double float, and the 16-bit compressed encoding (the decoder checks the low two bits). Guest programs are static riscv64gc-linux-musl binaries; the catalog builds apps for that target.

Execution model

  • The host calls vm_step(vm, budget).
  • The interpreter runs until the budget is spent, the process exits, or a syscall needs the host.
  • Internal syscalls (memory, threads, sockets, time) complete inside the module; file syscalls return FS_PENDING for the host's in-memory filesystem; epoll waits return EPOLL_BLOCKED so time and injected connections can advance.

Memory layout

text
WebAssembly linear memory (shared; 192 MiB initial, 2 GiB max)
├── VM struct (12,680 bytes)
├── guest RAM (the rest of ramMB: program text, heap via brk/mmap, stack)
├── scratch buffers (virtual server I/O)
└── data section: bundled ELFs, only in builds that embed them

Guest virtual addresses map directly to offsets in the RAM region. brk, mmap and mremap are bump allocators; munmap doesn't free (see Syscalls).

The VM struct

All machine state is one #[repr(C)] struct of 12,680 bytes (a compile-time assertion in types.rs); the JS host reads it at fixed offsets.

Offset
Size
Contents
0
560
CPU: x[32], pc, f[32], fcsr, status, exit code, budget, fault info
560
40
memory: brk start/current, stack limit
600
1,536
fd table (64 × 24 bytes)
2,216
552
FsRequest (syscall, fd, args, two 256-byte paths)
2,768
24
FsResponse
2,792
512
mmap entries (16)
3,320
40
signal state
3,360
16
TLS
3,376
64
thread / pipe / eventfd state
3,680
256
current working directory
3,936
36
run state
3,972
8,708
thread area (16 slots × 544 bytes)

Threads

clone creates thread slots (up to 16), futex provides wait/wake, and context switches happen at syscall boundaries. The memory is shared with atomics enabled, so this is real threading inside one instance. Node.js runs with UV_THREADPOOL_SIZE=0. fork is serialized: the child runs first, then the parent resumes.

Source map

File
Responsibility
runners/riscv/src/cpu.rs
interpreter loop, decode/dispatch, block cache
runners/riscv/src/decode.rs
instruction field extraction
runners/riscv/src/syscall.rs
Linux syscall dispatch (98 numbers)
runners/riscv/src/mem.rs
guest memory access, store watch for cached code
runners/riscv/src/elf.rs
ELF loader (segments, argv/envp/auxv)
runners/riscv/src/types.rs
the VM struct
runners/riscv/src/exports.rs
the WebAssembly exports
runners/riscv/src/term.rs, tty.rs
terminal model and line discipline
runners/riscv/src/alloc.rs, host.rs, lib.rs
bump allocator, host imports, crate root
runners/riscv/host/nanovm.mjs
the JS host: memory, MemFS, run loop, virtual server, snapshots
shell

What the guest sees

bash
uname -a
head -1 /proc/meminfo
busybox | head -1
loading…
Expected output
$ uname -a
Linux nanovm 6.1.0 #1 NanoVM riscv64 GNU/Linux
$ head -1 /proc/meminfo
MemTotal:       524288 kB
$ busybox | head -1
BusyBox v1.36.1 (2026-02-15 09:06:52 UTC) multi-call binary.

Measured speed is on Performance; feature tour on RISC-V emulator.