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.

diagram
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 criteria

The 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.

systemverilog
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
endclass

Code walkthrough

  1. Mailboxes are created before components so constructors can take them — connection-by-construction.

  2. gen2drv is bounded at depth 1: the generator blocks until the driver consumes, keeping stimulus lazily generated and reactive to backpressure.

  3. mon2scb is unbounded: a monitor must never stall on a full mailbox or it drops bus activity.

  4. run() uses fork...join_none then waits on explicit done criteria — drivers and monitors are forever loops and never “finish” on their own.

  5. 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.