Tests think in operations: "write a 6-beat burst to this region". Pins move beats: one data word per valid/ready handshake. If the test produces beats directly, every test reimplements burst splitting, and a protocol change touches every test.
Layered stimulus puts one translation in each layer. A scenario layer produces high-level items, a translation layer turns each item into lower-level items, and a driver moves the lowest layer onto the pins. Monitors do the reverse, so the scoreboard compares at the level the test thinks in.
STIMULUS (down) CHECKING (up)
scenario layer burst{id, payload[]} scoreboard compares bursts
│ burst_mb (bounded 1) ▲
translator burst -> beats, last │ rebuilt bursts
│ beat_mb (bounded 2) │
beat driver valid/ready handshake monitor beats -> burst on last
│ ▲
└──────────────► DUT (2-entry FIFO) ─────┘ out_ready from sink
Back-pressure flows UP: DUT full -> driver holds -> beat_mb fills
-> translator blocks in put() -> burst_mb fills -> scenario blocks.A bounded mailbox is what joins two layers. put() blocks when it is full, so a stalled DUT slows the translator, which slows the scenario layer. Nothing upstream runs ahead of the pins, and nothing needs a separate flow-control protocol between classes.
Unbounded mailboxes also work functionally, but the scenario layer then finishes generating long before the DUT accepts the data. Any feedback (for example a scenario that reacts to a response or to a credit count) then sees stale state. Small bounds keep the layers in step.
Layer | Owns | Must not know |
|---|---|---|
Scenario | What the test wants: ids, lengths, data, ordering | Beats, handshakes, signal names |
Translator | How an item maps to lower items: split, last, header | Timing on the pins |
Driver | Handshake timing on the pins | What a burst is |
Monitor | Rebuilding items from pin activity | What the test intended |
Scoreboard | Comparing rebuilt items with expected ones | How they were transported |
The DUT is a two-entry beat FIFO with valid/ready on both sides. The scenario layer randomizes 40 bursts of 1 to 8 beats and records each one as expected. The translator splits bursts into beats and marks the last one. The driver handshakes beats in, a sink applies back-pressure on the output from a fixed LFSR, and the monitor rebuilds bursts on last for the scoreboard.
// DUT: two-entry beat FIFO with valid/ready on both sides.
module beat_fifo (
input logic clk, rst_n,
input logic in_valid,
output logic in_ready,
input logic [19:0] in_beat, // {id[3:0], data[15:0]}
input logic in_last,
output logic out_valid,
input logic out_ready,
output logic [19:0] out_beat,
output logic out_last
);
logic [20:0] mem [2];
logic wp, rp;
logic [1:0] count;
wire push = in_valid && in_ready;
wire pop = out_valid && out_ready;
assign in_ready = (count != 2);
assign out_valid = (count != 0);
assign {out_last, out_beat} = mem[rp];
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) begin
wp <= 0; rp <= 0; count <= 0;
end else begin
if (push) begin mem[wp] <= {in_last, in_beat}; wp <= ~wp; end
if (pop) rp <= ~rp;
count <= count + 2'(push) - 2'(pop);
end
endmodule
// Layer 1: scenario. A burst is what a test thinks in.
class burst;
rand bit [3:0] id;
rand int unsigned len;
bit [15:0] payload [$];
constraint c_len { len inside {[1:8]}; }
function void post_randomize();
payload.delete();
repeat (len) payload.push_back(16'($urandom));
endfunction
function bit same(burst o);
if (id != o.id || payload.size() != o.payload.size()) return 0;
foreach (payload[k]) if (payload[k] != o.payload[k]) return 0;
return 1;
endfunction
endclass
// Layer 2: beat. What the pins carry.
class beat;
bit [3:0] id;
bit [15:0] data;
bit last;
endclass
Payload words are generated in post_randomize() from the burst length. Constraining the size of a dynamic array was avoided on purpose: in our runs Verilator 5.048 returned a size of 0 for payload.size() inside {[1:8]} whenever randomize() also had an inline with constraint (a plain randomize() sized it correctly), which would produce empty bursts and a hang. The bench also ends with a timeout branch so that a hang becomes a FAIL with a count instead of a stuck job.
The bench checks more than data. It fails if any expected burst never arrived, if a beat's id differs from its burst's id, and if back-pressure never reached the driver. Without the last check, a sink that was always ready would pass while never testing the stall path.
Mutant runs. Making the FIFO always ready (assign in_ready = 1'b1) overwrites entries when full and fails on the first burst (4 of 8 beats arrived) and also reports that back-pressure never reached the driver. Dropping last on the final beat of every burst with id 7 makes the monitor merge two bursts, and the run fails with "4 bursts never arrived" plus a compare error on the merged burst.
in_ready depends only on the FIFO count, which changes only at the rising edge. Its value at the falling edge is therefore the value the next rising edge sees. If ready depended combinationally on valid or on inputs that change mid-cycle, the driver would have to sample it at the edge instead, for example through a clocking block.
At least one mailbox between the scenario and the driver is unbounded or very deep, so the scenario ran far ahead of the pins. That is legal, but any scenario logic that reacts to DUT state is now working with stale information. Bound the mailboxes to keep layers in step.