Verifying the CPU

The pipeline works. It forwards, stalls and flushes, and it ran our test program perfectly. So: ship it? Not even close. "It ran my program" is to verification what "it compiled" is to software — a pulse, not a diagnosis. A CPU's state space is astronomical, its nastiest bugs live in the interactions (a forwarding path times a stall times an x0 corner), and a wrong CPU doesn't crash — it computes confidently incorrect numbers. This final lesson brings the whole verification arsenal — testbench discipline, constrained randomness, coverage, assertions — home to the machine we just built. CPUs, it turns out, have a superpower here: a CPU can run programs, so a CPU can test itself.

Self-checking programs

The cheapest tester is the device under test. Write a program that computes something, compares against the known answer using the CPU's own branch instruction, and steers to a pass or fail address. This is exactly how the official riscv-tests suite works — hundreds of tiny assembly programs, one per instruction behaviour, each ending in a self-grade:

# A self-checking test, riscv-tests style: the CPU grades its own homework. addi x1, x0, 5 addi x2, x0, 7 add x3, x1, x2 # the behaviour under test addi x4, x0, 12 # the EXPECTED answer, precomputed by the test author beq x3, x4, pass # the CPU itself compares fail: jal x0, fail # infinite loop: pc pinned at 'fail' = red flag pass: ... # next test case, or the magic 'all tests passed' store

The testbench only watches the pc: parked at fail means a bug; reaching the final store means all cases passed. Elegant — and subtly circular. The test uses ADDI, BEQ and JAL to grade ADD; if the graders themselves are broken, a test can "pass" for the wrong reason (a broken-everything CPU that never branches to fail looks suspiciously green). Self-checking programs are a superb smoke test and a lousy foundation — we need a referee standing outside the machine.

The golden model: co-simulation in lockstep

Here is the move that anchors real CPU verification — and the payoff of this module's oldest investment. Back in lesson 36 we wrote a single-cycle functional model: {pc, regs, mem} and a next(). That model is an instruction-set simulator (ISS) — an executable copy of the contract, so simple it is obviously right. Now run it in lockstep with the pipelined RTL: every time the pipeline retires an instruction from WB, let the ISS execute one instruction too, and compare architectural state — pc retired, every register. The instant they disagree you have not just "a bug somewhere" but the exact pc, register and cycle of first divergence. The complex machine is graded, retirement by retirement, by the simple one — which is why the single-cycle CPU was never a throwaway: slow-but-obvious is precisely what a referee should be.

Random programs, measured coverage

Lockstep comparison answers "is this run correct?" — but who supplies the runs? Hand-written tests exercise the hazards you already understand; the bug is, by definition, in one you don't. So generate programs randomly under constraints (the constrained-random idea, industrialised for CPUs by tools like riscv-dv): initialise every register first, keep load/store addresses in the legal range, then spray instructions with random destinations and sources. Dependent pairs, load-use chains, x0 destinations, the same register produced twice in a row — corner cases fall out of the generator at a rate no human matches. Random stimulus is only half the contract, though; the other half is measuring what you actually hit: bins for every instruction, every hazard distance (producer–consumer gap of 1, 2, 3), every forwarding path taken, every stall and flush — and the crosses that matter, like "load-use stall whose consumer also needs an EX/MEM forward on its other operand". Empty bin, missing test — regardless of how many millions of instructions happened to retire green.

The last layer sits inside the design: assertions that watch invariants on every cycle of every run, hand-written or random:

// Assertions live INSIDE the pipeline, watching every cycle of every test. // x0 is never architecturally written: assert property (@(posedge clk) disable iff (!rst_n) !(rf_we && rf_rd == 5'd0)); // A bubble carries no side effects: if the control bundle is NOP, nothing writes. assert property (@(posedge clk) disable iff (!rst_n) (id_ex.ctl == '0) |-> !id_ex_writes_anything); // Exactly one instruction can retire per cycle -- WB is a single port: assert property (@(posedge clk) disable iff (!rst_n) $onehot0({mem_wb_valid_alu, mem_wb_valid_load}));

That first assertion is our old friend from the register-file lesson — the x0 invariant, now standing guard permanently. When a random program trips it, the failure fires at the violating cycle, not thousands of instructions later when the corruption surfaces.

