Plumbing moves transactions between testbench parts without races or leaks. Containers hold them, threads drive them, and channels synchronize them.
Pattern | Container | Example |
|---|---|---|
Known size, dense indices | Dynamic array | A packet payload allocated once |
Sparse or non-integer keys | Associative array | A memory model keyed by address |
Ordered, grows and shrinks | Queue | Scoreboards, generators, monitors |
Reading a missing associative entry returns the type default without allocating. Guard reads with exists() so defaults never masquerade as data.
The listing below is class declarations only, with no top module, so this course's checks do not run it. It shows the queue scoreboard idiom with its empty guard.
// not-run: fragment: class declarations only, no top module
class fifo_txn;
bit [7:0] data;
function bit compare(fifo_txn other);
return data == other.data;
endfunction
endclass
class scoreboard;
fifo_txn exp_q[$];
function void write_in(fifo_txn t);
exp_q.push_back(t);
endfunction
function void write_out(fifo_txn t);
fifo_txn exp;
if (exp_q.size() == 0) begin
$display("DUT output with no expected transaction");
return;
end
exp = exp_q.pop_front();
if (!t.compare(exp)) $display("data ordering wrong");
endfunction
endclassThe same FIFO idiom runs here on plain integers, with the empty guard included. The testbench pushes five values and proves ordered drain plus end-of-test emptiness.
module tb_int_fifo;
int exp_q[$];
int got;
bit empty_flag;
int errors = 0;
initial begin
for (int i = 1; i <= 5; i++) exp_q.push_back(i * 10);
for (int i = 1; i <= 5; i++) begin
if (exp_q.size() == 0) empty_flag = 1;
else begin empty_flag = 0; got = exp_q.pop_front(); end
if (empty_flag !== 1'b0 || got !== i * 10) errors++;
end
if (exp_q.size() !== 0) errors++;
if (exp_q.size() == 0) empty_flag = 1;
if (empty_flag !== 1'b1) errors++;
if (errors == 0) $display("PASS: int queue drained in order with the empty guard set");
else $fatal(1, "FAIL: queue order wrong");
$finish;
end
endmoduleFixed and dynamic arrays allocate every element, used or not. Associative arrays allocate per written element plus lookup overhead, which is the only sane way to model gigabytes of address space.
Queues allocate to current size and grow as needed. An unbounded queue that is pushed but never popped is the classic testbench leak: memory climbs and the run slows over hours.
The defense is an end-of-test queue-empty check plus periodic watermark prints on long runs. The leaking queue is then obvious.
Array locator and ordering methods with with replace hand-written loops. Each is one line, and the return types are the trap: locator methods return a queue, possibly empty, never a scalar.
// snippet
// errs holds every matching transaction; idxs holds their positions
// fifo_txn errs[$] = q.find with (item.is_err);
// int idxs[$] = q.find_index with (item.len > 8);
// int total = q.sum with (int'(item.len));
// fifo_txn big[$] = q.max with (item.len); // queue of size 0 or 1
// fifo_txn ul[$] = q.unique with (item.len); // one txn per distinct len
// q.sort with (item.addr); // ascending, in placeCast inside sum so a narrow field cannot wrap silently. Guard max, min and unique results with size() before indexing element zero. unique returns elements, not the with values: to get the distinct lengths, collect ul[i].len in a loop.
fork...join waits for all branches, for parallel drive of independent interfaces. fork...join_any resumes at the first finished branch, the timeout-watchdog pattern.
fork...join_none resumes immediately and launches background services. With join_any and join_none the other branches keep running; resuming is not killing.
join_none children start only when the parent yields. Code that forks and then immediately changes a shared variable hands the child the changed value.
Every branch captures the same loop variable, and join_none branches start after the loop completes. All four branches see the loop's exit value, 4, one past the last port. (Icarus shows this; Verilator 5.048 copies the loop variable per iteration and prints 0 1 2 3, so the bench below passes there even without the fix.)
The fix copies the loop variable into an automatic local inside the fork. The initializer runs at fork time and each branch owns a private copy.
disable fork kills every child of the current thread, not just the intended fork. A timeout disable after an earlier join_none monitor launch silently kills the monitors, and the test passes vacuously.
Wrap the disable-able fork in an isolating fork...join so only the inner children are in scope. Named blocks with disable name are the other option.
The listing below is a single task that calls an undefined wait_response, so this course's checks do not run it. It shows the isolation wrapper around a timeout.
// not-run: fragment: one task; wait_response is not defined here
task automatic run_with_timeout();
fork begin : isolated
fork
wait_response();
#10us;
join_any
disable fork; // kills only the wait and timeout pair
end join
endtaskThe same fork rules run here on plain threads. The testbench proves per-branch loop capture, then proves join_any plus disable fork ends the slow branch.
module tb_fork_catch;
timeunit 1ns; timeprecision 1ps;
int errors = 0;
int hit[4];
bit slow_done = 0;
initial begin
for (int i = 0; i < 4; i++) hit[i] = -1;
for (int i = 0; i < 4; i++) begin : gen
automatic int idx = i;
fork begin : worker
#10;
hit[idx] = idx;
end join_none
end
#11;
for (int i = 0; i < 4; i++) if (hit[i] !== i) errors++;
fork
begin #5; end
begin #100; slow_done = 1; end
join_any
disable fork;
#110;
if (slow_done !== 1'b0) errors++;
if (errors == 0) $display("PASS: loop capture gave 0 1 2 3 and disable ended the slow branch");
else $fatal(1, "FAIL: fork behavior wrong");
$finish;
end
endmoduleA mailbox is a thread-safe FIFO with blocking built in: get suspends while empty and a bounded put suspends while full. A bare queue has no synchronization.
The hand-rolled queue-plus-event version races when the trigger lands before the wait is posted. Bound the mailbox to a few entries so generation stays coupled to consumption instead of racing thousands ahead.
A semaphore manages tokens for a shared resource: get blocks until keys exist, put returns them. One key is a mutex; N keys model limited outstanding transactions.
Keys leak when a holder is killed before put, which deadlocks everyone behind it. put also mints unchecked keys, so a double-put silently inflates the pool.
The listing below is declarations and a task with no module, so this course's checks do not run it. Verilator 5.048 supports mailboxes and semaphores; Icarus 13 has no semaphores.
// not-run: fragment: declarations and a task, no module
mailbox #(int) gen2drv = new(8); // bounded: backpressure for free
semaphore bus_lock = new(1); // one key: mutex
task automatic drive_atomic(int t);
bus_lock.get(1);
// drive(t); // critical section: sole bus owner
bus_lock.put(1); // always return the key
endtask@(ev) only sees a trigger that arrives while already waiting. ev.triggered stays true for the rest of the time step, so wait(done.triggered) passes whether the process arrived before or after the trigger.
The flag clears at the next step. Cross-timestep handoffs still belong in a mailbox, not an event.
The bound adds backpressure: a fast generator cannot race thousands of transactions ahead. Memory stays flat and stimulus keeps responding to DUT state.
disable fork kills it. What does the rest of the test see?A hang. Everyone behind get blocks forever on a key that never returns, which looks like a DUT deadlock.