The linear random testbench from the previous article generates thousands of input combinations automatically — but it still depends on a human to decide whether the results are correct. Every $display line in the log needs to be read and checked. At a hundred test cases that is annoying. At ten thousand, across fifty random seeds running overnight in regression, it is simply not possible.
The self-checking testbench closes this gap. Instead of printing outputs for a human to inspect, the testbench computes the expected output for every input it applies, compares the DUT's actual output against it automatically, and reports a failure immediately if they disagree — without anyone reading a log file. A regression suite of a hundred self-checking tests can run unattended overnight and produce a single clear result: pass count, fail count, and the exact input that caused each failure.
12 min read | Intermediate Level
What You'll Learn
- How to build an in-testbench checker that computes expected outputs and compares them automatically
- How to use
$errorand$fatalwith informative messages for immediate failure localisation - How to use a simple assertion to catch protocol violations independently of output checking
- Why self-checking testbenches are the minimum standard for unattended regression
From Manual Inspection to Automatic Checking
In every testbench style so far, output checking has been either absent (linear, file-based) or present but basic (the inline comparison added at the end of the linear random article). The self-checking testbench makes checking a first-class, structured part of the testbench — not an afterthought bolted on at the end.
The key idea is the reference model: an independent implementation of what the DUT is supposed to do, written in the testbench itself. For every set of inputs the testbench applies to the DUT, it also passes the same inputs to the reference model. The reference model computes the expected output. The testbench then compares the DUT's actual output against the expected output and calls $error if they differ.
The reference model does not need to be complex. For an adder, it is a single addition. For a CRC engine, it is a software CRC function. The key property is that it is independent of the RTL — if the DUT has a bug, the reference model disagrees with it.
Part 1: Self-Checking a Combinational DUT
We start with the familiar 4-bit adder. The checker is straightforward: compute a + b + cin in the testbench and compare against {cout, sum} from the DUT. This is the simplest possible reference model.
module tb_adder_selfcheck;
// ── DUT signals ────────────────────────────────────────────────
reg [3:0] a;
reg [3:0] b;
reg cin;
wire [3:0] sum;
wire cout;
// ── DUT instantiation ──────────────────────────────────────────
adder dut (
.a(a), .b(b), .cin(cin),
.sum(sum), .cout(cout)
);
// ── Clock ──────────────────────────────────────────────────────
reg clk = 0;
always #5 clk = ~clk;
// ── Checker state ──────────────────────────────────────────────
integer i;
integer pass_count;
integer fail_count;
reg [4:0] expected; // 5-bit: {cout_expected, sum_expected}
initial begin
$display("Seed = %0d", $get_initial_random_seed());
pass_count = 0;
fail_count = 0;
a = 0; b = 0; cin = 0;
@(posedge clk);
@(posedge clk);
for (i = 0; i < 1000; i = i + 1) begin
// ── Apply random stimulus ──────────────────────────────
a = $urandom;
b = $urandom;
cin = $urandom;
@(posedge clk); // wait for output to settle
// ── Reference model: independent computation ──────────
expected = a + b + cin;
// ── Checker: compare DUT output against expected ───────
// Use !== so X or Z on DUT outputs are caught as failures
if ({cout, sum} !== expected) begin
$error("FAIL [%0d]: a=%0d b=%0d cin=%0d | expected=%05b got=%b%04b",
i, a, b, cin, expected, cout, sum);
fail_count = fail_count + 1;
end else begin
pass_count = pass_count + 1;
end
end
// ── Final summary ──────────────────────────────────────────
$display("------------------------------------------");
$display("Results: %0d passed, %0d failed / 1000 total",
pass_count, fail_count);
if (fail_count > 0)
$fatal(1, "TEST FAILED — %0d errors detected", fail_count);
else
$display("TEST PASSED");
$finish;
end
endmodule
$fatal is stronger than $error. $error logs the failure and continues — useful when you want to count all failures across the full run. $fatal stops the simulation immediately — useful at the end when you want the simulator's exit code to reflect the test result so a regression script can detect failure without parsing log files.
Simulation Log — Passing Run
Seed = 42 ------------------------------------------ Results: 1000 passed, 0 failed / 1000 total TEST PASSED
Simulation Log — Failing Run (Buggy RTL)
If the DUT had a bug — say, the carry-out was computed incorrectly for a + b > 15 — a failing run would look like this:
Seed = 17 FAIL [23]: a=9 b=8 cin=0 | expected=10001 got=00001 FAIL [61]: a=12 b=11 cin=1 | expected=11000 got=01000 FAIL [204]: a=8 b=8 cin=0 | expected=10000 got=00000 ------------------------------------------ Results: 997 passed, 3 failed / 1000 total FATAL: TEST FAILED — 3 errors detected
Every failure line gives: the iteration number, the exact inputs that triggered the failure, the expected output in binary, and the DUT's actual output. A verification engineer reading this log can reproduce any of the three failures in under a minute by writing a directed test with those exact input values.
Quick Check
Q: The failing log shows expected=10001 got=00001 for a=9, b=8, cin=0. The expected value 10001 is a 5-bit number — what does bit[4] represent, and what specific bug does this pattern suggest?
Show Answer
Bit[4] is cout — the carry-out. expected=10001 means the correct answer is cout=1, sum=1 (9+8=17, which is 10001 in binary). got=00001 means the DUT produced cout=0, sum=1 — the sum is correct but the carry-out is never asserted. This suggests the bug is specifically in the carry-out logic: the adder computes the lower 4 bits correctly but fails to set cout when the result exceeds 15.
Part 2: Checking a Pipelined DUT
The adder above is combinational — the output is valid one clock edge after the input. For a pipelined DUT, the output arrives several cycles after the input, and multiple transactions can be in-flight simultaneously. A simple inline comparison no longer works because the expected output computed from the current input corresponds to a DUT output that has not arrived yet.
The solution is a queue: expected outputs are pushed in as inputs are applied, and popped out for comparison as DUT outputs arrive. This is the foundational pattern of a scoreboard.
The DUT below is a 2-stage pipelined version of the adder — inputs enter on cycle N, the result appears on cycle N+2:
// DUT: 2-stage pipelined adder
// Inputs sampled at cycle N
// Output valid at cycle N+2
module adder_pipe (
input clk,
input [3:0] a,
input [3:0] b,
input cin,
output [3:0] sum,
output cout
);
// Stage 1 registers
reg [3:0] a1, b1;
reg cin1;
// Stage 2 output registers
reg [3:0] sum;
reg cout;
always @(posedge clk) begin
// Pipeline stage 1: register the inputs
a1 <= a;
b1 <= b;
cin1 <= cin;
// Pipeline stage 2: compute and register the result
{cout, sum} <= a1 + b1 + cin1;
end
endmodule
The self-checking testbench for this DUT uses a small expected-output queue. On every cycle, a new expected value is pushed based on the current inputs. Two cycles later, when the DUT output arrives, the oldest expected value is popped and compared:
module tb_pipe_selfcheck;
reg [3:0] a, b;
reg cin, clk;
wire [3:0] sum;
wire cout;
adder_pipe dut (.clk(clk), .a(a), .b(b), .cin(cin),
.sum(sum), .cout(cout));
reg clk = 0;
always #5 clk = ~clk;
// ── Expected output queue ──────────────────────────────────────
// Sized to hold more entries than the pipeline depth (2 stages)
// Each entry is 5 bits: {cout_exp, sum_exp}
reg [4:0] exp_queue [0:15];
integer q_head; // index of the oldest entry (next to compare)
integer q_tail; // index where the next entry will be written
integer q_count; // number of entries currently in the queue
integer i;
integer pass_count, fail_count;
reg [4:0] expected;
// ── Queue push: called when inputs are applied ─────────────────
task push_expected;
input [4:0] val;
begin
exp_queue[q_tail] = val;
q_tail = (q_tail + 1) % 16;
q_count = q_count + 1;
end
endtask
// ── Queue pop: called when DUT output is ready ─────────────────
function [4:0] pop_expected;
begin
pop_expected = exp_queue[q_head];
q_head = (q_head + 1) % 16;
q_count = q_count - 1;
end
endfunction
initial begin
$display("Seed = %0d", $get_initial_random_seed());
q_head = 0; q_tail = 0; q_count = 0;
pass_count = 0; fail_count = 0;
a = 0; b = 0; cin = 0;
// Reset: drive zeros for 2 cycles (flush the pipeline)
repeat(4) @(posedge clk);
// ── Main loop: apply 100 random inputs ────────────────────
for (i = 0; i < 100; i = i + 1) begin
@(posedge clk);
// Apply new random inputs
a = $urandom;
b = $urandom;
cin = $urandom;
// Push the expected result into the queue immediately
// (the DUT will produce this result 2 cycles from now)
push_expected(a + b + cin);
// Check the DUT output if enough cycles have passed
// (queue has entries that are 2 cycles old or more)
if (q_count >= 2) begin
expected = pop_expected();
if ({cout, sum} !== expected) begin
$error("FAIL [%0d]: expected=%05b got=%b%04b",
i, expected, cout, sum);
fail_count = fail_count + 1;
end else begin
pass_count = pass_count + 1;
end
end
end
// ── Drain remaining pipeline stages ───────────────────────
repeat(2) begin
@(posedge clk);
if (q_count > 0) begin
expected = pop_expected();
if ({cout, sum} !== expected) begin
$error("DRAIN FAIL: expected=%05b got=%b%04b",
expected, cout, sum);
fail_count = fail_count + 1;
end else
pass_count = pass_count + 1;
end
end
$display("Results: %0d passed, %0d failed", pass_count, fail_count);
if (fail_count > 0)
$fatal(1, "TEST FAILED");
else
$display("TEST PASSED");
$finish;
end
endmodule
The drain loop at the end is essential for pipelined DUTs. When the main stimulus loop finishes, the last two inputs are still travelling through the pipeline — their outputs have not yet appeared. Without the drain, those final results are never checked. Any bug that only manifests in the last few transactions will be silently missed.
Quick Check
Q: The queue stores expected values and the DUT produces outputs 2 cycles after inputs. If the main loop runs for 100 cycles and the drain runs for 2 more, how many total comparisons should the checker perform — and what happens if the queue has entries remaining after the drain?
Show Answer
100 main loop comparisons (skipping the first 2 cycles where q_count < 2) + 2 drain comparisons = 100 total. If entries remain in the queue after the drain, it means the DUT produced fewer outputs than expected — either it dropped transactions or stalled. A properly written testbench should check q_count == 0 after the drain and call $error if any entries remain, since each unmatched entry represents a missing DUT response.
Part 3: Using $error and $fatal Effectively
Both $error and $fatal accept a format string identical to $display. The difference is behavioural: $error increments the simulator's error count and continues; $fatal stops the simulation immediately with a non-zero exit code.
The standard pattern for a self-checking testbench is to use $error throughout the run — collecting all failures — and use $fatal exactly once at the end, after checking the total fail count. This gives you a complete picture of all failures in a single run rather than stopping at the first one.
// ── Good error reporting pattern ───────────────────────────────
// During the test: use $error with full diagnostic context
// Include: which test, what inputs, what was expected, what arrived
if ({cout, sum} !== expected) begin
$error("FAIL [iter=%0d time=%0t]: a=0x%0h b=0x%0h cin=%0b | expected=0x%0h got=0x%0h",
i, $time, a, b, cin, expected, {cout, sum});
fail_count = fail_count + 1;
end
// At end of test: use $fatal to set simulator exit code
// Exit code 1 means failure — regression scripts check this
if (fail_count > 0)
$fatal(1, "SIMULATION FAILED: %0d / %0d tests failed",
fail_count, pass_count + fail_count);
else
$display("SIMULATION PASSED: all %0d tests passed", pass_count);
Always include $time in your $error messages. When debugging a failure, knowing the simulation timestamp lets you jump directly to that point in the waveform viewer — without it, you have to search the entire waveform for the wrong output, which can take significantly longer on large designs.
Part 4: Adding an Assertion
Output checking verifies that the DUT produced the right result. Assertions verify that the DUT behaved correctly along the way — catching protocol violations, illegal states, or timing constraint violations that might not be visible in the final output.
For a pipelined design, a useful assertion is that the DUT never drives X on its output after reset has been released. An X output means some internal register was not properly initialised, which is a design bug that could cause downstream logic to misbehave even if the computed value happens to be correct by coincidence.
// ── Assertion: DUT outputs must never be X after reset ─────────
// This always block monitors sum and cout on every clock edge
// If either is X or Z (four-state check), the assertion fires
reg rst_released; // flag: set once reset has been de-asserted
always @(posedge clk) begin
if (rst_released) begin
// ^sum is the reduction XOR — if any bit is X or Z,
// the result is X, which is not equal to 0 or 1
if (^sum === 1'bx)
$error("ASSERTION FAIL at %0t: sum contains X or Z (sum=%b)",
$time, sum);
if (cout === 1'bx)
$error("ASSERTION FAIL at %0t: cout is X or Z", $time);
end
end
This assertion uses the reduction XOR operator ^sum. If any bit in sum is X or Z, the XOR result is X — which is not equal to 0 or 1 in a four-state comparison. This is a compact way to check whether any bit in a multi-bit signal is unknown, without checking each bit individually.
Why This Enables Unattended Regression
The combination of automatic checking and informative error messages transforms the testbench from an interactive tool into a regression component. A regression script can launch a hundred instances of a self-checking testbench with different seeds, collect exit codes, and produce a pass/fail summary without anyone reading a log file during the run. Only failing runs — identified by a non-zero exit code from $fatal — need human attention.
This is the minimum standard for any testbench that runs in a shared regression environment. A testbench that relies on log inspection cannot be part of a nightly regression, because there is nobody reading logs at 3am when the regression completes.
Common Mistakes
Mistake #1: Using $display for failures and $finish at the end, relying on a human to check the log for error strings.
Wrong: Writing $display("ERROR: mismatch") when a check fails, then calling $finish at the end — the simulation always exits with a zero exit code, so a regression script sees "pass" even when dozens of errors were printed.
Right: Using $error during the run (which increments the simulator's internal error counter) and $fatal(1, ...) at the end when any failures occurred — the non-zero exit code signals failure to any script, CI system, or regression tool without parsing logs.
Why? Regression scripts check exit codes, not log content. A testbench that calls $finish regardless of test outcome is invisible to automated regression infrastructure — every run looks like a pass.
Mistake #2: Writing the reference model by translating the RTL into testbench code line by line.
Wrong: Reading the RTL implementation of the adder and writing the reference model as the same logic in a slightly different syntax — the reference model contains the same bug as the RTL, so the checker always agrees with the DUT.
Right: Writing the reference model from the specification, not the implementation — a single expected = a + b + cin derived from the spec description "produces the arithmetic sum of the two operands and carry-in."
Why? A reference model copied from the RTL is not independent. If the RTL has a logic error, the copied reference model has the same error, and the checker reports a pass when it should report a fail. The reference model's value comes entirely from its independence.
Mistake #3: Forgetting to drain the pipeline before calling $finish.
Wrong: Stopping stimulus at iteration 100 and immediately calling $finish — the last two inputs to a 2-stage pipeline are still in-flight and their outputs are never checked.
Right: After the stimulus loop, waiting exactly N more clock cycles (where N is the pipeline depth) and running the checker for those cycles before calling $finish — ensuring every applied input has a corresponding checked output.
Why? Bugs that only manifest in corner-case input combinations may appear in the last few transactions of a run. Without draining, those are the transactions that go unchecked — systematically hiding a class of bugs that only appear near the end of a sequence.
Practice Exercise
Challenge: Make the Memory Testbench Self-Checking
Take the state machine testbench from article 3.4 (the 8×8 synchronous memory with the write/read FSM) and the random stimulus from article 3.5. Combine them into a self-checking testbench that:
- Runs 50 random write-read cycles with random addresses (0–7) and random 8-bit data
- Automatically checks that each read-back matches the value written, using
$errorfor each mismatch - Prints a final summary with pass/fail counts and calls
$fatalif any failures occurred - Prints the seed at the start of the run
Hint
The reference model for a memory is simply a shadow memory: a reg [7:0] shadow_mem [0:7] array in the testbench. Every time the FSM writes to the DUT, also write the same value to shadow_mem[addr]. In the DONE state, compare rd_data against shadow_mem[addr] — that is your reference model check. No arithmetic needed; the reference model is just a mirror of what the DUT should contain.
Sample Solution — key additions only
// Add to testbench declarations:
reg [7:0] shadow_mem [0:7]; // reference model: mirrors DUT memory
reg [7:0] written_data; // saves write data for the read-back check
integer pass_count, fail_count;
// In the initial block before the FSM:
$display("Seed = %0d", $get_initial_random_seed());
pass_count = 0;
fail_count = 0;
// Clear shadow memory
begin : init_shadow
integer j;
for (j = 0; j < 8; j = j + 1)
shadow_mem[j] = 8'h00;
end
// WRITE_REQ state — random inputs + update shadow:
WRITE_REQ: begin
wr_en = 1;
addr = $urandom % 8;
wr_data = $urandom;
shadow_mem[addr] = wr_data; // reference model tracks every write
written_data = wr_data;
state = WRITE_WAIT;
end
// DONE state — compare against shadow memory:
DONE: begin
if (ack) begin
rd_en = 0;
// Reference model check: rd_data must match shadow_mem[addr]
if (rd_data !== shadow_mem[addr]) begin
$error("FAIL cycle %0d: addr=0x%0h expected=0x%0h got=0x%0h",
cycle, addr, shadow_mem[addr], rd_data);
fail_count = fail_count + 1;
end else
pass_count = pass_count + 1;
cycle = cycle + 1;
if (cycle < 50)
state = WRITE_REQ;
else begin
$display("Results: %0d passed, %0d failed", pass_count, fail_count);
if (fail_count > 0)
$fatal(1, "TEST FAILED");
else
$display("TEST PASSED");
$finish;
end
end
end
The shadow memory is the reference model. It requires no arithmetic — the testbench simply tracks what values were written and where, and verifies the DUT returns exactly those values when read. This is the standard pattern for memory verification at any scale.
Summary
- A self-checking testbench computes expected outputs via a reference model and compares them to DUT outputs automatically — no human reads the log to determine pass or fail
- The reference model must be written from the specification, not derived from the RTL — independence is what gives it the ability to catch DUT bugs
- For pipelined DUTs, a queue stores expected values as inputs are applied and drains them for comparison as outputs arrive; the pipeline must be drained at the end of the run
- Use
$errorduring the run to collect all failures and$fatal(1,...)at the end to set the simulator exit code — regression scripts check exit codes, not log content - Always include
$timein error messages for fast waveform navigation during debug - Assertions check protocol and structural correctness independently of output values — catching bugs that produce a numerically correct output through an illegal path