The seductive plan — "I'll write a test for forwarding, a test for stalls, a test for branches" — has a blind spot the size of the design: your tests are drawn from the same mental model that produced the RTL, so a misunderstanding creates the bug and excuses the test in the same stroke. If you believed load-use detection needn't check rs2, you wrote neither the logic nor the test for it. Random generation is the antidote precisely because it doesn't share your model — it will cheerfully emit the dependent-store-after-load-into- x0 sequence you never imagined. The industry's standard: directed tests to bring a block up, then constrained-random + coverage + assertions to grind it clean, with coverage — not optimism — deciding when to stop.

The whole rig, running — and catching a planted bug

Everything above, in one executable: the lesson-36 model as ISS, the lesson-39 pipeline as the design under test, a constrained-random program generator, and a lockstep comparator. Then the experiment that makes it real: we inject the classic forwarding bug — dropping the rd != x0 qualification — and let the co-sim hunt it down:

// -- Shared machinery: assembler + decoder (the module's toolkit, compact). -- function R(f7: number, f3: number, rd: number, rs1: number, rs2: number): number { return ((f7 << 25) | (rs2 << 20) | (rs1 << 15) | (f3 << 12) | (rd << 7) | 0x33) | 0; } function I(op: number, f3: number, rd: number, rs1: number, imm: number): number { return (((imm & 0xfff) << 20) | (rs1 << 15) | (f3 << 12) | (rd << 7) | op) | 0; } function S(f3: number, rs2: number, rs1: number, imm: number): number { return ((((imm >> 5) & 0x7f) << 25) | (rs2 << 20) | (rs1 << 15) | (f3 << 12) | ((imm & 0x1f) << 7) | 0x23) | 0; } const asm = { ADD: (rd: number, rs1: number, rs2: number) => R(0x00, 0, rd, rs1, rs2), SUB: (rd: number, rs1: number, rs2: number) => R(0x20, 0, rd, rs1, rs2), AND: (rd: number, rs1: number, rs2: number) => R(0x00, 7, rd, rs1, rs2), OR: (rd: number, rs1: number, rs2: number) => R(0x00, 6, rd, rs1, rs2), ADDI: (rd: number, rs1: number, imm: number) => I(0x13, 0, rd, rs1, imm), LW: (rd: number, rs1: number, imm: number) => I(0x03, 2, rd, rs1, imm), SW: (rs2: number, rs1: number, imm: number) => S(2, rs2, rs1, imm), }; interface Decoded { name: string; rd: number; rs1: number; rs2: number; imm: number; useRs1: boolean; useRs2: boolean } function decode(w: number): Decoded { const opcode = w & 0x7f, rd = (w >> 7) & 0x1f, f3 = (w >> 12) & 7; const rs1 = (w >> 15) & 0x1f, rs2 = (w >> 20) & 0x1f, f7 = (w >>> 25) & 0x7f; if (opcode === 0x33) return { name: f3 === 0 ? (f7 === 0x20 ? "SUB" : "ADD") : f3 === 7 ? "AND" : "OR", rd, rs1, rs2, imm: 0, useRs1: true, useRs2: true }; if (opcode === 0x13) return { name: "ADDI", rd, rs1, rs2, imm: w >> 20, useRs1: true, useRs2: false }; if (opcode === 0x03) return { name: "LW", rd, rs1, rs2, imm: w >> 20, useRs1: true, useRs2: false }; return { name: "SW", rd: 0, rs1, rs2, imm: ((w >> 25) << 5) | ((w >> 7) & 0x1f), useRs1: true, useRs2: true }; } function alu(op: string, a: number, b: number): number { return op === "SUB" ? (a - b) | 0 : op === "AND" ? a & b : op === "OR" ? a | b : (a + b) | 0; } // -- The golden model: lesson 36's single-cycle CPU IS an instruction-set simulator. -- interface ArchState { pc: number; regs: number[]; mem: number[] } function issStep(s: ArchState, imem: number[]): ArchState { const d = decode(imem[s.pc >> 2]); const regs = s.regs.slice(), mem = s.mem.slice(); const a = s.regs[d.rs1], b = s.regs[d.rs2]; if (d.name === "SW") mem[(a + d.imm) >> 2] = b; else if (d.rd !== 0) regs[d.rd] = d.name === "LW" ? mem[(a + d.imm) >> 2] | 0 : d.name === "ADDI" ? (a + d.imm) | 0 : alu(d.name, a, b); return { pc: s.pc + 4, regs, mem }; } // -- The design under test: lesson 39's pipelined CPU, with an optional injected bug. -- interface IDEX { pc: number; name: string; rd: number; rs1: number; rs2: number; useRs1: boolean; useRs2: boolean; a: number; b: number; imm: number } interface EXMEM { pc: number; name: string; rd: number; alu: number; storeVal: number } interface MEMWB { pc: number; name: string; rd: number; val: number; regWrite: number } interface PState { pc: number; regs: number[]; mem: number[]; if_id: { pc: number; word: number } | null; id_ex: IDEX | null; ex_mem: EXMEM | null; mem_wb: MEMWB | null; } function rtlStep(s: PState, imem: number[], buggy: boolean): PState { const regs = s.regs.slice(), mem = s.mem.slice(); if (s.mem_wb && s.mem_wb.regWrite && s.mem_wb.rd !== 0) regs[s.mem_wb.rd] = s.mem_wb.val; let mem_wb: MEMWB | null = null; if (s.ex_mem) { const m = s.ex_mem; let val = m.alu; if (m.name === "SW") mem[m.alu >> 2] = m.storeVal; if (m.name === "LW") val = mem[m.alu >> 2] | 0; mem_wb = { pc: m.pc, name: m.name, rd: m.rd, val, regWrite: m.name === "SW" ? 0 : 1 }; } let ex_mem: EXMEM | null = null; if (s.id_ex) { const e = s.id_ex; // The forwarding unit. "buggy" drops the rd != x0 qualification: spot the difference. const fwd = (rs: number, used: boolean): number | null => { if (!used) return null; if (s.ex_mem && s.ex_mem.name !== "SW" && (buggy || s.ex_mem.rd !== 0) && s.ex_mem.rd === rs) return s.ex_mem.alu; if (s.mem_wb && s.mem_wb.regWrite && (buggy || s.mem_wb.rd !== 0) && s.mem_wb.rd === rs) return s.mem_wb.val; return null; }; const fa = fwd(e.rs1, e.useRs1), fb = fwd(e.rs2, e.useRs2); const a = fa === null ? e.a : fa; const bReg = fb === null ? e.b : fb; const useImm = e.name === "ADDI" || e.name === "LW" || e.name === "SW"; ex_mem = { pc: e.pc, name: e.name, rd: e.rd, alu: alu(useImm ? "ADD" : e.name, a, useImm ? e.imm : bReg), storeVal: bReg }; } const d = s.if_id ? decode(s.if_id.word) : null; const loadUse = !!(d && s.id_ex && s.id_ex.name === "LW" && s.id_ex.rd !== 0 && ((d.useRs1 && d.rs1 === s.id_ex.rd) || (d.useRs2 && d.rs2 === s.id_ex.rd))); if (loadUse) return { pc: s.pc, regs, mem, if_id: s.if_id, id_ex: null, ex_mem, mem_wb }; let id_ex: IDEX | null = null; if (d && s.if_id) id_ex = { pc: s.if_id.pc, name: d.name, rd: d.rd, rs1: d.rs1, rs2: d.rs2, useRs1: d.useRs1, useRs2: d.useRs2, a: regs[d.rs1], b: regs[d.rs2], imm: d.imm }; const word = imem[s.pc >> 2]; return { pc: s.pc + 4, regs, mem, if_id: word === undefined ? null : { pc: s.pc, word }, id_ex, ex_mem, mem_wb }; } // -- Constrained-random instruction generation (riscv-dv in miniature). -- let seed = 1; const rnd = (n: number) => { seed = (Math.imul(seed, 1664525) + 1013904223) | 0; // a 32-bit LCG return (seed >>> 17) % n; }; function randomProgram(len: number): number[] { const prog: number[] = []; for (let r = 1; r <= 7; r++) prog.push(asm.ADDI(r, 0, rnd(100))); // constraint: init registers first for (let i = 0; i < len; i++) { const k = rnd(7), rd = rnd(8), rs1 = rnd(8), rs2 = rnd(8); // constraint: only x0..x7 if (k === 0) prog.push(asm.ADD(rd, rs1, rs2)); else if (k === 1) prog.push(asm.SUB(rd, rs1, rs2)); else if (k === 2) prog.push(asm.AND(rd, rs1, rs2)); else if (k === 3) prog.push(asm.OR(rd, rs1, rs2)); else if (k === 4) prog.push(asm.ADDI(rd, rs1, rnd(32))); else if (k === 5) prog.push(asm.LW(rd, 0, 4 * rnd(8))); // constraint: legal addresses else prog.push(asm.SW(rs2, 0, 4 * rnd(8))); } return prog; } // -- Lockstep co-simulation: every RTL retirement must match one ISS step. -- function cosim(imem: number[], buggy: boolean): string { let iss: ArchState = { pc: 0, regs: new Array(32).fill(0), mem: new Array(8).fill(0) }; let rtl: PState = { pc: 0, regs: new Array(32).fill(0), mem: new Array(8).fill(0), if_id: null, id_ex: null, ex_mem: null, mem_wb: null }; let retired = 0; for (let cycle = 0; cycle < 200; cycle++) { if (imem[rtl.pc >> 2] === undefined && !rtl.if_id && !rtl.id_ex && !rtl.ex_mem && !rtl.mem_wb) break; const wasInWb = rtl.mem_wb; // this instruction retires during this cycle rtl = rtlStep(rtl, imem, buggy); if (wasInWb) { iss = issStep(iss, imem); // the golden model executes the same instruction retired++; if (iss.pc - 4 !== wasInWb.pc) return `DIVERGE at retirement ${retired}: RTL retired pc=${wasInWb.pc}, ISS executed pc=${iss.pc - 4}`; for (let r = 0; r < 32; r++) if (iss.regs[r] !== rtl.regs[r]) return `DIVERGE at pc=${wasInWb.pc} (${wasInWb.name}): x${r} -- ISS says ${iss.regs[r]}, RTL says ${rtl.regs[r]}`; } } return `PASS -- ${retired} instructions retired, architectural state matched at every one`; } const prog = randomProgram(14); console.log("random program:", prog.length, "instructions"); prog.forEach((w, i) => { const d = decode(w); console.log(` ${String(i * 4).padStart(2)}: ${d.name.toLowerCase().padEnd(4)} rd=x${d.rd} rs1=x${d.rs1} rs2=x${d.rs2} imm=${d.imm}`); }); console.log("\ncorrect RTL vs ISS :", cosim(prog, false)); console.log("buggy RTL vs ISS :", cosim(prog, true));

