tb_top is the only module in the testbench. It owns everything that must exist at elaboration, and it holds no behavior: all behavior lives in classes.
tb_top RESPONSIBILITIES
1. clock gen always #5 clk = ~clk
2. reset gen assert, hold, release synchronously
3. interface bus_if bif (clk, rst_n)
4. DUT hookup per-pin connections from bif signals
5. vif handoff test.vif = bif, into the class world
6. kickoff wait out of reset, then t.run()
7. plumbing waves, timeout watchdog, finish
Static structure here, dynamic behavior in classes.Clocking block | Direction | Job |
|---|---|---|
drv_cb | Driver writes with output skew, reads with input sampling | Race-free driving just after the edge |
mon_cb | Input only, sampled just before the edge | Race-free observation of settled pins |
The full harness below runs in this course's checks on Verilator 5.048. It ends with a verdict: every generated transaction must have completed its handshake at the pins.
`timescale 1ns/1ps
interface bus_if (input logic clk, input logic rst_n);
logic valid, ready, write;
logic [15: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 simple_dut (
input logic clk,
input logic rst_n,
input logic valid,
input logic write,
input logic [15:0] addr,
input logic [31:0] wdata,
output logic ready,
output logic [31:0] rdata
);
logic [31:0] mem [0:65535];
assign ready = 1'b1;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) rdata <= 32'h0;
else if (valid && ready && write) mem[addr] <= wdata;
else if (valid && ready && !write) rdata <= mem[addr];
end
endmodule
class bus_txn;
rand bit write;
rand bit [15:0] addr;
rand bit [31:0] data;
bit [31:0] rdata;
function string sprint();
return $sformatf("%s addr=%h data=%h rdata=%h",
write ? "WR" : "RD", addr, data, rdata);
endfunction
endclass
class generator;
mailbox #(bus_txn) out;
int unsigned n;
bit done_flag = 0;
event done;
function new(mailbox #(bus_txn) out, int unsigned n);
this.out = out; this.n = n;
endfunction
task run();
repeat (n) begin
bus_txn t = new();
if (!t.randomize()) begin
$display("GENERATOR: randomize failed, stopping");
$finish;
end
out.put(t);
end
done_flag = 1;
-> done;
endtask
endclass
class driver;
mailbox #(bus_txn) in;
virtual bus_if vif;
int unsigned n_done = 0; // handshakes completed at the pins
function new(mailbox #(bus_txn) in);
this.in = in;
endfunction
task run();
forever begin
bus_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.data;
do @(vif.drv_cb); while (vif.drv_cb.ready !== 1);
vif.drv_cb.valid <= 0;
n_done++;
end
endtask
endclass
class env;
virtual bus_if vif;
int unsigned num_txns = 100;
int unsigned timeout_cycles = 50000; // 500 us: inside the 1 ms watchdog
generator gen;
driver drv;
function void build();
mailbox #(bus_txn) gen2drv = new(1);
gen = new(gen2drv, num_txns);
drv = new(gen2drv);
drv.vif = vif;
endfunction
task run();
bit finished = 0;
fork
begin
fork
gen.run();
drv.run();
join_none
// Generator done is not enough: the last items may still sit
// in the mailbox or in the driver. End on driver completion.
wait (gen.done_flag && drv.n_done == num_txns);
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
endclass
class base_test;
env e;
virtual bus_if vif;
virtual task run();
e = new();
e.vif = vif;
e.build();
e.run();
endtask
endclass
Four legacy defects are fixed here. The timescale covers the millisecond watchdog, and the unused modport is deleted because the DUT has plain ports. The generator sets a done flag before triggering its event, so an early finish cannot miss the wakeup. The environment ends on the driver's completion count, not on the generator: when the generator finishes, one item can still sit in the mailbox and one in the driver, and disable fork would kill them.
The listing's DUT ties ready high, so it never stalls. This wave uses a DUT that drops ready, to show the driver's wait. Watch valid against ready. A transfer happens only in slots where both are high.
Slot 0: idle, valid 0 and ready 0, data 0.
Slot 1: driver has asserted valid with data a1, DUT not yet ready.
Slot 2: ready joins, so the a1 transfer completes here.
Slot 3: driver has returned valid low for one idle cycle while data holds a1.
Slot 4: driver presents a new attempt, valid 1 with data b2, ready 0.
Slot 5: ready joins and the b2 transfer completes.
Classes cannot contain interfaces, so each class holds a typed reference called a virtual interface. The handoff flows top-down at build time: tb_top assigns the test handle, and the environment distributes it to driver and monitor before anything runs.
// snippet
// Connection by assignment: every holder of the handle
// sees the same physical interface instance.
class driver;
virtual bus_if vif; // handle, set by env before run()
endclass
class env;
virtual bus_if vif;
driver drv;
monitor mon;
function void build();
drv = new(); mon = new();
drv.vif = vif; // distribute downward
mon.vif = vif;
endfunction
endclassA program block runs its statements in the Reactive region, after design activity for the timeslot has settled. The theory is that testbench code then always sees post-update values, so a class of races disappears by construction.
Most modern flows skip program blocks anyway. Clocking blocks fix races at the pin boundary where they actually occur, with simpler semantics than global scheduling regions.
// snippet
// Approach A: program block (Spear-era style)
program automatic test_prog (bus_if bif);
initial begin
base_test t = new();
t.vif = bif;
t.run(); // runs in the Reactive region
end
endprogram // sim ends when this initial completes
// Approach B: plain module plus clocking blocks (industry-common)
module tb_top_plain;
// ... clock, reset, interface bif, DUT as in the harness above ...
base_test t;
initial begin
t = new();
// t.vif = bif; t.run();
end
// Race safety comes from the interface clocking blocks:
// @(vif.drv_cb) vif.drv_cb.valid <= 1; // skewed drive
// data = vif.mon_cb.rdata; // #1step sample
endmoduleImplicit end of simulation when program initials finish fights explicit end-of-test orchestration.
Clocking blocks still required: program schedules code late but does not specify drive skew at the pins.
UVM runs in modules, so mixing Reactive-region semantics with UVM phasing buys confusion, not safety.
A runnable subset of the harness discipline: negedge drives, settled sampling, and a bounded ready wait around a small DUT.
`timescale 1ns/1ps
module hs_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
gen.done_flag instead of gen.done.triggered?triggered is true only during the timestep that fires the event. A generator that finishes before the environment starts waiting misses the wakeup and hangs until the watchdog. The flag stays set, so the wait always observes it.