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.