Vertical reuse: a block environment inside a subsystem
Reuse a block environment inside a subsystem bench, keep its checks working when its bus becomes internal, and catch the block environment that still drives.
- Lesson
- U12.2
- Time
- 30 min
- Runs on
- Verilator with UVM
- Check
- 6 questions
Block B's environment moved into the subsystem bench unchanged, and its scoreboard reported no errors. It also reported no inputs: still active, its driver wrote valid = 0 at each idle falling edge of the internal bus, erasing every result that block A sent across. Block A's checks passed, and nothing compared the two blocks.
| Block B's agent | Block A in / out | Block B in / out | Block B scoreboard | Link check |
|---|---|---|---|---|
| Passive (the example) | 3 / 3 | 3 / 3 | 3 matched | 3 values matched |
| Left active (the hook) | 3 / 3 | 0 / 0 | No errors | Fails: 3 of A's results never reached B |
Measured on the example subsystem. Counts are the transfers and results each monitor published.
From block bench to subsystem bench
At block level, an environment owns everything for one block: an agent, a predictor and comparator from U8.1, and a config object. Vertical reuse puts that same environment class inside a larger bench, so the checks written once keep running at the next level of integration.
- Configure
The test fills one
sub_env_cfg. The sub env handscfg.blk_atoblk_aandcfg.blk_btoblk_b; block B's agent config saysUVM_PASSIVE.More context
The first two lines of the run show the result: "blk_a UVM_ACTIVE agent builds: mon sqr drv" and "blk_b UVM_PASSIVE agent builds: mon".
Follow the calls, values and waits
- data sub_env_cfg: sub_test_base → sub env
- data cfg.blk_a: sub env → blk_a env (active)
- data cfg.blk_b (passive agent): sub env → blk_b env (passive)
- Drive A15 ns (illustrative)
Only block A's driver touches pins. It puts 16 on
extand the design takes it at 15 ns.More context
Block B has no driver, so nothing else drives the subsystem. Left active, block B builds a driver that takes nothing, and the
LINKcheck reports three results that never arrived.Follow the calls, values and waits
- data x = 16: blk_a env (active) → ext bus
- data x = 16: ext bus → Block A RTL
- Cross35 ns (illustrative)
Block A shows 17 on its result pins. Inside the subsystem that result is block B's input, taken at 35 ns.
More context
At the same edge block A's own comparator logs "35 ns blk_a y=17 matches". One value is A's output and B's input at once.
Follow the calls, values and waits
- data A result 17 = B input: Block A RTL → mid bus
- Observe B's input35 ns (illustrative)
Block B's monitor watches
midand feeds its own predictor and comparator, unchanged from the block bench.More context
B's monitor samples 17 at 35 ns, and its predictor expects 18. Nothing in block B's environment knows that its input came from block A.
Follow the calls, values and waits
- data A result 17 = B input: mid bus → blk_b env (passive)
- Waiting: blk_b env (passive) — expects 18 from block B
- Check B55 ns (illustrative)
B's result, 18, is checked at 55 ns.
More context
The log reads "55 ns blk_b y=18 matches". Each B result arrives 20 ns after the A result that fed it.
Follow the calls, values and waits
- Released: blk_b env (passive)
Read the diagram as text
- sub_test_base: Builds sub_env_cfg once
- sub env: Passes each block its cfg; compares A outputs with B inputs
- blk_a env (active): drv, sqr, mon, pred, cmp
- blk_b env (passive): mon, pred, cmp; no driver
- ext bus: tb.ext, at the subsystem pins
- Block A RTL: Shows y = x + 1
- mid bus: tb.dut.mid, internal
- sub_test_base → sub env: cfg
- sub env → blk_a env (active): cfg.blk_a
- sub env → blk_b env (passive): cfg.blk_b
- blk_a env (active) → ext bus: drives
- ext bus → Block A RTL: x
- Block A RTL → mid bus: result is B input
- mid bus → blk_b env (passive): observed
Two copies of one environment class. What changed is one config field and where the virtual interface points.
Here the subsystem chains two copies of the U8 incrementer, so the bench holds two incr_env objects. Block A's input is still at the pins and its environment stays active. Block B's input is now block A's output, which the bench must not drive, so its agent becomes passive, as in U11.1.
What changes and what stays
| Part of block B's env | Block bench | Subsystem bench |
|---|---|---|
| Driver and sequencer | Built, drive B's pins | Not built |
| Monitor | Watches B's interface | Same class, pointed at dut.mid |
| Predictor and comparator | Fed by the monitor | Unchanged |
| Configuration | Set by the block test | Passed down by the sub env |
| Stimulus | B's own sequences | Only what block A produces |
| End of test | The block test drops after its sequence | Each block env drains itself |
The checking half of the environment moves unchanged.
This works only because the environment was written for it. The monitor reads pins, never the driver. The predictor works from observed inputs, never from the sequence. The agent reads its mode from its config before building children. An environment that broke any of these would need editing here.
Block B's coverage now fills only with what block A happens to produce. A block A that never outputs 0 leaves that hole in B's coverage, and the fix belongs in the subsystem's stimulus.
Configuration passes down one level at a time
uvm_config_db::set by the component named on it, for a path relative to itself.- The sub env hands down
The sub env gets
sub_env_cfgand sets one field for each child:cfg.blk_afor "blk_a" andcfg.blk_bfor "blk_b".More context
Both sets run in the sub env's
build_phase, before either block env is built. The paths are relative to the sub env, so they name only its children.Follow the calls, values and waits
- data cfg.blk_a: sub_env_cfg → cfg.blk_a
- data cfg.blk_b: sub_env_cfg → cfg.blk_b
- blk_a hands down
Block A's env gets its
incr_env_cfgand sets itsagtfield for "agt", exactly as it does on the block bench.More context
The run prints "blk_a UVM_ACTIVE agent builds: mon sqr drv". The agent reads only the object its parent set for it.
Follow the calls, values and waits
- data cfg.blk_a.agt (active): cfg.blk_a → cfg.blk_a.agt
- blk_b hands down
Block B's env runs the same code. Its agent config points at
dut.midand saysUVM_PASSIVE.More context
The run prints "blk_b UVM_PASSIVE agent builds: mon". The difference was decided by the test, two levels up, in the data it filled.
Follow the calls, values and waits
- data cfg.blk_b.agt (passive): cfg.blk_b → cfg.blk_b.agt
Read the diagram as text
- sub_env_cfg: Set by the test for "sub"
- cfg.blk_a: max_latency = 20 ns
- cfg.blk_b: max_latency = 20 ns
- cfg.blk_a.agt: vif = ext, UVM_ACTIVE
- cfg.blk_b.agt: vif = dut.mid, UVM_PASSIVE
- sub_env_cfg → cfg.blk_a: sub sets "blk_a"
- sub_env_cfg → cfg.blk_b: sub sets "blk_b"
- cfg.blk_a → cfg.blk_a.agt: blk_a sets "agt"
- cfg.blk_b → cfg.blk_b.agt: blk_b sets "agt"
No layer names a grandchild. The block env's own code, the "agt" step, is the same at block and subsystem level.
The subsystem config object holds one block config per block, and each block config holds its agent config. The test fills the whole tree and sets it once, for "sub". The sub env gets it and sets cfg.blk_a for "blk_a" and cfg.blk_b for "blk_b". Each block env sets its agent config for "agt", the same line it ran at block level.
class sub_test_base extends uvm_test;
function void build_phase(uvm_phase phase);
cfg = sub_env_cfg::type_id::create("cfg");
// ... get ext_vif and mid_vif, store them in cfg.blk_a.agt and cfg.blk_b.agt
cfg.blk_b.agt.is_active = UVM_PASSIVE; // block A drives block B's input now
uvm_config_db #(sub_env_cfg)::set(this, "sub", "cfg", cfg);
sub = sub_env::type_id::create("sub", this);
endfunction
endclassA test base class holds this build so every subsystem test inherits it. A derived test such as sub_smoke_test only adds its run_phase. The block test's own lines do not move up: they set paths such as "env" that do not exist in the subsystem. Copied in, they would leave block B's get empty, and incr_env stops with NOCFG at time 0.
Reaching the internal bus
Block B's monitor needs a virtual interface on mid, which exists only inside incr_sub. Because the RTL connects the blocks through an interface instance, the top module can hand it over with a hierarchical reference: uvm_config_db #(virtual incr_if)::set(null, "uvm_test_top", "mid_vif", dut.mid).
Hierarchical reference
uvm_config_db #(virtual incr_if)::set(
null, "uvm_test_top", "mid_vif", dut.mid);
Works when the internal bus is an interface instance, as here. The path ties the top module to the RTL hierarchy.
bind a probe onto plain wires
bind sub_rtl incr_probe_if b_probe (
.clk(clk), .valid(b_valid), .x(b_x),
.ready(b_ready), .out_valid(b_ov), .y(b_y));
// then pass tb.dut.b_probe to the bench
Puts an interface inside RTL you cannot edit. The probe's signals are inputs, so the monitor must read a read-only interface.
Most RTL connects blocks with plain wires, so bind is the common choice. A VIP meant for this kind of reuse gives its monitor an interface that a bound probe can satisfy. The Cookbook's integration example uses a third form: a binder module that assigns internal signals into a bench-level interface.
Each environment decides when it is done
The subsystem test drops its objection when block A's sequence returns, at 75 ns. At that moment block A owes one result, for 48. Block B owes one, for 33, and its third input, A's answer for 48, has not arrived yet. Neither the test nor the sub env knows the latencies, but each block env does: it is in its config, max_latency.
The drain has two different bounds. max_latency, 20 ns, is a property of the design: every result shows 20 ns after its input. On its own that cannot prove nothing more is coming, because new input may still arrive from upstream. Here it is enough: the test has stopped driving block A, and A's last result reaches B within A's own 20 ns, inside B's window, which then restarts. In general a block's quiet window must also cover the latency of everything upstream of it. drain_limit, 100 ns, is this test's policy: owed work that has not finished by then counts as lost. Without the deadline, a lost result would hold the objection until the global timeout, and check_phase would never report it.
incr_env, slightly shortened: hold the run phase until this block's work is done, or until the deadline.function void phase_ready_to_end(uvm_phase phase);
if (phase.get_name() != "run" || draining) return;
draining = 1;
phase.raise_objection(this, "draining");
fork begin
fork
begin
int seen;
do begin
wait (cmp.matched + cmp.mismatched == pred.predicted);
seen = pred.predicted;
#(cfg.max_latency); // quiet window: the design's latency
end while (pred.predicted != seen);
end
#(cfg.drain_limit); // failure deadline: this test's policy
join_any
disable fork;
phase.drop_objection(this, "draining");
end join_none
endfunctionUVM calls phase_ready_to_end on every component when the objection count first reaches zero. A component that raises an objection there keeps the phase open, and UVM asks again after it drops, up to 20 rounds by default.
B balances at 85 ns, but block A's last result reaches it at 95 ns, inside its quiet window, so B waits again.
The quiet window needs the restart. Block B balanced at 85 ns while block A still held 48. A drain that ended after the first window would have dropped at 105 ns with B's last result still owed. Only block A's own drain would have kept the run open. After any drain, check_phase in each block env reports predictions that never met a result.
When the block environment still drives
Leave block B active and nothing fails to build. B's driver gets no items, so at every falling edge it writes valid = 0, as the U8 driver does when idle. Block A's result sets mid.valid just after a rising edge, and B's driver clears it half a cycle later, before B samples it.
Each column holds a value from that time until the next column. The rising edge at 35 ns samples the 30 ns column, then block A clears its result. Passive: B takes 17. Active: B takes nothing.
Counting is not enough for that check. With bit 2 of the internal bus stuck at 1, block B takes 21, 37 and 53 where block A showed 17, 33 and 49. Both block scoreboards pass, because B predicts from what it took, and the counts are equal. Comparing A's y with B's x, in order, reports all three.
Every check inside block B still passes, because a scoreboard that sees no inputs predicts nothing and misses nothing. Only a check across the blocks notices. The sub env subscribes to block A's result port and block B's input port and pairs the values in order. Its check_phase reports LINK for every result of A that never reached B.
Try it
The first two lines show block B with only its monitor. Each block's comparator prints its own results; B's arrive 20 ns after A's, and each block env reports when it has drained.
Delete the cfg.blk_b.agt.is_active = UVM_PASSIVE; line in sub_test_base and rerun: block B builds a driver, takes nothing, and the LINK check reports three results that never arrived. Then build incr_sub with #(.STUCK(1)) in tb and watch the link compare values. Then set max_latency to 5 ns in incr_env_cfg and see which drain ends too early, and which check reports it.
blk_a UVM_ACTIVE agent builds: mon sqr drv blk_b UVM_PASSIVE agent builds: mon 35 ns blk_a y=17 matches 55 ns blk_b y=18 matches 65 ns blk_a y=33 matches 85 ns blk_b y=34 matches 95 ns blk_a y=49 matches 115 ns blk_a drained: 3 predicted, 3 compared 115 ns blk_b y=50 matches 135 ns blk_b drained: 3 predicted, 3 compared block A: 3 in, 3 out; block B: 3 in, 3 out; link 3 matched, 0 mismatched UVM_ERROR : 0Check yourself
Remember
- Vertical reuse moves a block environment into a larger bench unchanged; where its bus becomes internal, its agent turns passive and its monitor still feeds the same checks.
- Configuration objects nest: the test fills the tree once, and each layer sets its children's part at a path relative to itself.
- A passive monitor reaches an internal bus through a hierarchical reference to an interface instance, or through an interface bound onto RTL wires.
- Each block environment drains itself in
phase_ready_to_end: a quiet window set by the design's latency that restarts on new predictions, inside a deadline set by test policy so lost work still reachescheck_phase. - Block checks pass when they see nothing or a corrupted input, so the subsystem adds a check across blocks: every result one block shows must arrive, unchanged and in order, as the next block's input.