Skip to content

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:

  1. 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.
  2. 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
// in the env's connect_phase:
agent.mon.ap.connect(scoreboard.mon_imp);

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.