Open the topology of dma_subsys_test and count the monitors on the bus between the two DMAs. There are two: dma0.out_agt.mon and dma1.in_agt.mon, both passive, both connected by config_db to the very same interface, mid. Every word that crosses the middle is sampled twice and published twice. A reviewer flags it as waste: "delete one of them". Should you?
When a truck crosses a border, the exporting country's inspector checks what leaves and the importing country's inspector checks what arrives. It is the same truck on the same road, inspected twice, and nobody calls that waste: each inspector answers to a different authority and keeps working if the other is replaced. The two monitors on the middle bus are the same. dma0.out_agt belongs to dma0's block env and feeds dma0's scoreboard with dma0's outputs. dma1.in_agt belongs to dma1's block env and feeds dma1's scoreboard with dma1's inputs. That those are the same wires is a fact about this subsystem, not about either env.
This is vertical reuse: block envs reused unchanged inside a bigger env. Its value is that each block env stays complete and self-checking, so a failure localises at once ("dma1.sb mismatched, dma0.sb is clean: the bug is in dma1"). Deleting one monitor would mean editing a block env, or feeding one block's scoreboard from the other block's agent, and both destroy that.
dma_subsys_env reads a small config object, creating defaults if the test set none. When chain_internal is set, dma1's input is fed by dma0, so the env marks dma1.in_agt passive before the block envs are built. It then builds dma0 and dma1 from the unchanged dma_env class, and the end-to-end scoreboard if asked.
// snippet: from the course DMA bench
// Two DMAs in a chain (dma0's output feeds dma1's input), verified by reusing the
// block env twice, unchanged, plus an end-to-end checker.
class dma_subsys_cfg extends uvm_object;
bit has_e2e_checker = 1; // the end-to-end scoreboard
bit chain_internal = 1; // dma1's input is driven by dma0, so its agent is passive
`uvm_object_utils(dma_subsys_cfg)
function new(string name = "dma_subsys_cfg");
super.new(name);
endfunction
endclass
class dma_subsys_env extends uvm_env;
`uvm_component_utils(dma_subsys_env)
dma_subsys_cfg cfg;
dma_env dma0, dma1; // the block env, reused as is
dma_e2e_scoreboard e2e;
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#(dma_subsys_cfg)::get(this, "", "cfg", cfg))
cfg = dma_subsys_cfg::type_id::create("cfg"); // defaults if the test set none
if (cfg.chain_internal)
uvm_config_db#(uvm_active_passive_enum)::set(this, "dma1.in_agt", "is_active", UVM_PASSIVE);
dma0 = dma_env::type_id::create("dma0", this);
dma1 = dma_env::type_id::create("dma1", this);
if (cfg.has_e2e_checker) e2e = dma_e2e_scoreboard::type_id::create("e2e", this);
endfunction
function void connect_phase(uvm_phase phase);
if (cfg.has_e2e_checker) begin
dma0.apb.ap.connect(e2e.key0_export);
dma1.apb.ap.connect(e2e.key1_export);
dma0.in_agt.ap.connect(e2e.first_export);
dma1.out_agt.ap.connect(e2e.last_export);
end
endfunction
endclassNote what the end-to-end scoreboard subscribes to: dma0's input and dma1's output, plus both APB streams for the keys. It checks out = in ^ key0 ^ key1 and never looks at the middle. It is a third, independent opinion, and if it ever disagrees with two clean block scoreboards, the bug is in how the blocks were composed rather than in either block.
The block env asks config_db for its stream interfaces at env.in_agt.* and env.out_agt.*. The subsystem top sets the same names one level deeper, and gives mid to both agents that watch it:
// snippet: from dma_subsys_tb.sv
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);Nothing on mid is driven by the testbench: dut0 drives TDATA, TVALID and TLAST, and dut1 drives TREADY. Both monitors therefore see the real handshake between two pieces of RTL, which is precisely what passive monitoring is for.
Each monitor publishes its own fresh object. One beat becomes two axis_item objects, one per monitor. A subscriber that stores items never shares an object with the other block. (The fresh-object rule and the sampling rules are taught in The monitor.)
Each monitor samples through its own clocking block, so both see the values from just before the same edge and publish in the same timestep, with identical contents.
Subscribers never assume a beat is reported once in the whole bench. Within one block env it is; across envs it is not. Anything that subscribes to several block envs must choose one source per bus.
dma_subsys_test with +UVM_VERBOSITY=UVM_HIGH prints every beat each stream monitor publishes. The first word through the chain:
UVM_INFO dma_tb.sv(299) @ 305000: uvm_test_top.env.dma0.in_agt.mon [AXIS_MON] data=46ee4a23
UVM_INFO dma_tb.sv(299) @ 315000: uvm_test_top.env.dma0.out_agt.mon [AXIS_MON] data=46ee6801
UVM_INFO dma_tb.sv(299) @ 315000: uvm_test_top.env.dma1.in_agt.mon [AXIS_MON] data=46ee6801
UVM_INFO dma_tb.sv(299) @ 325000: uvm_test_top.env.dma1.out_agt.mon [AXIS_MON] data=57ff6801dma0 XORs with its key 0x00002222 (4a23 becomes 6801), and one clock later dma1 XORs with 0x11110000 (46ee becomes 57ff). The data values are random and differ by simulator and seed.
The two middle-bus lines have the same time and the same data, from two different monitors: one beat, two reports, each to its own block env.
At the end, all three checkers report 10 words matched: dma0.sb, dma1.sb and e2e.
A team added a subsystem-level coverage collector for "traffic between the blocks" and, wanting every bit of it, connected both dma0.out_agt.ap and dma1.in_agt.ap to the same analysis imp. Analysis ports allow that: an imp can have any number of ports connected, and a second connection never replaces the first. The collector reported twice the beats that were sent, its packet-length bins filled with lengths no test had produced (two interleaved copies of each packet, assembled into longer ones), and the team chased a phantom DUT bug for a day.
The collector needed one source per bus, not two. The rule generalises to anything above the block envs: a subsystem-level subscriber picks exactly one monitor per physical bus, documents which, and leaves the block envs' own subscribers alone. When coverage is collected inside each block env too, give each covergroup instance its own name (set_inst_name(get_full_name())) so the merged report shows dma0.out_agt and dma1.in_agt as two views of one bus rather than one inflated count.
chain_internal = 0, but the top still connects dma1's input to mid. What goes wrong?dma1.in_agt is built active, so its driver starts driving TVALID, TDATA and TLAST on mid, wires that dut0 already drives. Two drivers fight over the middle bus, and dma1's input no longer comes from dma0. chain_internal exists precisely to keep the agent passive when its bus is driven by RTL.
It checks the chain as a black box, out = in ^ key0 ^ key1, so it needs only the ends. Staying off the middle makes it independent of both block scoreboards: if a block scoreboard and the e2e checker disagree, the discrepancy points at how the blocks were composed, which a checker using the middle could not reveal.
Next: the stream agent was easy to reuse because it never needed to change shape. When the bus width or ID width differs between projects, you need VIP parameterization.