Read the verdicts. The correct pipeline: 21 retirements, state matched at every one. The buggy pipeline runs the same program and diverges at pc = 68 — an addi x7, x0, 27 that should produce 27 produced 43, because the lw x0 one slot earlier (a load that discards its result into x0) was wrongly forwarded into the x0 read. Notice how much the report hands you: the pc, the register, both values — and the culprit sitting one instruction upstream. That is what "divergence = bug + exact location" means in practice. Try it yourself: change the seed, lengthen the program, or invent your own bug (break the EX/MEM priority, or forward from stores) and watch the referee catch it.

Take our little rig and multiply by a million. Commercial CPU projects run billions of constrained-random instructions nightly across server farms and hardware emulators, every one checked in lockstep against a golden ISS (for RISC-V, typically Spike); formal tools prove properties of the decoder and forwarding logic outright; and verification engineers outnumber designers two-to-one or more. The stakes were made vivid in 1994 when the Pentium's FDIV unit shipped with five missing entries in a divider lookup table — a bug pattern random testing plus coverage would have cornered — costing Intel a $475M recall. Modern flows exist so that class of escape dies in simulation. When a fresh RISC-V core "boots Linux first try" on silicon, that is not luck; it is this lesson's loop — generate, co-simulate, cover, assert — run relentlessly for months. Verification is not the tax on design; it is most of the engineering.

And with that, the project arc closes: a contract (lesson 34), a register file, a single-cycle machine that became our referee, a control unit, a pipeline, its hazards healed — and a verification rig that lets you change any of it and know, within seconds, whether the machine still honours the contract. That loop — edit, run, referee — is chip design in miniature.