UVM Testbench Architecture: Full Component Hierarchy
When you first open a real verification environment, the sheer number of classes can feel overwhelming: sequences, sequencers, drivers, monitors, agents, environments, scoreboards. But the UVM testbench architecture is not a random pile of classes. It is a deliberate layering that separates what you want to test (the stimulus intent) from how that stimulus reaches the pins, and separates driving the design from checking it. Once you see the layers, every class has an obvious home.
This page builds the architecture from first principles using one tiny example: an 8-bit adder DUT with inputs a, b and output result. We will trace a single transaction from its birth in a sequence all the way to the scoreboard that judges it, and label every hop along the way.
A note on running the code: the UVM examples below are written for a UVM-capable simulator such as industry-standard simulation tools, VCS, industry-standard simulators, or the free EDA Playground UVM flow. They depend on the UVM base-class library, so they will not compile under bare Icarus Verilog or Verilator. Treat them as canonical structure to read and adapt. Further down, a self-contained plain-SystemVerilog block illustrates the same data flow with no UVM library at all, and that one you can actually run.
Why the testbench is layered
A good testbench answers three independent questions, and UVM gives each its own layer so you can change one without disturbing the others:
- What stimulus do I want? Expressed as transactions and sequences — abstract, protocol-level, reusable.
- How does stimulus reach the DUT? Handled by the driver and a virtual interface, the only place that knows about clocks and pin wiggling.
- Is the DUT correct? Decided by the monitor (which observes) and the scoreboard (which compares against a reference model).
Because these concerns are separated, the same adder sequence can run against an RTL model, a gate-level netlist, or a faster abstract model with zero changes. That reuse is the entire point of the architecture.
The component hierarchy
UVM components are built top-down and form a tree. The test sits at the root; the environment holds reusable blocks; each agent wraps the three classes that talk to one interface. Here is the hierarchy for our adder, from the outside in:
| Level | Component | Responsibility |
|---|---|---|
| 1 | Top module | Static world: instantiate DUT and interface, generate clock, call run_test(). |
| 2 | Test (uvm_test) | Picks the environment configuration and which sequence to run. |
| 3 | Environment (uvm_env) | Holds the agent(s) and the scoreboard; wires analysis connections. |
| 4 | Agent (uvm_agent) | Bundles sequencer, driver and monitor for one interface. |
| 5 | Sequencer / Driver / Monitor | Arbitrate, drive, and observe transactions. |
| 6 | Sequence & transaction | The stimulus content itself (objects, not components). |
Note the distinction in the last row: sequences and transactions are uvm_objects, not uvm_components. They are dynamic data that flows through the static component tree rather than being a permanent part of it.
Following one transaction: the data flow
This is the heart of the UVM testbench architecture. Read it as a relay race where a single alu_txn object is the baton:
- Sequence → sequencer. The sequence creates an
alu_txn, randomizes it, and offers it withstart_item/finish_item. The sequencer is a traffic controller: if several sequences compete, it arbitrates and lets one item through at a time. - Sequencer → driver. The driver pulls the item with
get_next_item(). It is the first component that knows the item is real and ready to be applied. - Driver → DUT. The driver translates abstract fields into pin activity on the virtual interface, respecting the clock. For the adder it assigns
aandb, waits a clock edge, then callsitem_done()to release the sequencer for the next item. - DUT → monitor. Independently of the driver, the monitor watches the same interface, samples
a,bandresultat the sampling point, and reconstructs them into a fresh transaction. The monitor never drives anything — it only observes. - Monitor → scoreboard. The monitor broadcasts its captured transaction through an
uvm_analysis_port. The scoreboard, subscribed via anuvm_analysis_imp, receives each one in itswrite()method, computes the expected sum with its own reference model, and reports a match or mismatch.
The driver and monitor are two separate paths deliberately: stimulus flows in through the driver, results flow out through the monitor, and the scoreboard sees only observed behaviour. If you ever let the scoreboard trust the driver’s intended values instead of the monitor’s observed values, a bug that corrupts the interface could pass silently.
Building the pieces in code
Below are the canonical UVM classes for the adder, in the order data flows through them.
Transaction and sequence
class alu_txn extends uvm_sequence_item;
rand bit [7:0] a;
rand bit [7:0] b;
bit [8:0] result;
`uvm_object_utils_begin(alu_txn)
`uvm_field_int(a, UVM_ALL_ON)
`uvm_field_int(b, UVM_ALL_ON)
`uvm_field_int(result, UVM_ALL_ON)
`uvm_object_utils_end
function new(string name = "alu_txn");
super.new(name);
endfunction
endclass
class add_sequence extends uvm_sequence #(alu_txn);
`uvm_object_utils(add_sequence)
function new(string name = "add_sequence");
super.new(name);
endfunction
task body();
repeat (8) begin
alu_txn tr = alu_txn::type_id::create("tr");
start_item(tr);
assert(tr.randomize());
finish_item(tr);
end
endtask
endclassDriver
class alu_driver extends uvm_driver #(alu_txn);
`uvm_component_utils(alu_driver)
virtual alu_if vif;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
if (!uvm_config_db#(virtual alu_if)::get(this, "", "vif", vif))
`uvm_fatal("DRV", "virtual interface not set")
endfunction
task run_phase(uvm_phase phase);
forever begin
alu_txn tr;
seq_item_port.get_next_item(tr); // from sequencer
vif.a <= tr.a; // drive DUT pins
vif.b <= tr.b;
@(posedge vif.clk);
seq_item_port.item_done();
end
endtask
endclassMonitor
class alu_monitor extends uvm_monitor;
`uvm_component_utils(alu_monitor)
virtual alu_if vif;
uvm_analysis_port #(alu_txn) ap;
function new(string name, uvm_component parent);
super.new(name, parent);
ap = new("ap", this);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
if (!uvm_config_db#(virtual alu_if)::get(this, "", "vif", vif))
`uvm_fatal("MON", "virtual interface not set")
endfunction
task run_phase(uvm_phase phase);
forever begin
alu_txn tr = alu_txn::type_id::create("tr");
@(posedge vif.clk);
tr.a = vif.a; // observe only
tr.b = vif.b;
tr.result = vif.result;
ap.write(tr); // broadcast to subscribers
end
endtask
endclassScoreboard
class alu_scoreboard extends uvm_scoreboard;
`uvm_component_utils(alu_scoreboard)
uvm_analysis_imp #(alu_txn, alu_scoreboard) item_collected;
int matched, mismatched;
function new(string name, uvm_component parent);
super.new(name, parent);
item_collected = new("item_collected", this);
endfunction
function void write(alu_txn tr); // called by monitor's ap
bit [8:0] expected = tr.a + tr.b; // reference model
if (tr.result === expected)
matched++;
else begin
mismatched++;
`uvm_error("SCB", $sformatf("a=%0d b=%0d got=%0d exp=%0d",
tr.a, tr.b, tr.result, expected))
end
endfunction
function void report_phase(uvm_phase phase);
`uvm_info("SCB", $sformatf("matched=%0d mismatched=%0d",
matched, mismatched), UVM_LOW)
endfunction
endclassAssembling the tree: agent, env, test, top
class alu_agent extends uvm_agent;
`uvm_component_utils(alu_agent)
alu_driver drv;
alu_monitor mon;
uvm_sequencer #(alu_txn) seqr;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
mon = alu_monitor::type_id::create("mon", this);
if (get_is_active() == UVM_ACTIVE) begin
drv = alu_driver::type_id::create("drv", this);
seqr = uvm_sequencer#(alu_txn)::type_id::create("seqr", this);
end
endfunction
function void connect_phase(uvm_phase phase);
if (get_is_active() == UVM_ACTIVE)
drv.seq_item_port.connect(seqr.seq_item_export);
endfunction
endclass
class alu_env extends uvm_env;
`uvm_component_utils(alu_env)
alu_agent agt;
alu_scoreboard scb;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
agt = alu_agent::type_id::create("agt", this);
scb = alu_scoreboard::type_id::create("scb", this);
endfunction
function void connect_phase(uvm_phase phase);
agt.mon.ap.connect(scb.item_collected); // monitor -> scoreboard
endfunction
endclass
class alu_test extends uvm_test;
`uvm_component_utils(alu_test)
alu_env env;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
env = alu_env::type_id::create("env", this);
endfunction
task run_phase(uvm_phase phase);
add_sequence seq = add_sequence::type_id::create("seq");
phase.raise_objection(this);
seq.start(env.agt.seqr); // launch stimulus on the sequencer
phase.drop_objection(this);
endtask
endclass
module top;
import uvm_pkg::*;
`include "uvm_macros.svh"
bit clk;
always #5 clk = ~clk;
alu_if intf(clk);
alu_dut dut(.clk(intf.clk), .a(intf.a), .b(intf.b), .result(intf.result));
initial begin
uvm_config_db#(virtual alu_if)::set(null, "*", "vif", intf);
run_test("alu_test");
end
endmoduleTrace the connections and the whole architecture becomes one sentence: the test builds the env, the env builds an agent plus a scoreboard, the agent connects its driver to the sequencer and exposes its monitor’s analysis port, and the env connects that port to the scoreboard. Stimulus is poured in at seq.start(env.agt.seqr); verdicts come out at report_phase.
A runnable illustration without the UVM library
To make the data flow concrete on a free simulator, here is the same idea stripped of UVM entirely. A real adder DUT is instantiated; the stimulus list plays the role of a sequence, the pin assignments play the driver, the sampling plays the monitor, and the reference function plays the scoreboard. This compiles and runs under Icarus Verilog or Verilator.
module dut (
input logic [7:0] a,
input logic [7:0] b,
output logic [8:0] sum
);
assign sum = a + b;
endmodule
module tb;
logic [7:0] a, b;
logic [8:0] sum;
int errors = 0;
dut u_dut (.a(a), .b(b), .sum(sum));
// SCOREBOARD reference model
function automatic logic [8:0] ref_add(input logic [7:0] x, y);
return x + y;
endfunction
// SEQUENCE: the stimulus content
logic [7:0] stim_a [4];
logic [7:0] stim_b [4];
initial begin
stim_a = '{8'd10, 8'd200, 8'd255, 8'd1};
stim_b = '{8'd5, 8'd100, 8'd255, 8'd254};
foreach (stim_a[i]) begin
// SEQUENCER -> DRIVER: hand over one transaction
a = stim_a[i];
b = stim_b[i];
#1; // DRIVER drives pins, DUT settles
// MONITOR samples, SCOREBOARD compares
if (sum !== ref_add(a, b)) begin
errors++;
$display("FAIL a=%0d b=%0d dut=%0d exp=%0d", a, b, sum, ref_add(a, b));
end else begin
$display("PASS a=%0d b=%0d sum=%0d", a, b, sum);
end
end
if (errors == 0)
$display("SCOREBOARD: all %0d transactions matched", $size(stim_a));
else
$display("SCOREBOARD: %0d mismatches", errors);
$finish;
end
endmodulePASS a=10 b=5 sum=15 PASS a=200 b=100 sum=300 PASS a=255 b=255 sum=510 PASS a=1 b=254 sum=255 SCOREBOARD: all 4 transactions matched /var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmp4knv8do9/tb.sv:48: $finish called at 4 (1s)
Each labelled comment maps to a UVM role. When you later replace the fixed list with a randomized add_sequence, the inline pin assignments with an alu_driver, and the if check with an alu_scoreboard fed by a monitor, you have grown this flat testbench into the full layered architecture — same data path, now reusable and scalable.
Quick check
Why are sequences and transactions objects rather than components?
They are dynamic data that is created, used, and discarded as stimulus flows through the testbench. Components (uvm_component) form the permanent, hierarchical structure that exists for the whole simulation, while transactions (uvm_object) are the baton that travels through that structure.
What is the sequencer’s job between the sequence and the driver?
It arbitrates. When multiple sequences request access, the sequencer decides which transaction is handed to the driver next, enforcing one-item-at-a-time delivery through the get_next_item/item_done handshake.
Why must the scoreboard receive data from the monitor, not the driver?
The monitor reports what actually appeared on the interface, while the driver only knows what it intended to send. Checking observed values catches bugs that corrupt the signals between intent and pins; trusting the driver would let such bugs pass silently.
Which UVM connection carries the monitor’s captured transactions to the scoreboard?
An analysis connection: the monitor publishes through a uvm_analysis_port and the scoreboard subscribes with a uvm_analysis_imp, whose write() method is invoked for every broadcast transaction. The env wires them together in connect_phase.
Share your question in comments or talk to our mentor team for batch guidance.
Compile and simulate this in the browser — no install. Edit the code and re-run instantly.
Learn Verilog with mentors, labs and placement support
This free series is maintained by the ChipXpert training team in Hyderabad & Bengaluru. Go deeper with instructor-led courses on real EDA tools.
Explore VLSI Courses