Part 6 · Testbench Architecture · Intermediate
The Environment Class
The env as container: constructing generator, driver, monitor, and scoreboard, wiring mailboxes, and the hand-rolled build/connect/run convention.
The env is a container, not a worker
The environment class does no protocol work itself. Its job is construction and plumbing : create each component, create the mailboxes that connect them, hand out shared handles (vif, config), and start every component's run task in parallel. If your env contains pin wiggling or checking logic, a layer boundary has been violated.
ENV WIRING DIAGRAM
env
│ new() gen drv mon scb
│ build() │ │ │ │
│ gen2drv ───────►├─────────►│ │ │
│ (mailbox#(txn)) │ .get() │ │ │
│ mon2scb ────────┼──────────┼─────────►├─────────►│
│ (mailbox#(txn)) │ │ .put() │ .get() │
│ vif ────────────┼─────────►│─────────►│ │
│ run() │ │ │ │
│ fork gen.run(); drv.run(); mon.run(); scb.run(); join_any
▼
components run concurrently; env waits for done criteriaThe hand-rolled build/connect/run convention
UVM enforces build/connect/run as phases. In a hand-built TB you follow the same discipline by convention: build() constructs everything, connection happens by passing mailbox handles into constructors (or assigning fields), and run() forks all component run tasks. Keeping these steps separate means no component starts before all wiring exists.
class env;
// shared handles, set by the test before build()
virtual bus_if vif;
int unsigned num_txns = 100;
// components
generator gen;
driver drv;
monitor mon;
scoreboard scb;
// channels
mailbox #(bus_txn) gen2drv;
mailbox #(bus_txn) mon2scb;
function void build();
// channels first — components receive them at construction
gen2drv = new(1); // bounded: generator stays 1 txn ahead
mon2scb = new(); // unbounded: never block the monitor
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
// end-of-test: generator finished AND scoreboard saw everything
wait (gen.done.triggered);
scb.wait_done();
disable fork; // stop the forever loops in drv/mon
endtask
function void report();
scb.report();
endfunction
endclassCode walkthrough
Mailboxes are created before components so constructors can take them — connection-by-construction.
gen2drv is bounded at depth 1: the generator blocks until the driver consumes, keeping stimulus lazily generated and reactive to backpressure.
mon2scb is unbounded: a monitor must never stall on a full mailbox or it drops bus activity.
run() uses fork...join_none then waits on explicit done criteria — drivers and monitors are forever loops and never “finish” on their own.
report() is separate so the test can decide when to print the final verdict.
Why mailboxes between components
A mailbox #(bus_txn) is a thread-safe FIFO of class handles with blocking put/get. It decouples producer and consumer rates: the generator can randomize the next transaction while the driver is still wiggling pins for the previous one, and the scoreboard can fall behind the monitor during a burst without losing data. This is exactly the role UVM's TLM ports and analysis ports later formalized.
Interview angle
A favorite: “why a mailbox and not a queue?” A queue plus your own event signaling can work, but mailbox gives you atomic blocking put/get with built-in synchronization — no missed-event races, no busy polling. Mention bounded vs unbounded as a flow-control decision and you have a senior answer.
Key takeaways
The env constructs, wires, and starts — it never drives pins or checks data itself.
Build channels before components; pass them in constructors so wiring is complete before run.
Bound the gen-to-driver mailbox for lazy stimulus; keep monitor-to-scoreboard unbounded.
fork...join_none plus explicit done criteria is the hand-rolled equivalent of UVM phasing and objections.
Common pitfalls
Starting component run() tasks inside build() — components race ahead of incomplete wiring.
Unbounded gen2drv mailbox — generator floods thousands of transactions, wrecking memory and reactive control.
Forgetting join_none — fork with join blocks forever on the driver's forever loop.
disable fork without first draining the scoreboard — in-flight transactions silently unchecked at end of test.