Part 2 · OOP for Verification · Intermediate
Lab: Generator → Driver Handshake
A complete runnable mini-lab: transaction class, mailbox pipe, driver with vif stub, and event-based completion.
What we are building
This lab assembles the whole chapter into one runnable testbench: a generator randomizes transactions and puts them into a bounded mailbox #(txn); a driver gets them and wiggles a small interface stub; an event signals per-transaction completion back, and the top-level test waits for the full count before finishing. Copy it into a single file and run it on any simulator (or EDA Playground) — no DUT required.
LAB SEQUENCE DIAGRAM (one transaction, repeated N times)
generator mailbox(2) driver clk/vif
│ │ │ │
│ randomize txn │ │ │
├── put(t) ──────────►│ │ │
│ (blocks if full) ├── get(t) ────────►│ │
│ │ ├── drive addr/data ─►│
│ │ │ @(posedge clk) │
│ │ │◄── ack (stub) ──────┤
│ │ │ │
│◄──────────── -> drv_done (event) ───────┤ │
│ done_count++ │ │ │
│ │ │ │
test: wait (done_count == N) → $finishThe complete code
// ---------- interface stub (stands in for a real DUT connection) ----------
interface bus_if (input bit clk);
logic valid;
logic [15:0] addr;
logic [31:0] data;
endinterface
// ---------- transaction ----------
class txn;
rand bit [15:0] addr;
rand bit [31:0] data;
constraint c_addr { addr inside {[16'h0000:16'h00FF]}; }
function string convert2string();
return $sformatf("addr=%04h data=%08h", addr, data);
endfunction
endclass
// ---------- generator ----------
class generator;
mailbox #(txn) mb;
int unsigned n;
function new(mailbox #(txn) mb, int unsigned n);
this.mb = mb; this.n = n;
endfunction
task run();
repeat (n) begin
txn t = new(); // fresh object EVERY iteration
assert (t.randomize())
else $fatal(1, "randomize failed");
mb.put(t); // backpressure: blocks when mb full
$display("[%0t] GEN : put %s", $time, t.convert2string());
end
$display("[%0t] GEN : all %0d txns generated", $time, n);
endtask
endclass
// ---------- driver ----------
class driver;
mailbox #(txn) mb;
virtual bus_if vif;
event drv_done; // fired once per completed txn
function new(mailbox #(txn) mb, virtual bus_if vif);
this.mb = mb; this.vif = vif;
endfunction
task run();
txn t;
vif.valid <= 0;
forever begin
mb.get(t); // blocks until work arrives
@(posedge vif.clk);
vif.valid <= 1;
vif.addr <= t.addr;
vif.data <= t.data;
@(posedge vif.clk); // one-cycle "transfer"
vif.valid <= 0;
$display("[%0t] DRV : done %s", $time, t.convert2string());
-> drv_done; // completion pulse
end
endtask
endclass
// ---------- top-level test ----------
module top;
localparam int N = 8;
bit clk;
always #5 clk = ~clk;
bus_if bif (clk);
initial begin
mailbox #(txn) mb = new(2); // small bound → see backpressure
generator gen = new(mb, N);
driver drv = new(mb, bif);
int unsigned done_count = 0;
fork
gen.run(); // producer
drv.run(); // consumer daemon (forever)
forever begin // completion counter
@(drv.drv_done); // safe: waiter armed before
done_count++; // any trigger can occur
end
join_none
wait (done_count == N); // level wait — no race
$display("[%0t] TEST: %0d/%0d transactions complete", $time,
done_count, N);
$finish;
end
endmoduleWalkthrough — why each piece is shaped this way
txn constructs a NEW object per generator iteration — the mailbox carries handles, so reusing one object would alias every queued entry (the mailbox lesson's pitfall).
mailbox #(txn) new(2) — deliberately small bound so you can watch GEN put messages stall while DRV is mid-transfer; raise N and the bound to see steady-state throttling.
driver gets via virtual bus_if — the class world reaches pins only through a virtual interface handle; here the interface is a stub, but the wiring is exactly what a real bench uses.
-> drv_done with an @(drv.drv_done) waiter is race-free HERE because the counter process is forked and blocked at @ before the first trigger can fire (drives take two clock edges); for same-timestep signaling you would switch to wait(drv_done.triggered).
wait (done_count == N) is a level-sensitive end-of-test condition — immune to ordering, unlike counting edges.
join_none + wait: the driver and counter are forever-loop daemons, so plain join would hang; the test instead blocks on the level condition and exits with $finish.
Experiments to run
Set the mailbox bound to 0 (unbounded) and add a #50 delay in the driver — watch mb.num() grow without limit.
Move -> drv_done before the waiter is forked and at time 0 — reproduce the lost-trigger race, then fix with .triggered.
Replace the done event with a second mailbox of completed txns — that is one step from a scoreboard.
Wrap the wait in the watchdog pattern from the process-control lesson, with a 10us limit.
Interview angle
"Sketch a generator-driver handshake" is a standard whiteboard task — this lab IS the expected answer: mailbox for data, event or count for completion.
Be ready to explain why the mailbox is bounded and what would change with new(0).
Map the pieces forward: generator → uvm_sequence, mailbox+get → seq_item_port, drv_done → item_done.
Key takeaways
Mailbox for data flow, event for completion, level wait for end-of-test — each primitive doing its one job.
A fresh transaction object per put is non-negotiable; handle aliasing is the lab's hidden trap.
Bounded mailbox depth is a tuning knob: small to expose flow control, larger for throughput.
This structure maps one-to-one onto UVM's sequencer-driver handshake — learn it here, recognize it there.
Common pitfalls
Reusing one txn handle in the generator loop — all queued entries mutate together.
Joining on the driver's forever loop with plain join — the test never ends.
Counting completions with @(drv_done) when triggers can precede the wait — undercounts; use .triggered or a flag.
Forgetting $finish after the level wait — daemons keep the simulation alive indefinitely.