Testbenches and Simulation

Here is the industry's uncomfortable arithmetic: on a serious chip project, more than half of the total engineering effort goes into verification — often nearer two-thirds. More people, more compute, more calendar time than writing the design itself. The reason is brutal economics: a software bug ships a patch; a silicon bug ships a recall, with mask sets costing millions and respins costing months. So the industry built an entire discipline around one question — how do we know the RTL is right before we pay for it? — and this module is that discipline. It begins with its humblest, most important artifact: the testbench.

A module with no pins

A testbench is a SystemVerilog module that wraps the design under test (DUT) — it instantiates your design, wiggles its inputs, and watches its outputs. Two things make it unlike any module you wrote in Module 1. It has no ports: nothing outside it exists; it is the whole universe as far as the DUT can tell. And it is never synthesized — it lives entirely in testbench country, so the sim-only dialect (initial, # delays, $error) is not just allowed but the whole point. Here is a complete self-checking bench for Module 1's full_adder:

module full_adder_tb; // no ports: the bench is the universe logic a, b, cin, sum, cout; // The design under test, wired to the bench's own signals. full_adder dut (.a(a), .b(b), .cin(cin), .sum(sum), .cout(cout)); initial begin for (int i = 0; i < 8; i++) begin {a, b, cin} = i[2:0]; // drive one input combination #10; // let the combinational logic settle if ({cout, sum} !== a + b + cin) // check against a REFERENCE: just add! $error("FAIL: %b+%b+%b gave %b%b", a, b, cin, cout, sum); end $display("all 8 combinations checked"); $finish; // stop the simulator end endmodule

Read the three verbs, because every testbench ever written has exactly these three jobs: drive (apply stimulus to the DUT's inputs), wait (let virtual time pass so the design reacts), and check (compare the DUT's outputs against what they should be). The check compares against a + b + cin — an independent, obviously-correct description of the intent. That little expression is doing enormous work, and it gets a name in a moment.

Clocks, resets and sequential DUTs

A sequential DUT needs a heartbeat, and the bench supplies it with the most famous three lines in verification:

logic clk = 0; initial forever #5 clk = ~clk; // toggle every 5 time units: period 10 initial begin rst_n = 0; // hold reset... repeat (2) @(posedge clk); // ...for two clock edges rst_n = 1; // release, and the DUT wakes up // drive and check, cycle by cycle, from here on end

forever #5 clk = ~clk toggles the clock every 5 time units — a full period of 10, high for 5 and low for 5, forever. The second initial block runs concurrently (the bench may have many, all in parallel — this is the simulation dialect flexing), holding reset across the first two edges so every register wakes into a known state before stimulus begins.

The anatomy, drawn

The picture to burn in: stimulus flows into two places — the DUT and the reference model (also called the golden model): a separate, simpler description of intent, like our a + b + cin. The checker's job is a single question asked forever: do they agree? Every disagreement is a bug — in the DUT, or occasionally in the model, and either way you learned something real.

Run a golden-model regression

Here is that architecture as running code: a gate-level DUT (with a planted bug), a golden model, a loop of stimulus, and pass/fail accounting. This shape — drive both, compare, count — is the skeleton of every serious verification environment on Earth:

// ── The DUT: a 4-bit ripple-carry adder built from gate logic... with a bug. ── function dutAdd(a: number, b: number): { sum: number; cout: number } { let sum = 0, carry = 0; for (let i = 0; i < 4; i++) { const ai = (a >> i) & 1, bi = (b >> i) & 1; sum |= (ai ^ bi ^ carry) << i; carry = (ai & bi) | (bi & carry); // BUG: forgot the (ai & carry) term! } return { sum, cout: carry }; } // ── The golden model: the INTENT, stated as simply as possible. ── const golden = (a: number, b: number) => ({ sum: (a + b) & 15, cout: a + b > 15 ? 1 : 0 }); // ── The bench: drive both, compare, count. (Seeded so a failure is re-runnable.) ── const seed = 42; let rng = seed; const rand16 = () => ((rng = (rng * 1103515245 + 12345) & 0x7fffffff) >> 8) % 16; let pass = 0, fail = 0; for (let t = 0; t < 100; t++) { const a = rand16(), b = rand16(); const d = dutAdd(a, b), g = golden(a, b); if (d.sum === g.sum && d.cout === g.cout) pass++; else { fail++; if (fail <= 3) console.log(`FAIL: ${a} + ${b} — dut says ${d.cout},${d.sum}; golden says ${g.cout},${g.sum}`); } } console.log(`seed ${seed}: ${pass} passed, ${fail} failed of ${pass + fail}`);

The buggy carry term only matters when ai & carry would have fired — some inputs sail through, which is exactly why counting a few passes proves nothing and regressions run many inputs. Note the printed seed: rerunning with the same seed replays the identical stimulus, so a failure found at 3 a.m. is reproducible at 9 a.m. — a theme the random-stimulus lesson makes central.

Under the hood, and at scale

What is the simulator actually doing? It is an event-driven program: it keeps an event queue ordered by virtual time, and only ever computes where something changed — a signal toggle schedules evaluations of just the blocks that read it, which may schedule further updates in the same instant (the zero-duration bookkeeping rounds are called delta cycles), until the moment quiesces and time jumps to the next event. That is why simulating a billion-gate chip is possible at all: at any instant, almost nothing is changing.

And benches are not run once. Teams accumulate them into a regression suite — hundreds or thousands of tests rerun automatically on every change to the RTL, exactly like a software CI pipeline but measured in compute-farm-nights. A change that breaks a test three subsystems away is caught before breakfast. The regression suite is the project's memory of every bug ever fixed.

Here is a fact that surprises every student: at most chip companies, verification engineers outnumber designers — sometimes two to one. And it is not the junior job. A great verification engineer is part detective (what input sequence would expose this bug?), part adversary (the designer says it works; assume it doesn't), part software architect (a modern bench is a bigger program than the design it tests). The mindset flip is the fun of it: a designer's good day is "nothing failed"; a verification engineer's good day is "I broke it" — every failure found in simulation is a failure that will never brick a product in the field. The industry's grim koan: if you haven't found a bug this week, your testbench is broken.

The classic beginner bench drives some inputs, dumps waveforms, and prints "test done". The engineer scrolls the waves, they look plausible, ship it. This is not a test — it is a screensaver. Eyeball-checking does not scale past ten cycles, is not repeatable, and silently rots: change the RTL next month and nobody re-stares at the waves. The iron rule: if it isn't self-checking, it isn't a test. Every bench must know the correct answer (a golden model, a stored expected trace, an invariant) and must fail loudly$error, non-zero exit, red in the regression dashboard — when reality disagrees. Waveforms are for debugging a failure after a self-check catches it, never for deciding that things pass. A thousand-test regression of self-checking benches is knowledge; a folder of waveform dumps is décor.