Day 1: UVM Architecture — Components, Objects, and a Working FIFO Testbench
A 30-day SystemVerilog/UVM verification series. Today: the skeleton of every UVM testbench, and how to actually build one.
Most UVM tutorials start with a diagram of boxes and arrows. That diagram is correct, but it doesn't answer the question every beginner actually asks: "How do I know what code to write, and in what order?"
This post answers that. We'll build a complete, minimal UVM environment for a synchronous FIFO write port — every file, in the order you'd actually write them — and then run a 10-question quiz to lock it in.
1. The Two Base Classes: Component vs Object
Everything in UVM descends from one of two classes. Getting this distinction right on Day 1 saves you weeks of confusion later.
uvm_component — the structural skeleton. These are created at time zero and live for the entire simulation. They form a hierarchy: test → env → agent → driver/monitor/sequencer. They participate in UVM phases. They have a parent.
uvm_object — the transient data. Transactions, sequences, configuration objects. Created when needed, destroyed after. No parent, no phases. A uvm_sequence_item is a uvm_object.
| Aspect | uvm_component | uvm_object |
|---|---|---|
| Lifetime | Whole simulation | Transient |
| Hierarchy | Has a parent, unique path name | No hierarchy |
| Phases | Participates (build, connect, run…) | Does not participate |
| Constructor | new(name, parent) | new(name) |
| Registration macro | `uvm_component_utils | `uvm_object_utils |
2. The DUT We'll Verify
A synchronous FIFO with a write port and a read port, 8 entries deep, 8 bits wide. Nothing fancy — just enough logic to exercise a real UVM environment.
// fifo_sync.sv
module fifo_sync #(parameter DEPTH = 8, WIDTH = 8) (
input logic clk,
input logic rst_n,
input logic wr_en,
input logic [WIDTH-1:0] wr_data,
output logic full,
input logic rd_en,
output logic [WIDTH-1:0] rd_data,
output logic empty
);
logic [WIDTH-1:0] mem [0:DEPTH-1];
logic [$clog2(DEPTH):0] wptr, rptr, count;
assign full = (count == DEPTH);
assign empty = (count == 0);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
wptr <= '0; rptr <= '0; count <= '0;
end else begin
if (wr_en && !full) begin
mem[wptr[$clog2(DEPTH)-1:0]] <= wr_data;
wptr <= wptr + 1;
end
if (rd_en && !empty) begin
rd_data <= mem[rptr[$clog2(DEPTH)-1:0]];
rptr <= rptr + 1;
end
case ({wr_en && !full, rd_en && !empty})
2'b10: count <= count + 1;
2'b01: count <= count - 1;
default: count <= count;
endcase
end
end
endmodule
3. The Creation Order (this is the part nobody tells you)
Write your files in this order. Each one only depends on files you've already written — no forward references, no confusion.
- Interface
- Transaction
- Driver
- Monitor
- Sequencer
- Agent
- Scoreboard
- Sequence
- Environment
- Test
- Top module
Let's build each one.
4. Interface (fifo_if.sv)
interface fifo_if #(parameter WIDTH = 8) (input logic clk);
logic rst_n;
logic wr_en;
logic [WIDTH-1:0] wr_data;
logic full;
logic rd_en;
logic [WIDTH-1:0] rd_data;
logic empty;
clocking drv_cb @(posedge clk);
default input #1step output #1;
output wr_en, wr_data, rd_en;
input full, empty, rd_data;
endclocking
clocking mon_cb @(posedge clk);
default input #1step;
input wr_en, wr_data, full, rd_en, rd_data, empty;
endclocking
modport DRV (clocking drv_cb, input rst_n);
modport MON (clocking mon_cb, input rst_n);
endinterface
Two clocking blocks, deliberately. The driver drives through drv_cb with a 1ns output skew — this prevents the driver and the DUT from seeing the same clock edge simultaneously, which is the classic source of race conditions in simulation. The monitor samples through mon_cb with #1step input skew, which means it reads signal values just before the edge — the same values the DUT's flip-flops saw.
5. Transaction (fifo_item.sv)
class fifo_item extends uvm_sequence_item;
rand bit [7:0] data;
rand bit is_write;
bit full_at_time;
`uvm_object_utils_begin(fifo_item)
`uvm_field_int(data, UVM_ALL_ON)
`uvm_field_int(is_write, UVM_ALL_ON)
`uvm_field_int(full_at_time, UVM_ALL_ON)
`uvm_object_utils_end
function new(string name = "fifo_item");
super.new(name);
endfunction
endclass
is_write and data are rand — the sequence randomizes them. full_at_time is not randomized; it's a passive observation the monitor fills in when it sends the item to the scoreboard. Mixing stimulus fields and observation fields in one item is common and fine, as long as you're disciplined about which is which.
6. Driver (fifo_driver.sv)
class fifo_driver extends uvm_driver #(fifo_item);
`uvm_component_utils(fifo_driver)
virtual fifo_if.DRV 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 fifo_if.DRV)::get(this, "", "vif", vif))
`uvm_fatal("NO_VIF", "driver did not get vif")
endfunction
task run_phase(uvm_phase phase);
vif.drv_cb.wr_en <= 0;
vif.drv_cb.wr_data <= 0;
forever begin
seq_item_port.get_next_item(req);
vif.drv_cb.wr_en <= req.is_write;
vif.drv_cb.wr_data <= req.data;
@(vif.drv_cb);
seq_item_port.item_done();
end
endtask
endclass
This is the get_next_item / item_done handshake. The driver asks the sequencer for the next item, drives it onto the pins for one clock, then signals completion. If you ever forget item_done(), the sequencer blocks and your simulation hangs with no error message — one of the most common beginner deadlocks.
7. Monitor (fifo_monitor.sv)
class fifo_monitor extends uvm_monitor;
`uvm_component_utils(fifo_monitor)
virtual fifo_if.MON vif;
uvm_analysis_port #(fifo_item) 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 fifo_if.MON)::get(this, "", "vif", vif))
`uvm_fatal("NO_VIF", "monitor did not get vif")
endfunction
task run_phase(uvm_phase phase);
fifo_item tr;
forever begin
@(vif.mon_cb);
if (vif.mon_cb.wr_en || vif.mon_cb.rd_en) begin
tr = fifo_item::type_id::create("tr");
tr.data = vif.mon_cb.wr_data;
tr.is_write = vif.mon_cb.wr_en;
tr.full_at_time = vif.mon_cb.full;
ap.write(tr);
end
end
endtask
endclass
The monitor is passive — it drives nothing. It samples the interface every clock and publishes a transaction through its uvm_analysis_port. The analysis port is special: it broadcasts to any number of subscribers (scoreboard, coverage) without blocking. The monitor should never wait on a consumer.
8. Sequencer (fifo_sequencer.sv)
class fifo_sequencer extends uvm_sequencer #(fifo_item);
`uvm_component_utils(fifo_sequencer)
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
endclass
That's the whole file. The base class already provides the arbitration, the FIFO, and the handshake. You only extend uvm_sequencer when you need custom arbitration (like round-robin between multiple sequences) — for now, the empty subclass is correct.
9. Agent (fifo_agent.sv)
class fifo_agent extends uvm_agent;
`uvm_component_utils(fifo_agent)
fifo_driver drv;
fifo_monitor mon;
fifo_sequencer sqr;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
mon = fifo_monitor::type_id::create("mon", this);
if (get_is_active() == UVM_ACTIVE) begin
drv = fifo_driver::type_id::create("drv", this);
sqr = fifo_sequencer::type_id::create("sqr", this);
end
endfunction
function void connect_phase(uvm_phase phase);
if (get_is_active() == UVM_ACTIVE)
drv.seq_item_port.connect(sqr.seq_item_export);
endfunction
endclass
The agent is a container. The get_is_active() check matters: a passive agent (used when you're reusing a VIP but someone else is driving that interface) only creates a monitor — no driver, no sequencer. You'll hit this a lot on real projects.
10. Scoreboard (fifo_scoreboard.sv)
class fifo_scoreboard extends uvm_scoreboard;
`uvm_component_utils(fifo_scoreboard)
uvm_analysis_imp #(fifo_item, fifo_scoreboard) exp_imp;
int unsigned writes_seen, reads_seen;
function new(string name, uvm_component parent);
super.new(name, parent);
exp_imp = new("exp_imp", this);
endfunction
function void write(fifo_item tr);
if (tr.is_write) writes_seen++;
else reads_seen++;
`uvm_info("SB", $sformatf("writes=%0d reads=%0d", writes_seen, reads_seen), UVM_MEDIUM)
endfunction
function void check_phase(uvm_phase phase);
if (writes_seen == 0 && reads_seen == 0)
`uvm_error("SB", "no traffic observed")
endfunction
endclass
Today the scoreboard just counts. On Day 7–9 we'll build a real reference model that predicts what the DUT should have done and compares. But the check_phase is the important structural lesson: it runs after run_phase ends, and it's the right place to assert "no unaccounted data" or "no traffic seen at all."
11. Sequence (fifo_seq.sv)
class fifo_write_seq extends uvm_sequence #(fifo_item);
`uvm_object_utils(fifo_write_seq)
function new(string name = "fifo_write_seq");
super.new(name);
endfunction
task body();
fifo_item tr;
repeat (20) begin
tr = fifo_item::type_id::create("tr");
start_item(tr);
if (!tr.randomize() with { is_write == 1; })
`uvm_error("SEQ", "randomize failed")
finish_item(tr);
end
endtask
endclass
start_item / finish_item is the sequence-side handshake that pairs with the driver's get_next_item / item_done. Don't confuse it with sequence.start(), which is what a test calls to launch a whole sequence.
12. Environment (fifo_env.sv)
class fifo_env extends uvm_env;
`uvm_component_utils(fifo_env)
fifo_agent agent;
fifo_scoreboard sb;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
agent = fifo_agent::type_id::create("agent", this);
sb = fifo_scoreboard::type_id::create("sb", this);
endfunction
function void connect_phase(uvm_phase phase);
agent.mon.ap.connect(sb.exp_imp);
endfunction
endclass
The env is where the monitor's analysis port gets connected to the scoreboard's analysis import. This is the only place that should know both exist — that separation is what makes each piece reusable.
13. Test (fifo_test.sv)
class fifo_test extends uvm_test;
`uvm_component_utils(fifo_test)
fifo_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 = fifo_env::type_id::create("env", this);
endfunction
task run_phase(uvm_phase phase);
fifo_write_seq seq;
phase.raise_objection(this);
seq = fifo_write_seq::type_id::create("seq");
seq.start(env.agent.sqr);
phase.drop_objection(this);
endtask
endclass
The raise_objection / drop_objection pair is how you tell UVM "don't end run_phase until my sequence is done." Without it, run_phase returns immediately and the simulation terminates before the sequence runs. (UVM will eventually time out and warn you, after 9200 seconds by default — not a fun way to spend an afternoon.)
14. Top Module (tb_top.sv)
module tb_top;
logic clk = 0;
always #5 clk = ~clk;
fifo_if fif (clk);
fifo_sync #(.DEPTH(8), .WIDTH(8)) dut (
.clk (clk),
.rst_n (fif.rst_n),
.wr_en (fif.wr_en),
.wr_data (fif.wr_data),
.full (fif.full),
.rd_en (fif.rd_en),
.rd_data (fif.rd_data),
.empty (fif.empty)
);
initial begin
fif.rst_n = 0;
repeat (2) @(posedge clk);
fif.rst_n = 1;
end
initial begin
uvm_config_db#(virtual fifo_if.DRV)::set(null, "*", "vif", fif);
uvm_config_db#(virtual fifo_if.MON)::set(null, "*", "vif", fif);
run_test();
end
endmodule
The interface is passed to drivers and monitors through uvm_config_db. Classes can't see module-scope signals directly — this is the only clean way to bridge that gap. Note the two ::set calls: one for the DRV modport, one for MON. The driver and monitor each ::get the flavor they need.
15. How to Run It
With a standard UVM install (Questa, VCS, or Xcelium), the compile-and-run flow is roughly:
vlog -sv fifo_sync.sv fifo_if.sv \
fifo_item.sv fifo_driver.sv fifo_monitor.sv \
fifo_sequencer.sv fifo_agent.sv fifo_scoreboard.sv \
fifo_seq.sv fifo_env.sv fifo_test.sv tb_top.sv
vsim -c tb_top +UVM_TESTNAME=fifo_test -do "run -all; quit"
The +UVM_TESTNAME=fifo_test plusarg tells run_test() which test to instantiate. This is why the test must be registered with `uvm_component_utils — the factory uses that registration to create it by name.
16. Quiz
Ten questions. One at a time in a real interactive system — here they're all shown, but the answers are close enough to the correct option that you have to actually know the material.
uvm_component and uvm_object?
uvm_component take?
uvm_object takes only name.uvm_object-derived class with the factory?
+UVM_TESTNAME=my_test, but my_test was never registered with the factory. What happens?
run_test() cannot create an unregistered type, so it reports a fatal error.run_test() in a top-level initial block actually do?
run_test() is the UVM entry point — it creates the test via the factory and starts phasing.config_db lookups. Objects have no path.uvm_component but forgets the `uvm_component_utils macro. What is the consequence?
::type_id::create() work. Without it, create() by name fails.What's Next
Day 2 will go deep on UVM phases — the build/connect/run/check/report order, why build_phase is top-down but connect_phase is bottom-up, and how objections actually control simulation end. The FIFO testbench from today will keep growing through the rest of the series.
Series: 30 days of SystemVerilog/UVM verification, project-based.
No comments:
Post a Comment