Module 10: Capstone Projects · Project 1 — 8-bit RISC Processor Core

Project 1, Part 4: Assembly and Verification

Two articles built the datapath and one built the control unit. This one wires them into a single module, writes a small program by hand using the ISA from Part 1, and verifies the result the way Section 6.4 taught: against an independent reference model, not against your own reading of a waveform.

The top-level assembly

module cpu_top (
    input logic clk, rst_n
);
    logic [7:0] pc, instr, alu_result, rd_data_a, rd_data_b, wr_data, mem_rd_data;
    logic [1:0] rd_addr_a, rd_addr_b, wr_addr;
    logic [2:0] opcode, imm_addr;
    logic       reg_wr_en, mem_we, pc_write, alu_b_sel, branch_taken, zero_flag;
    logic [1:0] wr_data_sel;

    // Instruction memory: the Section 7.1 parameterized ram_sp, DATA_WIDTH=8
    ram_sp #(.DATA_WIDTH(8), .DEPTH(256)) imem (
        .clk(clk), .we(1'b0), .addr(pc), .data_in(8'b0), .data_out(instr)
    );

    // Instruction decode: combinational field extraction, per Part 1's encoding
    assign opcode    = instr[7:5];
    assign wr_addr   = instr[4:3];
    assign rd_addr_a = instr[4:3];   // Rd doubles as read source A for R-type ops, and as the BEQ test register
    assign rd_addr_b = instr[2:1];   // Rs field
    assign imm_addr  = instr[2:0];

    reg_file rf (
        .clk(clk), .rd_addr_a(rd_addr_a), .rd_addr_b(rd_addr_b),
        .wr_addr(wr_addr), .wr_data(wr_data), .wr_en(reg_wr_en),
        .rd_data_a(rd_data_a), .rd_data_b(rd_data_b)
    );

    logic [7:0] alu_b;
    assign alu_b = alu_b_sel ? {5'b0, imm_addr} : rd_data_b;

    alu #(.WIDTH(8)) u_alu (
        .a(rd_data_a), .b(alu_b), .op({1'b0, opcode[1:0]}),
        .result(alu_result), .zero(zero_flag)
    );

    ram_sp #(.DATA_WIDTH(8), .DEPTH(256)) dmem (
        .clk(clk), .we(mem_we), .addr({5'b0, imm_addr}),
        .data_in(rd_data_a), .data_out(mem_rd_data)
    );

    always_comb begin
        unique case (wr_data_sel)
            2'b00: wr_data = alu_result;
            2'b01: wr_data = mem_rd_data;
            2'b10: wr_data = {5'b0, imm_addr};
            default: wr_data = 8'b0;
        endcase
    end

    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n) pc <= 8'b0;
        else if (pc_write) pc <= branch_taken ? {5'b0, imm_addr} : (pc + 1'b1);
    end

    // Control unit from Part 3
    cpu_control ctrl (
        .clk(clk), .rst_n(rst_n), .opcode(opcode), .rd_data_a(rd_data_a),
        .reg_wr_en(reg_wr_en), .mem_we(mem_we), .pc_write(pc_write),
        .alu_b_sel(alu_b_sel), .wr_data_sel(wr_data_sel), .branch_taken(branch_taken)
    );
endmodule

This is a literal wiring-together of the last three articles. Nothing new is invented, which is the payoff of designing the ISA, datapath and control unit as three separate concerns. One timing note: ram_sp has a registered read, so instr and mem_rd_data are valid one cycle after the address is presented. The three-state FSM gives that a whole cycle in FETCH and DECODE respectively, which is one more reason the three-cycle version is convenient for a first design.

The golden reference model

Section 6.4 was explicit: the most reliable self-checking testbenches do not hard-code expected values. They run an independent model of the same computation and compare. For a processor, that model is naturally ordinary behavioural code tracking the same architectural state (registers, memory, PC), computed by a separate code path with no logic shared with the RTL:

// Golden reference model: a behavioural, non-synthesizable software model
// of the SAME processor, used purely to compute expected values
class cpu_reference_model;
    bit [7:0] regs[4];
    bit [7:0] mem[256];
    bit [7:0] pc;

    function void step(bit [7:0] instr);
        bit [2:0] opcode = instr[7:5];
        bit [1:0] rd = instr[4:3];
        bit [1:0] rs = instr[2:1];
        bit [2:0] imm = instr[2:0];
        bit taken = 0;
        case (opcode)
            3'b000: regs[rd] = regs[rd] + regs[rs];
            3'b001: regs[rd] = regs[rd] - regs[rs];
            3'b010: regs[rd] = regs[rd] & regs[rs];
            3'b011: regs[rd] = regs[rd] | regs[rs];
            3'b100: regs[rd] = {5'b0, imm};
            3'b101: regs[rd] = mem[imm];
            3'b110: mem[imm] = regs[rd];
            3'b111: if (regs[rd] == 0) begin pc = imm; taken = 1; end
        endcase
        if (!taken) pc = pc + 1'b1;
    endfunction
