A small RISC-V RV32I emulator written in Nix. Real machine-code instructions
execute during nix eval: fetching, decoding, arithmetic, branching, RAM
updates and output all happen in the Nix language.
The emulator is 172 lines in rv32.nix, including comments. It needs only Nix
built-ins. There are no nixpkgs imports, derivations, external evaluators or
network dependencies when running the bundled programs.
Run
With nix-command and flakes enabled:
nix eval --json .#demos nix eval --raw .#demos.hello.stdout nix eval --raw .#demos.fibonacci.stdout nix eval --json .#tests
Or without flakes or experimental features:
nix-instantiate --eval --strict --json default.nix nix-instantiate --eval --strict --json tests.nix
For a single program's full register state and output, with nix-command enabled:
nix eval --json --file run.nix --apply 'run: run { program = "crc32"; }' nix eval --raw --file run.nix --apply 'run: (run { program = "hello"; }).stdout'
The machine-code images are included. A cross compiler or assembler is not needed to run them. Tested with Nix 2.18.1.
Programs
These are small, original assembly implementations of familiar algorithms,
assembled with GNU binutils using -march=rv32i -mabi=ilp32.
| Program | Result | Image bytes | Executed instructions |
|---|---|---|---|
| Hello World | Hello from RISC-V, evaluated by Nix! |
209 | 12 |
| Fibonacci | F(20) = 6765 | 180 | 1196 |
| Euclid's GCD | gcd(1071, 462) = 21 | 180 | 596 |
| Bubble sort | 1 2 3 4 6 7 8 9, one number per line | 260 | 2500 |
| CRC-32/ISO-HDLC | 123456789 gives 0xcbf43926, printed as 3421780262 |
233 | 3248 |
The instruction counts include formatting and output. Even decimal conversion
uses RV32I software division; the guest programs contain no M-extension
instructions. programs/io.S supplies a small shared output routine.
For each program, images/ includes the flat binary, a JSON byte array, an ELF
for independent execution in QEMU, and a disassembly. The emulator consumes the
JSON byte array; it does not parse assembly or ELF files.
Machine and instruction support
- All RV32I integer ALU, comparison, shift, branch, jump, load and store operations.
- 32 registers, with
x0fixed at zero; 32-bit wrapping arithmetic and addresses. - Little-endian, byte-addressable RAM: 1 MiB by default, initially zero-filled.
- The sample execution environment starts at
0x10000, withspat the top of RAM, aligned to 16 bytes. Other registers start at zero. - Naturally aligned accesses: misaligned instructions, loads and stores stop with an explicit fault reason. Memory accesses outside RAM also stop.
FENCEacts as a no-op in this single-hart sequential memory model.EBREAKstops withreason = "breakpoint"and advances past the instruction.- A minimal
ECALLenvironment supports Linux's register conventions forwrite(1, buffer, count)(64) andexit(code)(93). Other file descriptors return-EBADF; invalid write buffers return-EFAULT; unknown syscalls stop.
This is an educational user-level interpreter, not a full RISC-V platform.
There are no M/A/F/D/C/V extensions, CSRs, privileged modes, interrupt
controllers, MMU, operating system or general Linux syscall emulation. Unsupported
encodings stop with reason = "illegal-instruction". The steps field counts
completed emulator steps, including handled ECALL/EBREAK, rather than modeling
the architectural instret counter.
stdoutBytes is the exact output as byte values. stdout is a convenient text
view: printable ASCII and tab/newline/carriage return are preserved; other
bytes appear as \xNN. No real I/O occurs inside the emulator.
Use it as a Nix library
let cpu = import ./rv32.nix; image = builtins.fromJSON (builtins.readFile ./images/fibonacci.json); initial = cpu.load image; final = cpu.run 20000 initial; in cpu.report final
load { bytes; base ? 65536; entry ? base; ramSize ? 1048576; }creates a machine.step stateexecutes one instruction; a halted state is returned unchanged.run instructionBudget stateexecutes up to that many instructions.report statereturns registers, PC, output, exit status, reason and step count.readByte state addressandreadWord state addressinspect RAM.
Reaching the instruction budget returns reason = "budget", with
halted = false. Continue from exactly that snapshot:
let cpu = import ./rv32.nix; image = (import ./examples.nix).fibonacci.image; initial = cpu.load image; checkpoint = cpu.run 40 initial; final = cpu.run 2000 checkpoint; in { before = cpu.report checkpoint; after = cpu.report final; }
States are immutable values. Registers are a 32-element list; populated memory
bytes are a sparse attribute set keyed by decimal address strings. The run loop
uses builtins.foldl', forcing register values each step to avoid building
long chains of deferred updates. It does not require increasing Nix's call-depth
limit. The budget itself is a finite list, so keep budgets reasonable.
Verification and rebuilding
The checked-in fixtures cover 277 instruction and execution-environment
cases, including arithmetic boundaries, signed/unsigned comparisons, shift
masking, immediate extremes, negative branches, PC wrapping, JALR aliasing and
low-bit clearing, endianness, x0, invalid encodings and memory faults. Separate
checks cover snapshot/resume equivalence and the five example outputs.
The test instructions were encoded by GNU as/ld, independently of the Nix
decoder. The five example ELFs were also run in QEMU; all output and exit codes
matched. verification.json records that run. These are focused project tests,
not the upstream RISC-V architectural certification suite.
# Re-run the checked-in tests and examples; only Nix and Python 3 are required. python3 verify.py # Rebuild and compare against QEMU. Requires GNU RISC-V binutils and qemu-riscv32. python3 verify.py --rebuild --qemu
RISCV_PREFIX defaults to riscv64-unknown-elf-; the tools are explicitly asked
to emit RV32I/ILP32 despite the toolchain name. Set it for another GNU toolchain:
RISCV_PREFIX=riscv32-none-elf- python3 verify.py --rebuild --qemu
On Debian/Ubuntu, the optional verification tools are nix-bin,
binutils-riscv64-unknown-elf, qemu-user, and python3.
To add a program, put its assembly in programs/, define _start in
.text.start, then run python3 build.py. Register its image and expected
output in examples.nix. You can instead call cpu.load directly with any
compatible flat image represented as a list of bytes.
References: RV32I specification and Nix built-ins.