Part 6 · Testbench Architecture · Intermediate
Walkthrough: Complete Mini Testbench
An end-to-end annotated class-based testbench for a simple register-file DUT: interface, harness, transaction, generator, driver, monitor, scoreboard, and test.
The target and the architecture
The DUT is a small synchronous register file with a valid/ready write-read port: on valid && ready, a write stores wdata at addr, and a read returns the stored value on rdata the following cycle. Simple enough to fit on a page, real enough to need every testbench layer. This is the interview deliverable: be able to write this from memory.
MINI TB ARCHITECTURE
test ──builds──► env
│
┌───────────┼──────────────┬─────────────┐
▼ ▼ ▼ ▼
generator ──► driver monitor ───► scoreboard
rand txns │ vif.drv_cb │ vif.mon_cb │ exp model:
(mailbox) ▼ ▼ (mailbox) │ assoc array
┌───────── bus_if ─────────┐ │ mem[addr]
│ valid ready write │ ▼
│ addr wdata rdata │ exp == act ?
└───────────┬──────────────┘
▼
regfile DUTTransaction, generator, driver
// ---- transaction: the unit of communication between layers ----
class rf_txn;
rand bit write;
rand bit [3:0] addr;
rand bit [31:0] wdata;
bit [31:0] rdata; // filled by monitor on reads
static int next_id;
int id; // debug breadcrumb
function new();
id = next_id++;
endfunction
function string sprint();
return $sformatf("txn%0d %s addr=%h wdata=%h rdata=%h",
id, write ? "WR" : "RD", addr, wdata, rdata);
endfunction
endclass
// ---- generator: scenario layer ----
class generator;
mailbox #(rf_txn) out;
int unsigned n;
event done;
function new(mailbox #(rf_txn) out, int unsigned n);
this.out = out; this.n = n;
endfunction
task run();
repeat (n) begin
rf_txn t = new();
if (!t.randomize()) $fatal(1, "randomize failed");
out.put(t); // blocks if driver is behind (bounded mb)
end
-> done;
endtask
endclass
// ---- driver: command layer ----
class driver;
mailbox #(rf_txn) in;
virtual bus_if vif;
function new(mailbox #(rf_txn) in);
this.in = in;
endfunction
task run();
forever begin
rf_txn t;
in.get(t);
@(vif.drv_cb);
vif.drv_cb.valid <= 1;
vif.drv_cb.write <= t.write;
vif.drv_cb.addr <= t.addr;
vif.drv_cb.wdata <= t.wdata;
do @(vif.drv_cb); while (vif.drv_cb.ready !== 1);
vif.drv_cb.valid <= 0; // one idle cycle between txns
end
endtask
endclassCommentary
rf_txn carries a static-counter id — when the scoreboard screams, the id pins the failure to one generator decision.
The generator never sees a clock; it produces transactions and blocks on the mailbox — pure scenario layer.
The driver owns all timing: clocking-block drives, the ready wait loop, and the return-to-idle cycle.
Monitor, scoreboard, env, test, harness
// ---- monitor: passive observation only ----
class monitor;
mailbox #(rf_txn) out;
virtual bus_if vif;
function new(mailbox #(rf_txn) out);
this.out = out;
endfunction
task run();
forever begin
rf_txn t = new();
@(vif.mon_cb);
if (vif.mon_cb.valid === 1 && vif.mon_cb.ready === 1) begin
t.write = vif.mon_cb.write;
t.addr = vif.mon_cb.addr;
t.wdata = vif.mon_cb.wdata;
if (!t.write) begin
@(vif.mon_cb); // read data is one cycle later
t.rdata = vif.mon_cb.rdata;
end
out.put(t);
end
end
endtask
endclass
// ---- scoreboard: functional layer with inline reference model ----
class scoreboard;
mailbox #(rf_txn) in;
int unsigned expected_n, seen_n, errors;
bit [31:0] mem [bit [3:0]]; // associative array = golden model
function new(mailbox #(rf_txn) in, int unsigned expected_n);
this.in = in; this.expected_n = expected_n;
endfunction
task run();
forever begin
rf_txn t;
in.get(t);
if (t.write) mem[t.addr] = t.wdata;
else begin
bit [31:0] exp = mem.exists(t.addr) ? mem[t.addr] : '0;
if (t.rdata !== exp) begin
errors++;
$display("SCB ERROR: %s expected rdata=%h", t.sprint(), exp);
end
end
seen_n++;
end
endtask
task wait_done();
wait (seen_n >= expected_n);
endtask
function void report();
if (errors == 0) $display("TEST PASSED: %0d txns checked", seen_n);
else $display("TEST FAILED: %0d errors", errors);
endfunction
endclass
// ---- env and test wire it all together (per earlier lessons) ----
class env;
virtual bus_if vif;
int unsigned num_txns = 100;
generator gen; driver drv; monitor mon; scoreboard scb;
mailbox #(rf_txn) gen2drv; mailbox #(rf_txn) mon2scb;
function void build();
gen2drv = new(1); mon2scb = new();
gen = new(gen2drv, num_txns); drv = new(gen2drv);
mon = new(mon2scb); scb = new(mon2scb, num_txns);
drv.vif = vif; mon.vif = vif;
endfunction
task run();
fork gen.run(); drv.run(); mon.run(); scb.run(); join_none
wait (gen.done.triggered);
scb.wait_done();
disable fork;
endtask
endclass
class base_test;
env e; virtual bus_if vif;
virtual task run();
e = new(); e.vif = vif; e.build(); e.run(); e.scb.report();
endtask
endclassPer-piece commentary
Monitor samples only on observed valid && ready — it reconstructs transactions from pins, never trusting the driver.
The scoreboard's associative array IS the reference model — a write updates it, a read checks against it.
Reads of never-written addresses expect the reset value — a spec decision encoded in one line of the model.
wait_done() on a transaction count is the simplest end-of-test contract; later lessons replace it with smarter drain logic.
The test is four lines because every lower layer carries its own weight — that brevity is the architecture working.
Interview angle
Practice writing this whole stack — interface with clocking blocks, harness, six classes — in under 30 minutes on a whiteboard. Interviewers rarely care about perfect syntax; they watch whether the mailbox directions, the vif handoff, and the end-of-test logic come out right without hesitation.
Key takeaways
One transaction class flows through the whole TB: generator randomizes it, driver drives it, monitor reconstructs it, scoreboard checks it.
The monitor must rebuild transactions from pins — that independence is what makes checking trustworthy.
An associative array is a perfectly respectable reference model for memory-like DUTs.
If your test class is longer than ten lines, work has leaked up from a lower layer.
Common pitfalls
Monitor reading the generator's transactions instead of pins — the TB then checks itself, not the DUT.
Counting end-of-test on generated transactions but checking on monitored ones — off-by-in-flight hangs.
Using == instead of !== for data compare — X values slide through as matches.
Forgetting the extra cycle for read data in the monitor — every read appears corrupted by one transaction.