A blocking drive (vif.valid = 1) at @(posedge vif.clk), or a raw read of DUT outputs there, runs in the same step as the DUT flops. Old or new value wins by scheduling order, which varies between simulators.
A nonblocking drive (vif.valid <= 1) after the edge does not race: the DUT flops on that edge always see the old value. A clocking block goes further. It samples inputs before the edge and applies drives after it, so every component inherits the same explicit timing through vif.cb.
One canonical interface serves this lesson and every later one. It carries two clocking blocks and one modport per view.
The driver syncs on @(vif.drv_cb) and drives with <=. The monitor takes a modport-selected vif, which makes driving through it impossible. Both wait with a small loop: do @(vif.drv_cb); while (!vif.drv_cb.ready);. The standard also has a one-line form, @(vif.drv_cb iff vif.drv_cb.ready), which means the same; the loop runs on every simulator.
ready every other cycle and returns addr + 1. The driver sends A1, B2 and C3. What does the monitor print?Three lines: addr a1 data a2, addr b2 data b3, addr c3 data c4. Each beat is sampled once, at the edge where both valid and ready were high just before it.
interface bus_if (input logic clk);
timeunit 1ns; timeprecision 1ps;
logic [31:0] addr;
logic [31:0] rdata;
logic valid;
logic ready;
clocking drv_cb @(posedge clk); // driver's view
default input #1step output #2;
output addr, valid;
input ready, rdata;
endclocking
clocking mon_cb @(posedge clk); // monitor's view: samples everything
default input #1step;
input addr, valid, ready, rdata;
endclocking
modport DRV (clocking drv_cb);
modport MON (clocking mon_cb);
endinterface
class driver;
virtual bus_if vif;
function new(virtual bus_if vif);
this.vif = vif;
endfunction
task drive_one(bit [31:0] a);
@(vif.drv_cb); // sync to the clocking event
vif.drv_cb.addr <= a; // clocked drive: applied with output skew
vif.drv_cb.valid <= 1'b1;
do @(vif.drv_cb); while (!vif.drv_cb.ready); // wait for a sampled ready
vif.drv_cb.valid <= 1'b0;
endtask
endclass
class monitor;
virtual bus_if.MON vif; // modport-selected: input-only, driving is impossible
bit [31:0] seen [$];
function new(virtual bus_if.MON vif);
this.vif = vif;
endfunction
task run();
forever begin
@(vif.mon_cb);
if (vif.mon_cb.valid && vif.mon_cb.ready) begin
seen.push_back(vif.mon_cb.addr);
$display("monitor saw addr=%0h data=%0h", vif.mon_cb.addr, vif.mon_cb.rdata);
end
end
endtask
endclass
// A responder that accepts on every other cycle and returns addr + 1.
module dut (input logic clk, input logic valid, input logic [31:0] addr,
output logic ready, output logic [31:0] rdata);
timeunit 1ns; timeprecision 1ps;
logic toggle = 0;
always_ff @(posedge clk) toggle <= !toggle;
assign ready = toggle;
assign rdata = addr + 1;
endmodule
module tb_vif_cb;
timeunit 1ns; timeprecision 1ps;
logic clk = 0;
always #5 clk = ~clk;
bus_if bif (clk);
dut u (.clk(clk), .valid(bif.valid), .addr(bif.addr), .ready(bif.ready), .rdata(bif.rdata));
initial begin
driver d;
monitor m;
bif.valid = 0; bif.addr = 0;
d = new(bif);
m = new(bif);
fork m.run(); join_none // the monitor watches in the background
d.drive_one(32'hA1);
d.drive_one(32'hB2);
d.drive_one(32'hC3);
repeat (2) @(posedge clk);
if (m.seen.size() == 3 && m.seen[0] == 32'hA1 && m.seen[2] == 32'hC3)
$display("PASS: the class drove 3 beats through drv_cb and the monitor sampled each once through mon_cb");
else $fatal(1, "FAIL: monitor saw %p", m.seen);
$finish;
end
endmodulemonitor saw addr=a1 data=a2
monitor saw addr=b2 data=b3
monitor saw addr=c3 data=c4
PASS: the class drove 3 beats through drv_cb and the monitor sampled each once through mon_cbSync with @(vif.cb), never with @(posedge vif.clk) in class code.
Drive with vif.cb.sig <= value; clocking drives take the output skew.
Sample by reading vif.cb.sig: the pre-edge value, identical on every simulator.
Never mix raw vif.sig and vif.cb.sig on one signal: the skews disagree.
Split roles with one clocking block and modport per component.
Each slot below samples just before its rising edge, the instant a #1step input skew observes. The ready flop always shows what valid was one slot earlier.
Slot 0: valid 0, addr 00, ready 0. Idle; the flop holds 0.
Slot 1: valid 1, addr a1. First beat offered; ready still 0, not yet captured.
Slot 2: valid 1, addr b2. ready 1: the slot-1 beat is captured.
Slot 3: valid 0, addr holds b2. ready stays 1 on the held capture.
Slot 4: valid 1, addr c3. ready 0: the slot-3 idle is captured.
Slot 5: valid 1, addr holds c3. ready 1: the slot-4 beat is captured.
That one-slot lag is the whole contract. A monitor never sees the live wire, only the settled values the DUT captured.
vif.valid = 1 (blocking) right after @(posedge vif.clk), and the DUT misses the first beat on one simulator only. What is wrong?The blocking drive races the DUT flops on that edge. Move the drive onto vif.drv_cb.valid so it takes the output skew on every simulator.
vif.mon_cb.addr and vif.addr in one process and they disagree. Why?The two reads use different skews: pre-edge sample versus live wire. Pick the cb view and never mix them on one signal.
virtual bus_if.MON instead of virtual bus_if?The modport restricts it to the input-only clocking block. Driving through that vif cannot compile, so the monitor role is enforced by the type.
Run 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 ignores the iff guard in @(vif.cb iff ...) when the clocking block is reached through a class's virtual interface (probed on 2026-10-18: the wait returned with the condition false), so the class code waits with an explicit loop. At module level the iff form works.