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.
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.
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.
"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.
| 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 oneFirst 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 suites110 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/VTWwere 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.
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.
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.
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.
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.
- 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.