HomeRISC-V from ScratchDay 16
DAY 16 · PHASE 3 — PIPELINE & OPTIMIZE

Testbench & Simulation

By EcrioniX · Updated 2026-06-11

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.

The Six Pillars of a Good CPU Testbench

  1. $dumpfile / $dumpvars — capture waveforms for visual debugging
  2. $readmemh — load programs from text hex files, not hardcoded arrays
  3. $monitor — auto-print whenever a watched signal changes
  4. $display at clock edge — log register/memory state each cycle
  5. Self-checking assertions — compare outputs to expected values, auto-pass/fail
  6. Multiple test cases — run several programs in sequence in one simulation

Technique 1: Waveform Capture with $dumpfile

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:

waveform snippet
initial begin
    $dumpfile("sim.vcd");       // output file name
    $dumpvars(0, tb_riscv_core); // 0 = all levels, scope = top module
    // ... rest of testbench
end

Technique 2: Loading Programs with $readmemh

Never hardcode instruction words in a testbench. Put the program in a .hex file and use $readmemh. This lets you swap programs without recompiling.

$readmemh usage
// 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

Technique 3: $monitor for Live Signal Tracing

$monitor fires a print every time any of its arguments changes value. Use it to trace PC and the instruction word every cycle:

$monitor snippet
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

Technique 4: Cycle-by-Cycle Register Dump

At each negative clock edge (after registers settle), log the full register file. Useful for stepping through a small program by hand:

reg_dump snippet
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

Technique 5: Self-Checking Assertions

A professional testbench never asks you to "look at the waveform to see if it passed". It tells you:

assert snippet
// 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

tb_full.v — Comprehensive Multi-Program Testbench

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

Running the Simulation with Icarus Verilog

If you have Icarus Verilog installed, compile and simulate with:

simulate.sh
# 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 &

Day 16 Takeaways

FAQ

What is $dumpfile and $dumpvars?

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

What is a self-checking testbench?

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.

How do you load a program into Verilog simulation?

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.

Previous
← Day 15: First Working Core

← Full roadmap