Skip to content

DOOMCore v1

Back to project overview

This core is based roughly on my previous RV32E CPU: tiny-riscv. However it had to undergo many changes to make it suitable to run DOOM.

tiny-riscv is not a very useful core. It assumes it can hit in both imem and dmem every cycle every time. On hardware with real DRAM and non-perfect caches, this assumption is unrealistic. Speaking of caches, the original tiny-riscv didn't implement any. Stalling every instruction to wait for DRAM would slow performance by a few orders of magnitude. Finally, it cannot interact with the rest of the system as the instruction and data ports do not support AXI.

Overview

This core has a 4 stage pipeline that runs at 81 MHz*. There are four real stages: Fetch, Decode, Execute, Load/Store; with writeback possible at the end of both Execute and Load/Store stages thanks to a dual write-port register file.

There are two 8KB caches for instruction and data. Both caches are direct mapped, with the dcache being write-through, write-around (the icache is read-only).

Initially, I am targeting RV32I + Zmmul (multiply only) for the ISA. The existing core already supported the full RV32I ISA, adding multiply instructions is very simple. Just modify the decode unit and add a new multiply function to the execution unit. Later, I may add custom instructions to speed up hot loops in the game engine.

* This pipeline could run much faster (100 MHz+), but due to a hardware flaw in the GW2AR-LV18QN88C8/I7 FPGA, 81 MHz is the rough upper limit of stability.

Instruction fetch / icache

[TODO: insert shitty diagram]

The instruction fetch keeps track of the current* PC and feeds them into the icache which will either return an instruction on a hit, or stall until the cacheline with the requested instruction is returned from DRAM.

The branch unit will calculate branch targets and pass them to the fetch unit whenever they are encountered. In these cases, the currently fetching instruction is discarded (FE flush) and the fetch unit starts over with the new PC (the branch target)

If the icache is in the process of filling a cacheline for a badpath PC when a branch comes in, it will stall until that cache fill is complete before starting fetch on the good path PC (branch target).

The fetch unit is decoupled from the rest of the core with a skid buffer (why?). The fetch unit continues to produce valid instructions as fast as it can until the downstream skid buffer deasserts ready (stall). at this point the fetch unit holds the current PC and any cache fetches that complete in the meantime will be held until the downstream stages resume.

* this is a pipelined design where 3+ PCs can be active at a time. Defining one of them as "current" is not useful.

Decode

RV32 decode is quite simple and typically would not require its own stage, but in the DOOMCore, hazard forwarding also occurs in the the decode stage.

Execute / Branch

Branch and integer instructions are both executed in the 3rd stage.

Since im not worried about minimizing logic area, the branch unit performs its own branch computations as well as computes the branch target. If the current instruction seen by the BRU is a jump or a conditional branch that evaluates to true, it asserts branch which causes the fetch unit to flush and start fetching from branch_target instead.

The ALU impelements all of the other integer instructions (not loads or stores). Since DOOMCore targets an FPGA with lots of hard logic adders and LUTs, no exciting optimizations occur here. This is probably the most boring part of the design.

Load/Store

The final stage contians a typical load store unit connected to a dcache. Loads and stores occur in order, with the exception of deferred load stalls. When a load is outstanding, the core is free to continue executing other ALU (non LS) instructions. Instead of stalling the core completely, one just needs to keep track of the destination register of the outstanding load. the stall logic is exactly the same as standard data-hazard checking logic.