SystemVerilog adds powerful non-synthesizable constructs specifically for testbenches: interfaces for clean DUT connection, clocking blocks for race-free stimulus, classes for reusable components, and IPC primitives (mailbox, semaphore, event) for multi-threaded stimulus generation.
An interface bundles related signals into a single object. Instead of 20+ ports on your DUT, you pass one interface. The DUT and testbench both connect to the same interface instance:
// Define the AXI-lite interface interface axi_lite_if #(parameter DW = 32) (input logic clk, rstn); // Write Address logic [31:0] awaddr; logic awvalid, awready; // Write Data logic [DW-1:0] wdata; logic wvalid, wready; // Write Response logic [1:0] bresp; logic bvalid, bready; // ... AR, R channels ... endinterface // Top-level testbench instantiation module tb_top; logic clk, rstn; always #5 clk = ~clk; initial {clk, rstn} = 2'b0; initial begin #20 rstn = 1; end // Instantiate interface axi_lite_if #(32) axi (.clk(clk), .rstn(rstn)); // Pass to DUT my_slave dut (.axi(axi)); // Pass to test via uvm_config_db (Day 13) endmodule
A modport defines a directional view of the interface. The master modport drives awvalid/awaddr/wdata; the slave modport drives awready/wready. This enforces directionality at compile time:
interface axi_lite_if (input logic clk); logic awvalid, awready; logic [31:0] awaddr; // Master: drives valid/addr, receives ready modport master (output awvalid, awaddr, input awready, input clk); // Slave: receives valid/addr, drives ready modport slave (input awvalid, awaddr, output awready, input clk); endinterface // DUT uses the slave modport module my_slave (axi_lite_if.slave axi); // awvalid and awaddr are inputs; awready is output endmodule
Without a clocking block, there is a race between the testbench driving signals and the DUT sampling them at the same clock edge. The clocking block adds programmable input and output skews to eliminate this:
interface dut_if (input logic clk); logic valid; logic [7:0] data; logic ready; // Clocking block for the testbench driver clocking driver_cb @(posedge clk); // input skew: sample 1ns before rising edge default input #1ns; // output skew: drive 1ns after rising edge default output #1ns; output valid, data; // TB drives these input ready; // TB samples this endclocking modport drv (clocking driver_cb); endinterface // Using the clocking block in a driver task drive_packet(input [7:0] d); // Drives exactly 1ns after posedge — no race condition vif.driver_cb.valid <= 1; vif.driver_cb.data <= d; @(vif.driver_cb); // advance one clock cycle endtask
A virtual interface is a handle to an interface instance. Class-based components (like UVM agents) are dynamic objects — they cannot statically refer to a module-level interface. A virtual interface variable bridges this gap:
// In the driver class: class my_driver; virtual dut_if vif; // handle — not an instance task drive(input [7:0] d); vif.driver_cb.valid <= 1; vif.driver_cb.data <= d; @(vif.driver_cb); endtask endclass // In top-level testbench, assign the actual interface: initial begin my_driver drv = new(); drv.vif = dut_intf; // assign the module-level interface // In UVM: uvm_config_db #(virtual dut_if)::set(null, "*", "vif", dut_intf) end
A program block is a special module designed for testbench code. Unlike modules, programs respond to the Reactive region (after Active/NBA) and automatically add a #0 delay to avoid races. Programs call $exit instead of $finish:
program tb_prog (dut_if.drv dut); initial begin // Program block is scheduled in Reactive region // — avoids Active-region races with DUT dut.driver_cb.valid <= 0; @(dut.driver_cb); dut.driver_cb.valid <= 1; dut.driver_cb.data <= 8'hAB; @(dut.driver_cb); $exit; // ends the program (not the whole simulation) end endprogram
SystemVerilog classes support full OOP: inheritance, polymorphism, encapsulation. All UVM components and sequences are SV classes. Key concepts:
// Base transaction class class base_pkt; rand logic [7:0] addr; rand logic [31:0] data; rand logic read; // Constraint: read accesses only in 0–127 range constraint c_read_range { if (read) addr inside {[8'h00:8'h7F]}; } function void print(); $display("addr=%0h data=%0h read=%0b", addr, data, read); endfunction endclass // Derived class — overrides constraint class burst_pkt extends base_pkt; rand logic [3:0] burst_len; // Inherited constraint still applies constraint c_burst { burst_len inside {[1:16]}; } endclass // Dynamic creation and randomization base_pkt pkt = new(); assert(pkt.randomize()) else $fatal(1, "Randomize failed"); pkt.print();
A mailbox is a thread-safe queue. The generator thread creates packets and puts them; the driver thread gets them. This decouples the two threads — the generator can run ahead, and the driver paces itself:
// Parameterized mailbox (type-safe, SV2005+) mailbox #(base_pkt) gen2drv = new(); // unbounded mailbox #(base_pkt) mon2scb = new(16); // bounded to 16 entries // Generator thread initial begin for (int i = 0; i < 100; i++) begin base_pkt p = new(); assert(p.randomize()); gen2drv.put(p); // blocks if mailbox full (bounded) end end // Driver thread initial begin base_pkt p; forever begin gen2drv.get(p); // blocks until packet available drive_packet(p); // apply to DUT end end
// Semaphore — mutual exclusion (e.g., shared bus access) semaphore bus_key = new(1); // 1 key = mutex task drive_bus(base_pkt p); bus_key.get(1); // acquire key — blocks if in use drive_packet(p); bus_key.put(1); // release key endtask // Event — synchronise threads by triggering/waiting event reset_done; // Thread 1 — triggers event after reset initial begin rst_n = 0; repeat(4) @(posedge clk); rst_n = 1; ->reset_done; // trigger event end // Thread 2 — waits for event before starting initial begin @reset_done; // wait for event trigger // now safe to drive stimulus end
| Construct | Waits for | Use case |
|---|---|---|
fork…join | All threads complete | Multiple independent tasks that must all finish |
fork…join_any | First thread completes | Timeout: run DUT + watchdog, stop when either finishes |
fork…join_none | Does not wait — all threads run in background | Launch background monitors, checkers |
// fork…join — wait for all 3 threads fork drive_wr_transactions(); drive_rd_transactions(); drive_irq_stimulus(); join // fork…join_any — timeout watchdog pattern fork begin run_test(); $display("DONE"); end begin #10ms; $fatal(1, "TIMEOUT"); end join_any disable fork; // kill the other thread // fork…join_none — background monitor fork forever begin @(vif.valid); monitor_capture(); end join_none // continues immediately, monitor runs in background