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.