Hierarchical DUT access

Verification often needs to reach past the ports: to check an internal counter, preload a memory, or force a net to inject a fault. In cpptb you reach these objects the same way you reach a port — by naming the path the RTL already uses:

const auto status = dut.block1.block2.status.get();
dut.block1.block2.control.deposit(0x12);
dut.lanes[1].state.force(3);
dut.lanes[1].state.release();

The C++ path mirrors the elaborated SystemVerilog instance, generate-block, and object path. A misspelled path, invalid array index, or unsupported operation is a C++ compile error.

Runnable examples

Start with the fault-injection example. It is a complete framework testbench that starts a clock, resets the DUT, reads and forces an internal resolved net, forces clocked state, deposits and forces a memory element, and checks the resulting top-level outputs. Its C++ and pure SystemVerilog forms execute the same 13 checks over the same 7 clock edges:

make cpp-dpi-fault-injection-run
make cpp-dpi-fault-injection-sv-run

The central sequence uses the generated Dut directly and is registered like any other cpptb coroutine:

Task<void> fault_injection_sequence(Dut dut, TestContext& test) {
    dut.clk.set_now(0);
    test.start_clock(dut.clk, 10_ns);

    dut.rst_n.set(0);
    co_await clock_cycles(dut.clk, 2);
    dut.rst_n.set(1);

    dut.resolved_value.force(0xa5);
    test.expect_eq("force is immediately readable",
                   dut.resolved_value.get(), 0xa5u);

    co_await ReadOnly{};
    test.expect_eq("forced net reaches output", dut.resolved_o.get(), 0xa5u);
    co_await NextTimeStep{};
    dut.resolved_value.release();
}

CPPTB_REGISTER_TEST(fault_injection_sequence);

For adjacent typed access patterns, see rich data for wide packed values, fixed point, multidimensional arrays, packed structs, and enums. Fault injection is the smallest complete bench built around hierarchy access, and the examples index routes to every complete C++/pure-SV pair in the standard regression.

Operations

Top-level input ports use set() because they are normal testbench drives. Like every port drive under the write model, set() queues and flushes at the timestep’s ReadWrite point:

dut.request.set(1);
const auto response = dut.response.get();

Objects below the DUT hierarchy use explicit SystemVerilog backdoor operations:

const auto before = dut.core.pending.get();
dut.core.pending.deposit(0x2a);
dut.core.pending.force(0x3f);
dut.core.pending.release();
  • get() reads the current value immediately.

  • deposit(value) performs one blocking assignment immediately.

  • force(value) overrides normal HDL drivers immediately and remains active.

  • release() removes that force immediately.

Unlike a port set(), none of these queues: hierarchy operations apply the instant they are called, which is what makes them backdoors. deposit() is available on storage the simulator can assign directly; a resolved net accepts force() and release() but not deposit(). The timing summary puts the port and hierarchy operations side by side.

None of these operations advances simulation time or adds an evaluation phase. An immediate get() of the same object sees a deposit or force. When dependent RTL must execute before a check, settle first — co_await ReadOnly{} in a clocked bench, or an explicit Delay where nothing else creates timesteps:

dut.core.pending.deposit(0x2a);
test.expect_eq("immediate backdoor read", dut.core.pending.get(), 0x2au);

co_await ReadOnly{};
test.expect_eq("dependent output", dut.pending_o.get(), 0x2au);

After release(), a variable retains its last forced value until RTL writes it again. A net returns to its resolved drivers. These are the corresponding SystemVerilog semantics.

Arrays and generated scopes

Fixed unpacked memories preserve their declared index range:

dut.memory[5].deposit(0xbeef);
const auto value = dut.memory[5].get();

Multidimensional arrays take one index per dimension:

dut.coefficients[1][3].deposit(7);

Moving a whole region does not need a loop: memories carry allocation-free span forms that read or deposit a contiguous block in one call, starting at a given element index:

std::array<uint32_t, 64> image{};
dut.memory.get_into(0, image);        // read 64 words starting at index 0
dut.memory.deposit(128, image);       // deposit them starting at index 128

These are the backdoor primitives the memory models build on; the exact signatures are in the signals reference.

Elaborated instance and generate arrays use the same syntax and accept either a literal or runtime index:

const auto lane_state = dut.lanes[2].state.get();
const auto selected = dut.lanes[index].state.get();
const auto width = dut.lanes[index].block.WIDTH;

Homogeneous array elements return one generated typed view. An out-of-range runtime index reports the complete scope-array path and valid indices. Elaborated parameters remain read-only values on that selected view. Because a runtime index may select any element, using an operation on an array field generates that operation for the corresponding field in every homogeneous element. Unused fields and unused arrays still emit no DPI transport.

Packed and typed values

Signals up to 64 bits use native integer values. Wider packed values use Bits<W>:

Bits<137> command;
command.set_word(0, 0x1234'5678u);
command.set_word(4, 0x1ffu);
dut.core.command.deposit(command);
const Bits<137> observed = dut.core.command.get();

Generated packed enum and struct value types preserve names and fields. The same signal API accepts and returns those generated types. get_as<T>() and deposit_as(value) provide the explicit conversion point for fixed-point or other user value types that expose the required bit conversion.

For four-state objects, get_logic(), deposit_logic(), and force_logic() use LogicBits<W> with separate value and X/Z planes:

const auto stimulus = LogicBits<4>::from_string("10xz");
dut.core.bus.deposit_logic(stimulus);
const auto sampled = dut.core.bus.get_logic();

The ordinary get(), deposit(), and force() operations remain available as two-state operations. Four-state behavior depends on simulator support; Verilator, the current end-to-end reference backend, does not yet preserve X/Z semantics. Known LogicBits values work, while unknown writes fail before transport. See four-state values for the complete value API, capability gate, diagnostics, and current upstream limitation.

Hierarchical triggers

One-bit hierarchical objects support the same trigger vocabulary as ports:

co_await RisingEdge{dut.core.done};
co_await FallingEdge{dut.core.busy};
co_await Edge{dut.core.phase};

Only paths used by the compiled testbench receive generated edge observers.

Generation cost

The whole elaborated hierarchy is represented in the generated C++ type, but the proxy objects carry no runtime state. A discovery compile records the operations actually instantiated by the testbench; the final wrapper emits DPI exports and edge observers only for those path/operation pairs. An unused hierarchical object therefore adds no simulator process, callback, or runtime transport cost.

Inspect the inferred catalog without creating a user configuration file:

uv run --frozen cpptb-codegen rtl/design.sv --inspect-hierarchy
uv run --frozen cpptb-codegen rtl/design.sv \
  --hierarchy-json build/design-hierarchy.json
uv run --frozen cpptb-codegen rtl/design.sv \
  --check-hierarchy build/design-hierarchy.json

The JSON form is an optional review or CI snapshot of elaboration. It is generator output, never testbench input.

What can be forced

force() is generated for hierarchical objects used by the compiled testbench, not for ordinary DUT ports. Registered input clocks stay owned by start_clock() and cannot safely be forced or paused through the public API.

Calling force() or release() on an ordinary port produces an intentional compile-time diagnostic rather than a generic missing-member error. For a scheduler-owned clock, the diagnostic directs you back to TestContext::start_clock() and states that coherent clock pause and override are not yet supported.