SystemVerilog Testbench Example 1 built an environment without a generator, applying stimulus directly from the test. This example adds a generator back in, uses an event to pace it against the driver, and introduces a more advanced monitor pattern that runs two coordinated threads at once — along with a coordination gap in that pattern worth understanding before reusing it.

14 min read | Intermediate Level

What You'll Learn

  • How a generator paces itself against the driver using an event, instead of firing off every item at once
  • How two monitor threads, coordinated by a semaphore, attempt to pipeline capturing a transaction's two phases
  • Why two identical threads that both start on the same event, unoffset from each other, need extra care
  • How this environment's join_any differs meaningfully from Example 1's, since one component here actually terminates on its own

Design

The design is an address-based router: any transaction whose address falls at or below ADDR_DIV is routed to the "a" output port, and everything else goes to the "b" port, one clock cycle after the input is presented:


module switch
  # (parameter ADDR_WIDTH = 8,
     parameter DATA_WIDTH = 16,
     parameter ADDR_DIV = 8'h3F
    )
  
  ( input clk,
   	input rstn,
    input vld,
  
    input [ADDR_WIDTH-1:0] 	addr,
   input [DATA_WIDTH-1:0] 	data,
  
   output reg [ADDR_WIDTH-1:0] 	addr_a,
   output reg [DATA_WIDTH-1:0] 	data_a,

   output reg [ADDR_WIDTH-1:0] 	addr_b,
   output reg [DATA_WIDTH-1:0] 	data_b
  );
  
  always @ (posedge clk) begin
    if (!rstn) begin
      	addr_a <= 0;
    	data_a <= 0;
    	addr_b <= 0;
    	data_b <= 0;
    end else begin
      if (vld) begin
      	if (addr >= 0 & addr <= ADDR_DIV) begin
        	addr_a <= addr;
      		data_a <= data;
          	addr_b <= 0;
          	data_b <= 0;
      	end else begin
          	addr_a <= 0;
          	data_a <= 0;
        	addr_b <= addr;
        	data_b <= data;
      	end
      end
    end
  end
endmodule

addr >= 0 is always true here, since addr is declared as an unsigned [ADDR_WIDTH-1:0] input — it can never actually be negative. The real routing decision is entirely the addr <= ADDR_DIV half of the condition; the >= 0 check contributes nothing. Harmless, but worth recognizing so you don't assume it's doing more than it is.

Transaction Object

The transaction carries both the original request fields and the two possible routed-output fields, so a single object can represent an entire transaction from request to routed result:


// This is the base transaction object that will be used
// in the environment to initiate new transactions and
// capture transactions at DUT interface
class switch_item;
  rand bit [7:0]  	addr;
  rand bit [15:0] 	data;
  bit [7:0] 		addr_a;
  bit [15:0] 		data_a;
  bit [7:0] 		addr_b;
  bit [15:0] 		data_b;

    // 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 data=0x%0h addr_a=0x%0h data_a=0x%0h addr_b=0x%0h data_b=0x%0h",
              	$time, tag, addr, data, addr_a, data_a, addr_b, data_b);
  endfunction
endclass

Generator

The generator produces a fixed number of randomized transactions, waiting for the driver to signal completion of each one before creating the next — using the same event-based pacing pattern introduced in Example 1's driver:


// The generator class is used to generate a random
// number of transactions with random addresses and data
// that can be driven to the design
class generator;
  mailbox drv_mbx;
  event drv_done;
  int num = 20;
  
  task run();
    for (int i = 0; i < num; i++) begin
      switch_item item = new;
      item.randomize();
      $display ("T=%0t [Generator] Loop:%0d/%0d create next item", $time, i+1, num);
      drv_mbx.put(item);
      @(drv_done);
    end
    $display ("T=%0t [Generator] Done generation of %0d items", $time, num);
  endtask
endclass

Unlike the driver and monitor, this task actually returns once it's produced all num items — a detail that turns out to matter later in the Environment class below.

Driver

The driver here is simpler than Example 1's, since this design has no ready handshake — a single clock cycle is enough to hand off a transaction:


