Testbench Architecture¶
The standard component hierarchy¶
uvm_test
└── uvm_env
├── uvm_agent (per interface, e.g. one per bus/protocol)
│ ├── uvm_sequencer
│ ├── uvm_driver
│ └── uvm_monitor
└── scoreboard (plain uvm_component or uvm_subscriber)
Every box in that diagram is deliberately its own class, not folded together for convenience. The separation exists because each piece has a genuinely different job and a different reason to change:
| Component | Job | Talks to hardware? |
|---|---|---|
| Sequencer | Arbitrates which sequence gets to send the next item to the driver | No |
| Driver | Pins a transaction onto DUT pins, cycle-by-cycle, via a virtual interface | Yes (drives) |
| Monitor | Passively observes DUT pins, reconstructs transactions, broadcasts them | Yes (samples only, never drives) |
| Agent | Bundles sequencer+driver+monitor for one interface; can run active or passive | — |
| Scoreboard | Compares expected vs. actual, usually fed by monitor(s) via analysis ports | No |
| Env | Instantiates and connects agents + scoreboard(s) for the whole DUT | — |
| Test | Selects which sequence(s) run, configures the env for this specific test | No |
Why the driver and monitor are separate classes¶
This is the single most common "why does UVM do it this way" question. Two reasons, both load-bearing:
- A monitor must exist even when nothing is driving. In a passive agent (say, observing a bus that some other part of the testbench drives, or a real second DUT instance in a multi-DUT setup), you want the monitor's reconstructed-transaction stream for coverage/scoreboarding with no driver at all. If driving and monitoring were one class, you couldn't have one without the other.
- They run on physically different timing. A driver typically acts on a clocking-block edge (drive after the edge). A monitor typically samples passively, often needs to catch protocol violations exactly as they happen mid-cycle, and must never accidentally drive a signal (a bug in a combined class could accidentally turn a "monitor" into a bus contender). Keeping them separate makes "monitor never drives" a structural property of the code, not just a convention someone has to remember.
Agent: active vs. passive¶
class my_agent extends uvm_agent;
my_driver drv;
my_sequencer sqr;
my_monitor mon;
virtual function void build_phase(uvm_phase phase);
mon = my_monitor::type_id::create("mon", this);
if (get_is_active() == UVM_ACTIVE) begin
drv = my_driver::type_id::create("drv", this);
sqr = my_sequencer::type_id::create("sqr", this);
end
endfunction
endclass
is_active (set via uvm_config_db before build_phase runs) controls whether the agent instantiates a driver+sequencer at all. This single flag is what lets the same agent class serve as either a fully active stimulus-driving agent, or a purely passive observer — depending entirely on how the test/env configures it, with zero code duplication.
Connecting monitor → scoreboard: analysis ports¶
The monitor doesn't call the scoreboard directly (that would create a hard dependency in the wrong direction — a monitor shouldn't need to know who's listening). Instead it broadcasts via a uvm_analysis_port, and anything interested subscribes:
class my_monitor extends uvm_monitor;
uvm_analysis_port #(my_txn) ap;
function void build_phase(uvm_phase phase);
ap = new("ap", this);
endfunction
task run_phase(uvm_phase phase);
my_txn t;
forever begin
// ... reconstruct transaction from vif ...
ap.write(t); // broadcast — any number of subscribers can be connected, or zero
end
endtask
endclass
class scoreboard extends uvm_scoreboard;
uvm_analysis_imp #(my_txn, scoreboard) mon_imp;
function void write(my_txn t); // called automatically when ap.write() fires
// compare against expected model
endfunction
endclass
This decoupling is why you can add a second scoreboard, a coverage collector, and a protocol checker — all subscribing to the same monitor's analysis port — without changing the monitor at all.
Getting the virtual interface into the class world¶
Interfaces live in modules; drivers/monitors are classes. The standard bridge is uvm_config_db, set once from the top-level testbench module and retrieved in build_phase:
// tb_top.sv
axi_if dut_if(.clk(clk));
initial uvm_config_db#(virtual axi_if)::set(null, "*", "vif", dut_if);
// my_driver.sv
virtual axi_if vif;
function void build_phase(uvm_phase phase);
if (!uvm_config_db#(virtual axi_if)::get(this, "", "vif", vif))
`uvm_fatal("NOVIF", "virtual interface not found in config_db")
endfunction
The "*" wildcard path and silent get failures
set(null, "*", "vif", dut_if) broadcasts to every component at every hierarchy depth. This is convenient but means a typo'd field name ("vif" vs "Vif") or a scoping mismatch fails silently unless you check the get() return value, as shown above with uvm_fatal. A missing virtual interface with no fatal check just leaves vif as its default (null), and the first driver access to it crashes with a much less obvious null-handle error deep in run_phase, far from the actual root cause.