Declaring a vif takes one line: virtual bus_if vif. Binding fills it from the static world, so the reference always travels from tb_top into the classes.
Constructor binding is the strong default. An object that exists already holds a valid vif, and the null check lives in exactly one place.
The standard guard compares the handle with null in the constructor:
// snippet: the constructor guard; bus_if is declared in the listing below
class driver;
virtual bus_if vif;
function new(virtual bus_if vif);
if (vif == null)
$fatal(1, "driver::new called with null vif");
this.vif = vif; // bind at construction: never null afterward
endfunction
endclassNot every simulator builds a comparison of a virtual interface with null yet, so this course's runnable listings record the binding in a flag instead and check the flag. The idea is the same: no pin access until the binding is proven.
Never half-built: an object that exists already holds a valid vif.
One guard site: the null check sits in new, not before every pin access.
Visible dependency: the signature shows reviewers that a driver requires a bus_if.
A vif defaults to null. The first pin access before binding fatals, usually at time zero from a loop that started too early.
The root cause is always ordering. Bind first, guard second, and start time-consuming processes last.
Two places to guard in a hierarchy, shown as a fragment:
// snippet: guard patterns; bus_if and driver are the ones above
class monitor;
virtual bus_if vif;
// Pattern 1: guard in new() when binding via constructor
function new(virtual bus_if vif);
if (vif == null) $fatal(1, "monitor: null vif at construction");
this.vif = vif;
endfunction
endclass
class env;
driver drv;
monitor mon;
// Pattern 2: late binding via a connect step -- guard there,
// and again at start-of-run as a final safety net
function void connect(virtual bus_if bif);
drv = new(bif); // construct here: assigning drv.vif on a null drv crashes
mon = new(bif);
endfunction
task run();
if (drv == null || drv.vif == null)
$fatal(1, "env::run: drv never bound -- check connect order");
fork drv.run(); join_none
endtask
endclassconnect constructs every child before returning. The run guard checks the child handle first, then its vif, so neither dereference can fire.
connect assigns drv.vif directly instead of constructing drv, and drv was never built. What happens?The assignment itself dereferences a null drv. Construct each child in connect (drv = new(bif)), then guard drv == null before drv.vif.
if (drv.vif == null) in run() itself crashes with a null handle. Why?It dereferenced a possibly-null drv to reach the vif. Guard the child handle first: if (drv == null || drv.vif == null).
e.run() is called after e.connect(bif). What would happen if the two calls were swapped?run() would find drv still null and stop the simulation with its own clear message, instead of crashing later on a null vif.
A real vif bound in connect, guarded in run, and only then started: four beats on the bus.
interface bus_if (input logic clk);
logic [31:0] addr;
logic valid;
endinterface
class driver;
virtual bus_if vif;
local bit bound; // set only when a real instance is handed over
function void connect(virtual bus_if v);
vif = v;
bound = 1;
endfunction
function bit is_bound();
return bound;
endfunction
task run(int n); // drive n beats, then stop
repeat (n) begin
@(posedge vif.clk);
vif.valid <= 1'b1;
vif.addr <= vif.addr + 4;
end
@(posedge vif.clk);
vif.valid <= 1'b0;
endtask
endclass
class env;
driver drv;
function void connect(virtual bus_if bif);
drv = new(); // construct the child first
drv.connect(bif); // BIND
endfunction
task run();
if (drv == null || !drv.is_bound()) // GUARD the child, then its binding
$fatal(1, "env::run: driver never bound; check connect order");
drv.run(4); // START
endtask
endclass
module tb_bind;
logic clk = 0;
always #5 clk = ~clk;
bus_if bif (clk);
int beats = 0;
always @(negedge clk) if (bif.valid) beats++;
initial begin
env e;
bif.valid = 0; bif.addr = 0;
e = new();
if (e.drv != null) $fatal(1, "FAIL: driver exists before connect");
e.connect(bif);
e.run();
@(negedge clk);
if (beats == 4 && bif.addr == 32'h10) $display("PASS: bind, guard, start: the driver drove 4 beats and the address reached 10");
else $fatal(1, "FAIL: %0d beats, addr %h", beats, bif.addr);
$finish;
end
endmodulePASS: bind, guard, start: the driver drove 4 beats and the address reached 10Run with Verilator 5.048 (--binary --timing) on 2026-10-18; every bench printed the PASS line shown, with no warnings. These class benches were run on Verilator only.
Verilator 5.048 does not build a comparison of a virtual interface with null (a C++ compile error), so the runnable bench records the binding in a flag; the null-compare guard is shown as a fragment.