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.
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.
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.
x0 fixed at zero; 32-bit wrapping arithmetic and addresses.0x10000, with sp at the top
of RAM, aligned to 16 bytes. Other registers start at zero.FENCE acts as a no-op in this single-hart sequential memory model.EBREAK stops with reason = "breakpoint" and advances past the instruction.ECALL environment supports Linux's register conventions for
write(1, buffer, count) (64) and exit(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.
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 state executes one instruction; a halted state is returned unchanged.run instructionBudget state executes up to that many instructions.report state returns registers, PC, output, exit status, reason and step count.readByte state address and readWord state address inspect 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.
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.
2 commits
Hacker News (1)
Nix
45.6%
Python
43.1%
Assembly
10.1%
Linker Script
1.3%
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.
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.
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.
x0 fixed at zero; 32-bit wrapping arithmetic and addresses.0x10000, with sp at the top
of RAM, aligned to 16 bytes. Other registers start at zero.FENCE acts as a no-op in this single-hart sequential memory model.EBREAK stops with reason = "breakpoint" and advances past the instruction.ECALL environment supports Linux's register conventions for
write(1, buffer, count) (64) and exit(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.
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 state executes one instruction; a halted state is returned unchanged.run instructionBudget state executes up to that many instructions.report state returns registers, PC, output, exit status, reason and step count.readByte state address and readWord state address inspect 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.
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.
Hacker News (1)
2 commits
Nix
45.6%
Python
43.1%
Assembly
10.1%
Linker Script
1.3%