SystemVerilog Interfaces and Virtual Interfaces: Bridging Static Hardware and Dynamic Testbenches
Every verification engineer meets this bug eventually. The interface is instantiated next to the DUT, the driver is built, the driver has a vif property, and yet the first signal access reports that vif is null. The waveform shows sensible signals, and nothing about the RTL connectivity is broken.
This is usually not a connectivity problem. It is a reference problem. Something did not cross the boundary between SystemVerilog’s static elaborated world and its dynamic object world.
Elaborated hierarchy Runtime objects
-------------------- ---------------
interface instance <--- virtual handle ---> driver object
owns signals stores reference
An interface instance is part of the elaborated simulation hierarchy. It owns the protocol signals and any clocking blocks, modports, tasks, assertions, or other declarations placed inside it. A virtual interface is a typed variable that refers to an existing interface instance. It owns no signals and holds no copy of the interface.
Think of the instance as an installed control panel wired to real signals. The virtual interface is a typed access badge identifying which panel a class can use. Copying the badge does not copy the panel. Multiple badges can identify the same panel, and an unassigned badge identifies nothing—which is exactly what a null vif means.
The analogy has limits. A virtual interface does not transport transactions, spawn a proxy process, duplicate signal state, create hierarchy, or rewire DUT ports when reassigned. A modport-qualified handle is a compile-time access view, not a runtime security mechanism.
The keyword virtual is also overloaded. Virtual classes and virtual methods are object-oriented mechanisms involving abstract types and dynamic dispatch. A virtual interface is neither. It is a reference to an interface instance.
Instances, handles, and the elaboration boundary
An interface declaration defines reusable structure. Instantiating that declaration creates a named object in the elaborated hierarchy.
interface bus_if(input logic clk);
logic valid;
logic ready;
logic [31:0] data;
endinterface
module tb;
logic clk;
bus_if bus(clk);
endmodule
bus_if is the declaration. bus is the instance. The members bus.valid, bus.ready, and bus.data belong to that instance because the simulator elaborated bus_if bus(clk) inside tb.
Calling an interface “a bundle of wires” is a useful introduction, but an interface can also contain parameters, modports, clocking blocks, tasks, functions, assertions, and coverage declarations. Whether a particular interface is synthesizable depends on its contents, use, and implementation-tool support. The language construct is not inherently testbench-only.
Classes use a different creation model. Module and interface instances acquire fixed identities during elaboration. Class objects are constructed during simulation with new() or through a framework such as the UVM factory. A class therefore cannot contain an interface instance declaration:
class bus_driver;
bus_if bus; // Illegal: this would be an interface instance declaration.
endclass
The legal bridge is a virtual-interface property:
class bus_driver;
virtual bus_if vif;
endclass
That declaration creates a nullable variable of type virtual bus_if. It creates no bus_if instance, signals, processes, or structural connectivity.
| Question | Interface instance | Virtual interface variable |
|---|---|---|
| How does it come into existence? | Elaboration | Variable declaration; later assignment |
| Does it own protocol signals? | Yes | No |
| Can a class contain it? | Not as an instance | Yes, as a property |
| Can it be null? | No | Yes |
| What does assignment copy? | Not applicable | A reference, never signal values |
| Can references be redirected? | Instance identity is fixed | Yes, to a compatible instance |
Multiple virtual-interface variables may refer to the same interface instance:
virtual bus_if a;
virtual bus_if b;
a = tb.bus0;
b = a;
Both variables now refer to tb.bus0. If two active drivers receive those handles, both act on the same physical signals. That can produce an unintended multi-driver failure even though every declaration is type-correct.
A handle can also be redirected:
interface id_if;
logic [7:0] data;
endinterface
module identity_example;
id_if bus0();
id_if bus1();
virtual id_if vif;
initial begin
bus0.data = 8'h11;
bus1.data = 8'h22;
vif = bus0;
$display("bus0 through vif: %h", vif.data);
vif = bus1;
$display("bus1 through vif: %h", vif.data);
end
endmodule
After reassignment, the same expression, vif.data, reaches a different instance. Reassignment does not change either instance’s DUT connectivity. Production agents normally bind once and retain that identity; changing it during traffic makes ownership and debug unnecessarily difficult.
A complete plain-SystemVerilog example
Virtual interfaces are SystemVerilog language features, not UVM features. The following self-contained example includes a parameterized interface, clocking block, modports, response model, class driver, constructor injection, reset, and one transfer.
`timescale 1ns/1ps
interface bus_if #(
parameter int DATA_W = 8
)(
input logic clk,
input logic rst_n
);
logic valid;
logic ready;
logic [DATA_W-1:0] data;
clocking drv_cb @(posedge clk);
default input #1step output #0;
output valid, data;
input ready;
endclocking
clocking mon_cb @(posedge clk);
default input #1step;
input valid, ready, data;
endclocking
modport DUT (
input clk, rst_n, valid, data,
output ready
);
modport DRIVER (
input rst_n,
clocking drv_cb
);
modport MONITOR (
input rst_n,
clocking mon_cb
);
endinterface
class bus_driver;
virtual bus_if #(8).DRIVER vif;
function new(virtual bus_if #(8).DRIVER vif);
if (vif == null)
$fatal(1, "bus_driver received a null virtual interface");
this.vif = vif;
endfunction
task send(logic [7:0] value);
while (!vif.rst_n)
@(vif.drv_cb);
@(vif.drv_cb);
vif.drv_cb.valid <= 1'b1;
vif.drv_cb.data <= value;
do
@(vif.drv_cb);
while (!vif.drv_cb.ready);
vif.drv_cb.valid <= 1'b0;
endtask
endclass
module tb;
logic clk = 1'b0;
logic rst_n = 1'b0;
bus_if #(8) bus(clk, rst_n);
bus_driver drv;
always #5 clk = ~clk;
// Minimal ready-response model standing in for a DUT.
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n)
bus.ready <= 1'b0;
else
bus.ready <= bus.valid;
end
initial begin
bus.valid = 1'b0;
bus.data = '0;
// A full interface instance is assignment-compatible with the
// corresponding modport-qualified virtual-interface formal.
drv = new(bus);
repeat (2) @(posedge clk);
rst_n <= 1'b1;
drv.send(8'hA5);
@(posedge clk);
$display("Transfer completed, data=%h", bus.data);
$finish;
end
endmodule
Three declarations carry the central idea. bus_if #(8) bus(clk, rst_n) creates the real interface instance. virtual bus_if #(8).DRIVER vif creates only a typed reference variable. drv = new(bus) associates that variable with the existing instance through its DRIVER view.
The full-interface-to-modport-qualified assignment is permitted by SystemVerilog’s virtual-interface compatibility rules when the underlying interface type and parameterization are compatible. Some projects instead pass an explicit modport selection, such as bus.DRIVER, for emphasis or tool compatibility.
The instance and class also have independent lifetimes. Losing the final handle to drv would not remove bus; the interface remains part of tb for the entire simulation. Constructing another driver with new(bus) would give both objects access to the same instance.
Virtual interface, modport, and clocking block
These constructs answer three different questions:
- A virtual interface selects the concrete interface instance.
- A modport selects the role-specific access view.
- A clocking block defines signal timing relative to a clocking event.
A plain virtual bus_if #(8) handle exposes the unrestricted interface view. A virtual bus_if #(8).DRIVER handle documents that the class expects the driver view and allows the compiler to reject accesses unavailable through that modport.
Modports are not runtime authorization. They provide language-level direction and visibility rules, and diagnostics can vary across operations and tools. Full-interface handles remain reasonable for small benches, shared utilities, or projects where tool portability outweighs narrower role typing.
Clocking blocks solve a separate scheduling problem. Direct access is legal:
@(posedge vif.clk);
vif.valid <= 1'b1;
It can also be correct when the project has deliberately defined its event-region behavior. The risk is that DUT and testbench code may sample or drive around the same edge using assumptions that are not expressed anywhere.
Clocking-block access makes the timing contract explicit:
@(vif.drv_cb);
vif.drv_cb.valid <= 1'b1;
In the complete example, input #1step samples inputs immediately before the clocking event, while output #0 drives clocking outputs at zero skew according to clocking-block semantics. Those are example choices, not universal protocol settings. Skews must match the DUT timing model and project methodology.
Clocking blocks reduce dependence on ad hoc scheduler ordering, but they cannot prevent every race. Incorrect skew, asynchronous signals accessed outside the block, mixed access styles, or multiple drivers can still produce failures.
Passing the handle through UVM
In UVM, the static top normally places a virtual-interface handle into uvm_config_db before run_test(). A factory-created component retrieves it during build_phase. This is configuration lookup, not interface creation or DUT connection.
A shared typedef keeps the property, set(), and get() specializations from drifting textually. The interface declaration must be compiled before the package containing this typedef.
package bus_vif_pkg;
typedef virtual bus_if #(32).DRIVER bus32_driver_vif_t;
typedef virtual bus_if #(32) bus32_full_vif_t;
endpackage
The top-level module uses the role-specific typedef when configuring the driver:
module tb;
import uvm_pkg::*;
import bus_vif_pkg::*;
`include "uvm_macros.svh"
logic clk = 1'b0;
logic rst_n = 1'b0;
bus_if #(32) bus(clk, rst_n);
always #5 clk = ~clk;
initial begin
uvm_config_db#(bus32_driver_vif_t)::set(
null,
"uvm_test_top.env.agent.drv",
"vif",
bus
);
run_test();
end
endmodule
The narrow path is intentional: exactly one driver receives this instance. Passing bus stores a compatible reference to an already elaborated interface. It does not copy values into UVM.
The driver declares and retrieves the same typedef:
class bus_driver extends uvm_driver #(bus_item);
`uvm_component_utils(bus_driver)
bus32_driver_vif_t vif;
function new(string name = "bus_driver",
uvm_component parent = null);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
if (!uvm_config_db#(bus32_driver_vif_t)::get(
this, "", "vif", vif)) begin
`uvm_fatal(
"NO_VIF",
$sformatf("No bus interface configured for %s",
get_full_name())
)
end
if (vif == null) begin
`uvm_fatal(
"NULL_VIF",
$sformatf("Null bus interface configured for %s",
get_full_name())
)
end
endfunction
endclass
A UVM component constructor must retain the factory-compatible name and parent argument shape. SystemVerilog permits those arguments to have defaults, as shown here; the defaults are optional convenience, not a different factory contract.
The two checks distinguish different failures. A false get() result means no matching resource was found. A successful lookup followed by vif == null means a matching resource contained a null handle. That second case can arise through a null setter argument, an uninitialized configuration object, or a later override.
The config_db type mismatch that looks like a path failure
The complete type is part of the UVM configuration contract. These calls do not match:
uvm_config_db#(bus32_full_vif_t)::set(
null,
"uvm_test_top.env.agent.drv",
"vif",
bus
);
if (!uvm_config_db#(bus32_driver_vif_t)::get(
this, "", "vif", vif)) begin
`uvm_error("NO_VIF", "Lookup failed")
end
The first call uses bus32_full_vif_t, an unrestricted virtual-interface type. The second uses bus32_driver_vif_t, a modport-qualified type. Even if ordinary SystemVerilog assignment compatibility would allow a value to cross those views, the two calls use different uvm_config_db specializations. The public typed API does not perform a conversion from a resource stored under one specialization to a lookup under another. No assumption about the library’s internal table implementation is required to explain the failure.
Parameterization produces the same issue:
virtual bus_if #(8).DRIVER vif8;
virtual bus_if #(32).DRIVER vif32;
Those are different interface specializations. A 32-bit instance cannot satisfy an 8-bit virtual-interface property, and a UVM resource stored using the 32-bit type is not retrieved through the 8-bit config_db specialization.
Use one shared typedef for each intended role and specialization. Apply it to the component property, set(), get(), constructor or setter arguments, and any agent configuration object carrying the handle.
Map replicated agents explicitly
Two compatible interface instances still have different identities. Configure replicated agents with explicit paths:
bus_if #(32) bus0(clk, rst_n);
bus_if #(32) bus1(clk, rst_n);
initial begin
uvm_config_db#(bus32_driver_vif_t)::set(
null,
"uvm_test_top.env.agent0.drv",
"vif",
bus0
);
uvm_config_db#(bus32_driver_vif_t)::set(
null,
"uvm_test_top.env.agent1.drv",
"vif",
bus1
);
run_test();
end
The mapping is auditable: agent0 uses bus0, and agent1 uses bus1. Configuration identifies which static instance each dynamic component accesses; it does not alter the instances’ DUT connections.
A broad wildcard can collapse that mapping:
uvm_config_db#(bus32_driver_vif_t)::set(
null,
"uvm_test_top.env.*",
"vif",
bus0
);
Both drivers may retrieve bus0, causing contention while bus1 remains idle. Wildcards are useful for intentional sharing and tightly bounded hierarchies, but replicated active agents should default to narrow paths.
Larger agents often benefit from one configuration object carrying the virtual interface, active/passive state, protocol options, and a logical port name or index. Validate that object once during build_phase. The explicit identifier matters because portable virtual-interface instance-path introspection is limited; logging port_id="bus0" is more reliable than expecting every simulator to print a useful handle representation.
A short diagnostic sequence
When a virtual interface fails, first print the receiver’s get_full_name(). Debug the hierarchy UVM actually built, not the path you expected it to build. Then check the boolean returned by get() and distinguish a missing resource from a matching resource containing null.
Compare the field name exactly, including spelling and whitespace. Next compare the complete type: interface declaration, parameter values, modport qualification, and shared typedef. If the type and lookup are correct, verify which concrete interface instance was configured for that component and log its logical port identifier.
Search for wildcard settings or overrides that also match the receiver. A valid handle can still identify the wrong compatible instance. Finally, inspect signal access: confirm that drivers and monitors use their intended modports and clocking blocks rather than mixing raw-edge access with the project’s clocking-block API.
Keep virtual interfaces in signal-facing drivers and monitors. Sequences should exchange transactions through sequencers, and scoreboards should use analysis or TLM interfaces. A class being able to hold a virtual interface does not make direct signal access appropriate everywhere.
Portability and references
The normative language reference is IEEE 1800-2023, SystemVerilog—Unified Hardware Design, Specification, and Verification Language, particularly its interface, modport, clocking-block, class, and virtual-interface provisions.
For UVM, consult the documentation matching the project version. Current standardized environments should use the applicable edition of IEEE 1800.2, Standard for Universal Verification Methodology Language Reference Manual. Projects using UVM 1.2 should also retain the UVM 1.2 uvm_config_db class reference with their versioned methodology documentation.
Historical Accellera proposals and vendor tutorials are useful secondary explanations, but they are not substitutes for IEEE 1800-2023 or the applicable UVM specification. Parameterized, modport-qualified virtual interfaces combined with clocking blocks, typedefs, and UVM configuration have exposed differences in older simulator versions and compilation flows. Before adopting a project-wide declaration style, compile a representative example—including full-interface-to-qualified-handle assignment—on every supported simulator and version.
Audit one active agent in the current environment. Identify its concrete interface instance, trace the handle through set() and get(), verify the shared typedef, parameters, modport, field name, and component path, log the logical port identity, and confirm that the driver and monitor use the intended clocking blocks.
Discussion
0 comments
No comments yet — start the conversation.