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 rand fields 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...join style to actual intent matters even in working code — join_any on all-forever components 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.

Did you find what you were looking for?

Quiz

Test Your Knowledge

1 of 5 Score: 0 / 5