eisbaw/nix-riscv

RISC-V emulator in pure Nix 🤪 because

0

stars

2

commits

Nix

primary language

Sep 14, 2026

updated

README

nix-riscv

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.

ProgramResultImage bytesExecuted instructions
Hello WorldHello from RISC-V, evaluated by Nix!20912
FibonacciF(20) = 67651801196
Euclid's GCDgcd(1071, 462) = 21180596
Bubble sort1 2 3 4 6 7 8 9, one number per line2602500
CRC-32/ISO-HDLC123456789 gives 0xcbf43926, printed as 34217802622333248

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 x0 fixed 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, with sp at 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.
  • FENCE acts as a no-op in this single-hart sequential memory model.
  • EBREAK stops with reason = "breakpoint" and advances past the instruction.
  • A minimal 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.

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 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.

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.

Contributors

eisbaw

2 commits

eisbaw/nix-riscv

RISC-V emulator in pure Nix 🤪 because

0

stars

2

commits

Nix

primary language

Sep 14, 2026

updated

README

nix-riscv

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.

ProgramResultImage bytesExecuted instructions
Hello WorldHello from RISC-V, evaluated by Nix!20912
FibonacciF(20) = 67651801196
Euclid's GCDgcd(1071, 462) = 21180596
Bubble sort1 2 3 4 6 7 8 9, one number per line2602500
CRC-32/ISO-HDLC123456789 gives 0xcbf43926, printed as 34217802622333248

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 x0 fixed 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, with sp at 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.
  • FENCE acts as a no-op in this single-hart sequential memory model.
  • EBREAK stops with reason = "breakpoint" and advances past the instruction.
  • A minimal 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.

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 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.

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.

See what people are saying

Contributors

eisbaw

2 commits

Languages

Nix

45.6%

Python

43.1%

Assembly

10.1%

Linker Script

1.3%