SystemVerilog TestBench introduced the concepts and components of a simple testbench in the abstract. This article puts most of those pieces together into a practical, working example — a driver, a monitor, a scoreboard, and the environment and test classes that tie them together — showing how the SystemVerilog features covered throughout this tutorial combine into a reusable verification structure. This particular environment intentionally omits the generator, so stimulus is applied directly from the test instead.
14 min read | Intermediate Level
What You'll Learn
- How a transaction class, driver, monitor, and scoreboard divide up responsibility in a testbench
- How a virtual interface lets these class-based components reach the DUT
- How a mailbox connects the driver to the test, and the monitor to the scoreboard
- Why an environment class exists purely to wire components together, making the whole structure reusable across tests
Design
The design is a simple register-controller peripheral: a write provides its address and data together in a single clock cycle, while a read takes one extra cycle — the design deasserts ready for that one cycle to signal that no new transaction can start until the read completes.
// Note that in this protocol, write data is provided
// in a single clock along with the address while read
// data is received on the next clock, and no transactions
// can be started during that time indicated by "ready"
// signal.
module reg_ctrl
# (
parameter ADDR_WIDTH = 8,
parameter DATA_WIDTH = 16,
parameter DEPTH = 256,
parameter RESET_VAL = 16'h1234
)
( input clk,
input rstn,
input [ADDR_WIDTH-1:0] addr,
input sel,
input wr,
input [DATA_WIDTH-1:0] wdata,
output reg [DATA_WIDTH-1:0] rdata,
output reg ready);
// Some memory element to store data for each addr
reg [DATA_WIDTH-1:0] ctrl [DEPTH];
reg ready_dly;
wire ready_pe;
// If reset is asserted, clear the memory element
// Else store data to addr for valid writes
// For reads, provide read data back
always @ (posedge clk) begin
if (!rstn) begin
for (int i = 0; i < DEPTH; i += 1) begin
ctrl[i] <= RESET_VAL;
end
end else begin
if (sel & ready & wr) begin
ctrl[addr] <= wdata;
end
if (sel & ready & !wr) begin
rdata <= ctrl[addr];
end else begin
rdata <= 0;
end
end
end
// Ready is driven using this always block
// During reset, drive ready as 1
// Else drive ready low for a clock low
// for a read until the data is given back
always @ (posedge clk) begin
if (!rstn) begin
ready <= 1;
end else begin
if (sel & ready_pe) begin
ready <= 1;
end
if (sel & ready & !wr) begin
ready <= 0;
end
end
end
// Drive internal signal accordingly
always @ (posedge clk) begin
if (!rstn) ready_dly <= 1;
else ready_dly <= ready;
end
assign ready_pe = ~ready & ready_dly;
endmodule
Transaction Object
The transaction (or "packet") is a plain class that describes one register access. It carries rand fields for the driver to randomize and drive, and a plain rdata field for the monitor to fill in after observing a read:
class reg_item;
// This is the base transaction object that will be used
// in the environment to initiate new transactions and
// capture transactions at DUT interface
rand bit [7:0] addr;
rand bit [15:0] wdata;
bit [15:0] rdata;
rand bit wr;
// This function allows us to print contents of the data packet
// so that it is easier to track in a logfile
function void print(string tag="");
$display ("T=%0t [%s] addr=0x%0h wr=%0d wdata=0x%0h rdata=0x%0h",
$time, tag, addr, wr, wdata, rdata);
endfunction
endclass
Driver
The driver's only job is to pull a transaction out of a mailbox and drive it onto the DUT interface, waiting out the ready handshake described above before considering the transfer done:
// The driver is responsible for driving transactions to the DUT
// All it does is to get a transaction from the mailbox if it is
// available and drive it out into the DUT interface.
class driver;
virtual reg_if vif;
event drv_done;
mailbox drv_mbx;
task run();
$display ("T=%0t [Driver] starting ...", $time);
@ (posedge vif.clk);
// Try to get a new transaction every time and then assign
// packet contents to the interface. But do this only if the
// design is ready to accept new transactions
forever begin
reg_item item;
$display ("T=%0t [Driver] waiting for item ...", $time);
drv_mbx.get(item);
item.print("Driver");
vif.sel <= 1;
vif.addr <= item.addr;
vif.wr <= item.wr;
vif.wdata <= item.wdata;
@ (posedge vif.clk);
while (!vif.ready) begin
$display ("T=%0t [Driver] wait until ready is high", $time);
@(posedge vif.clk);
end
// When transfer is over, raise the done event
vif.sel <= 0;
->drv_done;
end
endtask
endclass
drv_done is declared and triggered here, but nothing in this particular environment ever waits on it — it's there for a future component (such as a generator) that would use it to pace how quickly new stimulus is produced, matching each transaction's actual completion instead of firing them all at once. See SystemVerilog Event for how a component elsewhere would consume it.
Monitor
The monitor passively watches the same virtual interface the driver uses, without ever driving anything itself. It reconstructs a transaction from what it observes and forwards it to the scoreboard through a separate mailbox:
// The monitor has a virtual interface handle with which it can monitor
// the events happening on the interface. It sees new transactions and then
// captures information into a packet and sends it to the scoreboard
// using another mailbox.
class monitor;
virtual reg_if vif;
mailbox scb_mbx; // Mailbox connected to scoreboard
task run();
$display ("T=%0t [Monitor] starting ...", $time);
// Check forever at every clock edge to see if there is a
// valid transaction and if yes, capture info into a class
// object and send it to the scoreboard when the transaction
// is over.
forever begin
@ (posedge vif.clk);
if (vif.sel) begin
reg_item item = new;
item.addr = vif.addr;
item.wr = vif.wr;
item.wdata = vif.wdata;
if (!vif.wr) begin
@(posedge vif.clk);
item.rdata = vif.rdata;
end
item.print("Monitor");
scb_mbx.put(item);
end
end
endtask
endclass
For a read, the monitor waits one extra clock edge before sampling rdata — matching exactly the one-cycle latency the design itself introduces for reads.
Scoreboard
The scoreboard checks data integrity by remembering what was last written to each address, so it can verify that a later read from that same address returns the same value:
// The scoreboard is responsible to check data integrity. Since the design
// stores data it receives for each address, scoreboard helps to check if the
// same data is received when the same address is read at any later point
// in time. So the scoreboard has a "memory" element which updates it
// internally for every write operation.
class scoreboard;
mailbox scb_mbx;
reg_item refq[256];
task run();
forever begin
reg_item item;
scb_mbx.get(item);
item.print("Scoreboard");
if (item.wr) begin
if (refq[item.addr] == null)
refq[item.addr] = new;
refq[item.addr] = item;
$display ("T=%0t [Scoreboard] Store addr=0x%0h wr=0x%0h data=0x%0h", $time, item.addr, item.wr, item.wdata);
end
if (!item.wr) begin
if (refq[item.addr] == null)
if (item.rdata != 'h1234)
$display ("T=%0t [Scoreboard] ERROR! First time read, addr=0x%0h exp=1234 act=0x%0h",
$time, item.addr, item.rdata);
else
$display ("T=%0t [Scoreboard] PASS! First time read, addr=0x%0h exp=1234 act=0x%0h",
$time, item.addr, item.rdata);
else
if (item.rdata != refq[item.addr].wdata)
$display ("T=%0t [Scoreboard] ERROR! addr=0x%0h exp=0x%0h act=0x%0h",
$time, item.addr, refq[item.addr].wdata, item.rdata);
else
$display ("T=%0t [Scoreboard] PASS! addr=0x%0h exp=0x%0h act=0x%0h",
$time, item.addr, refq[item.addr].wdata, item.rdata);
end
end
endtask
endclass
refq is a fixed-size array of transaction handles, one slot per address, defaulting to null until something has actually been written there — which is exactly what the "first time read" branch checks for, comparing against RESET_VAL (16'h1234) from the design.
Quick Check
Q: Why does the scoreboard compare against 'h1234 specifically when refq[item.addr] is still null?
Show Answer
Because that's the design's own RESET_VAL parameter — the value every address is initialized to on reset. If nothing has ever been written to an address, a read should still return that reset value, not garbage.
Environment
The environment is a container class with no logic of its own — it just instantiates the driver, monitor, and scoreboard, wires their handles together, and starts them running:
// The environment is a container object simply to hold all verification
// components together. This environment can then be reused later and all
// components in it would be automatically connected and available for use
// This is an environment without a generator.
class env;
driver d0; // Driver to design
monitor m0; // Monitor from design
scoreboard s0; // Scoreboard connected to monitor
mailbox scb_mbx; // Top level mailbox for SCB <-> MON
virtual reg_if vif; // Virtual interface handle
// Instantiate all testbench components
function new();
d0 = new;
m0 = new;
s0 = new;
scb_mbx = new();
endfunction
// Assign handles and start all components so that
// they all become active and wait for transactions to be
// available
virtual task run();
d0.vif = vif;
m0.vif = vif;
m0.scb_mbx = scb_mbx;
s0.scb_mbx = scb_mbx;
fork
s0.run();
d0.run();
m0.run();
join_any
endtask
endclass
All three of s0.run(), d0.run(), and m0.run() contain a forever loop, so none of them ever actually finishes on its own — which means this join_any can never resolve either. It happens to be harmless here, because whatever calls env.run() uses join_none on it anyway (see the Test class below) and never waits for it to return. But join_none would describe the actual intent more precisely: start all three components running in the background, and don't wait on any of them.
This class is what makes the environment reusable — a different test can create a new env, assign a different virtual interface, and get the exact same driver/monitor/scoreboard wiring without rewriting any of it.
Test
Since this environment has no generator, the test itself supplies stimulus directly — creating transactions and pushing them into the driver's mailbox:
// an environment without the generator and hence the stimulus should be
// written in the test.
class test;
env e0;
mailbox drv_mbx;
function new();
drv_mbx = new();
e0 = new();
endfunction
virtual task run();
e0.d0.drv_mbx = drv_mbx;
fork
e0.run();
join_none
apply_stim();
endtask
virtual task apply_stim();
reg_item item;
$display ("T=%0t [Test] Starting stimulus ...", $time);
item = new;
item.randomize() with { addr == 8'haa; wr == 1; };
drv_mbx.put(item);
item = new;
item.randomize() with { addr == 8'haa; wr == 0; };
drv_mbx.put(item);
endtask
endclass
The stimulus here is a simple write-then-read-back sequence at the same address (8'haa) — exactly the kind of case the scoreboard's stored-reference check is built to validate.
Interface
The interface bundles every DUT-facing signal together, giving class-based components a single handle to reach them all through:
// The interface allows verification components to access DUT signals
// using a virtual interface handle
interface reg_if (input bit clk);
logic rstn;
logic [7:0] addr;
logic [15:0] wdata;
logic [15:0] rdata;
logic wr;
logic sel;
logic ready;
endinterface
Testbench Top
The top-level module ties everything together: it instantiates the DUT and the interface, drives reset, then hands control over to the test:
// Top level testbench contains the interface, DUT and test handles which
// can be used to start test components once the DUT comes out of reset. Or
// the reset can also be a part of the test class in which case all you need
// to do is start the test's run method.
module tb;
reg clk;
always #10 clk = ~clk;
reg_if _if (clk);
reg_ctrl u0 ( .clk (clk),
.addr (_if.addr),
.rstn(_if.rstn),
.sel (_if.sel),
.wr (_if.wr),
.wdata (_if.wdata),
.rdata (_if.rdata),
.ready (_if.ready));
initial begin
test t0;
clk <= 0;
_if.rstn <= 0;
_if.sel <= 0;
#20 _if.rstn <= 1;
t0 = new;
t0.e0.vif = _if;
t0.run();
// Once the main stimulus is over, wait for some time
// until all transactions are finished and then end
// simulation. Note that $finish is required because
// there are components that are running forever in
// the background like clk, monitor, driver, etc
#200 $finish;
end
// Simulator dependent system tasks that can be used to
// dump simulation waves.
initial begin
$dumpfile("dump.vcd");
$dumpvars;
end
endmodule
t0.e0.vif is assigned before t0.run() is called — this ordering matters, since env::run() reads its own vif property to hand down to the driver and monitor the moment it starts. The explicit $finish at the end is necessary precisely because the clock generator, monitor, and driver all run in an endless loop and would otherwise never let the simulation end on its own — see SystemVerilog Program Block for how a program block's built-in completion model can avoid needing this in other structures.
Where Does This Pattern Show Up Elsewhere?
This driver/monitor/scoreboard/environment/test split is exactly the structure UVM formalizes and standardizes — UVM's driver, monitor, scoreboard, agent, and environment base classes exist to provide this same separation of responsibility, along with a common set of conventions for connecting them, so different teams' components can be reused together more easily than with a hand-rolled structure like this one.
Practice Exercise
Challenge: Add a Second Write-Read Pair
Extend test::apply_stim() to also write to and then read back from address 8'h55, after the existing 8'haa pair.
Hint
Follow the exact same pattern as the existing two transactions — a new reg_item, randomized with a fixed addr and wr, put into drv_mbx.
Solution
virtual task apply_stim();
reg_item item;
$display ("T=%0t [Test] Starting stimulus ...", $time);
item = new;
item.randomize() with { addr == 8'haa; wr == 1; };
drv_mbx.put(item);
item = new;
item.randomize() with { addr == 8'haa; wr == 0; };
drv_mbx.put(item);
item = new;
item.randomize() with { addr == 8'h55; wr == 1; };
drv_mbx.put(item);
item = new;
item.randomize() with { addr == 8'h55; wr == 0; };
drv_mbx.put(item);
endtask
Because the scoreboard's refq array is indexed per address, the new pair at 8'h55 is checked completely independently of the existing 8'haa pair — no other class needs to change at all.
Summary
In this tutorial, you saw how several previously covered SystemVerilog features combine into a working testbench:
- A transaction class describes one unit of stimulus, with
randfields for the driver and plain fields for the monitor to fill in - A driver and a monitor share a virtual interface, but only the driver drives signals — the monitor only observes
- Mailboxes connect components asynchronously: test to driver, and monitor to scoreboard
- A scoreboard remembers past writes to check that later reads return consistent data
- An environment class exists purely to wire components together, making the whole structure reusable across different tests
- Matching
fork...joinstyle to actual intent matters even in working code —join_anyon all-forevercomponents describes a promise that can never be kept, even when it happens to be harmless
Continuing the Testbench Components Chapter
Continue with SystemVerilog Testbench Example 2.