Before we pipeline the CPU in Day 17, let us level up our verification skills. A good testbench is what separates a hobby project from industrial-quality RTL. Today we cover every core testbench technique: waveform dumps, hex program loading, register monitors, self-checking assertions, and a comprehensive multi-program testbench for riscv_core.v.
VCD (Value Change Dump) is the universal waveform format. Any simulator writes it; GTKWave and most EDA tools read it. Always add these three lines to every testbench:
initial begin
$dumpfile("sim.vcd"); // output file name
$dumpvars(0, tb_riscv_core); // 0 = all levels, scope = top module
// ... rest of testbench
end
Never hardcode instruction words in a testbench. Put the program in a .hex file and use $readmemh. This lets you swap programs without recompiling.
// Load from file into imem array
$readmemh("program.hex", dut.imem0.mem);
// You can also specify address range:
$readmemh("prog2.hex", dut.imem0.mem, 0, 63); // load first 64 words
$monitor fires a print every time any of its arguments changes value. Use it to trace PC and the instruction word every cycle:
initial begin
$monitor("t=%0t pc=%08h inst=%08h x1=%0d x2=%0d",
$time, dut.pc, dut.inst, dut.rf.regs[1], dut.rf.regs[2]);
end
At each negative clock edge (after registers settle), log the full register file. Useful for stepping through a small program by hand:
integer cyc;
always @(negedge clk) begin
cyc = cyc + 1;
$display("Cycle %0d: PC=%08h", cyc, dut.pc);
$display(" x1=%0d x2=%0d x3=%0d x4=%0d",
dut.rf.regs[1], dut.rf.regs[2],
dut.rf.regs[3], dut.rf.regs[4]);
end
A professional testbench never asks you to "look at the waveform to see if it passed". It tells you:
// Helper task: check expected == actual
task check;
input [63:0] expected;
input [31:0] actual;
input [127:0] label;
begin
if (actual === expected[31:0])
$display("PASS: %s = %0d", label, actual);
else begin
$display("FAIL: %s expected=%0d got=%0d",
label, expected[31:0], actual);
failures = failures + 1;
end
end
endtask
This testbench runs three programs back-to-back: the sum loop (verifies arithmetic + branches), a byte store/load (verifies byte memory access), and a JALR return test (verifies function call mechanics). It reports a total pass/fail count at the end.
// tb_full.v — Comprehensive testbench for riscv_core
// Runs multiple programs and self-checks all results
`timescale 1ns/1ps
module tb_full;
reg clk = 0, rst = 1;
integer failures = 0;
always #5 clk = ~clk;
riscv_core dut (.clk(clk), .rst(rst));
// ── Helper task ───────────────────────────────────────────────
task check_reg;
input [4:0] reg_num;
input [31:0] expected;
input [7:0] label;
begin
if (dut.rf.regs[reg_num] === expected)
$display(" PASS: x%0d = %0d", reg_num, expected);
else begin
$display(" FAIL: x%0d expected=%0d got=%0d",
reg_num, expected, dut.rf.regs[reg_num]);
failures = failures + 1;
end
end
endtask
task reset_cpu;
begin
rst = 1;
repeat(2) @(posedge clk);
// Clear memories
begin : clr
integer k;
for (k=0; k<256; k=k+1) begin
dut.imem0.mem[k] = 32'h00000013; // NOP
dut.dmem0.mem[k] = 8'h00;
end
end
rst = 0;
end
endtask
initial begin
$dumpfile("tb_full.vcd");
$dumpvars(0, tb_full);
// ── TEST 1: Sum 1..5 ─────────────────────────────────────
$display("\n=== Test 1: Sum 1..5 ===");
rst = 1;
$readmemh("program.hex", dut.imem0.mem);
@(posedge clk); @(posedge clk);
rst = 0;
repeat(50) @(posedge clk);
#1;
check_reg(2, 32'd15, "sum"); // x2 should hold 15
// ── TEST 2: Byte store then LBU ──────────────────────────
$display("\n=== Test 2: Byte store / LBU ===");
reset_cpu();
// addi x1,x0,0xAB = 0x0AB00093
dut.imem0.mem[0] = 32'h0ab00093; // addi x1,x0,171
// sb x1,4(x0) = 0x00100223
dut.imem0.mem[1] = 32'h00100223; // sb x1,4(x0)
// lbu x2,4(x0) = 0x00400103 -- wait, lbu opcode
dut.imem0.mem[2] = 32'h00404103; // lbu x2,4(x0)
dut.imem0.mem[3] = 32'h0000006f; // halt
repeat(10) @(posedge clk); #1;
check_reg(2, 32'd171, "LBU"); // zero-extended 0xAB = 171
// ── TEST 3: JAL + JALR return ────────────────────────────
$display("\n=== Test 3: JAL / JALR ===");
reset_cpu();
// addi x1,x0,0 x1=0
dut.imem0.mem[0] = 32'h00000093; // addi x1,x0,0
// jal ra, func (offset=+8)
dut.imem0.mem[1] = 32'h008000ef; // jal ra, +8
// addi x1,x1,77 (should be SKIPPED)
dut.imem0.mem[2] = 32'h04d08093; // addi x1,x1,77 (skipped)
// halt
dut.imem0.mem[3] = 32'h0000006f; // jal 0 (halt, but skipped)
// func: addi x1,x0,42
dut.imem0.mem[4] = 32'h02a00093; // addi x1,x0,42 (func:)
// jalr x0,ra,0 (return)
dut.imem0.mem[5] = 32'h00008067; // jalr x0,ra,0
// after return: halt
dut.imem0.mem[6] = 32'h0000006f; // halt (PC=24)
repeat(15) @(posedge clk); #1;
check_reg(1, 32'd42, "func result"); // x1=42
check_reg(1, 32'd42, "no overwrite"); // 77 was skipped
// ── Final result ─────────────────────────────────────────
$display("\n=== Results: %0d failure(s) ===",failures);
if (failures == 0) $display("ALL TESTS PASSED");
$finish;
end
endmodule
If you have Icarus Verilog installed, compile and simulate with:
# Compile all RTL + testbench
iverilog -o sim.out \
pc.v imem.v regfile.v alu.v immgen.v control.v \
dmem.v branch_unit.v riscv_core.v tb_full.v
# Run simulation
vvp sim.out
# View waveforms (requires GTKWave)
gtkwave tb_full.vcd &
$dumpfile sets the VCD output filename. $dumpvars(0, scope) tells the simulator to record all signals under that scope. Open the .vcd file in GTKWave to visually inspect every signal over time.
A testbench that automatically compares outputs to expected values and prints PASS or FAIL without manual waveform inspection. It uses a task with an if/else and $display.
Use $readmemh("file.hex", memory_array). Each line in the hex file is one word. You can also specify start/end addresses as optional parameters.