Skip to content

Latest commit

 

History

121 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

DoomV

A RISC-V CPU, built from scratch in C++, that boots bare-metal DOOM.

No OS, no Linux, no existing core as a reference implementation — just an instruction decoder, a register file, a memory bus, and enough of the RV64GC spec (plus Zicsr, Zifencei, and the V vector extension) to run a real game.

image

Why

My Gameboy emulator was done, I'd taken a chunk of computer architecture coursework by that point, and I had a month free before starting a co-op. I wanted a project that would actually make me use that coursework instead of just having sat through it, and "build a CPU and see if it can run Doom" seemed like the right amount of stupid.

The original goal was small on purpose: get bare-metal DOOM booting, and if it works, stop — nothing else needs to be implemented to call it done. That happened a while ago. Everything past that point (F/D, C, V, formal verification against a reference simulator) is stuff I kept going on because it was fun, not because the project needed it.

What's actually implemented

DoomV's default target is RV64GC plus Zicsr and Zifencei — i.e. RV64IMAFDC_Zicsr_Zifencei — with the V (vector) extension on top, opt-in since it's not needed to boot Doom itself.

Extension Status
I (base integer)
M (mul/div)
A (atomics)
C (compressed)
F / D (single/double float)
Zicsr
Zifencei ✅ (no-op — see below)
V (vector) ✅, off by default
Zba / Zbb / Zbs (bitmanip)
Zcb (compressed bitmanip/mem)
Zicond (conditional move)
Zvbb (vector bitmanip)
Zihintpause / Zihintntl ✅ (hints — retire without effect)
Zimop / Zcmop ✅ (write zero to rd)
Zicbom / Zicbop ✅ (no cache to manage — see below)
Zicboz (cbo.zero)
Zawrs (wrs.nto/wrs.sto) ✅ (retires immediately)
Zicntr (cycle/time/instret)
Zihpm (hpmcounter3-31) ✅ (read as zero)
Zfa (additional FP)
Zfhmin (half-precision converts)
Zvfhmin (vector half converts)
Svinval (fine-grained TLB invalidation) ✅ (no TLB — see below)
Svnapot (64KB contiguous PTEs)
Svpbmt (page-based memory types)

The bitmanip families and Zicond are on by default despite Doom never emitting them: every modern riscv64 Linux userspace assumes them, so defaulting them off would only manufacture illegal instructions.

The last three rows are the RVA23 extensions that are defined to do nothing observable. Several of them were already retiring correctly before they had names here, because they are encoded inside another instruction's space in a way that discards the result — pause is a fence, the ntl.* hints are c.add into x0, and prefetch.* are ori into x0. Claiming them properly still matters: it lets -march gate them and the dashboard name them, and Zimop/Zcmop carry one real rule — they must write zero to rd, which is the guarantee that makes those reserved encodings safe for a future extension to claim. Zicbom's cache-management ops are satisfied trivially by a machine with no cache; what is not modelled is the menvcfg/senvcfg permission layer that lets M-mode make them trap in S/U mode.

Zicboz is different from its Zicbom siblings — cbo.zero is a real store, and the address is aligned down to the 64-byte block, so cbo.zero (base+8) still zeroes from base.

Zawrs retires immediately. The spec explicitly permits that, and the software contract is built around it: the surrounding loop always re-checks its condition, because a wrs may return for any reason or none. On a single-hart interpreter it is also the only implementation that terminates, since no other hart exists to break the reservation.

