userland.run70Try the demo
Feature · RISC-V runner

A RISC-V Linux emulator in WebAssembly

At the core of userland.run is NanoVM: an RV64GC RISC-V CPU and a Linux system-call layer written in #![no_std] Rust and compiled to WebAssembly. It loads ordinary statically linked Linux ELF binaries — BusyBox, the real Node.js v25 executable, ripgrep — and runs them instruction by instruction inside a browser tab.

Real binaries, not reimplementations

Most in-browser runtimes reimplement an API (usually Node's) on top of browser primitives. NanoVM takes the other route: it emulates a CPU and enough of the Linux kernel ABI that programs compiled for riscv64gc-linux-musl run unchanged. The trade-off is speed — every guest instruction is interpreted — in exchange for fidelity: the same binary, the same behaviour, the same edge cases.

shell

Ask the kernel what it is

These lines run through BusyBox on the emulated CPU. The machine type is the RISC-V one, whatever your laptop is.

bash
uname -m
uname -s
busybox | head -n 1
loading…
Expected output
riscv64
Linux
BusyBox v1.36.1 (2026-02-15 09:06:52 UTC) multi-call binary.
shell

Pipes, redirects and a little awk

A normal shell pipeline: sh forks processes, the emulator connects them with pipes, and the results land in the in-memory filesystem.

bash
printf 'beta 3\nalpha 1\ngamma 2\n' > scores.txt
sort -k2 -n scores.txt | awk '{ print NR ". " $1 }'
wc -l < scores.txt
loading…
Expected output
1. alpha
2. gamma
3. beta
3

How it works

NanoVM follows the design Fabrice Bellard used for fast interpreters. The full write-up is in the RISC-V runner internals.

  • One monolithic exec() loop. Dense instruction dispatch compiles to WebAssembly br_table jump tables; fat LTO fuses the decoder and execution units into a single function with CPU registers held in locals.
  • RV64GC. The 64-bit base ISA with integer multiply/divide, atomics, single and double floating point, and 16-bit compressed instructions.
  • No allocation in the hot path. #![no_std] Rust with zero crate dependencies; maths such as sqrt lowers straight to wasm opcodes.
  • Cooperative scheduling. The host calls vm_step(budget); the guest returns control when the budget is spent, a process exits, or it needs file I/O — so the page stays responsive.
  • A thin host boundary. File I/O goes through a shared-memory request/response protocol to an in-memory POSIX filesystem, not per-instruction callbacks.
  • Threads. clone + futex give cooperative multithreading with context switches at syscall boundaries — which Node.js's worker threads rely on.

The slim image (nano.wasm) is 2.46 MB and contains only the emulator; BusyBox, Node.js and other tools are fetched from the signed app catalog on demand. console.wasm (3.2 MB) has BusyBox built in for terminals.

The Linux syscall layer

The syscall reference lists 87 Linux system calls. Most are handled inside the wasm module; file-system calls are forwarded to the JavaScript MemFS.

AreaSyscallsExamples
File system23openat, read, write, getdents64, statx, renameat2
Sockets14socket, bind, listen, accept4, connect, sendto
Identity & system13getuid, uname, sysinfo, getrandom, prlimit64
Process & threads10clone, futex, exit_group, set_tid_address
Memory6brk, mmap, mremap (munmap/mprotect are no-op stubs)
Epoll & events6epoll_pwait, eventfd2, pipe2, ppoll
Signals6stubs: rt_sigaction, kill, tgkill, …
I/O control4ioctl, fcntl, dup, dup3
Time & timers5clock_gettime, nanosleep, timerfd_create
Memory is a bump allocator inside a single wasm linear memory, which tops out at 2 GB. Guest RAM is configurable (ramMB); Node.js needs about 1.8 GB, BusyBox is happy with 256–512 MB.

Running commands from JavaScript

The SDK exposes the VM as Nano. run executes one command (whitespace-split, no shell), exec runs an exact argv, shExec runs a full shell line. Every call takes an instruction budget, maxSteps, so a runaway guest can't spin forever.

SDK · JavaScript

run, exec, shExec and an instruction budget

The last call is an infinite shell loop with a 5 million instruction budget: it is cut off and reports exit code -1.

javascript
const a = await nano.run("echo one two");
print("run:", a.stdout.trim(), "exit", a.exitCode);

const b = await nano.exec(["printf", "%s|", "a b", "c"]);
print("exec:", b.stdout);

const c = await nano.shExec("seq 1 5 | awk '{ s += $1 } END { print s }'");
print("shExec:", c.stdout.trim());

const d = await nano.shExec("while :; do :; done", { maxSteps: 5_000_000 });
print("budget exhausted, exit", d.exitCode);
loading…
Expected output
run: one two exit 0
exec: a b|c|
shExec: 15
budget exhausted, exit -1

Real ELF binaries from the catalog

Anything built as a static riscv64gc-linux-musl executable can run. The catalog publishes signed builds of common tools; installing one verifies every chunk against the catalog key before it touches the VFS, then the binary runs on the emulator.

catalog app

Install the ripgrep ELF and run it on the VM

This is ripgrep 14.1.0 compiled for RV64GC — not the wasm build from WASI apps.

bash
rg --version
rg -n 'emulat' notes.txt
loading…
Expected output
installed ripgrep@14.1.0 (1 file, verified)
ripgrep 14.1.0 (rev e50df40a19)

features:-simd-accel,-pcre2

PCRE2 is not available in this build of ripgrep.
1:a cpu emulator
3:emulating rv64gc

Node.js on the emulator

The catalog's node package is the upstream Node.js v25.4.0 binary built for riscv64. Running it on the VM is the fidelity path — the real V8, the real libuv, native addons — and also the slowest: expect V8 to take a while to start under emulation. For everyday scripts the SDK can route node to the browser's own JS engine instead (see engines).

Node.js

process.arch on the real Node binary

With needs: ["node-vm"] this sample installs node from the catalog and runs it on the RISC-V VM with a 1.8 GB guest. The first run downloads Node and starts V8 under emulation, so it takes longer than the other samples.

javascript
const os = require("os");
console.log("arch:", process.arch, "platform:", process.platform);
console.log("node:", process.version, "endianness:", os.endianness());
loading…
Expected output
arch: riscv64 platform: linux
node: v25.4.0 endianness: LE
dev server

An HTTP server on the emulated CPU

The same Node binary serving HTTP from inside the VM. Requests from the preview iframe are injected as connections into the guest's socket layer (see dev servers).

javascript
const http = require("http");
http
  .createServer((req, res) => {
    res.setHeader("content-type", "text/html");
    res.end("<h1>served by node on " + process.arch + "</h1>");
  })
  .listen(8080, () => console.log("listening on 8080"));
loading…
Expected output
served by node on riscv64
javascript
import { createNano } from "@userland-run/nano-sdk";

const nano = await createNano({ image: { wasm: "/nano/nano.wasm" }, ramMB: 1800 });
await nano.installApp("node");           // riscv64 ELF, signature-verified, OPFS-cached
nano.fs.writeFile("/app/main.js", "console.log(process.arch)");
const r = await nano.node(["/app/main.js"]); // engines.node defaults to "vm"
console.log(r.stdout);                      // riscv64

Honest limits

  • Speed. An interpreter is much slower than native code or a JIT. Shell work and short scripts feel instant; starting V8 under emulation takes noticeable time. See performance.
  • Memory. One wasm linear memory, at most 2 GB for guest RAM plus the image.
  • Signals are stubs and munmap/mprotect are no-ops; programs that depend on real signal delivery or memory protection behave differently.
  • Networking goes through the host (fetch for outbound HTTP, an injected-connection bridge for inbound) — see networking.

FAQ

Why RISC-V and not x86?

RV64GC is a small, regular instruction set that decodes quickly and maps well onto a dense WebAssembly dispatch loop, and mainstream toolchains (GCC, LLVM, Rust, Go) target riscv64 Linux directly.

Can I run my own binary?

Yes, if it is a statically linked riscv64 Linux executable that stays within the implemented syscalls. Write it into the VFS with nano.fs.writeFile(path, bytes, 0o755) and run it, or publish it to the catalog.

Is it a full system emulator like QEMU?

No. NanoVM is a user-mode emulator: it runs Linux programs and implements the kernel's system-call interface itself, so there is no guest kernel to boot.

Does it work offline?

The emulator is a static wasm file, so yes once it and any binaries you need are cached. The bundled build (make build-full) embeds BusyBox, Node.js and the dev tools for fully offline use.