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.
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
// ---- 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
endmoduleWhy 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.