The counters are one number. mtime advances once per retired instruction (that is what makes this machine's timer deterministic), so cycle, time and instret all read the same counter — an interpreter that retires one instruction per step has cycle == instret by construction. hpmcounter3 through hpmcounter31 read as zero, which the spec permits and which is the honest answer for a machine that counts no events.

Zfa is mostly not new arithmetic — it is arithmetic that differs from an existing instruction only in a corner, which is what makes it worth testing carefully. fminm/fmaxm return a canonical NaN when either operand is NaN, where FMIN/FMAX return the other operand; fleq/fltq return the same value as FLE/FLT for every input and differ only in which NaNs raise invalid; and fcvtmod.w.d wraps modulo 2³² where every other float-to-int conversion saturates. Adding it surfaced a latent bug in the base F and D extensions, where FMIN/FMAX/FEQ raised invalid for a quiet NaN when the spec reserves that for a signalling one — a documented simplification that was harmless for results and wrong for flags, and that the accrued-flags-only test could not see.

RVA23 mandates Zfhmin and Zvfhmin, not the full Zfh/Zvfh — half precision arithmetic is an expansion option, so there is no fadd.h here and software is expected to widen to single, compute, and narrow back. MinGW has no _Float16 on x86, so every half conversion is hand-written integer code (src/extensions/ext_fp16.hpp) rather than a host FPU operation. Narrowing rounds exactly once from the source significand: going double → float → half rounds twice, and a value that is an exact midpoint in half but not in single rounds the wrong way.

Adding them surfaced two older gaps. exec_v_fp returned silently for any SEW other than 32 or 64, so every vector FP instruction at e16 was a no-op; and the whole widening/narrowing half of VFUNARY0 (vfwcvt.*/vfncvt.*, 14 instructions) was ignored the same way. Both are implemented now. Separately, float-to-integer conversions never reported inexact: collect_fflags() reads MXCSR, but std::llrint on this toolchain goes through x87, so the flag was dropped. It is now decided from the values — a conversion is inexact exactly when its result converts back to something different — which holds for every rounding mode and does not depend on the host.

Svnapot and Svpbmt add no instructions — they give meaning to page table entry bits that were previously ignored, so the only way to exercise them is an actual page-table walk. Svnapot's N bit makes the low four bits of the physical page number come from the virtual address, so one entry covers 64KB; an implementation that ignores it still translates, it just aliases every address in the range onto the same page. Svpbmt's memory types are genuinely unobservable here — with no caches, PMA, NC and IO behave alike — so what makes it real is what must fault: the reserved type 3, and any nonzero type while menvcfg.PBMTE is clear, which is how an OS probes for the extension. Bits 60:54 of a PTE are now checked as reserved too; without that the other two would be meaningless.

Svinval's three instructions retire without effect for the same reason fence.i does: mmu_translate walks the page table in guest memory on every access, so a translation can never be stale and there is nothing to invalidate.

CSR accesses are privilege-checked: writing a read-only CSR, or touching one above the current privilege level, raises an illegal instruction, and cycle/time/instret are gated in S- and U-mode by the mcounteren/scounteren chain. CSR numbers this machine gives no meaning to are still readable and writable — OpenSBI detects hart features by reading a spread of CSRs to see which trap, so making unknown ones illegal is a much larger change than making privilege boundaries real.

Every extension is a runtime toggle, not a compile-time one — pass -march=rv64imafdc_zicsr_zifencei (the default), add a v for vector support, or trim it down to something like rv32ima if you want to see it fail in more interesting ways. fence.i is implemented as a genuine no-op rather than being unsupported: there's no instruction cache here to invalidate, since every fetch reads straight out of live guest memory, so the correct emulation of "make sure instruction fetches see recent stores" is to just not need to do anything.

Correctness

"It draws pixels that look like Doom" is not a correctness bar, so this is measured against the architecture instead of against itself.

Sail is the golden reference. The Sail RISC-V model is generated from the same source the architecture is defined in, rather than being an independent reimplementation, and it is the only model riscv-arch-test ships an RVA23S64 configuration for -- there is no spike RVA23S64 config at all. spike is still run behind --ref spike, because an independent implementation disagreeing is a signal even when it turns out to be the one that is wrong, but it does not decide anything.

Live results

suite what it is result
riscv-arch-test RVA23S64 the certification suite, 663 tests, signature-diffed against Sail 663 / 663
differential 19 hand-written suites, 639 cases, diffed against Sail 19 / 19
damo-rv-priv-ats hypervisor, the only H coverage that exists anywhere -- 43 groups 13 / 43 groups, 1757 / 2079 assertions; in progress
Linux OpenSBI + 6.12 + busybox boots to an interactive shell
DOOM bare-metal, no OS plays

Every suite runs headless (-ng), which is what makes them practical to run at all: arch-test's 663 tests take about 40 seconds and the 43-group hypervisor suite about 10. Reproduce all of it with one command:

tools/verification/verify.sh          # build, then every suite
tools/verification/verify.sh --quick  # skip the two slow suites
tools/verification/verify.sh archtest # just one

First time only, to build the reference model and fetch the suites:

tools/verification/tests/archtest/setup.sh          # toolchain, Sail 0.13.1, act
tools/verification/tests/archtest/gen_reference.sh  # compile tests + Sail signatures
tools/verification/tests/suites/fetch.sh            # precompiled third-party suites

What that process actually found

110 bugs, written up individually in docs/BUGS.md. A few that say something about the method:

  • Floating point had to stop using the host FPU. Three ordinary bugs took the D and F families from 103 failures to 78, and then it stopped moving -- because RMM (round-to-nearest-ties-away) has no x86 encoding at all and was being approximated by round-to-nearest-even, wrong on every exact tie, and because host exception flags are unreliable on this machine. F/D now compute in Berkeley SoftFloat, which is what spike uses and why Sail and spike agree with each other. 103 failures to zero.
  • The hand-written suites passed the whole time. All 19 matched Sail while 123 certification tests failed. They never fed a signalling NaN to fcvt.d.s, never exercised RMM, never hit a tie. Where an established suite covers the ground, it is better evidence than anything written to match one's own implementation.
  • Linux was never actually booting. "238 lines to /bin/sh" was the emulator halting on an illegal instruction, which looks identical to a clean boot because the log simply stops. Making illegal instructions trap exposed it: userspace issued a vsetivli, the hart correctly called it illegal because the device tree never mentioned V, and the kernel turned that into SIGILL and killed init.
  • Whole features were missing and nothing noticed. PMP did not exist. mstatus.MPRV, mstatus.SD, mstatus.TVM, hstatus.VTSR/VTVM/VTW were defined bits that nothing consulted. Physical memory attributes were unchecked, so a page-table walk off the end of RAM read zeros, saw an invalid PTE, and reported a page fault where the architecture requires an access fault.

The harness lives in tools/verification/. tests/differential/ is the hand-written suites, tests/archtest/ drives riscv-arch-test, and tests/suites/ runs the precompiled third-party suites.

Code layout

src/
  main.cpp             entry point, CLI flags, wires everything together
  doom_system.*         top-level system: owns decoder, registers, memory, gui
  memory.*              guest RAM, WAD/ELF loader, MMIO bus (framebuffer, input)
  registers.*           x0-31, f0-31, v0-31, PC, CSRs, and the trace history ring
  extensions.*           the enabled-extension table + -march= parser
  riscv_decoder.*        instruction word -> decoded struct, extension gate, dispatch
  riscv_core.hpp          shared declarations for each extension's execute function
  extensions/            one file per extension (ext_i.cpp, ext_m.cpp, ext_v_*.cpp, ...)
  debugger.*             breakpoints, halt conditions, crash/signature dumps
  gui.*                  the SDL window, framebuffer scaling, and the debug dashboard

Each extension owns its own decode + execute logic in its own file under src/extensions/ — nothing shared gets fought over, and adding a new extension (which happened more than once) never means touching a giant switch statement that also handles six other things.

Building & running

make
./riscv_doom.exe tools/doom/doombuild/DOOM1.WAD tools/doom/doombuild/doomv-free.elf

DOOM1.WAD (the shareware IWAD) is the only WAD checked into this repo — it's free to redistribute. Point it at your own DOOM.WAD/DOOM2.WAD if you own a copy, and rebuild the guest ELF from tools/doom/doombuild/ to match.

Command-line options

riscv_doom.exe <wad> <elf> [options]                      # bare-metal / test ELF
riscv_doom.exe -opensbi=<f> -kernel=<f> -dtb=<f> -initrd=<f> [options]   # Linux
Option Meaning
-ng Headless: no SDL window, and the process exits as soon as the guest stops. Aliases: -nogui, -headless, --headless.
-march=<isa> Override the enabled extension set, e.g. rv64imafdc_zicsr_zifencei. Without it a Linux boot gets the full RVA23S64 profile and everything else gets the rv64imafdc_zicsr default.
-break=<hex_pc> Halt and dump full CPU state when the pc reaches this address.
-sig=<hex_begin>:<hex_end> Dump this memory range to signature.log on halt — how the arch-test harness pulls signatures.
-tohost=<hex_addr> Stop when the guest stores a nonzero word to this address, and write the value to tohost.log. This is how every bare-metal RISC-V suite reports its verdict, and it is the only stop signal for suites that export no signature symbols.
-opensbi=<path> OpenSBI firmware ELF (fw_jump.elf). Any of the four Linux options selects Linux-boot mode.
-kernel=<path> Kernel Image.
-dtb=<path> Flattened device tree.
-initrd=<path> Initramfs cpio archive.

-ng is what makes the conformance suites practical. With a window open a finished test never exits on its own and has to be killed from outside, so every test cost its full timeout whether it passed or not; headless, the whole 663-test arch-test run takes about 40 seconds and the 43-group hypervisor suite about 10.

Key bindings live in controls.json if you want to remap them.

The guest side — the actual Doom binary that runs on this CPU — is built separately in tools/doom/doombuild/: a cross-compiled doomgeneric with a small platform layer (doomgeneric_doomv.c, w_file_doomv.c, a libc shim) that talks to DoomV's MMIO instead of a real OS.

Design notes

The core dispatches on a plain switch statement rather than a table of function pointers. I went in assuming function pointers would be the "proper" approach, but it turns out projects like QEMU deliberately avoid that pattern — indirect calls through a function pointer table are worse for branch prediction than a switch the compiler can reason about and group cases in. So the core is, structurally, uglier than I'd like and faster than the elegant version would've been.

Atomics were implemented even though a single-hart bare-metal target never strictly needs them, because toolchain-emitted code (and doomgeneric's own init sequence) assumes they exist. In practice every load/store here is already atomic by construction, so the A extension mostly just needed to exist and decode correctly.

Sources

  • riscv-card — the reference sheet I built the initial decoder off of.
  • knazarov/rve — not code I read, but the project that first showed me what set of extensions I'd actually need to get something like this booting.
  • riscv-opcodes — the authoritative encoding tables the V extension was implemented from.
  • spike and riscv-arch-test — the reference simulator and compliance suite used for verification.

About

RISC-V Core that runs DOOM

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages