The chip has two DMAs in a chain: the output stream of one feeds the input stream of the other. The block bench took weeks to build and is trusted. The subsystem deadline is in ten days. Writing a new environment would mean new agents, a new scoreboard and new bugs. The bench instead builds its subsystem environment from two copies of dma_env, without editing a line of it. What had to be true of the block env for that to work, and what still had to change?
Vertical reuse means using a verification component written for one level (a block) inside the environment of the next level up (a subsystem, then the chip). The block env becomes a sub-env. Three things change around it, and none of them is inside it. Agents on buses that another DUT now drives become passive: they keep monitoring, and stop driving. The virtual interfaces come from a different top. And a new checker covers what no block checker can see, the behaviour of the chain as a whole.
Shipping containers work this way. A container is packed and sealed once, then stacked on any ship. The ship adds lashing between containers and its own manifest; nobody opens the containers to re-pack them for each voyage. The block env is the container, the subsystem env is the ship, and the end-to-end checker is the manifest.
The subsystem bench is a second file. It defines DMA_NO_TOP, includes dma_tb.sv for the package and interfaces, and supplies its own top with two DUTs:
// not-run: needs the UVM library. Paste into EDA Playground with UVM 1.2 selected.
// dma_subsys_tb.sv - two DMAs in a chain, verified by reusing the block bench.
// dma0's output stream feeds dma1's input. Compile with dma_dut.sv; this file
// includes dma_tb.sv (keep both files side by side) and replaces its top module.
// Run: +UVM_TESTNAME=dma_subsys_test
`define DMA_NO_TOP
`include "dma_tb.sv"
module top;
import uvm_pkg::*;
import dma_pkg::*;
logic clk = 0;
always #5 clk = ~clk;
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 apb0 (clk, rst_n), apb1 (clk, rst_n);
axis_if s_ax (clk, rst_n); // into dma0
axis_if mid (clk, rst_n); // dma0 -> dma1, driven only by the DUTs
axis_if m_ax (clk, rst_n); // out of dma1
irq_if irq0 (clk), irq1 (clk);
initial m_ax.tready = 1'b1;
dma_dut dut0 (
.clk, .rst_n,
.paddr(apb0.paddr), .psel(apb0.psel), .penable(apb0.penable), .pwrite(apb0.pwrite),
.pwdata(apb0.pwdata), .prdata(apb0.prdata), .pready(apb0.pready), .pslverr(apb0.pslverr),
.s_tdata(s_ax.tdata), .s_tvalid(s_ax.tvalid), .s_tlast(s_ax.tlast), .s_tready(s_ax.tready),
.m_tdata(mid.tdata), .m_tvalid(mid.tvalid), .m_tlast(mid.tlast), .m_tready(mid.tready),
.irq(irq0.irq)
);
dma_dut dut1 (
.clk, .rst_n,
.paddr(apb1.paddr), .psel(apb1.psel), .penable(apb1.penable), .pwrite(apb1.pwrite),
.pwdata(apb1.pwdata), .prdata(apb1.prdata), .pready(apb1.pready), .pslverr(apb1.pslverr),
.s_tdata(mid.tdata), .s_tvalid(mid.tvalid), .s_tlast(mid.tlast), .s_tready(mid.tready),
.m_tdata(m_ax.tdata), .m_tvalid(m_ax.tvalid), .m_tlast(m_ax.tlast), .m_tready(m_ax.tready),
.irq(irq1.irq)
);
// The same block-level names, one level deeper: the block env needs no change.
initial begin
uvm_config_db#(virtual apb_if)::set(null, "uvm_test_top.env.dma0.apb.*", "vif", apb0);
uvm_config_db#(virtual apb_if)::set(null, "uvm_test_top.env.dma1.apb.*", "vif", apb1);
uvm_config_db#(virtual axis_if)::set(null, "uvm_test_top.env.dma0.in_agt.*", "vif", s_ax);
uvm_config_db#(virtual axis_if)::set(null, "uvm_test_top.env.dma0.out_agt.*", "vif", mid);
uvm_config_db#(virtual axis_if)::set(null, "uvm_test_top.env.dma1.in_agt.*", "vif", mid);
uvm_config_db#(virtual axis_if)::set(null, "uvm_test_top.env.dma1.out_agt.*", "vif", m_ax);
uvm_config_db#(virtual irq_if)::set(null, "uvm_test_top", "irq0_vif", irq0);
uvm_config_db#(virtual irq_if)::set(null, "uvm_test_top", "irq1_vif", irq1);
run_test("dma_subsys_test");
end
endmoduleThe same field names, one level deeper. The block top sets vif on uvm_test_top.env.apb.*; this top sets it on uvm_test_top.env.dma0.apb.* and uvm_test_top.env.dma1.apb.*. Every driver and monitor still asks for vif from its own scope, so none of them changes.
One interface, two consumers. mid is set for both dma0.out_agt.* and dma1.in_agt.*. Two monitors watching one bus is normal and costs nothing; two drivers on one bus is a bug.
`mid` is driven only by the DUTs: dut0's output ports and dut1's TREADY. No testbench code touches it, which is why dma1.in_agt must be passive.
The interrupts go to the test as irq0_vif and irq1_vif, because the subsystem test waits for both. It sets no timeout, so a lost word or a missing interrupt would hang until the library's default (9200 s) instead of failing fast; a real subsystem test calls uvm_root::get().set_timeout(...) in build, as the base test lesson recommends.
dma_subsys_env, shown in The Environment and Its Config Object, creates dma0 and dma1 with dma_env::type_id::create, makes dma1.in_agt passive when chain_internal is set, and adds dma_e2e_scoreboard. Its connect_phase feeds the checker from four places that already exist inside the block envs: both APB monitors (for the keys), dma0's input monitor (first words) and dma1's output monitor (last words). The checker's whole model is two lines:
// snippet: the end-to-end model in dma_e2e_scoreboard
virtual function void write_first(axis_item t);
expected.push_back(t.data ^ key0 ^ key1); // a word entering dma0, as it should leave dma1
endfunctionIt needs no knowledge of the word in the middle. That is deliberate: the integration checker checks the chain's contract, and the block scoreboards check each block.
Each block scoreboard still predicts from its own input monitor and compares against its own output monitor. For dma1.sb, the input monitor is the passive dma1.in_agt watching the middle bus, which is exactly why a passive agent keeps its monitor. When the end-to-end check fails, the block results say where: if dma0.sb passes and dma1.sb fails, the bug is in dma1. Removing them to save run time throws that localization away; turn them off per run with a has_scoreboard knob instead, if you must.
// snippet: from the course DMA bench
// Two DMAs in a chain. Each block keeps its own scoreboard; the end-to-end
// checker covers the chain. Run with dma_subsys_tb.sv.
class dma_subsys_test extends uvm_test;
`uvm_component_utils(dma_subsys_test)
dma_subsys_env env;
virtual irq_if irq0_vif, irq1_vif;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
dma_subsys_cfg cfg = dma_subsys_cfg::type_id::create("cfg");
super.build_phase(phase);
uvm_config_db#(dma_subsys_cfg)::set(this, "env", "cfg", cfg);
env = dma_subsys_env::type_id::create("env", this);
if (!uvm_config_db#(virtual irq_if)::get(this, "", "irq0_vif", irq0_vif) ||
!uvm_config_db#(virtual irq_if)::get(this, "", "irq1_vif", irq1_vif))
`uvm_fatal("NOVIF", "irq interfaces not set for the subsystem test")
endfunction
function void end_of_elaboration_phase(uvm_phase phase);
uvm_root::get().print_topology();
endfunction
task program_dma(apb_sequencer sqr, bit [31:0] key, int unsigned len);
apb_rw_seq s;
bit [31:0] vals[3] = '{key, len, 32'h3};
bit [7:0] regs[3] = '{KEY, LEN, CTRL};
foreach (regs[i]) begin
s = apb_rw_seq::type_id::create("s");
s.addr = regs[i]; s.write = 1; s.data = vals[i];
s.start(sqr);
end
endtask
task run_phase(uvm_phase phase);
axis_pkt_seq pkt = axis_pkt_seq::type_id::create("pkt");
phase.raise_objection(this);
program_dma(env.dma1.apb.sqr, 32'h1111_0000, 10); // downstream first: ready before data arrives
program_dma(env.dma0.apb.sqr, 32'h0000_2222, 10);
if (!pkt.randomize() with { n == 10; }) `uvm_fatal("RAND", "axis_pkt_seq randomize failed")
pkt.start(env.dma0.in_agt.sqr);
wait (irq0_vif.irq === 1'b1 && irq1_vif.irq === 1'b1);
#100ns;
phase.drop_objection(this);
endtask
endclassIt extends uvm_test, not dma_base_test: its env is a different class and the base test's helpers hard-code env.apb.sqr. So it writes its own program_dma. It programs dma1 first, so that the downstream DMA is running before data reaches it, then dma0, then streams 10 words into dma0 and waits for both interrupts. Designing a Test Library shows how a shared programming sequence removes that duplication.
Piece | Block bench | Subsystem bench |
|---|---|---|
| Written here | Unchanged, instantiated twice |
Agent modes |
| Also |
Virtual interfaces |
|
|
Checking | One block scoreboard | Two block scoreboards plus |
Test |
| New |
At chip level the same env stacks one level further, and three more things usually change. The APB agents go passive too, because the CPU or an interconnect now drives the register bus; the block scoreboards still learn KEY from the passive APB monitors, so they keep working. Internal buses have no top-level ports, so their interfaces are attached with bind to the RTL instances and published from there. And stimulus moves up: software running on the CPU, or a chip-level virtual sequence driving the few interfaces still at the chip boundary. Block scoreboards may be turned off selectively for speed, with integration checkers and assertions taking over.
Interface | Block | Subsystem | SoC |
|---|---|---|---|
DMA APB | active | active (per DMA) | passive: the CPU programs it |
dma0 input stream | active | active | passive if another IP feeds it |
middle stream | (output: passive) | passive, two monitors | passive, often via |
dma1 output stream | passive | passive | passive |
Run with scripts/tools/uvm-bench/run.sh scripts/data/uvm-course/bench/dma_subsys_tb.sv and +UVM_TESTNAME=dma_subsys_test:
UVM_INFO dma_tb.sv(411) @ 875000: uvm_test_top.env.dma0.sb [SB] 10 words matched, 0 mismatched
UVM_INFO dma_tb.sv(411) @ 875000: uvm_test_top.env.dma1.sb [SB] 10 words matched, 0 mismatched
UVM_INFO dma_tb.sv(1178) @ 875000: uvm_test_top.env.e2e [E2E] 10 words matched end to end, 0 mismatched
UVM_ERROR : 0
UVM_FATAL : 010 / 10 / 10. Each block scoreboard matched all 10 words through its own DMA, and the chain matched all 10 end to end.
The same line number, dma_tb.sv(411), prints for both block scoreboards: one class, two instances, told apart by their full names.
dma1.sb matched 10 words although its env drives nothing on its input. The passive monitor on the middle bus supplied every prediction.
Imagine chain_internal did not exist and dma1.in_agt stayed active, as it is in the block env. Its stream driver starts at time 0 with vif.tvalid <= 1'b0 on the middle bus, which dut0's output port already drives. Depending on the simulator, that is an elaboration error about a variable with both a continuous and a procedural driver, or a bus whose TVALID is overwritten. Even a driver that is never given an item has done damage, because the reset value it drives is a drive.
Teams hit this when a sub-env's agent modes are hard-coded for its block bench, or when someone "simplifies" a config by removing a knob that is 1 in every test they know about. The rule that prevents it: every agent on an interface that can be internal at a higher level must have its mode decided by configuration, and the integrating env, which knows what drives each bus, sets it.
dma0.sb reports 10 matched; dma1.sb reports 9 matched and 1 mismatched. Where do you look first?In dma1. dma0's output, observed on the middle bus, was correct for all 10 words, and dma1's own scoreboard, predicting from that same middle bus, saw a wrong output word. That localization is what keeping the block scoreboards buys.
dma0.out_agt and dma1.in_agt both receive the mid interface without conflict, while making either one active would be a bug?Monitors only sample. Any number of them can watch one interface, each publishing its own objects. An active agent's driver writes the interface's signals, and on mid those signals are already driven by dut0 (and TREADY by dut1).
dma_env still works unchanged, and what must the chip env set?The APB monitor and the block scoreboard keep working: the scoreboard learns KEY from observed APB writes, whoever made them. The chip env must set the APB agent passive (and the stream agents, if other IPs drive those streams), and the chip top must publish interfaces for buses that have no chip-level ports, typically through bind.
Next: at chip level some agents come from vendors, not from you. Integrating Third-Party VIP shows how to fit one into the bench without letting it leak.