Part IIISingle-Cycle, Multi-Cycle, and Pipelined CPUs

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

Bash
# 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-tools

Verify each tool after installation:

Verify macOS installations

Bash
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

Bash
# 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

Bash
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

Bash
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

Bash
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

Bash
# 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

Bash
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

Scala
// 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

Plain Text
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-up

The 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

Code
// 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

Code
// 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

Code
// 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

Code
// 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

Code
// 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

Code
// 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

Code
// 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

Code
// 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

Code
// 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

Code
// 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)

Code
// 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

Code
// 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

Bash
# 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 (n1)1(n \ll 1) \mathbin{|} 1, where nn 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

Bash
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

Scala
// 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

Bash
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

Makefile
# 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

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

13.Worked Examples

14.Exercises

Book mode
computer-architecturesingle-cycle-multi-cycle-and-pipelined-cpus
Was this helpful?