Project --- Building a 5-Stage Pipelined RV32IM CPU in Chisel
August 3, 2026·32 min read·intermediate
This project chapter is the capstone of Part III. The reader will implement a complete five-stage pipelined RV32IM CPU in Chisel from scratch, equipped with hazard detection, full forwarding, branch resolution…
This project chapter is the capstone of Part III. The reader will implement a complete five-stage pipelined RV32IM CPU in Chisel from scratch, equipped with hazard detection, full forwarding, branch resolution in the execute stage, and clean integration with the RISC-V test suites. The result is a working core that runs the official riscv-tests unit tests and the riscv-arch-test compatibility suite, simulated with Verilator and visualized with Surfer. The project is simulation- only by design. No FPGA flow is involved.
The structure follows the ALU project of Chapter 11. First, the Setup and Installation section walks the reader through getting the Chisel toolchain working on macOS, Linux, and Windows. Then the project skeleton is laid out. Then the five Chisel modules (IF, ID, EX, MEM, WB) are implemented one at a time, with the hazard detection and forwarding units folded into ID and EX respectively. Then the testbench is written. Finally, the test suites are run and the waveforms are inspected. The chapter ends with worked examples and exercises.
The CPU built here is the direct descendant of the textbook pipeline of Chapter 29. It implements the mechanisms studied in Chapter 30. It does not implement exceptions, interrupts, or the privileged ISA. Those extensions are left for a follow-on project and discussed briefly at the end of the chapter.
01.Setup and Installation
The project requires five tools: a Java Development Kit (JDK), sbt (the Scala build tool), Verilator (for simulation and the ChiselTest backend), Surfer (for waveform viewing), and the RISC-V GNU cross-toolchain (to compile the test binaries). The toolchain matches the one set up in Chapter 11 for the ALU project, with the RISC-V cross-toolchain from Chapter 24 added on top.
macOS (Homebrew)
macOS setup via Homebrew
# JDK 21 (current LTS)
brew install --cask temurin@21
# sbt, Verilator, Surfer
brew install sbt verilator
brew install --cask surfer
# RISC-V GNU cross-toolchain (bare-metal target)
brew tap riscv-software-src/riscv
brew install riscv-toolsVerify each tool after installation:
Verify macOS installations
| java -version # Temurin 21.x | |
| sbt --version # sbt 1.x | |
| verilator --version # Verilator 5.x | |
| surfer --version | |
| riscv64-unknown-elf-gcc --version # RISC-V GCC 13.x or later |
Linux (Arch as canonical)
Arch Linux setup
| # JDK 21 | |
| sudo pacman -S jdk21-openjdk | |
| # sbt and Surfer from the AUR | |
| yay -S sbt surfer | |
| # Verilator | |
| sudo pacman -S verilator | |
| # RISC-V GNU cross-toolchain (bare-metal) | |
| sudo pacman -S riscv64-elf-gcc riscv64-elf-binutils \ | |
| riscv64-elf-newlib |
For Debian or Ubuntu, replace pacman with apt:
Debian/Ubuntu alternative
| sudo apt install default-jdk verilator \ | |
| gcc-riscv64-unknown-elf binutils-riscv64-unknown-elf | |
| # sbt: follow https://www.scala-sbt.org/download/ | |
| # Surfer: download the AppImage from the Surfer GitHub releases |
For Fedora:
Fedora alternative
| sudo dnf install java-21-openjdk verilator \ | |
| gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu | |
| # sbt and Surfer: same as the Debian instructions |
The cross-toolchain binaries carry a target-triple prefix that differs from one packaging to the next. Homebrew’s riscv-tools and Debian’s gcc-riscv64-unknown-elf install riscv64-unknown-elf-*, Arch’s riscv64-elf-gcc installs riscv64-elf-*, and Fedora’s gcc-riscv64-linux-gnu installs riscv64-linux-gnu-*. The test programs in this project are freestanding assembly and never link against a C library, so any of the three works. Set a shell variable once and use it everywhere a cross tool is invoked:
Pin the cross-tool prefix
| export RVPREFIX=riscv64-unknown-elf- # macOS, Debian, Ubuntu | |
| # export RVPREFIX=riscv64-elf- # Arch, ArchWSL | |
| # export RVPREFIX=riscv64-linux-gnu- # Fedora | |
| ${RVPREFIX}gcc --version # RISC-V GCC 13.x or later |
Windows (ArchWSL)
The recommended path on Windows is ArchWSL, the Arch Linux distribution for WSL2. Once ArchWSL is installed, the setup is identical to the Arch Linux instructions above.
Windows setup via ArchWSL
| # Inside the ArchWSL terminal: | |
| sudo pacman -S jdk21-openjdk verilator \ | |
| riscv64-elf-gcc riscv64-elf-binutils riscv64-elf-newlib | |
| yay -S sbt surfer |
02.Project Skeleton
Create the project directory and initialize the sbt build:
Create the project tree
| mkdir -p rv32-pipe/src/main/scala/cpu | |
| mkdir -p rv32-pipe/src/test/scala/cpu | |
| mkdir -p rv32-pipe/tests/asm | |
| cd rv32-pipe |
build.sbt for the pipelined CPU
// build.sbt
val chiselVersion = "6.7.0"
lazy val root = (project in file("."))
.settings(
name := "rv32-pipe",
scalaVersion := "2.13.14",
libraryDependencies ++= Seq(
"org.chipsalliance" %% "chisel" % chiselVersion,
"edu.berkeley.cs" %% "chiseltest" % "6.0.0" % "test"
),
addCompilerPlugin(
"org.chipsalliance" % "chisel-plugin"
% chiselVersion cross CrossVersion.full
)
)Source tree overview
rv32-pipe/
build.sbt
src/main/scala/cpu/
Const.scala # opcodes, ALU codes, control encodings
AluUnit.scala # ALU adapted from the project in Chapter 11
RegFile.scala # 32-entry register file with two read ports
ImmGen.scala # immediate generator for I/S/B/U/J types
Fetch.scala # IF stage and PC logic
Decode.scala # ID stage with hazard detection
Execute.scala # EX stage with the forwarding unit
Memory.scala # MEM stage with data memory interface
Writeback.scala # WB stage
PipelineRegs.scala # IF/ID, ID/EX, EX/MEM, MEM/WB bundles
Core.scala # top-level module that wires the stages
src/test/scala/cpu/
CoreUnitTest.scala # ChiselTest-based unit tests
RiscvTestsRun.scala # harness for riscv-tests
tests/asm/ # hand-written assembly tests for early bring-upThe remainder of the chapter walks through each Scala file. The order is bottom-up: shared constants first, then leaf modules (ALU, register file, immediate generator), then pipeline-stage modules, then the top-level wiring.
03.Pipeline Register Classes
Before writing the stage logic, define the four pipeline registers as Chisel Bundle classes. A Bundle groups related signals into a record. Wrapping a Bundle in Reg produces a registered version that latches on every clock edge.
Pipeline register bundles
| // PipelineRegs.scala | |
| package cpu | |
| import chisel3._ | |
| import chisel3.util._ | |
| import Const._ | |
| class IfIdBundle extends Bundle { | |
| val pc = UInt(32.W) | |
| val instr = UInt(32.W) | |
| val valid = Bool() | |
| } | |
| class IdExBundle extends Bundle { | |
| // Operand values | |
| val pc = UInt(32.W) | |
| val rs1Val = UInt(32.W) | |
| val rs2Val = UInt(32.W) | |
| val imm = UInt(32.W) | |
| // Register specifiers, kept for forwarding lookup | |
| val rs1 = UInt(5.W) | |
| val rs2 = UInt(5.W) | |
| val rd = UInt(5.W) | |
| // Control signals | |
| val aluOp = UInt(ALU_OP_W.W) | |
| val aluSrc1 = UInt(2.W) // 0 = rs1, 1 = pc, 2 = constant zero | |
| val aluSrc2 = UInt(1.W) // 0 = rs2, 1 = imm | |
| val memRead = Bool() | |
| val memWrite = Bool() | |
| val regWrite = Bool() | |
| val memToReg = Bool() | |
| val branch = Bool() | |
| val jump = Bool() | |
| val jalr = Bool() // JALR targets rs1 + imm, not pc + imm | |
| val funct3 = UInt(3.W) // for branch type and load/store size | |
| val valid = Bool() | |
| } | |
| class ExMemBundle extends Bundle { | |
| val aluOut = UInt(32.W) | |
| val rs2Val = UInt(32.W) // value to store on a store instr | |
| val rd = UInt(5.W) | |
| val memRead = Bool() | |
| val memWrite = Bool() | |
| val regWrite = Bool() | |
| val memToReg = Bool() | |
| val funct3 = UInt(3.W) | |
| val valid = Bool() | |
| } | |
| class MemWbBundle extends Bundle { | |
| val memData = UInt(32.W) | |
| val aluOut = UInt(32.W) | |
| val rd = UInt(5.W) | |
| val regWrite = Bool() | |
| val memToReg = Bool() | |
| val valid = Bool() | |
| } |
Two design choices in the bundles are worth pointing out. First, the rs1 and rs2 register specifiers travel from ID into EX. They are needed by the forwarding unit even though they were already used in ID to read the register file. Second, the valid bit accompanies every pipeline register. When the hazard detection unit injects a bubble, it clears the valid bit on the downstream register rather than trying to override every individual control signal. A pipeline register with valid=0 is treated as a NOP by every downstream stage.
Shared constants for the CPU
| // Const.scala | |
| package cpu | |
| import chisel3._ | |
| object Const { | |
| // ALU operation codes. This is a re-encoding of the Chapter 11 | |
| // ALU function table, not a copy of it (see the note below). | |
| val ALU_OP_W = 4 | |
| val ALU_ADD = 0.U(ALU_OP_W.W) | |
| val ALU_SUB = 1.U(ALU_OP_W.W) | |
| val ALU_AND = 2.U(ALU_OP_W.W) | |
| val ALU_OR = 3.U(ALU_OP_W.W) | |
| val ALU_XOR = 4.U(ALU_OP_W.W) | |
| val ALU_SLL = 5.U(ALU_OP_W.W) | |
| val ALU_SRL = 6.U(ALU_OP_W.W) | |
| val ALU_SRA = 7.U(ALU_OP_W.W) | |
| val ALU_SLT = 8.U(ALU_OP_W.W) | |
| val ALU_SLTU = 9.U(ALU_OP_W.W) | |
| // RV32IM opcode field (bits 6:0) for the major instruction classes | |
| val OP_LUI = "b0110111".U | |
| val OP_AUIPC = "b0010111".U | |
| val OP_JAL = "b1101111".U | |
| val OP_JALR = "b1100111".U | |
| val OP_BR = "b1100011".U | |
| val OP_LOAD = "b0000011".U | |
| val OP_STORE = "b0100011".U | |
| val OP_OPIMM = "b0010011".U | |
| val OP_OP = "b0110011".U | |
| } |
AluUnit is the Chapter 11 ALU with three changes, so the two function tables are related but not identical. First, RV32I requires a logical right shift and the Chapter 11 table has no SRL entry, so SRL is added and NOR, which no RV32I instruction needs, is dropped. That reshuffle is why codes 5 through 9 above differ from the codes in the Chapter 11 table. Second, the ports are renamed to io.a, io.b, io.fn, and io.y to keep the execute-stage wiring short. Third, the separate shamt port is removed and the shift amount is taken from the low five bits of io.b, which is what RV32I already encodes: for SLL, SRL, and SRA the amount sits in the low five bits of rs2, and for SLLI, SRLI, and SRAI it sits in the low five bits of the I-type immediate. Deriving AluUnit from the Chapter 11 source with those three changes is the first piece of RTL to write.
04.The Fetch Stage
The fetch stage holds the program counter, reads the instruction memory, and supplies the next PC for the following cycle. Branch resolution and stalls drive the next-PC mux from outside this stage.
The Fetch module
| // Fetch.scala | |
| package cpu | |
| import chisel3._ | |
| import chisel3.util._ | |
| class Fetch(memSize: Int = 4096) extends Module { | |
| val io = IO(new Bundle { | |
| // Inputs from later stages | |
| val stall = Input(Bool()) | |
| val flush = Input(Bool()) | |
| val branchPC = Input(UInt(32.W)) | |
| val takeBranch = Input(Bool()) | |
| // Outputs to IF/ID | |
| val ifId = Output(new IfIdBundle) | |
| // Instruction memory interface | |
| val imemAddr = Output(UInt(32.W)) | |
| val imemData = Input(UInt(32.W)) | |
| }) | |
| val pc = RegInit(0.U(32.W)) | |
| // Next-PC selection | |
| val pcPlus4 = pc + 4.U | |
| val nextPC = Mux(io.takeBranch, io.branchPC, pcPlus4) | |
| // Stall freezes the PC; flush forces a bubble next cycle | |
| when (!io.stall) { | |
| pc := nextPC | |
| } | |
| io.imemAddr := pc | |
| io.ifId.pc := pc | |
| io.ifId.instr := io.imemData | |
| // A flush clears valid so ID treats this fetch as a NOP | |
| io.ifId.valid := !io.flush | |
| } |
The instruction memory is wired into this module through the imemAddr and imemData ports. The top-level Core.scala module connects these ports to a simple Chisel memory model. In a more realistic design, the instruction memory would sit behind a cache, but for this project a single-cycle zero-latency instruction memory is sufficient.
05.The Decode Stage and Hazard Detection
The decode stage reads the instruction word, extracts the register specifiers and immediate, reads the register file, generates the control signals, and runs the hazard detection unit. The HDU is the only block in the decode stage that depends on the state of a later pipeline stage (specifically, the ID/EX register).
Immediate generation
The RV32IM instruction formats encode immediates differently across the I, S, B, U, and J types (Chapter 15 covers the encodings). A single ImmGen module decodes all five forms and outputs the sign-extended 32-bit immediate value.
Immediate generator
| // ImmGen.scala | |
| package cpu | |
| import chisel3._ | |
| import chisel3.util._ | |
| class ImmGen extends Module { | |
| val io = IO(new Bundle { | |
| val instr = Input(UInt(32.W)) | |
| val imm = Output(UInt(32.W)) | |
| }) | |
| // Extract the sign bit (bit 31) for sign extension | |
| val sign = io.instr(31) | |
| // I-type: bits 31:20, sign-extended | |
| val immI = Cat(Fill(20, sign), io.instr(31, 20)) | |
| // S-type: bits 31:25 and 11:7, sign-extended | |
| val immS = Cat(Fill(20, sign), io.instr(31, 25), io.instr(11, 7)) | |
| // B-type: bits 31|7|30:25|11:8 << 1, sign-extended | |
| val immB = Cat(Fill(19, sign), io.instr(31), io.instr(7), | |
| io.instr(30, 25), io.instr(11, 8), 0.U(1.W)) | |
| // U-type: bits 31:12 << 12 | |
| val immU = Cat(io.instr(31, 12), 0.U(12.W)) | |
| // J-type: bits 31|19:12|20|30:21 << 1, sign-extended | |
| val immJ = Cat(Fill(11, sign), io.instr(31), io.instr(19, 12), | |
| io.instr(20), io.instr(30, 21), 0.U(1.W)) | |
| // Select based on opcode (bits 6:0) | |
| val opcode = io.instr(6, 0) | |
| io.imm := MuxLookup(opcode, immI)(Seq( | |
| Const.OP_STORE -> immS, | |
| Const.OP_BR -> immB, | |
| Const.OP_LUI -> immU, | |
| Const.OP_AUIPC -> immU, | |
| Const.OP_JAL -> immJ | |
| )) | |
| } |
The register file
The register file is a 32-entry, 32-bit-wide asynchronous-read file with two read ports and one write port. Asynchronous read means the read path is pure combinational logic, unlike the SyncReadMem used later for the instruction and data memories, whose read data appears only on the following clock edge. The pipeline needs the combinational behavior here because ID reads the operands and latches them into ID/EX within the same cycle. Register x0 is hardwired to zero. A simultaneous read and write of the same register returns the new value (the write happens in the first half of the cycle, the read in the second half), which is the standard RISC-V convention.
The register file
| // RegFile.scala | |
| package cpu | |
| import chisel3._ | |
| import chisel3.util._ | |
| class RegFile extends Module { | |
| val io = IO(new Bundle { | |
| val rs1 = Input(UInt(5.W)) | |
| val rs2 = Input(UInt(5.W)) | |
| val rs1Val = Output(UInt(32.W)) | |
| val rs2Val = Output(UInt(32.W)) | |
| val wAddr = Input(UInt(5.W)) | |
| val wData = Input(UInt(32.W)) | |
| val wEnable = Input(Bool()) | |
| }) | |
| val regs = Reg(Vec(32, UInt(32.W))) | |
| // Write side: first half of the cycle | |
| when (io.wEnable && io.wAddr =/= 0.U) { | |
| regs(io.wAddr) := io.wData | |
| } | |
| // Read side: returns the written value on same-cycle write, | |
| // and forces x0 to zero. | |
| io.rs1Val := Mux(io.rs1 === 0.U, 0.U, | |
| Mux(io.wEnable && io.wAddr === io.rs1, io.wData, | |
| regs(io.rs1))) | |
| io.rs2Val := Mux(io.rs2 === 0.U, 0.U, | |
| Mux(io.wEnable && io.wAddr === io.rs2, io.wData, | |
| regs(io.rs2))) | |
| } |
The decode module and the hazard detection unit
The Decode module instantiates the register file, the immediate generator, and a small control ROM. The hazard detection unit sits inside the same module and reads the current ID/EX register to determine whether the instruction currently in EX is a load whose destination matches the source of the instruction now in ID.
The Decode module with hazard detection
| // Decode.scala | |
| package cpu | |
| import chisel3._ | |
| import chisel3.util._ | |
| import Const._ | |
| class Decode extends Module { | |
| val io = IO(new Bundle { | |
| val ifId = Input(new IfIdBundle) | |
| val idEx = Output(new IdExBundle) | |
| // Register file write-back from WB stage | |
| val wAddr = Input(UInt(5.W)) | |
| val wData = Input(UInt(32.W)) | |
| val wEnable = Input(Bool()) | |
| // Hazard detection talks to IF and the pipeline register | |
| val stall = Output(Bool()) | |
| val bubble = Output(Bool()) | |
| // Current ID/EX register for hazard detection lookup | |
| val idExCur = Input(new IdExBundle) | |
| }) | |
| val regFile = Module(new RegFile) | |
| val immGen = Module(new ImmGen) | |
| val instr = io.ifId.instr | |
| val opcode = instr(6, 0) | |
| val rs1 = instr(19, 15) | |
| val rs2 = instr(24, 20) | |
| val rd = instr(11, 7) | |
| val funct3 = instr(14, 12) | |
| val funct7 = instr(31, 25) | |
| regFile.io.rs1 := rs1 | |
| regFile.io.rs2 := rs2 | |
| regFile.io.wAddr := io.wAddr | |
| regFile.io.wData := io.wData | |
| regFile.io.wEnable := io.wEnable | |
| immGen.io.instr := instr | |
| // Control signal decode (excerpt: ADD, SUB, LW, SW, BEQ, JAL) | |
| val regWrite = WireDefault(false.B) | |
| val memRead = WireDefault(false.B) | |
| val memWrite = WireDefault(false.B) | |
| val memToReg = WireDefault(false.B) | |
| val aluSrc1 = WireDefault(0.U(2.W)) | |
| val aluSrc2 = WireDefault(0.U(1.W)) | |
| val aluOp = WireDefault(ALU_ADD) | |
| val branch = WireDefault(false.B) | |
| val jump = WireDefault(false.B) | |
| val jalr = WireDefault(false.B) | |
| // funct3 does not equal the ALU function code, so the two are | |
| // related by an explicit lookup. Bit 30 of the instruction, | |
| // which is funct7(5), separates SUB from ADD on the OP opcode | |
| // and SRA from SRL on both ALU opcodes. | |
| val isRType = (opcode === OP_OP) | |
| val aluFn = MuxLookup(funct3, ALU_ADD)(Seq( | |
| "b000".U -> Mux(isRType && funct7(5), ALU_SUB, ALU_ADD), | |
| "b001".U -> ALU_SLL, | |
| "b010".U -> ALU_SLT, | |
| "b011".U -> ALU_SLTU, | |
| "b100".U -> ALU_XOR, | |
| "b101".U -> Mux(funct7(5), ALU_SRA, ALU_SRL), | |
| "b110".U -> ALU_OR, | |
| "b111".U -> ALU_AND | |
| )) | |
| switch (opcode) { | |
| is (OP_OP) { | |
| regWrite := true.B | |
| aluSrc2 := 0.U // rs2 | |
| aluOp := aluFn | |
| } | |
| is (OP_OPIMM) { | |
| regWrite := true.B | |
| aluSrc2 := 1.U // imm | |
| aluOp := aluFn | |
| } | |
| is (OP_LOAD) { | |
| regWrite := true.B | |
| memRead := true.B | |
| memToReg := true.B | |
| aluSrc2 := 1.U | |
| aluOp := ALU_ADD | |
| } | |
| is (OP_STORE) { | |
| memWrite := true.B | |
| aluSrc2 := 1.U | |
| aluOp := ALU_ADD | |
| } | |
| is (OP_BR) { | |
| branch := true.B | |
| aluOp := ALU_SUB | |
| } | |
| is (OP_JAL) { | |
| regWrite := true.B | |
| jump := true.B | |
| } | |
| is (OP_JALR) { | |
| regWrite := true.B | |
| jump := true.B | |
| jalr := true.B | |
| aluSrc2 := 1.U // ALU computes the rs1 + imm target | |
| aluOp := ALU_ADD | |
| } | |
| is (OP_LUI) { | |
| regWrite := true.B | |
| aluSrc1 := 2.U // constant zero | |
| aluSrc2 := 1.U // imm | |
| aluOp := ALU_ADD // 0 + imm | |
| } | |
| is (OP_AUIPC) { | |
| regWrite := true.B | |
| aluSrc1 := 1.U // PC | |
| aluSrc2 := 1.U // imm | |
| aluOp := ALU_ADD | |
| } | |
| } | |
| // Hazard detection: load-use detector | |
| val curIsLoad = io.idExCur.memRead && io.idExCur.valid | |
| val curRd = io.idExCur.rd | |
| val depRs1 = curIsLoad && (curRd === rs1) && (curRd =/= 0.U) | |
| val depRs2 = curIsLoad && (curRd === rs2) && (curRd =/= 0.U) | |
| val loadUseHaz = depRs1 || depRs2 | |
| io.stall := loadUseHaz && io.ifId.valid | |
| io.bubble := loadUseHaz && io.ifId.valid | |
| // Drive ID/EX outputs (cleared to a NOP on bubble or invalid) | |
| val valid = io.ifId.valid && !loadUseHaz | |
| io.idEx.pc := io.ifId.pc | |
| io.idEx.rs1Val := regFile.io.rs1Val | |
| io.idEx.rs2Val := regFile.io.rs2Val | |
| io.idEx.imm := immGen.io.imm | |
| io.idEx.rs1 := rs1 | |
| io.idEx.rs2 := rs2 | |
| io.idEx.rd := rd | |
| io.idEx.aluOp := aluOp | |
| io.idEx.aluSrc1 := aluSrc1 | |
| io.idEx.aluSrc2 := aluSrc2 | |
| io.idEx.memRead := memRead && valid | |
| io.idEx.memWrite := memWrite && valid | |
| io.idEx.regWrite := regWrite && valid | |
| io.idEx.memToReg := memToReg | |
| io.idEx.branch := branch && valid | |
| io.idEx.jump := jump && valid | |
| io.idEx.jalr := jalr | |
| io.idEx.funct3 := funct3 | |
| io.idEx.valid := valid | |
| } |
06.The Execute Stage and Forwarding
The execute stage runs the ALU, evaluates branch conditions, resolves the branch target, and selects forwarded operands.
The forwarding unit
The forwarding unit looks at the EX/MEM and MEM/WB pipeline registers to find the freshest value of each ALU operand.
The forwarding unit
| // Inside Execute.scala | |
| class ForwardingUnit extends Module { | |
| val io = IO(new Bundle { | |
| val idExRs1 = Input(UInt(5.W)) | |
| val idExRs2 = Input(UInt(5.W)) | |
| val exMemRd = Input(UInt(5.W)) | |
| val exMemRegWr = Input(Bool()) | |
| val memWbRd = Input(UInt(5.W)) | |
| val memWbRegWr = Input(Bool()) | |
| val fwdA = Output(UInt(2.W)) // 00 rf, 01 EX/MEM, 10 MEM/WB | |
| val fwdB = Output(UInt(2.W)) | |
| }) | |
| // Forward A: prefer EX/MEM over MEM/WB | |
| io.fwdA := MuxCase(0.U, Seq( | |
| (io.exMemRegWr && (io.exMemRd =/= 0.U) && | |
| (io.exMemRd === io.idExRs1)) -> 1.U, | |
| (io.memWbRegWr && (io.memWbRd =/= 0.U) && | |
| (io.memWbRd === io.idExRs1)) -> 2.U | |
| )) | |
| io.fwdB := MuxCase(0.U, Seq( | |
| (io.exMemRegWr && (io.exMemRd =/= 0.U) && | |
| (io.exMemRd === io.idExRs2)) -> 1.U, | |
| (io.memWbRegWr && (io.memWbRd =/= 0.U) && | |
| (io.memWbRd === io.idExRs2)) -> 2.U | |
| )) | |
| } |
The priority ordering (EX/MEM before MEM/WB) is essential. The EX/MEM register holds the result of the instruction that finished EX one cycle ago. The MEM/WB register holds the result of the instruction that finished EX two cycles ago. When both write to the same register specifier, the newer value is in EX/MEM, so EX/MEM wins. Reversing the priority would feed a stale value to the ALU.
The execute module
The execute module ties the ALU, the forwarding unit, and the branch resolution logic together.
The Execute module
| // Execute.scala | |
| package cpu | |
| import chisel3._ | |
| import chisel3.util._ | |
| import Const._ | |
| class Execute extends Module { | |
| val io = IO(new Bundle { | |
| val idEx = Input(new IdExBundle) | |
| val exMem = Output(new ExMemBundle) | |
| // Forwarding inputs from later stages | |
| val exMemFwd = Input(UInt(32.W)) // EX/MEM ALU result | |
| val memWbFwd = Input(UInt(32.W)) // MEM/WB writeback data | |
| val exMemRd = Input(UInt(5.W)) | |
| val exMemRegWr = Input(Bool()) | |
| val memWbRd = Input(UInt(5.W)) | |
| val memWbRegWr = Input(Bool()) | |
| // Branch outputs to fetch | |
| val takeBranch = Output(Bool()) | |
| val branchPC = Output(UInt(32.W)) | |
| val flush = Output(Bool()) | |
| }) | |
| val alu = Module(new AluUnit) // adapted from Chapter 11 | |
| val fwd = Module(new ForwardingUnit) | |
| // Forwarding | |
| fwd.io.idExRs1 := io.idEx.rs1 | |
| fwd.io.idExRs2 := io.idEx.rs2 | |
| fwd.io.exMemRd := io.exMemRd | |
| fwd.io.exMemRegWr := io.exMemRegWr | |
| fwd.io.memWbRd := io.memWbRd | |
| fwd.io.memWbRegWr := io.memWbRegWr | |
| val op1 = MuxLookup(fwd.io.fwdA, io.idEx.rs1Val)(Seq( | |
| 1.U -> io.exMemFwd, | |
| 2.U -> io.memWbFwd | |
| )) | |
| val op2 = MuxLookup(fwd.io.fwdB, io.idEx.rs2Val)(Seq( | |
| 1.U -> io.exMemFwd, | |
| 2.U -> io.memWbFwd | |
| )) | |
| // ALU input mux: source 1 may be PC or a constant zero (LUI), | |
| // source 2 may be imm | |
| val aluA = MuxLookup(io.idEx.aluSrc1, op1)(Seq( | |
| 1.U -> io.idEx.pc, | |
| 2.U -> 0.U(32.W) | |
| )) | |
| val aluB = Mux(io.idEx.aluSrc2.asBool, io.idEx.imm, op2) | |
| alu.io.a := aluA | |
| alu.io.b := aluB | |
| alu.io.fn := io.idEx.aluOp | |
| // Branch condition evaluation | |
| val eq = (op1 === op2) | |
| val lt = (op1.asSInt < op2.asSInt) | |
| val ltu = (op1 < op2) | |
| val branchTaken = io.idEx.branch && MuxLookup(io.idEx.funct3, | |
| false.B)(Seq( | |
| "b000".U -> eq, // BEQ | |
| "b001".U -> !eq, // BNE | |
| "b100".U -> lt, // BLT | |
| "b101".U -> !lt, // BGE | |
| "b110".U -> ltu, // BLTU | |
| "b111".U -> !ltu // BGEU | |
| )) | |
| val jumpTaken = io.idEx.jump && io.idEx.valid | |
| io.takeBranch := (branchTaken || jumpTaken) && io.idEx.valid | |
| // JALR targets rs1 + imm with bit 0 cleared, which is the ALU | |
| // result. Everything else targets pc + imm. | |
| io.branchPC := Mux(io.idEx.jalr, | |
| alu.io.y & "hFFFFFFFE".U(32.W), | |
| io.idEx.pc + io.idEx.imm) | |
| io.flush := io.takeBranch | |
| // Outputs to EX/MEM. A jump writes the link address pc + 4 to | |
| // rd rather than the ALU result. | |
| io.exMem.aluOut := Mux(io.idEx.jump, | |
| io.idEx.pc + 4.U, | |
| alu.io.y) | |
| io.exMem.rs2Val := op2 // store data, post-forward | |
| io.exMem.rd := io.idEx.rd | |
| io.exMem.memRead := io.idEx.memRead | |
| io.exMem.memWrite := io.idEx.memWrite | |
| io.exMem.regWrite := io.idEx.regWrite | |
| io.exMem.memToReg := io.idEx.memToReg | |
| io.exMem.funct3 := io.idEx.funct3 | |
| io.exMem.valid := io.idEx.valid | |
| } |
07.The Memory and Writeback Stages
The MEM stage drives the data memory interface. For loads it reads from the memory and forwards the result. For stores it writes the register-file value to the memory. For everything else it is transparent.
The Memory module
| // Memory.scala | |
| package cpu | |
| import chisel3._ | |
| import chisel3.util._ | |
| class Memory extends Module { | |
| val io = IO(new Bundle { | |
| val exMem = Input(new ExMemBundle) | |
| val memWb = Output(new MemWbBundle) | |
| // Data memory interface | |
| val dmemAddr = Output(UInt(32.W)) | |
| val dmemRData = Input(UInt(32.W)) | |
| val dmemWData = Output(UInt(32.W)) | |
| val dmemWE = Output(Bool()) | |
| val dmemRE = Output(Bool()) | |
| val dmemSize = Output(UInt(3.W)) // funct3 controls size | |
| }) | |
| io.dmemAddr := io.exMem.aluOut | |
| io.dmemWData := io.exMem.rs2Val | |
| io.dmemWE := io.exMem.memWrite | |
| io.dmemRE := io.exMem.memRead | |
| io.dmemSize := io.exMem.funct3 | |
| io.memWb.memData := io.dmemRData | |
| io.memWb.aluOut := io.exMem.aluOut | |
| io.memWb.rd := io.exMem.rd | |
| io.memWb.regWrite := io.exMem.regWrite | |
| io.memWb.memToReg := io.exMem.memToReg | |
| io.memWb.valid := io.exMem.valid | |
| } |
The writeback stage selects between the ALU result and the loaded memory value, then drives the register-file write port.
The Writeback module
| // Writeback.scala | |
| package cpu | |
| import chisel3._ | |
| import chisel3.util._ | |
| class Writeback extends Module { | |
| val io = IO(new Bundle { | |
| val memWb = Input(new MemWbBundle) | |
| val wAddr = Output(UInt(5.W)) | |
| val wData = Output(UInt(32.W)) | |
| val wEnable = Output(Bool()) | |
| }) | |
| io.wAddr := io.memWb.rd | |
| io.wData := Mux(io.memWb.memToReg, io.memWb.memData, | |
| io.memWb.aluOut) | |
| io.wEnable := io.memWb.regWrite && io.memWb.valid | |
| } |
08.Top-Level Wiring
The top-level Core module instantiates all five stages, the pipeline registers, the instruction memory, and the data memory.
The top-level Core module (abridged)
| // Core.scala | |
| package cpu | |
| import chisel3._ | |
| import chisel3.util._ | |
| class Core(memSize: Int = 4096) extends Module { | |
| val io = IO(new Bundle { | |
| val halt = Output(Bool()) // asserted when tohost is written | |
| }) | |
| // Stages | |
| val fetch = Module(new Fetch(memSize)) | |
| val decode = Module(new Decode) | |
| val ex = Module(new Execute) | |
| val mem = Module(new Memory) | |
| val wb = Module(new Writeback) | |
| // Pipeline registers | |
| val ifId = RegInit(0.U.asTypeOf(new IfIdBundle)) | |
| val idEx = RegInit(0.U.asTypeOf(new IdExBundle)) | |
| val exMem = RegInit(0.U.asTypeOf(new ExMemBundle)) | |
| val memWb = RegInit(0.U.asTypeOf(new MemWbBundle)) | |
| // IF -> IF/ID | |
| when (!decode.io.stall) { | |
| ifId := fetch.io.ifId | |
| } | |
| // ID -> ID/EX (insert a bubble when stalling, and on a flush, | |
| // which kills the wrong-path instruction sitting in ID; the | |
| // one in IF is killed by Fetch clearing its valid bit) | |
| idEx := Mux(decode.io.bubble || ex.io.flush, | |
| 0.U.asTypeOf(new IdExBundle), | |
| decode.io.idEx) | |
| // EX -> EX/MEM | |
| exMem := ex.io.exMem | |
| // MEM -> MEM/WB | |
| memWb := mem.io.memWb | |
| // Wire the stages | |
| fetch.io.stall := decode.io.stall | |
| fetch.io.flush := ex.io.flush | |
| fetch.io.branchPC := ex.io.branchPC | |
| fetch.io.takeBranch := ex.io.takeBranch | |
| decode.io.ifId := ifId | |
| decode.io.idExCur := idEx | |
| decode.io.wAddr := wb.io.wAddr | |
| decode.io.wData := wb.io.wData | |
| decode.io.wEnable := wb.io.wEnable | |
| ex.io.idEx := idEx | |
| ex.io.exMemFwd := exMem.aluOut | |
| ex.io.memWbFwd := wb.io.wData | |
| ex.io.exMemRd := exMem.rd | |
| ex.io.exMemRegWr := exMem.regWrite | |
| ex.io.memWbRd := memWb.rd | |
| ex.io.memWbRegWr := memWb.regWrite | |
| mem.io.exMem := exMem | |
| wb.io.memWb := memWb | |
| // Instantiate the memories (simplified single-port behavior) | |
| val imem = SyncReadMem(memSize, UInt(32.W)) | |
| val dmem = SyncReadMem(memSize, UInt(32.W)) | |
| fetch.io.imemData := imem.read(fetch.io.imemAddr >> 2) | |
| mem.io.dmemRData := dmem.read(mem.io.dmemAddr >> 2) | |
| when (mem.io.dmemWE) { | |
| dmem.write(mem.io.dmemAddr >> 2, mem.io.dmemWData) | |
| } | |
| // Termination: writing to address 0x1000 sets halt. The test | |
| // programs are linked at address 0, so 0x1000 sits above the | |
| // code and acts as the tohost location. | |
| io.halt := mem.io.dmemWE && (mem.io.dmemAddr === 0x1000.U) | |
| } |
09.Testbench with ChiselTest
The testbench loads a compiled RISC-V program into the instruction memory, runs the simulation until the program writes to the tohost address, and checks the result.
ChiselTest harness
| // CoreUnitTest.scala | |
| package cpu | |
| import chisel3._ | |
| import chiseltest._ | |
| import org.scalatest.flatspec.AnyFlatSpec | |
| class CoreUnitTest extends AnyFlatSpec with ChiselScalatestTester { | |
| behavior of "Core" | |
| it should "add two registers and write the result to x3" in { | |
| test(new Core(256)) { c => | |
| // Load the program (hand-assembled). The S-type immediate | |
| // is 12-bit signed, so 0x1000 does not fit in one store | |
| // and the tohost address is built with LUI first. | |
| // addi x1, x0, 7 | |
| // addi x2, x0, 5 | |
| // add x3, x1, x2 | |
| // lui x4, 0x1 // x4 = 0x00001000 | |
| // sw x3, 0(x4) // tohost write | |
| val prog = Seq( | |
| "h00700093".U, | |
| "h00500113".U, | |
| "h002081b3".U, | |
| "h00001237".U, | |
| "h00322023".U | |
| ) | |
| // Pre-load instruction memory via Chisel debug poke | |
| // (in the real testbench this happens through a memory | |
| // file passed to SyncReadMem.read at elaboration time) | |
| // ... | |
| c.clock.step(20) | |
| c.io.halt.expect(true.B) | |
| } | |
| } | |
| } |
A more practical setup pre-loads the instruction memory from a hex file generated by the RISC-V assembler. The Chisel loadMemoryFromFileInline API supports this directly.
Building the test program
| # tests/asm/addtest.s | |
| # addi x1, x0, 7 | |
| # addi x2, x0, 5 | |
| # add x3, x1, x2 | |
| # lui x4, 0x1 # x4 = 0x00001000, the tohost address | |
| # sw x3, 0(x4) | |
| # beq x0, x0, . # loop forever (caught by halt) | |
| ${RVPREFIX}as -march=rv32i -mabi=ilp32 \ | |
| addtest.s -o addtest.o | |
| ${RVPREFIX}ld -m elf32lriscv -Ttext 0 \ | |
| addtest.o -o addtest.elf | |
| ${RVPREFIX}objcopy -O verilog addtest.elf addtest.hex |
The Verilog hex file is what Chisel’s loadMemoryFromFileInline can read at simulation start time to pre-load the instruction memory.
10.Running riscv-tests
The riscv-tests suite is the canonical unit-test bundle for RISC-V implementations. Each test is a small assembly program that exercises one instruction. The tohost location stays zero while the test is running. On pass, the test writes 0x1 to it. On fail it writes , where is the number of the sub-test that failed. Any non-zero write therefore means the test has finished, and the value being exactly 0x1 means it passed.
Cloning and building riscv-tests
| git clone https://github.com/riscv-software-src/riscv-tests | |
| cd riscv-tests | |
| git submodule update --init --recursive | |
| autoconf | |
| ./configure --prefix=$PWD/install | |
| make | |
| make install |
The build produces an install/share/riscv-tests/isa/ directory containing ELF files for each instruction test. The RV32IM tests are prefixed with rv32ui-p- for the user- mode I extension and rv32um-p- for the M extension.
Running riscv-tests in ChiselTest
// RiscvTestsRun.scala
package cpu
import chisel3._
import chiseltest._
import org.scalatest.flatspec.AnyFlatSpec
class RiscvTestsRun extends AnyFlatSpec with ChiselScalatestTester {
val testList = Seq(
"rv32ui-p-add", "rv32ui-p-addi", "rv32ui-p-and",
"rv32ui-p-andi", "rv32ui-p-beq", "rv32ui-p-bge",
"rv32ui-p-bgeu", "rv32ui-p-blt", "rv32ui-p-bltu",
"rv32ui-p-bne", "rv32ui-p-jal", "rv32ui-p-jalr",
"rv32ui-p-lw", "rv32ui-p-sw", "rv32ui-p-or", "rv32ui-p-ori"
// ... and so on for every RV32I and RV32M instruction
)
// The loop variable must not be called "test": that name is
// the driver method ChiselScalatestTester provides, and a
// local binding would shadow it inside the loop body.
for (testName <- testList) {
it should s"pass $testName" in {
test(new Core(4096)).withAnnotations(
Seq(VerilatorBackendAnnotation,
WriteVcdAnnotation)
) { c =>
// Load the test ELF into imem via a hex file
// (see tests/asm/loader.sh for the conversion)
// ...
c.clock.step(10000)
c.io.halt.expect(true.B)
}
}
}
}The first time the suite runs, expect several failures. Each failure points at one bug in the implementation. Common first-pass bugs include incorrect immediate sign extension (J or B type), incorrect funct3-to-branch-type decoding, incorrect forwarding priority (MEM/WB before EX/MEM rather than the opposite), and missing same-cycle write-then-read in the register file.
11.Running riscv-arch-test
The riscv-arch-test suite is the RISC-V Foundation’s official compatibility framework. It runs each test against a golden reference (Spike or Sail) and compares signatures. A test passes if the implementation’s signature matches the golden reference’s signature byte for byte.
Cloning riscv-arch-test
| git clone https://github.com/riscv-non-isa/riscv-arch-test | |
| cd riscv-arch-test |
The arch-test harness expects the implementation to provide a Makefile.include that declares how to assemble and link a test, and how to extract the signature region after the test completes. The framework then runs each test under the implementation, runs the same test under Spike, and diffs the two signatures.
Skeleton Makefile.include
| # In riscv-arch-test/riscv-target/rv32-pipe/Makefile.include | |
| TARGET_SIM = $(SBT) "runMain cpu.ArchTestRunner" | |
| TARGET_FLAGS = --signature=$(*).signature.output | |
| RUN_TARGET = \ | |
| $(TARGET_SIM) $(TARGET_FLAGS) $(work_dir_isa)/$<; |
The signature handler in the Chisel testbench dumps the implementation’s writes to the signature region into a text file matching the format the framework expects. After every test runs, the framework reports pass or fail for each.
12.Waveform Inspection with Surfer
When a test fails, the next step is to look at the waveform. ChiselTest writes a VCD file when the WriteVcdAnnotation is passed to withAnnotations. The default location is test_run_dir/<test-name>/<top>.vcd.
Open a VCD in Surfer
| surfer test_run_dir/should_pass_rv32ui-p-add/Core.vcd |
The Surfer workflow for debugging a failing test follows three steps. First, find the cycle where the implementation writes the wrong value to tohost. Second, scroll back through the pipeline stages to find where the wrong value first appeared. Third, trace the wrong value back through the forwarding muxes, the ALU, and the register-file outputs until the root cause is visible.