The DUT is a small synchronous register file on a valid/ready port. A write stores wdata at addr, and a read returns the stored value on rdata one cycle after the handshake.
MINI TB ARCHITECTURE
test --builds--> env
|
+-----------+--------------+------------+
v v v v
generator --> driver monitor --> scoreboard
rand txns | vif.drv_cb | vif.mon_cb | model:
(mailbox) v v (mailbox) | mem[addr]
+--------- rf_if ---------+ |
| valid ready write | v
| addr wdata rdata | read == model ?
+------------+------------+
v
rf_dutRequest | Handshake | Visible result |
|---|---|---|
Write |
|
|
Read |
|
|
One transaction class flows through every layer. The generator randomizes it and never sees a clock, and the driver owns all timing: clocking-block drives, the ready wait, and the return to idle.
Piece | Role | Key line |
|---|---|---|
rf_txn | Unit of communication between layers | Static id counter pins each failure to one generator decision |
generator | Scenario layer, clock-free | Sets |
driver | Command layer, owns all timing | Drives through |
The generator takes address-window and error-rate knobs so tests can narrow stimulus without touching plumbing. Error-rate shaping itself is detailed in the stimulus topic; here the knob is only stored and forwarded.
The complete file below runs in this course's checks. The driver waits for one clock edge before its first drive, so the first transaction always starts on a fresh edge after reset.
`timescale 1ns/1ps
interface rf_if (input logic clk, input logic rst_n);
logic valid, ready, write;
logic [3:0] addr;
logic [31:0] wdata, rdata;
clocking drv_cb @(posedge clk);
default input #1step output #2;
output valid, write, addr, wdata;
input ready, rdata;
endclocking
clocking mon_cb @(posedge clk);
default input #1step;
input valid, ready, write, addr, wdata, rdata;
endclocking
endinterface
module rf_dut (
input logic clk,
input logic rst_n,
input logic valid,
input logic write,
input logic [3:0] addr,
input logic [31:0] wdata,
output logic ready,
output logic [31:0] rdata
);
logic [31:0] mem [0:15];
assign ready = 1'b1;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
rdata <= 32'h0;
for (int i = 0; i < 16; i++) mem[i] <= 32'h0;
end
else if (valid && ready && write) begin
mem[addr] <= wdata;
rdata <= 32'h0;
end
else if (valid && ready && !write) rdata <= mem[addr];
end
endmodule
class rf_txn;
rand bit write;
rand bit [3:0] addr;
rand bit [31:0] wdata;
logic [31:0] rdata; // filled by the monitor; 4-state keeps X visible
static int unsigned next_id = 0;
int unsigned id; // debug breadcrumb
function new();
id = next_id++;
endfunction
function string sprint();
return $sformatf("txn%0d %s addr=%h wdata=%h rdata=%h",
id, write ? "WR" : "RD", addr, wdata, rdata);
endfunction
endclass
class generator;
mailbox #(rf_txn) out;
int unsigned n;
int unsigned err_pct = 0;
bit [3:0] addr_lo = 0, addr_hi = 4'hF;
bit done_flag = 0;
event done;
function new(mailbox #(rf_txn) out, int unsigned n);
this.out = out; this.n = n;
endfunction
function void set_err_pct(int unsigned pct);
err_pct = (pct > 100) ? 100 : pct;
endfunction
task run();
repeat (n) begin
rf_txn t = new();
if (!t.randomize() with { addr inside {[addr_lo:addr_hi]}; }) begin
$display("GENERATOR: randomize failed, stopping");
$finish;
end
out.put(t); // blocks if the driver is behind
end
done_flag = 1;
-> done;
endtask
endclass
class driver;
mailbox #(rf_txn) in;
virtual rf_if vif;
function new(mailbox #(rf_txn) in);
this.in = in;
endfunction
task run();
@(vif.drv_cb); // sync to a clock edge before the first drive
forever begin
rf_txn t;
in.get(t);
@(vif.drv_cb);
vif.drv_cb.valid <= 1;
vif.drv_cb.write <= t.write;
vif.drv_cb.addr <= t.addr;
vif.drv_cb.wdata <= t.wdata;
do @(vif.drv_cb); while (vif.drv_cb.ready !== 1);
vif.drv_cb.valid <= 0; // one idle cycle between txns
end
endtask
endclass
class monitor;
mailbox #(rf_txn) out;
virtual rf_if vif;
int unsigned rd_latency = 1; // DUT read-data latency in cycles
function new(mailbox #(rf_txn) out);
this.out = out;
endfunction
task run();
forever begin
rf_txn t = new();
@(vif.mon_cb);
if (vif.mon_cb.valid === 1 && vif.mon_cb.ready === 1) begin
t.write = vif.mon_cb.write;
t.addr = vif.mon_cb.addr;
t.wdata = vif.mon_cb.wdata;
if (!t.write) begin
repeat (rd_latency) @(vif.mon_cb);
t.rdata = vif.mon_cb.rdata;
end
out.put(t);
end
end
endtask
endclass
class scoreboard;
mailbox #(rf_txn) in;
int unsigned expected_n, seen_n, bad;
bit [31:0] mem [bit [3:0]]; // associative array is the golden model
function new(mailbox #(rf_txn) in, int unsigned expected_n);
this.in = in; this.expected_n = expected_n;
endfunction
task run();
forever begin
rf_txn t;
in.get(t);
if (t.write) mem[t.addr] = t.wdata;
else begin
bit [31:0] exp = mem.exists(t.addr) ? mem[t.addr] : 32'h0;
if (t.rdata !== exp) begin
bad++;
$display("SCOREBOARD: %s returned %h, model holds %h",
t.sprint(), t.rdata, exp);
end
end
seen_n++;
end
endtask
task wait_done();
wait (seen_n >= expected_n);
endtask
function void report();
if (seen_n != expected_n) begin
bad++;
$display("SCOREBOARD: saw %0d of %0d, some responses never arrived",
seen_n, expected_n);
end
if (bad == 0) $display("SCOREBOARD: %0d transactions matched", seen_n);
else $display("SCOREBOARD: %0d bad results in %0d seen", bad, seen_n);
endfunction
endclass
class env;
virtual rf_if vif;
int unsigned num_txns = 100;
int unsigned timeout_cycles = 50000; // 500 us: inside the 1 ms watchdog
generator gen;
driver drv;
monitor mon;
scoreboard scb;
mailbox #(rf_txn) gen2drv;
mailbox #(rf_txn) mon2scb;
function void build();
gen2drv = new(1); // bounded: stimulus stays lazy under backpressure
mon2scb = new(); // unbounded: the monitor never stalls
gen = new(gen2drv, num_txns);
drv = new(gen2drv);
mon = new(mon2scb);
scb = new(mon2scb, num_txns);
drv.vif = vif;
mon.vif = vif;
endfunction
function void set_err_pct(int unsigned pct);
gen.set_err_pct(pct);
endfunction
task run();
bit finished = 0;
fork
begin
fork
gen.run(); drv.run(); mon.run(); scb.run();
join_none
wait (gen.done_flag);
scb.wait_done();
finished = 1;
end
begin
repeat (timeout_cycles) @(posedge vif.clk);
if (!finished) $display("ENV: end-of-test timeout reached");
end
join_any
disable fork;
endtask
function void report();
scb.report();
endfunction
endclass
class base_test;
env e;
virtual rf_if vif;
virtual task run();
e = new(); e.vif = vif; e.build(); e.run(); e.report();
endtask
endclass
Four legacy defects are fixed in this file. The generator uses a done flag, the monitor parameterizes read-data latency instead of assuming it, the environment bounds the end-of-test wait with a timeout, and the scoreboard report detects dropped transactions by comparing seen and expected counts. The DUT resets its memory, so a read of an unwritten address returns 0, not X, and rdata is a 4-state field, so any X still reaches the !== check.
The monitor has one dependency to know. While it waits rd_latency cycles for read data, it does not look at the handshake on those edges. That is safe here only because the driver always idles one cycle between transactions. With back-to-back or pipelined traffic, fork the data capture so the handshake loop samples every edge.
The listing's DUT ties ready high and its driver idles between transactions. This wave uses a DUT that stalls and back-to-back requests, to show the same rules under backpressure. Watch valid, ready, and rdata. The read handshake and the read data are one slot apart.
Slot 0: idle, all control low and rdata 0.
Slot 1: driver presents the write, valid 1 and write 1, DUT not yet ready.
Slot 2: ready joins, so the write completes here.
Slot 3: driver presents the read, valid 1 and write 0, and the handshake completes while rdata still reads 0.
Slot 4: valid has dropped and rdata shows dead, one cycle after the read handshake.
Slot 5: back to idle with rdata holding dead for the monitor to sample.
A module-only smoke variant of the same testbench that runs on Icarus and Verilator. Tasks stand in for the class layers. There is no separate monitor: the read task samples rdata at the pins, and the model is written from the stimulus values.
`timescale 1ns/1ps
module rf_dut (
input logic clk,
input logic rst_n,
input logic valid,
input logic write,
input logic [3:0] addr,
input logic [31:0] wdata,
output logic ready,
output logic [31:0] rdata
);
logic [31:0] mem [0:15];
assign ready = 1'b1;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) rdata <= 32'h0;
else if (valid && write) begin
mem[addr] <= wdata;
rdata <= 32'h0;
end
else if (valid && !write) rdata <= mem[addr];
end
endmodule
seen_n below expected_n. What does that mean, and which mechanism catches it first?Some DUT responses never arrived: the monitor saw fewer transactions than the generator produced. The environment timeout branch fires first, and scb.report() then names the shortfall (saw N of M) instead of hanging.
rd_latency cycles before sampling read data?The DUT returns read data one cycle after the handshake, so sampling immediately would compare the previous transaction data. The latency is a parameter because it is a DUT property, not a testbench constant.