Building a Reusable Layered Environment | FPGA Pour Tous
Summary
Building a Reusable Layered Environment
Assemble transactions, a generator, driver, monitors, reference model, scoreboard, and coverage into an extensible testbench.
A stable chain of responsibilities
A layered environment separates responsibilities so that errors can be located and tests can reuse the same infrastructure:
the test sets the configuration, constraints, and transaction count;
the generator randomizes a blueprint transaction and sends a copy;
the driver turns that transaction into signal-level activity;
the input monitor reconstructs what the DUT actually accepted;
the reference model produces the expected result from that observation;
the output monitor reconstructs the actual response;
the scoreboard matches and compares expected and observed results;
coverage samples transactions that were actually accepted.
The driver does not decide whether a result is correct. A monitor never drives the DUT. The model reads no internal DUT signal. These boundaries prevent the same code from producing stimulus and confirming its own error.
1. Define the physical boundary once
The interface centralizes the timing protocol. The driver and monitor receive different views of the same physical instance:
The top-level module instantiates the DUT, clock, and interface. Dynamic objects then receive a virtual interface through their constructors or through the environment configuration.
2. Transport an independent transaction
class stream_item; rand logic [31:0] data; rand int unsigned idle_cycles; constraint legal_idle { idle_cycles inside {[0:8]}; } function stream_item copy(); stream_item result = new(); result.data = data; result.idle_cycles = idle_cycles; return result; endfunctionendclass
The generator retains a blueprint to preserve any randc history, checks every randomize() call, and places blueprint.copy() in a typed mailbox. The driver therefore never sees an object that the generator can still modify.
3. Work at transaction level in the driver and signal level in the clocking block
class stream_driver; stream_drv_vif_t vif; mailbox #(stream_item) inbox; int unsigned driven_count; function new(stream_drv_vif_t vif, mailbox #(stream_item) inbox); this.vif = vif; this.inbox = inbox; endfunction task run(); stream_item item; forever begin inbox.get(item); repeat (item.idle_cycles) @vif.drv_cb; vif.drv_cb.valid <= 1'b1; vif.drv_cb.data <= item.data; do @vif.drv_cb; while (!vif.drv_cb.ready); vif.drv_cb.valid <= 1'b0; driven_count++; end endtaskendclass
The driver follows the handshake cycle by cycle and never accesses the DUT's internal hierarchy. Assignments to clocking-block outputs are nonblocking.
4. Let the monitor reconstruct what happened
task run(); forever begin @vif.mon_cb; if (vif.mon_cb.rst_n && vif.mon_cb.valid && vif.mon_cb.ready) begin stream_item observed = new(); observed.data = vif.mon_cb.data; observed_mb.put(observed); end endendtask
The monitor is passive. It publishes a transaction only after the protocol has accepted it. For a block that transforms a stream, use one monitor at the input and another at the output. The expected result must start from the observed input, not from the object the generator hoped to send: reset, error injection, or interruption may have changed the actual transfer.
5. Predict, store, and match
The reference model applies a rule that is simpler than and independent from the RTL to each observed input transaction. It sends a copy of the predicted response to the scoreboard.
Storage depends on the contract:
strictly ordered responses: compare the head of a queue;
reordered responses with a unique identifier: use an associative array indexed by identifier;
reordered responses without a direct key: use a locator method, then explicitly check for zero, one, or several matches.
After a match, the scoreboard removes the expected item. It maintains separate counts for expected, observed, matched, and erroneous transactions. A mismatch message includes the identifier, expected result, observed result, time, and any useful context.
6. Build, run, and then drain
The environment owns the mailboxes and component handles. Its build() method creates and connects them without starting any thread. Its run() method starts timed activities in parallel. Its wrap_up() method checks counters and remaining expectations.
The condition pending() == 0 alone would be insufficient: it may be true just after the generator finishes, before the driver or model has produced the first expected result. The skeleton therefore waits in turn for every command to be accepted, every expected result to be created, and every result to be compared. It waits for compared_count, not the number of successful matches: an incorrect response must lead promptly to a FAIL report instead of blocking until timeout. A watchdog remains essential when a response never arrives. When the protocol allows zero or several responses per command, these counters must represent that rule explicitly instead of assuming a one-to-one match.
Wrap-up fails if no check ran, an error was observed, or an expected item remains in the scoreboard. Coverage from a failed run must not be merged into the campaign.
7. Extend without rewriting the environment
A test does not replace the driver and monitors for every scenario. Instead, it changes:
blueprint constraints;
configuration and transaction count;
a derived class instantiated in place of a base type;
callbacks provided around transmission or observation.
Callback order matters. An alteration must occur before transmission; the expected model and coverage act after confirmation that the transaction was actually sent or received. In a large environment, publishing to multiple subscribers is preferable to direct calls, but the responsibility remains the same.
Validate the testbench too
Deliberately injecting a wrong response must trigger the scoreboard. Dropping a response must trigger the timeout or outstanding-item check. Duplicating a response must produce an unexpected observation. A clearly faulty DUT that receives PASS exposes a nondiscriminating testbench, regardless of its coverage.
Key takeaways
Virtual interfaces separate the physical interface from class-based components.
The generator randomizes a blueprint and sends an independent copy.
The driver applies, monitors observe, the model predicts, and the scoreboard decides.
Expected results come from an input that was actually accepted, and coverage from a transaction that was actually observed.
Build, Run, and Wrap-up make construction, concurrency, draining, and the verdict explicit.
Tests change configuration, constraints, and extensions, not the shared infrastructure.