Your first week on a new DV team, someone hands you a regression log. Two hundred tests, all green. Then a designer asks a simple question: how do you know the DMA actually moved the right data? You open one passing test and find it programs the registers, waits, and ends. Nothing ever compared an output word against anything. The test was green because it never checked anything.
This lesson builds the opposite: a complete, small UVM testbench that drives a real design, predicts what should come out, checks every word, and ends only when the work is done. You will read every part of it, run it, and then break it on purpose. Every later lesson in this course adds to this same bench, so the time you spend here pays off all the way to the senior chapters.
The course uses one small design throughout: a stream DMA. Software programs it over APB (a simple register bus), then data words flow in on one AXI-Stream port and out on another. On the way through, each word is XORed with a key register, so the output is never just a copy of the input. When the job finishes, the DMA raises an interrupt.
Offset | Register | Fields |
|---|---|---|
0x00 | CTRL | bit 0 GO (write 1 to start), bit 1 IRQ_EN |
0x04 | STATUS | bit 0 BUSY (read-only), bit 1 DONE and bit 2 ERR (write 1 to clear) |
0x08 | LEN | bits 15:0, number of words to move |
0x0C | KEY | every word is XORed with this value |
0x10 | COUNT | words moved so far (read-only) |
Three interfaces, three kinds of traffic: register accesses that wait for a ready signal, a data stream with valid/ready handshakes, and a level-sensitive interrupt. That mix is why the DMA is a good teaching design: almost every UVM topic, from reactive agents to the register layer, has a natural home in it.
A UVM testbench splits the work the way a well-run kitchen does. The test is the head chef deciding what is cooked today. Sequences are the recipes: they describe stimulus as transactions ("write 0xA5A5_0F0F to KEY") without caring about pins. A driver is the cook who turns each recipe step into wiggling signals at the right clock edges. A monitor is the person at the pass who watches every plate that leaves, without touching it, and reports what it saw. The scoreboard is the critic who knows what each plate should look like and complains when one is wrong.
The reason for the split is reuse and trust. A monitor that never drives can be reused at chip level where nothing in the testbench is allowed to drive that bus. A scoreboard that only listens to monitors judges what really happened on the pins, not what the stimulus intended to happen. When those roles blur, you get the green-but-checking-nothing test from the opening.
Read the arrows. Stimulus flows left to right through drivers. Observations flow from monitors into the scoreboard. No arrow goes from a sequence or driver to the scoreboard: the critic never asks the cook what was intended.
Classes cannot hold wires, so each bus is bundled into a SystemVerilog interface and the classes get a virtual interface handle to it. Timing is fixed by two rules. Monitors sample through a clocking block, mon_cb, whose #1step input skew reads every signal just before the clock edge: exactly the value the DUT samples on that edge. Drivers wait on @(posedge clk) and drive with nonblocking assignments, so everything they change lands after the edge, the way a flip-flop output would. Together the two rules remove a whole family of races where the testbench and the design disagree about which value was on the bus.
// snippet: from the course DMA bench
interface apb_if (input logic clk, input logic rst_n);
logic [7:0] paddr;
logic psel, penable, pwrite;
logic [31:0] pwdata, prdata;
logic pready, pslverr;
// Monitors sample through mon_cb: #1step reads each signal just before the clock
// edge, which is exactly the value the DUT samples on that edge. Drivers drive with
// nonblocking assignments right after @(posedge clk) (see apb_driver).
clocking mon_cb @(posedge clk);
default input #1step;
input paddr, psel, penable, pwrite, pwdata, prdata, pready, pslverr;
endclocking
endinterfaceA sequence item is the transaction a sequence hands to a driver. It holds the fields of one access and nothing about timing. rdata and slverr are not random: the driver fills them in on the way back. The uvm_object_utils macro registers the class with the factory, which is what later lets a test swap in an error-injecting item without editing this file.
// snippet: from the course DMA bench
class apb_item extends uvm_sequence_item;
rand bit [7:0] addr;
rand bit write;
rand bit [31:0] data; // write data
bit [31:0] rdata; // filled by the driver on reads
bit slverr; // filled by the driver
constraint word_aligned_c { addr[1:0] == 2'b00; }
`uvm_object_utils(apb_item)
function new(string name = "apb_item");
super.new(name);
endfunction
function string convert2string();
return write ? $sformatf("WR addr=%02h data=%08h%s", addr, data, slverr ? " SLVERR" : "")
: $sformatf("RD addr=%02h rdata=%08h%s", addr, rdata, slverr ? " SLVERR" : "");
endfunction
function void do_copy(uvm_object rhs);
apb_item r;
super.do_copy(rhs);
$cast(r, rhs);
addr = r.addr; write = r.write; data = r.data; rdata = r.rdata; slverr = r.slverr;
endfunction
endclassThe driver pulls one item at a time (get_next_item), drives it, and reports completion (item_done). APB has two phases: a setup clock with PSEL high and PENABLE low, then an access phase with PENABLE high that lasts until the slave raises PREADY. Read data is valid in the cycle PREADY is high, so that is where the driver captures it.
// snippet: from the course DMA bench
class apb_driver extends uvm_driver #(apb_item);
`uvm_component_utils(apb_driver)
virtual apb_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 apb_if)::get(this, "", "vif", vif))
`uvm_fatal("NOVIF", "apb_if not set for this driver")
endfunction
task run_phase(uvm_phase phase);
vif.psel <= 1'b0;
vif.penable <= 1'b0;
@(posedge vif.rst_n);
forever begin
seq_item_port.get_next_item(req);
drive(req);
seq_item_port.item_done(); // rdata travels back inside req itself
end
endtask
// Setup phase: PSEL=1, PENABLE=0 for one clock. Access phase: PENABLE=1, held
// until the slave answers PREADY=1; read data is valid in that cycle.
// Every wait is on the same event, @(posedge vif.clk), and every drive is a
// nonblocking assignment, so a value read right after the edge is the value
// from before it: the one the DUT sampled.
task drive(apb_item it);
@(posedge vif.clk);
vif.paddr <= it.addr;
vif.pwrite <= it.write;
vif.pwdata <= it.data;
vif.psel <= 1'b1;
vif.penable <= 1'b0;
@(posedge vif.clk);
vif.penable <= 1'b1;
do @(posedge vif.clk); while (!vif.pready);
it.rdata = vif.prdata;
it.slverr = vif.pslverr;
vif.psel <= 1'b0;
vif.penable <= 1'b0;
endtask
endclassThe monitor watches the bus and recognises a completed transfer on the clock edge where PSEL, PENABLE and PREADY are all high. For each one it creates a new item and broadcasts it on an analysis port, which can have any number of listeners. Creating a new object every time matters: subscribers often store what they receive, and a monitor that reuses one handle would quietly rewrite every stored copy on the next transfer.
// snippet: from the course DMA bench
class apb_monitor extends uvm_monitor;
`uvm_component_utils(apb_monitor)
virtual apb_if vif;
uvm_analysis_port #(apb_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 apb_if)::get(this, "", "vif", vif))
`uvm_fatal("NOVIF", "apb_if not set for this monitor")
endfunction
// A transfer completes on the edge where PSEL, PENABLE and PREADY are all 1.
task run_phase(uvm_phase phase);
forever begin
@(vif.mon_cb);
if (vif.mon_cb.psel && vif.mon_cb.penable && vif.mon_cb.pready) begin
apb_item it = apb_item::type_id::create("it"); // a fresh object per transfer
it.addr = vif.mon_cb.paddr;
it.write = vif.mon_cb.pwrite;
it.data = vif.mon_cb.pwdata;
it.rdata = vif.mon_cb.prdata;
it.slverr = vif.mon_cb.pslverr;
`uvm_info("APB_MON", it.convert2string(), UVM_HIGH)
ap.write(it);
end
end
endtask
endclassThe agent packages a sequencer, a driver and a monitor for one interface. Its is_active setting decides whether it drives: an active agent builds all three; a passive agent builds only the monitor. The output stream uses a passive copy of the same AXI-Stream agent, because the DUT drives that port and the bench must only watch.
// snippet: from the course DMA bench
class apb_agent extends uvm_agent;
`uvm_component_utils(apb_agent)
apb_sequencer sqr;
apb_driver drv;
apb_monitor mon;
uvm_analysis_port #(apb_item) ap; // the agent's public output
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase); // reads is_active from config_db
mon = apb_monitor::type_id::create("mon", this);
if (get_is_active() == UVM_ACTIVE) begin
sqr = apb_sequencer::type_id::create("sqr", this);
drv = apb_driver::type_id::create("drv", this);
end
endfunction
function void connect_phase(uvm_phase phase);
ap = mon.ap;
if (get_is_active() == UVM_ACTIVE)
drv.seq_item_port.connect(sqr.seq_item_export);
endfunction
endclassHere is the part the green-but-checking-nothing test was missing. The scoreboard listens to three monitors. APB writes to KEY update its copy of the key. Every input word becomes a prediction, word ^ key, pushed onto a queue. Every output word pops the oldest prediction and compares. At the end, check_phase fails the test if predictions were never matched, or if nothing was compared at all.
// snippet: from the course DMA bench
// Predicts every output word from the input word and the KEY register the
// DUT was programmed with, then compares in order. The write_* methods are
// virtual so a derived scoreboard (a factory override) can extend them.
class dma_scoreboard extends uvm_scoreboard;
`uvm_component_utils(dma_scoreboard)
uvm_analysis_imp_apb #(apb_item, dma_scoreboard) apb_export;
uvm_analysis_imp_in #(axis_item, dma_scoreboard) in_export;
uvm_analysis_imp_out #(axis_item, dma_scoreboard) out_export;
bit [31:0] key;
bit [31:0] expected[$];
int unsigned n_match, n_mismatch;
function new(string name, uvm_component parent);
super.new(name, parent);
apb_export = new("apb_export", this);
in_export = new("in_export", this);
out_export = new("out_export", this);
endfunction
virtual function void write_apb(apb_item t);
if (t.write && !t.slverr && t.addr == KEY) key = t.data;
endfunction
virtual function void write_in(axis_item t);
expected.push_back(t.data ^ key);
endfunction
virtual function void write_out(axis_item t);
bit [31:0] exp;
if (expected.size() == 0) begin
`uvm_error("SB_UNEXP", $sformatf("output word %08h with nothing expected", t.data))
n_mismatch++;
return;
end
exp = expected.pop_front();
if (t.data !== exp) begin
`uvm_error("SB_MISMATCH", $sformatf("expected %08h, got %08h", exp, t.data))
n_mismatch++;
end else begin
n_match++;
end
endfunction
function void check_phase(uvm_phase phase);
if (expected.size() != 0)
`uvm_error("SB_LEFTOVER", $sformatf("%0d expected word(s) never came out", expected.size()))
if (n_match == 0)
`uvm_error("SB_EMPTY", "no words were compared: the test checked nothing")
endfunction
function void report_phase(uvm_phase phase);
`uvm_info("SB", $sformatf("%0d words matched, %0d mismatched", n_match, n_mismatch), UVM_LOW)
endfunction
endclassThe three uvm_analysis_imp_decl macros create three differently named write methods (write_apb, write_in, write_out) so one class can receive three streams of traffic.
The env builds the agents and the scoreboard and wires each monitor's port to a scoreboard input. The test owns the scenario: program KEY and LEN, start the job with the interrupt enabled, send one packet, wait for the interrupt, then check and clear STATUS. It raises an objection for the whole time it is busy. UVM's run phase ends only when every objection has been dropped, which is how the simulator knows the test is finished rather than idle.
// snippet: from the course DMA bench
// Program KEY and LEN, start a job with the interrupt enabled, stream one
// packet of LEN words, wait for the interrupt, then check and clear STATUS.
class dma_smoke_test extends dma_base_test;
`uvm_component_utils(dma_smoke_test)
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
task run_phase(uvm_phase phase);
bit [31:0] status, key = 32'hA5A5_0F0F;
int unsigned n_words = 8;
axis_pkt_seq pkt = axis_pkt_seq::type_id::create("pkt");
phase.raise_objection(this, "smoke test running");
reg_write(KEY, key);
reg_write(LEN, n_words);
reg_write(CTRL, 32'h3); // IRQ_EN | GO
if (!pkt.randomize() with { n == n_words; })
`uvm_fatal("RAND", "axis_pkt_seq randomize failed")
pkt.start(env.in_agt.sqr);
wait (irq_vif.irq === 1'b1);
reg_read(STATUS, status);
if (status[1] !== 1'b1) `uvm_error("STATUS", $sformatf("DONE not set: STATUS=%08h", status))
reg_write(STATUS, 32'h2); // write 1 to clear DONE
reg_read(STATUS, status);
if (status[2:0] !== 3'b000) `uvm_error("STATUS", $sformatf("STATUS not clear: %08h", status))
#100ns; // let the last output word drain
phase.drop_objection(this, "smoke test done");
endtask
endclassThe top module makes the clock and reset, instantiates the interfaces and the DUT, puts each virtual interface into uvm_config_db for the components that need it, and calls run_test. A +UVM_TESTNAME= plusarg on the command line overrides the name given to run_test, which is how one compiled bench runs a whole regression of different tests.
// snippet: from the course DMA bench
module top;
import uvm_pkg::*;
import dma_pkg::*;
logic clk = 0;
always #5 clk = ~clk; // 100 MHz
rst_if rst (clk);
wire rst_n = rst.rst_n;
initial begin
rst.rst_n = 1'b0;
repeat (4) @(posedge clk);
rst.rst_n <= 1'b1;
end
apb_if apb (clk, rst_n);
axis_if s_ax (clk, rst_n);
axis_if m_ax (clk, rst_n);
irq_if irqi (clk);
// The output side accepts every beat unless a test adds a reactive slave
// (axis_sink_agent), whose driver then drives TREADY.
initial m_ax.tready = 1'b1;
dma_dut dut (
.clk, .rst_n,
.paddr(apb.paddr), .psel(apb.psel), .penable(apb.penable), .pwrite(apb.pwrite),
.pwdata(apb.pwdata), .prdata(apb.prdata), .pready(apb.pready), .pslverr(apb.pslverr),
.s_tdata(s_ax.tdata), .s_tvalid(s_ax.tvalid), .s_tlast(s_ax.tlast), .s_tready(s_ax.tready),
.m_tdata(m_ax.tdata), .m_tvalid(m_ax.tvalid), .m_tlast(m_ax.tlast), .m_tready(m_ax.tready),
.irq(irqi.irq)
);
initial begin
uvm_config_db#(virtual apb_if)::set(null, "uvm_test_top.env.apb.*", "vif", apb);
uvm_config_db#(virtual axis_if)::set(null, "uvm_test_top.env.in_agt.*", "vif", s_ax);
uvm_config_db#(virtual axis_if)::set(null, "uvm_test_top.env.out_agt.*", "vif", m_ax);
uvm_config_db#(virtual irq_if)::set(null, "uvm_test_top", "irq_vif", irqi);
uvm_config_db#(virtual irq_if)::set(null, "uvm_test_top.env.irq.*", "vif", irqi);
uvm_config_db#(virtual rst_if)::set(null, "uvm_test_top*", "rst_vif", rst);
run_test("dma_smoke_test");
end
endmoduleThe two complete files, the design and the testbench, are on The DMA bench: full source, ready to paste into EDA Playground. Choose UVM 1.2 under Libraries and a simulator such as Aldec Riviera-PRO, and run. This exact bench has been built and run with Verilator and the open-source UVM library, so the listing is known to work.
The end of the log should look like this (the paths and the time unit vary by simulator):
UVM_INFO dma_tb.sv(411) @ 655000: uvm_test_top.env.sb [SB] 8 words matched, 0 mismatched
--- UVM Report Summary ---
** Report counts by severity
UVM_ERROR : 0
UVM_FATAL : 08 words matched means every output word was predicted and compared. That number is your proof the test checked something.
UVM_ERROR : 0 and UVM_FATAL : 0 is the pass condition. Regression scripts grep for exactly these lines.
The topology table printed at the start (from print_topology) shows out_agt with only a monitor: it really is passive.
The bug that looks like success. A team once lost a week to a DMA that occasionally corrupted one word in a long transfer. Their scoreboard compared output words against the input words from the stimulus sequence, not the monitored input, and they had forgotten the XOR. Every compare failed on day one, so someone "fixed" the scoreboard to compare against the output monitor's own previous value. The tests went green and stayed green through the corruption. The fix was the structure in this lesson: predict from the observed input and the observed register write, and compare against the observed output.
Try it on this bench. Change the DUT so word 6 uses ~key instead of key, rerun, and you get exactly one error:
UVM_ERROR dma_tb.sv(396) @ ...: uvm_test_top.env.sb [SB_MISMATCH] expected 52c7d685, got ad38297a
UVM_INFO dma_tb.sv(411) @ ...: uvm_test_top.env.sb [SB] 7 words matched, 1 mismatchedRemove the objection from run_phase, and the test ends at time 0. No traffic, no compares, and check_phase reports SB_EMPTY. Without that check the test would pass.
Drop the objection right after `pkt.start()` without waiting for the interrupt, and the last output words may still be inside the DUT when the run phase ends. SB_LEFTOVER catches it.
Make the output agent active by mistake, and its driver fights the DUT for the bus. A passive agent for DUT-driven ports is not a style choice.
The env sets is_active to UVM_PASSIVE for out_agt in config_db before creating it. The agent's build_phase calls super.build_phase(), which reads that setting, and then builds only the monitor. With no driver there is nothing to drive the port.
The scoreboard would now trust the intent, not the bus. If the APB write never reached the DUT (a wrong address, a SLVERR, a driver bug), the sequence's key and the DUT's key differ, and the scoreboard would blame the data path for a register-path bug, or miss it entirely. Predicting from the APB monitor checks that the write really happened.
dma_scoreboard fail the test when zero words were compared, even though nothing mismatched?A test that compared nothing proves nothing. Zero compares usually means the stimulus never ran, the monitors were not connected, or the test ended too early. Treating it as an error turns a silent false pass into a visible failure.
Next: the bench works, but every component is hard-wired. In Testbench architecture you will see why the four-layer split exists, and how the same agent serves block, subsystem and chip benches without a single edit.