// 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 switch_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
      switch_item item;
  
      $display ("T=%0t [Driver] waiting for item ...", $time);
      drv_mbx.get(item);  
	  item.print("Driver");
      vif.vld 	<= 1;
      vif.addr 	<= item.addr;
      vif.data <= item.data;
  
      // When transfer is over, raise the done event
      @ (posedge vif.clk);
      vif.vld 	<= 0;
      ->drv_done;
    end  
  endtask
endclass

Because the generator immediately creates the next item the moment drv_done fires, and the driver immediately picks that item back up from the mailbox, transactions here run essentially back-to-back — vld goes high again on the very next clock cycle after it drops. There's no idle gap between transactions in this testbench.

Monitor

The monitor forks two identical threads, coordinated by a semaphore, intending to pipeline the two phases of a transaction — capturing the request while a previous transaction's routed result is still being awaited:


// 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 switch_if vif;
  mailbox scb_mbx;
  semaphore sema4;
  
  function new ();
    sema4 = new(1);
  endfunction
  
  task run();
    $display ("T=%0t [Monitor] starting ...", $time);
  
    // To get a pipeline effect of transfers, fork two threads
    // where each thread uses a semaphore for the address phase
	fork
      sample_port("Thread0");
      sample_port("Thread1");
    join
  endtask
  
  task sample_port(string tag="");
    // This task monitors the interface for a complete
    // transaction and pushes into the mailbox when the
    // transaction is complete
    forever begin
      @(posedge vif.clk);
      if (vif.rstn & vif.vld) begin
        switch_item item = new;
        sema4.get();
        item.addr = vif.addr;
        item.data = vif.data;
        $display("T=%0t [Monitor] %s First part over",
                 						$time, tag);
        @(posedge vif.clk);
		sema4.put();
        item.addr_a = vif.addr_a;
        item.data_a = vif.data_a;  
        item.addr_b = vif.addr_b;
        item.data_b = vif.data_b;
        $display("T=%0t [Monitor] %s Second part over",
                 						$time, tag);  
        scb_mbx.put(item);
        item.print({"Monitor_", tag});  
      end
    end
  endtask
endclass

Thread0 and Thread1 both start executing sample_port() at the exact same simulation time, and both immediately wait on the same @(posedge vif.clk) — neither is offset from the other. Since transactions here run back-to-back with no idle gap, on the very first vld pulse, both threads see it at once and both attempt to process it as their own transaction, racing for the semaphore. The intended pipelining — one thread handling a transaction's "first part" while the other is free to catch the next one — depends on the two threads naturally alternating which pulse each one is free for, and starting them unoffset like this doesn't guarantee that alternation actually happens correctly from the very first transaction onward. Exactly how this plays out depends on simulator scheduling details for two processes woken by the identical event, which isn't something to assume works out safely without checking the actual waveform or log for your specific tool. Trace through (or simulate) this pattern carefully before reusing it, rather than assuming the "pipeline" comment guarantees correct behavior.

Quick Check

Q: Why does the fact that transactions run back-to-back (no idle gap) make the two-thread monitor's coordination gap actually matter here, rather than being a purely theoretical concern?

Show Answer

If there were an idle gap between every transaction's vld pulse, both threads would have time to separate out — whichever thread finishes its two-cycle job first would naturally be the one waiting at the top of the loop when the next pulse arrives, and the other would still be occupied. With no gap at all, both threads can end up at the top of their loop at the same moment more than once, which is exactly when the unoffset starting point becomes a real risk instead of just a theoretical one.

Scoreboard

The scoreboard checks that a transaction was routed to the correct port, based on the exact same address condition the design itself uses:


