Part 6 · Testbench Architecture · Intermediate
Component Handshakes
Generator-driver done signaling, request-response paths, blocking vs nonblocking inter-component calls, and deadlock patterns.
How does the generator know the driver is done?
The naive generator loop — put N transactions into a mailbox and return — finishes long before the driver has driven anything. If the env treats the generator's return as "stimulus done," it starts draining while most transactions still sit in the mailbox. Three mechanisms close the loop, in increasing explicitness.
Bounded mailbox — new(1) makes put() block until the driver get()s. The generator's last put returning means the driver has accepted (not finished driving) the last item. Cheapest, slightly loose.
Per-item event or done flag — driver triggers ->item_done after fully driving each item; the generator (or env) waits for the last one. Exact, a little more wiring.
Completion counter — driver increments items_driven; env waits until items_driven == items_generated. Most explicit, works with any number of observers, and doubles as an end-of-test statistic.
class generator;
mailbox #(bus_txn) mbx; // bounded: new(1) in env
int unsigned n_items = 100;
event gen_done;
task run();
repeat (n_items) begin
bus_txn t = new();
void'(t.randomize());
mbx.put(t); // blocks while driver is busy (depth 1)
end
-> gen_done; // last item ACCEPTED by driver
endtask
endclass
class driver;
mailbox #(bus_txn) mbx;
int unsigned items_driven; // completion counter for the env
task run();
forever begin
bus_txn t;
mbx.get(t);
drive_one(t); // full pin-level protocol
items_driven++;
end
endtask
endclass
// env: wait for true completion, not generator return
// wait (drv.items_driven == gen.n_items);Request-response and call directions
Some components must talk in both directions: a driver requests the next item and returns a response (read data, error status) that the generator may need before constructing the next transaction. Two mailboxes — one per direction — give the classic request-response channel; the generator blocks on the response when (and only when) the next item depends on it.
// req/rsp channel: generator ⇄ driver
mailbox #(bus_txn) req_mbx = new(1);
mailbox #(bus_txn) rsp_mbx = new(1);
// generator side — reactive stimulus
task generator::run();
bus_txn req, rsp;
repeat (n_items) begin
req = new();
void'(req.randomize());
req_mbx.put(req);
rsp_mbx.get(rsp); // block: next item depends on rsp
if (rsp.resp != 0) error_seen++; // adapt future stimulus
end
endtask
// driver side
task driver::run();
bus_txn req;
forever begin
req_mbx.get(req);
drive_one(req); // fills req.data on reads
rsp_mbx.put(req); // same object back as response
end
endtaskBlocking vs nonblocking calls between components
Blocking task call (drv.drive_one(t)) — caller waits for protocol completion; simple, but serializes caller and callee into one logical thread.
Nonblocking function call (scb.push_expected(t)) — must complete in zero time; right for handing over data, wrong for anything that consumes time.
Mailbox between free-running threads — full decoupling with buffering; the default for any producer-consumer pair in this course.
Rule of thumb: functions to transfer data, mailboxes to transfer work, blocking tasks only when the caller genuinely cannot proceed without completion.
Deadlock patterns
CLASSIC TB DEADLOCKS
1. CROSSED BLOCKING PUTS (mutual wait)
gen: req_mbx.put(req); rsp_mbx.get(rsp);
driver: rsp_mbx.put(old); req_mbx.get(req);
│ │
└──── each waits for the other; both bounded → FROZEN
2. MISSED EVENT (trigger before wait)
thread A: -> done; // fires at time T
thread B: @(done); // starts waiting at time T (later)
└─ B waits forever: @ only sees FUTURE triggers
3. CONSUMER DIED, BOUNDED MAILBOX FULL
driver thread killed by disable fork
generator: mbx.put() blocks forever → watchdog is the only escape
Diagnosis: when the bench hangs, print every mailbox's num()
and every thread's last-reached line — the full mailbox or the
un-fired event identifies the loop instantly.Avoiding them
Order request-response consistently: requester always put-then-get, responder always get-then-put.
Prefer wait(flag == 1) over @(event) when the waiter might start late — a level check cannot miss a past trigger.
Keep a watchdog (previous topic) running — deadlocks then cost one timeout, not a hung regression slot.
When killing threads with disable, drain or rebuild their mailboxes — a dead consumer leaves a full mailbox as a landmine.
Interview angle
Expect "How does your generator know the driver finished?" — name all three mechanisms (bounded mailbox, per-item event, completion counter) and say which signals acceptance vs completion. The follow-up is usually a deadlock hunt: be ready to spot the crossed put/get ordering and the trigger-before-wait race in code on a whiteboard.
Key takeaways
Generator returning ≠ stimulus driven — close the loop with a counter, event, or bounded mailbox.
Request-response = two mailboxes with a consistent put/get order on each side.
Functions move data, mailboxes move work, blocking tasks only when completion truly gates the caller.
wait() on a flag beats @(event) whenever the waiter might arrive after the trigger.
Common pitfalls
Treating generator return as end of stimulus — drain starts with a mailbox full of undriven items.
Crossed put/get ordering on a req/rsp pair of bounded mailboxes — instant mutual deadlock.
@(event) after the trigger already fired — the waiter sleeps forever; use a flag plus wait().
Unbounded mailbox hiding a slow consumer — memory grows for hours, then the run dies far from the cause.