endclass

The test program and testbench

module tb_cpu_top;
    logic clk = 0, rst_n;
    cpu_top dut (.clk(clk), .rst_n(rst_n));
    cpu_reference_model ref_model;

    always #5 clk = ~clk;

    bit [7:0] program [0:3] = '{
        8'b100_00_101,   // LDI R0, 5
        8'b100_01_011,   // LDI R1, 3
        8'b000_00_01_0,  // ADD R0, R1
        8'b110_00_000    // ST [0], R0
    };

    initial begin
        ref_model = new();
        rst_n = 0;

        // Load the same program into the DUT's instruction memory and run
        // it through the reference model to compute the expected state
        foreach (program[i]) begin
            dut.imem.mem[i] = program[i];
            ref_model.step(program[i]);
        end

        @(negedge clk); rst_n = 1;

        // 4 instructions * 3 cycles per instruction = 12 cycles
        repeat (12) @(negedge clk);

        // Compare the DUT's memory[0] against the reference model's computed value
        if (dut.dmem.mem[0] === ref_model.mem[0])
            $display("PASS: memory[0] = %0d, reference model agrees", dut.dmem.mem[0]);
        else
            $display("FAIL: memory[0] = %0d, reference model expected %0d", dut.dmem.mem[0], ref_model.mem[0]);

        $finish;
    end
endmodule

The expected value (8, for this program) is not typed into the testbench. It comes out of the reference model executing the same program. A more thorough version steps the reference model alongside the RTL every instruction and compares the whole register file, catching a divergence at the instruction where it happens rather than at the end. That is the golden-model discipline from Section 6.4 at the scale of a small processor, and it is how real processor verification works: millions of randomly generated instruction sequences (the Section 9.2 methodology) run against both the RTL and a reference model, flagging the exact instruction where they diverge.

Practice Exercise

Extend the program with a BEQ that should not be taken (the tested register is nonzero), and add a check that the PC advanced to the next instruction rather than jumping to the target.

Solution

Add after the existing four instructions: LDI R2, 1 (a nonzero value), then BEQ R2, 0 (branch to address 0 if R2 is zero, which it is not, so this should fall through). The check confirms the PC after the BEQ equals the address after it, 6, not the branch target 0:

dut.imem.mem[4] = 8'b100_10_001;   // LDI R2, 1
dut.imem.mem[5] = 8'b111_10_000;   // BEQ R2, 0

repeat (6) @(negedge clk);   // two more instructions * 3 cycles each

if (dut.pc == 8'd6)
    $display("PASS: not-taken branch fell through to PC=6");
else
    $display("FAIL: PC=%0d, expected 6 (not-taken branch should fall through)", dut.pc);

This exercises the scenario from the control unit article’s exercise: pc_write stays asserted even when branch_taken is false, so a not-taken branch falls through instead of stalling the processor.

The complete processor is the wiring-together of three separately designed pieces, ISA, datapath and control, and its verification rests on the golden-model discipline from Section 6.4 scaled up to an instruction-executing machine. A reference model computing the same program’s expected state, independently of the RTL, is what proves correctness, not a hand-picked value that happened to match on the first run. That closes Project 1. In the next article, Project 2 turns from processor architecture to protocol design with a UART, built the same way: specification, datapath, control, verification.

← Project 1, Part 3: The Control Unit Project 2, Part 1: The UART Protocol →