Part 2 · OOP for Verification · Intermediate

Building a Component Hierarchy by Hand

env containing agents containing driver/monitor/scoreboard, parent handles, and build/connect/run phasing by convention.

From a pile of classes to a tree

Generators, drivers, monitors, scoreboards — so far each was constructed ad hoc in an initial block. Real environments organize them into a tree : an env owns one agent per interface plus the scoreboard; each agent owns the driver, monitor, and generator for its interface. Three conventions make the tree work: every component stores a name and a parent handle (so any component can print its full hierarchical path for debug); construction proceeds top-down (parents construct children); and execution is split into phases — build everything, then connect everything, then run everything — so no component ever runs against a half-built neighbor.

diagram
COMPONENT TREE AND PHASE WAVES

  env "env0"
   ├── agent "agt0"                       PHASING (by convention)
   │     ├── generator "gen"
   │     ├── driver    "drv"              wave 1  build_all()    top-down:
   │     └── monitor   "mon"                      construct children
   └── scoreboard "sb"                    wave 2  connect_all()  siblings:
                                                  share mailboxes/handles
  full names (name + parent chain):       wave 3  run_all()      parallel:
   env0.agt0.drv                                  fork every run() task
   env0.agt0.mon                                  join_none + end-of-test
   env0.sb
                                          Rule: NOTHING runs until
  dataflow after connect:                 EVERYTHING is built & connected —
   gen  mb_req  drv  pins              that is why build and connect are
   mon  mb_obs  sb  ◄ exp ─ gen         separate waves, not constructors
                                          doing everything.

The skeleton: component base, agent, env

systemverilog
// ---- minimal component base: identity + phase API ----
virtual class component;
  string    name;
  component parent;

  function new(string name, component parent);
    this.name = name;  this.parent = parent;
  endfunction

  function string full_name();
    return (parent == null) ? name : {parent.full_name(), ".", name};
  endfunction

  virtual function void build();    endfunction  // construct children
  virtual function void connect();  endfunction  // wire siblings
  virtual task          run();      endtask      // time-consuming behavior
endclass

// ---- agent: one interface's worth of components ----
class agent extends component;
  generator          gen;
  driver             drv;
  monitor            mon;
  mailbox #(bus_txn) mb_req;          // gen → drv, owned by the agent
  virtual bus_if     vif;

  function new(string name, component parent, virtual bus_if vif);
    super.new(name, parent);
    this.vif = vif;
  endfunction

  virtual function void build();
    mb_req = new(4);
    gen = new("gen", this);           // parent handle = this
    drv = new("drv", this);
    mon = new("mon", this);
  endfunction

  virtual function void connect();
    gen.mb  = mb_req;                 // wiring lives in connect, not new
    drv.mb  = mb_req;
    drv.vif = vif;
    mon.vif = vif;
  endfunction

  virtual task run();
    fork
      gen.run();
      drv.run();
      mon.run();
    join_none
  endtask
endclass

// ---- env: agents + scoreboard ----
class env extends component;
  agent              agt;
  scoreboard         sb;
  mailbox #(bus_txn) mb_obs;          // mon → sb

  function new(string name, virtual bus_if vif);
    super.new(name, null);            // env is the root: no parent
    agt = new("agt0", this, vif);     // children constructed by parent
    sb  = new("sb",  this);
  endfunction

  virtual function void build();
    mb_obs = new();
    agt.build();                      // recurse the build wave down
    sb.build();
  endfunction

  virtual function void connect();
    agt.connect();
    agt.mon.mb_out = mb_obs;          // cross-child wiring: env's job
    sb.mb_in       = mb_obs;
    sb.connect();
  endfunction

  virtual task run();
    agt.run();
    fork sb.run(); join_none
  endtask
endclass

// ---- top: drive the waves in order ----
module top;
  bit clk;  always #5 clk = ~clk;
  bus_if bif (clk);

  initial begin
    env e = new("env0", bif);
    e.build();                        // wave 1: everything exists
    e.connect();                      // wave 2: everything is wired
    e.run();                          // wave 3: everything executes
    #100_000;                         // crude end-of-test budget
    $finish;
  end
endmodule

Why the waves are separate — and the UVM mapping

The separation is not bureaucracy. If the agent's constructor also wired the monitor to the scoreboard, it would need a scoreboard handle that does not exist yet — env constructs the agent before the scoreboard. Splitting into waves means by the time any connect() runs, every component already exists; and by the time any run() starts, every connection is in place. The same logic, generalized and enforced by a scheduler instead of convention, is exactly UVM's phase system.

Hand-built convention → UVM equivalent

  • component base with name + parent handle → uvm_component (new(name, parent), get_full_name()).

  • build() wave, top-down → build_phase (UVM even auto-recurses top-down for you).

  • connect() wave for sibling wiring → connect_phase; mailbox handles → TLM ports/exports.

  • run() with fork/join_none → run_phase, where UVM adds objections for clean end-of-test instead of the crude #delay.

  • Manual e.build(); e.connect(); e.run(); in top → run_test(), which walks all phases over the whole tree automatically.

Interview angle

  • "Why does UVM separate build_phase and connect_phase?" — answer from THIS lesson: connections need all endpoints to exist first.

  • "What is the parent argument in uvm_component::new for?" — tree membership and full-path names; you just implemented it in five lines.

  • "Design a two-interface env" — second agent in env, one scoreboard comparing the two observed streams; the structure above scales directly.

Key takeaways

  • A testbench is a tree: env owns agents, agents own driver/monitor/generator; parents construct children.

  • Phase waves — build, connect, run — guarantee nothing executes against a half-built hierarchy.

  • Name + parent handle gives every component a free hierarchical debug path.

  • Hand-rolling this once makes uvm_component, the phase system, and run_test() self-evident.

Common pitfalls

  • Wiring siblings inside constructors — order-dependent null handles the moment the env grows.

  • Starting run() before connect() completes everywhere — drivers read null mailboxes intermittently.

  • Forgetting the parent handle — full_name() collapses and log messages lose their source path.

  • Plain join on forever-running components — the env's run() never returns; daemons need join_none plus a real end-of-test.