Part 6 · Testbench Architecture · Intermediate
Driver Design
The mailbox consumption loop, clocking-block pin driving, handshake timing, idle-state driving, and reset-aware flush and re-init.
The driver's contract
The driver is the only stimulus-side class that touches pins. Its contract: consume transactions from the mailbox one at a time , expand each into legal protocol cycles through the virtual interface's clocking block, drive a well-defined idle state between transactions, and react to reset at any moment. Everything is timing; nothing is decision-making — the generator already decided what to send.
DRIVER TIMELINE (valid/ready handshake)
clk ─┐_┌─┐_┌─┐_┌─┐_┌─┐_┌─┐_┌─┐_┌─
│ │ │ │ │ │
state IDLE│DRIVE──────►│IDLE│DRIVE
│ │
valid ______/▔▔▔▔▔▔▔▔▔▔▔\_____/▔▔▔
ready __________/▔▔▔\__________/▔▔
▲
└ txn accepted here (valid && ready)
gap=1 idle cycle ──┘
driver holds addr/wdata stable while valid && !ready (stall)A complete reset-aware driver
class driver;
mailbox #(bus_txn) in;
virtual bus_if vif;
int unsigned driven_n;
function new(mailbox #(bus_txn) in);
this.in = in;
endfunction
task run();
drive_idle();
forever begin
bus_txn t;
in.get(t);
// honor generator-requested spacing
repeat (t.gap) @(vif.drv_cb);
fork : drive_or_reset
drive_one(t);
watch_reset();
join_any
disable drive_or_reset;
if (vif.rst_n !== 1) handle_reset();
end
endtask
task drive_one(bus_txn t);
@(vif.drv_cb);
vif.drv_cb.valid <= 1;
vif.drv_cb.write <= (t.kind == WRITE);
vif.drv_cb.addr <= t.addr;
vif.drv_cb.wdata <= t.wdata;
t.t_driven = $time;
// hold everything stable until the DUT accepts
do @(vif.drv_cb); while (vif.drv_cb.ready !== 1);
drive_idle();
driven_n++;
endtask
// idle is an explicit, driven state — never Z, never stale
task drive_idle();
vif.drv_cb.valid <= 0;
vif.drv_cb.write <= 0;
vif.drv_cb.addr <= '0;
vif.drv_cb.wdata <= '0;
endtask
task watch_reset();
@(negedge vif.rst_n);
endtask
task handle_reset();
bus_txn dropped;
drive_idle(); // pins safe immediately
while (in.try_get(dropped)) // flush stale stimulus
$display("DRV: flushed txn%0d on reset", dropped.id);
@(posedge vif.rst_n); // wait for release
repeat (2) @(vif.drv_cb); // post-reset settle
endtask
endclassCode walkthrough
All pin writes go through vif.drv_cb with nonblocking assignments — the clocking block applies the output skew, eliminating drive races.
The ready wait uses !== 1 so an X on ready stalls (and the watchdog catches it) instead of being accepted as truthy garbage.
drive_one holds addr/wdata stable during a stall — many handshake protocols require stability while valid && !ready.
fork...join_any races the transaction against reset; whichever finishes first wins and the other is disabled.
handle_reset does three jobs in order: pins to idle now, flush the mailbox of pre-reset stimulus, re-synchronize after release.
Protocol timing beyond valid/ready
Request/grant protocols add an arbitration wait before driving: request, block until grant, then transfer. The shape of the driver stays identical — only drive_one changes.
task drive_one_req_gnt(bus_txn t);
@(vif.drv_cb);
vif.drv_cb.req <= 1;
do @(vif.drv_cb); while (vif.drv_cb.gnt !== 1); // arbitration wait
vif.drv_cb.addr <= t.addr; // transfer phase
vif.drv_cb.wdata <= t.wdata;
@(vif.drv_cb);
vif.drv_cb.req <= 0;
endtaskInterview angle
“What does your driver do during reset?” — the three-step answer: idle the pins, flush the mailbox, re-sync after release. Most candidates only have step one.
“Why nonblocking through a clocking block?” — the clocking block schedules the drive with output skew after the edge; the design samples the old value at the edge, so no race.
“What is your idle state?” — explicitly driven zeros/known values; floating or stale pins make monitor and assertion behavior unrepeatable.
Key takeaways
Drivers expand one transaction at a time into protocol cycles; all decisions already happened upstream.
Drive only via the clocking block with <= — output skew makes pin driving race-free by construction.
Idle is a driven state, applied before the first transaction and after every transaction.
Reset handling = idle pins immediately, flush stale mailbox stimulus, re-synchronize after release.
Common pitfalls
Driving interface signals directly (vif.valid = 1) instead of through the clocking block — reintroduces the races layering paid to remove.
while (!vif.drv_cb.ready) — an X on ready passes the ! test as true-ish in some coding mistakes; compare explicitly with !== 1.
Continuing to drive a transaction through reset — the post-reset DUT sees half a protocol transaction.
No gap honoring — driver ignores t.gap and the carefully weighted back-to-back distribution never reaches the pins.