// The scoreboard is responsible to check data integrity. Since
// the design routes packets based on an address range, the
// scoreboard checks that the packet's address is within valid
// range.
class scoreboard;
  mailbox scb_mbx;
  
  task run();
    forever begin
      switch_item item;
      scb_mbx.get(item);
  
      if (item.addr inside {[0:'h3f]}) begin
        if (item.addr_a != item.addr | item.data_a != item.data)
          $display ("T=%0t [Scoreboard] ERROR! Mismatch addr=0x%0h data=0x%0h addr_a=0x%0h data_a=0x%0h", $time, item.addr, item.data, item.addr_a, item.data_a);
        else
          $display ("T=%0t [Scoreboard] PASS! addr=0x%0h data=0x%0h addr_a=0x%0h data_a=0x%0h", $time, item.addr, item.data, item.addr_a, item.data_a);
  
      end else begin
        if (item.addr_b != item.addr | item.data_b != item.data)
          $display ("T=%0t [Scoreboard] ERROR! Mismatch addr=0x%0h data=0x%0h addr_b=0x%0h data_b=0x%0h", $time, item.addr, item.data, item.addr_b, item.data_b);
        else
          $display ("T=%0t [Scoreboard] PASS! addr=0x%0h data=0x%0h addr_b=0x%0h data_b=0x%0h", $time, item.addr, item.data, item.addr_b, item.data_b);
      end
    end
  endtask
endclass

This logic is only as trustworthy as the transaction objects it receives — if the monitor's coordination gap above ever causes a mismatched item to reach here, the scoreboard has no way to know that and will check it as if it were a legitimate, correctly-paired transaction.

Environment

The environment wires all four components 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
class env;
  driver 		d0; 		// Driver handle
  monitor 		m0; 		// Monitor handle
  generator		g0; 		// Generator Handle
  scoreboard	s0; 		// Scoreboard handle
  
  mailbox 	drv_mbx; 		// Connect GEN -> DRV
  mailbox 	scb_mbx; 		// Connect MON -> SCB
  event 	drv_done; 		// Indicates when driver is done
  
  virtual switch_if vif; 	// Virtual interface handle
  
  function new();
    d0 = new;
    m0 = new;
    g0 = new;
    s0 = new;
    drv_mbx = new();
    scb_mbx = new();
  
    d0.drv_mbx = drv_mbx;
    g0.drv_mbx = drv_mbx;
    m0.scb_mbx = scb_mbx;
    s0.scb_mbx = scb_mbx;
  
    d0.drv_done = drv_done;
    g0.drv_done = drv_done;
  endfunction
  
  virtual task run();
    d0.vif = vif;
    m0.vif = vif;
  
    fork
      d0.run();
      m0.run();
      g0.run();
      s0.run();
    join_any
  endtask
endclass

This is a better-matched use of join_any than the one in Testbench Example 1. There, every forked component ran forever, so join_any could never actually resolve. Here, g0.run() (the generator) genuinely finishes once it's produced all 20 items, giving join_any a real completion point to resolve on — while the driver, monitor, and scoreboard keep running in the background to let any in-flight transactions finish.

Test

The test class here is minimal — there's no directed stimulus to write, since the generator supplies it automatically:


// Test class instantiates the environment and starts it.
class test;
  env e0;
  
  function new();
    e0 = new;
  endfunction
  
  task run();
    e0.run();
  endtask
endclass

Note that this call to e0.run() is a plain, blocking call — not forked. That matters for how the top-level testbench module below actually completes.

Interface


// Design interface used to monitor activity and capture/drive
// transactions
interface switch_if (input bit clk);
  logic 		rstn;
  logic 		vld;
  logic [7:0] 	addr;
  logic [15:0]	data;
  
  logic [7:0] 	addr_a;
  logic [15:0]	data_a;
  
  logic [7:0] 	addr_b;
  logic [15:0]	data_b;
endinterface

Testbench Top


// Top level testbench module to instantiate design, interface
// start clocks and run the test
module tb;
  reg clk;
  
  always #10 clk =~ clk;
  switch_if 	_if (clk);
  switch u0 ( 	.clk(clk),
             .rstn(_if.rstn),
             .addr(_if.addr),
             .data(_if.data),
             .vld (_if.vld),
             .addr_a(_if.addr_a),
             .data_a(_if.data_a),
             .addr_b(_if.addr_b),
             .data_b(_if.data_b));
  test t0;
  
  initial begin
    {clk, _if.rstn} <= 0;
  
    // Apply reset and start stimulus
    #20 _if.rstn <= 1;
    t0 = new;
    t0.e0.vif = _if;
    t0.run();
  
    // Because multiple components and clock are running
    // in the background, we need to call $finish explicitly
    #50 $finish;
  end
  
  // System tasks to dump VCD waveform file
  initial begin
    $dumpvars;
    $dumpfile ("dump.vcd");
  end
endmodule

Because t0.run() is a plain blocking call, and because the environment's join_any genuinely resolves once the generator finishes, this initial block actually waits for all 20 items to be generated before reaching #50 $finish — that final delay is just a buffer to let any transaction still in flight through the driver, monitor, and scoreboard finish up, rather than the sole reason the simulation ever ends.

Where Does This Pattern Show Up Elsewhere?

Splitting a transaction into separate address and data phases, and trying to pipeline capturing them, mirrors how real bus protocols like AXI work — separate address and data channels that can be in flight for different transactions at the same time. Getting the monitor coordination right for that kind of pipelining is a genuinely common challenge in verification, which is exactly why the coordination gap flagged above is worth understanding rather than skipping past.

Common Beginner Mistakes

Mistake #1: Starting two "pipeline" threads without offsetting them

As the Monitor section above shows, two threads that both begin waiting on the same event at the same time aren't automatically staggered into alternating coverage of consecutive events. Why? If a pipelining pattern depends on one thread being "ahead" of the other, that offset has to be built in deliberately — it doesn't emerge just from forking two copies of the same task.

Mistake #2: Writing a redundant range check on an unsigned value

addr >= 0 on an unsigned addr is always true, and adds nothing to the condition it's part of. Why? It's easy to copy a range-check pattern from signed-value code without noticing it no longer does anything meaningful once the variable is unsigned — harmless here, but worth recognizing rather than assuming it's an active check.

Mistake #3: Assuming join_any is always the wrong (or always the right) choice

As the contrast between this article and Example 1 shows, join_any's usefulness depends entirely on whether any of the forked tasks actually terminates on its own. Why? With every task running forever, join_any can never resolve; with even one task — like this generator — genuinely finishing, it becomes a meaningful and useful completion signal.

Practice Exercise

Challenge: Stagger the Two Monitor Threads

Propose a change to sample_port() so that Thread1 starts one clock cycle later than Thread0, so the two threads are no longer both reacting to the very first vld pulse together.

Hint

You don't need to change sample_port() itself — you need to delay when Thread1's copy of it starts running, before it enters its own forever loop.

Solution

task run();
  $display ("T=%0t [Monitor] starting ...", $time);

  fork
    sample_port("Thread0");
    begin
      // Give Thread1 a one-cycle head start delay so it isn't
      // reacting to the exact same first pulse as Thread0
      @(posedge vif.clk);
      sample_port("Thread1");
    end
  join
endtask

This offsets Thread1 by one clock cycle relative to Thread0 before it enters its own forever loop, which is a plausible step toward the alternating coverage the original comment describes — though as with the flagged concern above, this specific fix should still be verified against an actual simulation run rather than assumed correct from reading the code alone.

Summary

In this tutorial, you saw a second working testbench that builds on Example 1's structure:

  • A generator paces itself against the driver using an event, rather than producing all stimulus up front
  • A monitor can fork multiple threads to try to pipeline capturing different phases of a transaction — but doing so correctly requires the threads to be properly staggered, not just duplicated
  • Two threads that both start waiting on the same event at the same time are not automatically offset from each other
  • join_any is only a meaningful synchronization point when at least one of the forked tasks can actually terminate on its own
  • A plain (non-forked) call to a task that eventually completes lets the caller naturally wait for that completion, rather than needing a separate mechanism to detect it

Continuing the Testbench Components Chapter

Continue with SystemVerilog Testbench Example Adder.

Did you find what you were looking for?

Quiz

Test Your Knowledge

1 of 5 Score: 0 / 5