SystemVerilog · Module 7
Hierarchical References & $root
Symbolic pointers vs wire connections, $root scope, force/release semantics, testbench backdoor patterns, why upward references break encapsulation, and 5 debugging labs.
Module 7 · Page 7.7
Every signal in your design has a unique address in the hierarchy — like a file path in a filesystem. Hierarchical references let you reach any signal from anywhere. $root anchors that path to the top. Used correctly, this is a powerful testbench tool. Used incorrectly, it destroys encapsulation and creates unmaintainable code.
What is a Hierarchical Reference?
Every module instance in SystemVerilog lives at a specific location in the design hierarchy — like a directory in a folder tree. A hierarchical reference is a dotted path that uniquely identifies any signal, parameter, or instance anywhere in that tree.
The syntax mirrors a file path: level1.level2.level3.signal_name where each segment is either an instance name or a generate-block name.
$root — The Implicit Top-Level Scope
$root is the implicit, unnamed scope that sits above all module instances in the simulation. It is the absolute starting point of the entire design hierarchy. Any hierarchical reference that begins with $root is an absolute path — it works the same regardless of where in the hierarchy it is written.
// ── Absolute path — starts from $root ────────────────────────────
// Works identically no matter where this line is written
logic sig_abs = $root.chip_top.u_cpu.u_alu.sum;
// ── Absolute path — top module name alone is also absolute ───────
// (chip_top is at the top level — same as $root.chip_top)
logic sig_abs2 = chip_top.u_cpu.u_alu.sum;
// ── Relative path — resolved from the current scope ──────────────
// Written inside module cpu_core: 'u_alu' is a local instance
logic sig_rel = u_alu.sum; // valid inside cpu_core only
// ── When are they different? ──────────────────────────────────────
// If the same module is instantiated twice:
// chip_top.u_cpu_a.u_alu.sum ← ALU inside first CPU
// chip_top.u_cpu_b.u_alu.sum ← ALU inside second CPU
// $root.chip_top.u_cpu_a.u_alu.sum ← unambiguous absolute path
// ── $root in a package or program block ──────────────────────────
// $root is especially useful in program blocks and packages
// where the current scope has no module hierarchy context
program tb_program;
initial begin
// Must use absolute path — no relative context in a program
$display("ALU sum = %h", $root.chip_top.u_cpu.u_alu.sum);
end
endprogramAbsolute vs Relative Hierarchical Paths
A hierarchical reference is either absolute (starts from $root or the top-level module name) or relative (resolved from the current module's scope). Understanding the difference prevents confusing path errors.
| Type | Starts with | Resolved from | Works in |
|---|---|---|---|
| Absolute | $root.x or top module name | The simulation root — always the same | Anywhere: modules, programs, packages, assertions |
| Relative (downward) | An instance name directly below current scope | The scope where the reference appears | Inside the module that owns the instance |
| Relative (upward) | A parent's signal or instance name | Must traverse upward through hierarchy | Works but strongly discouraged — breaks encapsulation |
// ════ Scenario: tb_top monitors signals inside chip_top ═══════════
module tb_top;
chip_top u_dut (...); // DUT is u_dut inside tb_top
initial begin
// ── Absolute path — most readable ─────────────────────────
$display("sum (abs) = %h", u_dut.u_cpu.u_alu.sum);
// 'u_dut' is in tb_top's scope → this is a downward relative path
// ── $root absolute path — unambiguous everywhere ──────────
$display("sum ($root) = %h", $root.tb_top.u_dut.u_cpu.u_alu.sum);
// ── Access a localparam inside a sub-module ───────────────
$display("WIDTH = %0d", u_dut.u_cpu.u_alu.WIDTH);
// parameters and localparams are also accessible via hierarchy
// ── Access a generate-block signal ───────────────────────
// Inside u_alu: generate for (genvar i=0; i<N; i++) begin : gen_fa
$display("FA[2] carry = %b", u_dut.u_cpu.u_alu.gen_fa[2].u_fa.cout);
end
endmoduleTestbench Backdoor Access
The most common legitimate use of hierarchical references is testbench backdoor access — reading or forcing internal DUT signals that are not exposed as ports. This is valuable for:
Observing Internal State
Read a state machine's current state or a counter's value directly, without adding debug ports to the RTL.
Initialising Memory
Pre-load a RAM array directly into the DUT's internal memory before simulation starts, skipping a lengthy boot sequence.
Force / Release
Override a signal to a specific value (force) to test a corner case, then release it back to normal RTL drive.
Coverage Collection
Collect coverage on internal signals that are not exported, enabling measurement of internal toggle and state coverage.
module tb_top;
chip_top u_dut (...);
initial begin
// ── ① Backdoor READ — observe internal signal ────────────────
@(posedge u_dut.u_cpu.clk); // wait on DUT's internal clock
$display("ALU result = %0h", u_dut.u_cpu.u_alu.result);
$display("FSM state = %s", u_dut.u_cpu.state.name());
// ── ② Backdoor WRITE — inject value directly ─────────────────
// Works for simulation only — synthesiser ignores hierarchical LHS
u_dut.u_cpu.u_alu.result = 32'h1234_5678; // inject test value
// ── ③ FORCE / RELEASE — override then restore ────────────────
force u_dut.u_cpu.rst_n = 1'b0; // hold in reset
#100;
release u_dut.u_cpu.rst_n; // let RTL drive it again
// ── ④ Memory initialisation (backdoor) ───────────────────────
// Pre-load DUT's internal RAM before simulation starts
for (int i = 0; i < 256; i++)
u_dut.u_mem.u_ram.mem[i] = 32'h0; // zero the RAM
u_dut.u_mem.u_ram.mem[0] = 32'hC001_C0DE; // load boot code
// ── ⑤ Access a parameter via hierarchy ───────────────────────
$display("ALU width = %0d", u_dut.u_cpu.u_alu.DATA_WIDTH);
end
endmoduleUpward References — Why You Should Avoid Them
A child module can technically reference a signal in its parent using a hierarchical path that traverses upward. This is called an upward reference. It works in simulation — but it is one of the worst things you can do in RTL design.
// ════ WRONG: Upward hierarchical reference ════════════════════════
module alu_unit; // ← child module
// Reaching up to read parent's signal — terrible practice
logic clk = cpu_core.clk; // ❌ upward reference to parent's 'clk'
// Problems:
// 1. alu_unit can ONLY be used inside cpu_core — not reusable
// 2. You cannot tell the module's inputs by reading its ports
// 3. Tool may not support this in synthesis
// 4. Rename cpu_core → cpu_subsystem and it silently breaks
endmodule
// ════ CORRECT: Use a port ════════════════════════════════════════
module alu_unit (
input logic clk // ← receive clk through a proper port
);
// ✅ Reusable. Self-documenting. Works in synthesis and simulation.
endmoduleHierarchical References in Assertions
Assertions written in a testbench or checker module often need to observe internal DUT signals — signals that are not exposed as ports. Hierarchical references are the correct and standard way to do this. This is one of the most legitimate uses of hierarchical paths.
module tb_top;
chip_top u_dut (...);
// ── Alias internal signals for cleaner assertion syntax ──────────
logic clk = u_dut.u_cpu.clk;
logic rst_n = u_dut.u_cpu.rst_n;
logic [31:0] result = u_dut.u_cpu.u_alu.result;
logic valid = u_dut.u_cpu.u_alu.result_valid;
// ── Concurrent assertion using aliased internal signals ──────────
// After reset, if valid goes high, result must not be zero
ap_result_nonzero: assert property (
@(posedge clk) disable iff (!rst_n)
valid |-> (result != 32'h0)
) else
$error("ASSERTION FAIL: valid high but result is zero at time %0t", $time);
// ── Assertion directly on hierarchical path (no alias needed) ────
ap_fifo_no_overflow: assert property (
@(posedge clk) disable iff (!rst_n)
!(u_dut.u_mem.u_fifo.full && u_dut.u_mem.u_fifo.wr_en)
) else
$fatal(1, "FIFO overflow detected!");
// ── Cover property on internal state ─────────────────────────────
cp_busy_state: cover property (
@(posedge clk)
(u_dut.u_cpu.state == 3'b101) // cover the BUSY state
);
endmoduleRules, Limits, and What You Can Reference
| What You Can Reference | Allowed? | Example |
|---|---|---|
| Internal signal (logic, wire) | Yes — read and observe | u_dut.u_cpu.sum |
| Parameter / localparam | Yes — read only | u_dut.u_cpu.DATA_WIDTH |
| Instance name | Yes — as a path segment | u_dut.u_cpu.u_alu |
| Generate block signal | Yes — include block index | u_dut.gen_fa[2].u_fa.sum |
| Force / Release | Yes — simulation only | force u_dut.rst_n = 0; |
| Backdoor write (=) | Yes — simulation only, LHS | u_dut.mem[0] = 32'h0; |
| Synthesisable drive via hierarchy | No — not synthesisable | Never use as synthesisable driver |
| Upward reference in RTL | Technically yes, but always wrong | Never write in production RTL |
// ── Read a signal ─────────────────────────────────────────────
$display("%h", u_dut.u_cpu.result);
// ── Wait on an internal event ────────────────────────────────
@(posedge u_dut.u_cpu.clk);
@(u_dut.u_mem.u_fifo.full); // wait until FIFO is full
// ── Read a parameter ────────────────────────────────────────
$display("ALU width: %0d", u_dut.u_cpu.u_alu.DATA_WIDTH);
// ── Generate-block path ─────────────────────────────────────
$display("FA[2] carry: %b", u_dut.u_cpu.u_alu.gen_fa[2].u_fa.cout);
// ── Backdoor write ──────────────────────────────────────────
u_dut.u_mem.mem_array[0] = 32'hC0DE;
// ── Force / Release ─────────────────────────────────────────
force u_dut.u_cpu.stall = 1'b1; // override RTL driver
#50;
release u_dut.u_cpu.stall; // restore RTL control
// ── $root in a program block ─────────────────────────────────
program checker;
initial
$display("%b", $root.tb_top.u_dut.u_cpu.done);
endprogramWhen to Use — and When to Avoid
Use hierarchical references for...
Testbench backdoor access, assertion monitors on internal signals, memory pre-loading, force/release in directed tests, coverage on internal state, and debug display tasks.
Never use them for...
Driving signals in RTL modules, replacing ports with upward references, anything that goes through synthesis. If it is in a .sv file that synthesis sees, use ports instead.
Common Mistakes
// ════ MISTAKE 1: Upward reference instead of port ════════════════
module alu_bad;
always_ff @(posedge cpu_core.clk) // ❌ upward reference to parent
result <= a + b;
endmodule
// ✅ FIX: pass clk as a port
module alu_good (input logic clk);
always_ff @(posedge clk) result <= a + b; // ✅ clean port
endmodule
// ════ MISTAKE 2: Wrong instance name in path ══════════════════════
$display("%h", u_dut.u_core.u_alu.sum); // ❌ 'u_core' doesn't exist
// — the instance is actually named 'u_cpu'
// ✅ FIX: double-check instance names in the RTL
$display("%h", u_dut.u_cpu.u_alu.sum); // ✅ correct path
// ════ MISTAKE 3: Forgetting the generate block index ══════════════
$display("%b", u_dut.u_cpu.gen_fa.u_fa.cout); // ❌ missing [i]
// gen_fa is an indexed array of blocks
// ✅ FIX: include the index
$display("%b", u_dut.u_cpu.gen_fa[2].u_fa.cout); // ✅ correct
// ════ MISTAKE 4: Using force without release ══════════════════════
force u_dut.rst_n = 0;
// ... simulation continues forever with rst_n=0 — RTL never recovers
// ✅ FIX: always release after the test
force u_dut.rst_n = 0;
#100;
release u_dut.rst_n; // ✅ RTL drives rst_n againQuick Reference — Hierarchical References Cheat Sheet
// ── Path syntax ──────────────────────────────────────────────
inst1.inst2.signal_name // relative downward path
top_module.inst1.inst2.signal_name // absolute path
$root.top_module.inst1.inst2.signal_name // absolute from $root
inst1.gen_block[i].inst2.signal // through generate block
// ── Read (observe) ───────────────────────────────────────────
$display("%h", u_dut.u_cpu.result);
@(posedge u_dut.clk);
@(u_dut.u_fifo.full);
// ── Alias (cleaner assertions) ───────────────────────────────
logic clk = u_dut.clk; // alias for readability
// ── Force / Release ──────────────────────────────────────────
force u_dut.signal = value;
release u_dut.signal;
// ── Backdoor write ───────────────────────────────────────────
u_dut.mem[0] = 32'h0; // simulation only
// ── Access parameter ─────────────────────────────────────────
u_dut.u_alu.DATA_WIDTH // read parameter value via hierarchy
// ── Rules ────────────────────────────────────────────────────
// • Use for: testbench monitoring, assertions, force, memory init
// • Never use for: RTL drivers, replacing ports, upward refs in RTL
// • $root = implicit top — use for absolute paths in programs/packages
// • Always release after force
// • Generate block path: gen_name[index].instance.signalVerification Usage — Hierarchical References in the Testbench
Hierarchical references are a verification-side feature first and foremost. They let the testbench see and manipulate DUT internals that don't appear on the module's port list — a register file's storage, a FIFO's pointer, a state machine's current state. Used judiciously, this enables fast initialisation, white-box checks, and surgical fault injection. Used carelessly, it couples the testbench to the DUT's internal structure and breaks every time the DUT refactors.
// ── Pattern 1: Fast memory init (bypass slow protocol writes) ──
class mem_init_seq extends uvm_sequence;
task body();
// Load 64 KiB image directly into the RAM backing storage
for (int i = 0; i < 16384; i++)
top.dut.u_l2_cache.u_ram.mem[i] = init_image[i];
`uvm_info("INIT", "Memory pre-loaded via backdoor", UVM_LOW)
endtask
endclass
// ── Pattern 2: White-box scoreboard — read DUT state for golden compare ──
class arch_state_scoreboard extends uvm_scoreboard;
virtual function void check_phase(uvm_phase phase);
// Read DUT's architectural register file via hierarchical reference
for (int r = 0; r < 32; r++) begin
if (top.dut.u_cpu.reg_file.x[r] !== expected_x[r])
`uvm_error("REGCHK",
$sformatf("x[%0d]: got %h, exp %h",
r, top.dut.u_cpu.reg_file.x[r], expected_x[r]))
end
endfunction
endclass
// ── Pattern 3: Surgical fault injection — flip a bit, observe recovery ──
task automatic inject_seu(input int reg_idx, input int bit_pos);
bit [31:0] before = top.dut.u_cpu.reg_file.x[reg_idx];
bit [31:0] flipped = before ^ (32'h1 << bit_pos);
force top.dut.u_cpu.reg_file.x[reg_idx] = flipped;
@(posedge top.clk);
release top.dut.u_cpu.reg_file.x[reg_idx];
`uvm_info("SEU",
$sformatf("Flipped x[%0d] bit %0d: %h → %h",
reg_idx, bit_pos, before, flipped), UVM_LOW)
endtaskSimulation Behavior — How Hierarchical References Resolve and Drive
A read through a hierarchical reference behaves like any other variable read — it samples the current value at the time of the read. A force through a hierarchical reference is different: it overrides the procedural and continuous drivers of the target signal and holds the forced value until a release reverses it. Understanding the timing of force/release at the simulation-region level is the difference between a successful fault-injection test and a deadlocked DUT.
// DUT — normal continuous driver on internal signal
module dut;
logic [7:0] state;
always_ff @(posedge clk)
state <= next_state; // regular NBA driver
endmodule
// Testbench — surgical override of 'state'
initial begin
@(posedge clk);
force top.dut.state = 8'h42; // active from this point
@(posedge clk);
@(posedge clk);
release top.dut.state; // procedural driver wins again
endWaveform Analysis — Identifying a force in the Viewer
Modern waveform viewers annotate forced signals visually so you can tell at a glance whether a signal's value is coming from the design or from a testbench override. In Verdi: a small "F" marker next to the signal name; the signal name itself may turn red. In DVE: the signal trace shows a yellow band. In GTKWave: forced segments render in a lighter colour.
t=10 t=20 t=30 t=40 t=50 t=60 t=70 t=80
clk __│‾│___│‾│___│‾│___│‾│___│‾│___│‾│___│‾│___│‾│__
dut.next_state 01 02 03 04 05 06 07 08
dut.state 00 → 01 → 02 → 42 → 42 → 42 → 06 → 07
^^ ^^ ^^ ^^
forced by TB released — picks up next NBA
└── F annotation in Verdi/DVE here ──┘
Reading the trace:
• t=10–t=30: dut.state follows next_state via the always_ff — normal operation
• t=30: TB issues 'force top.dut.state = 8'h42;'
• t=30–t=60: viewer marks the signal as forced; value held at 0x42 regardless of next_state
• t=60: TB issues 'release top.dut.state;'
• t=70 onwards: next clock edge writes next_state into the (now unforced) flop normallyIndustry Insights — Hierarchical Reference Practices in Production Teams
Debugging Academy — 5 Real Hierarchical Reference Bugs
Each lab is a real failure mode. Buggy code, symptom, root cause, fix.
Forgotten release — Forced Signal Outlives the Test
RESOURCE LEAK// SEU injection test — flips a bit, expects DUT to recover
task automatic test_seu();
@(posedge clk);
force top.dut.u_cpu.reg_file.x[5] = 32'hDEADBEEF;
@(posedge clk);
@(posedge clk);
check_recovery();
// ← forgot: release top.dut.u_cpu.reg_file.x[5];
endtask
// Later test in the same regression:
task automatic test_arithmetic();
set_reg(5, 32'h00000001);
add(5, 5, 6);
assert (top.dut.u_cpu.reg_file.x[6] == 32'h00000002); // FAILS
endtasktest_seu passes. test_arithmetic fails — register 5 reads as 0xDEADBEEF instead of the value it was supposedly written. The failure looks like a register-write bug in the DUT until someone notices register 5 reads as 0xDEADBEEF in every later test, not just one.
force holds until release or end-of-simulation. The test that injected the SEU forgot to release; the force persisted across the test boundary into test_arithmetic. The DUT's always_ff couldn't update x[5] because the force was still active.
Wrap every force in a task that pairs it with release:
task automatic force_for_one_cycle(input string path, input bit [31:0] val);
// Caller-provided hierarchical path via virtual handle or generate-resolved tag
force /* resolved path */ = val;
@(posedge clk);
release /* resolved path */;
endtaskThe wrapper guarantees release. Add a regression-end check that asserts no forces are active: enumerate known forced paths in a final block and $fatal on any leftover.
Upward Reference Rebinds After Module Reuse
SCOPE DRIFTmodule child;
// Upward reference — no explicit scope
always_ff @(posedge clk)
if (enable) // reaches up to parent's 'enable'
counter <= counter + 1;
endmodule
module parent_a;
logic enable; // child binds to this
logic clk;
child u_c();
endmodule
// Six months later, child is reused under parent_b:
module parent_b;
logic enable; // also has 'enable' — different semantics!
logic clk;
child u_c(); // now binds to parent_b.enable
endmodulechild works correctly under parent_a. When reused under parent_b, it silently binds to parent_b.enable — a different signal with different timing semantics — and the counter counts at unexpected times. No compile error, no warning that the binding changed.
The upward reference resolves to "closest enclosing scope with this name." Reusing child under a different parent rebinds silently. The child's behavior depends on its environment, violating encapsulation.
module child (input logic clk, input logic enable);
always_ff @(posedge clk)
if (enable) counter <= counter + 1;
endmodule
module parent_a;
logic enable;
logic clk;
child u_c(.clk(clk), .enable(enable)); // explicit
endmodulePass signals through ports. Add a lint rule rejecting upward references (Spyglass upward_reference) and fail CI on any occurrence. No exceptions.
Missing Generate Index — Probe Lands on Iteration 0
AMBIGUOUS PATH// DUT has a 4-stage generate-for pipeline
module pipe;
generate
for (genvar i = 0; i < 4; i = i + 1) begin : g_stage
stage_reg u_s();
end
endgenerate
endmodule
// Test wants to probe stage 3:
initial begin
if (top.dut.u_pipe.g_stage.u_s.value != expected) // ← no [3]!
$error("Stage 3 mismatch");
endSome tools (VCS old versions, Verilator) silently bind to g_stage[0] and the test appears to probe stage 0 instead of stage 3. The bug surfaces as "stage 3 randomly reads the value of stage 0" — actually it always does, but only matters when stage 0 and stage 3 happen to differ. Other tools (Questa, modern Xcelium) error on the ambiguous reference.
Generate-for produces an array of named scopes: g_stage[0], g_stage[1], ..., g_stage[N-1]. The path must include the bracketed index to disambiguate. SystemVerilog LRM doesn't strictly require the error — implementations vary.
Always specify the index: top.dut.u_pipe.g_stage[3].u_s.value. For all-stage probes, use a generate-for in the testbench:
generate
for (genvar i = 0; i < 4; i = i + 1) begin : g_probe
initial @(posedge clk)
if (top.dut.u_pipe.g_stage[i].u_s.value != expected[i])
$error("Stage %0d mismatch", i);
end
endgenerateHierarchical Read Race — Sampling Wrong Side of NBA
TIMING REGION RACE// Scoreboard reads DUT state immediately on the clock edge
always @(posedge clk) begin
if (top.dut.u_cpu.x[5] != expected_value)
$error("Reg mismatch");
end
// DUT also writes x[5] on the same edge:
always_ff @(posedge clk)
if (we) x[5] <= wdata;Sporadic false-positives on the scoreboard check. The same test passes 9 out of 10 simulator runs, fails on the 10th. Re-running with a different seed doesn't always reproduce. The error message correctly reports that the value doesn't match, but adding $display changes the outcome ("the bug disappears when I add a print statement").
Both the scoreboard's always @ and the DUT's always_ff trigger on the same posedge clk. Within the Active region they execute in implementation-defined order; the scoreboard might read x[5] before or after the DUT's NBA update lands. Race.
Use a clocking block to sample at the Postponed region, which is after all NBAs have settled:
clocking cb @(posedge clk);
input #1step x_5 = top.dut.u_cpu.x[5]; // Postponed region sample
endclocking
initial forever begin
@cb;
if (cb.x_5 != expected_value) $error("Reg mismatch");
endAlternative: read the DUT signal one cycle later via a registered shadow. Both approaches eliminate the race.
Renamed DUT Internal — Hundreds of Tests Break Overnight
REFACTOR FALLOUT// Hundreds of tests reference internal RAM directly:
test_basic.sv: top.dut.u_cache.u_ram.mem[i] = init_data[i];
test_seu.sv: force top.dut.u_cpu.reg_file.x[5] = 0;
scoreboard.sv: if (top.dut.u_alu.result_r != expected) ...
arch_check.sv: top.dut.u_cpu.pc_r ...
// Architect renames u_cache → u_l2_cache. Suddenly:
// Error: hierarchical reference to non-existent scope 'u_cache'
// Hundreds of tests fail to compile. Day-long refactor sprint follows.Every test that uses the renamed path fails to elaborate. CI is red for everyone. "Why did you change the DUT?" "Because it should have been u_l2_cache from the start." Refactor blocked until every test is updated.
Every test file directly hardcoded the DUT's internal hierarchy. The DUT and tests became tightly coupled by name. Any internal rename ripples to every test that uses any path through the renamed scope.
Refactor the backdoors into a single probe package. Every test calls tb_probe_pkg::read_ram(i) instead of writing top.dut.u_cache.u_ram.mem[i] inline. When the DUT renames, update the package once; every test stays as-is. The cost is one round of mechanical refactoring; the payoff is permanent immunity to internal renames.
Interview Q&A — 12 Questions on Hierarchical References
Drawn from real interviews at chip-design and verification companies. Try to answer before reading each response.
A hierarchical reference is a name that addresses a signal or task by its position in the design's instance tree, using dot-separated scopes. Example: top.dut.u_cpu.reg_file.x[5] — read or write the x[5] storage inside the reg_file instance, inside u_cpu, inside dut, inside top. The reference resolves at elaboration time into a direct pointer to that storage cell.
Best Practices — Hierarchical Reference Rules to Walk Away With
- No hierarchical references in RTL. They are testbench-only. Lint rule fails CI on any hierarchical reference outside
tb,program, orinterfacescopes. - Isolate all backdoor access in a single probe package. Tests call
tb_probe_pkg::read_x(idx); the package owns every hierarchical path. DUT renames touch one file. - Always pair force with release. Wrap in a task:
force_for_cycles(path, val, n)— never expose rawforceto test authors. - Use absolute paths in cross-module code. Start with
$root.or the top module name. Avoid relative paths that depend on calling context. - Name every generate block. Hierarchical paths through generate blocks need
g_name[index]. Unnamed blocks produce tool-dependent paths. - Use bind for read-only DUT observation. Coverage collectors, assertion checkers, monitoring probes — all cleaner via
bindthan scattered hierarchical reads. - Read DUT signals in the Postponed region. Use a clocking block with
input #1stepfor samples that must land after NBAs. Avoid sampling at the Active region for signals the DUT updates. - No upward references in RTL. Pass everything through ports. Upward references silently rebind on module reuse — lint should reject them as errors.
- Add an end-of-test assertion that no forces are leaking. Enumerate known forced paths in a
finalblock;$fatalon any leftover. - Document every hierarchical reference at the call site. A comment explaining why this signal is accessed via the backdoor (e.g., "fast init — not exposed on port") makes the dependency visible to the next reader.