Chapter 13 of 16
Interfaces and Modports: Bundling and Organizing Signals
Understand why interface exists for connecting a DUT to a testbench, how modport restricts each side's read/write direction over the signals so tools catch connection mistakes at compile time, and how a clocking block gives a sequential DUT's signals a precise, race-free timing contract.
In earlier chapters' examples, the DUT and testbench were always connected signal by signal, written out by hand in the port list — the more signals there are, the longer and more error-prone that list gets. This chapter introduces interface: bundling a group of related signals into a single unit, so connecting them means passing one interface handle instead of wiring up each signal individually.
The pain point without interface
Say a simple bus has four signals — clk, valid, addr, data — that both the DUT and the testbench need:
module dut (
input logic clk,
input logic valid,
input logic [7:0] addr,
output logic [7:0] data
);
always_comb begin
data = valid ? 8'hAA : 8'h00;
end
endmodule
module tb;
logic clk, valid;
logic [7:0] addr, data;
dut u_dut (
.clk (clk),
.valid (valid),
.addr (addr),
.data (data)
);
// ...
endmoduleFour signals is still manageable, but real bus protocols often have a dozen or more — hand-writing the port list for each one is tedious, and it's easy to get the connection order or names wrong. Worse, whenever the protocol changes (say, a new signal gets added), three separate places need updating in sync: the DUT's port list, the testbench's signal declarations, and the instantiation's connections.
interface: bundling a group of signals into a single unit
interface simple_bus_if;
logic clk;
logic valid;
logic [7:0] addr;
logic [7:0] data;
endinterfaceSimilar to chapter 5's struct, interface organizes related signals together — the difference is that interface exists specifically to pass a whole group of signals between modules, and once instantiated, it can be used directly as a single port:
module dut (simple_bus_if bus);
always_comb begin
bus.data = bus.valid ? 8'hAA : 8'h00;
end
endmodule
module tb;
simple_bus_if bus_if(); // instantiate the interface
dut u_dut (bus_if); // just pass this one interface handle
initial begin
bus_if.valid = 1'b1;
#10 $display("data = %0h", bus_if.data);
end
endmoduledut's port list goes from four signals down to a single simple_bus_if; if the bus protocol later gains a new signal, only the interface definition itself needs to change — no more syncing updates across every module's port list.
modport: restricting read/write direction per perspective
The approach above has another problem: signals inside an interface are readable and writable by anyone by default — both the DUT and the testbench can assign directly to bus.data, and if both sides accidentally drive the same signal, that's a conflict the compiler won't proactively warn you about, since it has no idea which side is "supposed to" be read-only or write-only. modport solves exactly this: from a given perspective, it declares which signals are inputs and which are outputs:
interface simple_bus_if;
logic clk;
logic valid;
logic [7:0] addr;
logic [7:0] data;
modport dut_mp (
input clk, valid, addr,
output data
);
modport tb_mp (
output clk, valid, addr,
input data
);
endinterfacedut_mp and tb_mp are two "views" of the same set of signals: from the DUT's perspective, clk/valid/addr are inputs and data is an output; from the testbench's perspective, it's the exact opposite. A module declares which view it's using in its port declaration:
module dut (simple_bus_if.dut_mp bus);
always_comb begin
bus.data = bus.valid ? 8'hAA : 8'h00; // legal: data is an output in dut_mp
// bus.addr = 8'h01; // ✗ compile error: addr is an input in dut_mp
end
endmoduleOnce dut accesses the interface through the dut_mp view, trying to assign to addr (an input in that view) is flagged as a compile error right away — the same philosophy as chapter 8's "let unique case/always_comb catch problems during development": rather than debugging a signal conflict after it shows up in simulation, the tool catches it for you at compile time.
clocking block: a precise timing contract with a sequential DUT
Everything so far — simple_bus_if's dut — has been purely combinational (always_comb). Swap in a real sequential DUT, the kind chapter 4's D flip-flop example previewed (q <= d inside always_ff, with the <=/= distinction promised for chapter 8), and a new problem shows up: data only updates on posedge clk, so exactly when, relative to that edge, the testbench drives valid/addr and samples data stops being a minor detail.
module dut (simple_bus_if.dut_mp bus);
always_ff @(posedge bus.clk) begin
bus.data <= bus.valid ? 8'hAA : 8'h00;
end
endmoduleIf the testbench assigns bus_if.valid = 1'b1; at (or too close to) the same simulation time as posedge clk, whether the DUT's always_ff sees the old or new value of valid on that edge depends on simulation event-ordering details the testbench has no direct control over — a race condition between two independent processes (the testbench and the DUT) that happen to both care about the same clock edge.
clocking block is SystemVerilog's answer: a block, declared inside the interface, that gives every signal it manages an explicit, guaranteed timing relationship to a clock edge instead of leaving it to chance.
A simulator note before you try this: Icarus Verilog — the free simulator this track has used everywhere so far — does not implement
clockingblocks at all (confirmed directly: even the simplest possibleclocking ... endclockingfails to compile on it). For this section's examples, switch simulators on EDA Playground instead: under Tools & Simulators, pick a commercial-grade option such as Aldec Riviera-PRO — EDA Playground gives free, no-license-required access to it (log in with Google/Facebook if you don't have a company or university email), and it implements the full SystemVerilog feature set, clocking blocks included. Everything else in this track still runs fine on Icarus; this is the one section that needs the switch.
interface simple_bus_if;
logic clk;
logic valid;
logic [7:0] addr;
logic [7:0] data;
clocking cb @(posedge clk);
default input #1step output #1;
output valid, addr;
input data;
endclocking
modport dut_mp (
input clk, valid, addr,
output data
);
modport tb_mp (clocking cb);
endinterfaceclocking cb @(posedge clk);ties this block to a specific clock edge — every signal it lists gets sampled or driven relative to that edge, not to whenever the testbench happens to execute a statement.default input #1step output #1;sets the timing contract:input #1stepsamples one simulation step before the clocking event, guaranteeing a stable, settled value from just before the edge — never racing the DUT's ownalways_ffupdate at that same edge.output #1delays a driven value by 1 time unit after the clocking event, giving it time to be stable well before the next edge arrives.output valid, addr; input data;lists which signals this clocking block manages, direction stated from the testbench's side (thiscbis whattb_mpexposes).modport tb_mp (clocking cb);— a modport can expose just a clocking block instead of individual signals. A module usingtb_mpcan only reachvalid/addr/datathroughcb, never the raw signals directly, so the timing guarantee can't accidentally be bypassed.
Using it looks like this:
module tb;
simple_bus_if bus_if();
dut u_dut (bus_if);
initial begin
bus_if.clk = 0;
forever #5 bus_if.clk = ~bus_if.clk;
end
initial begin
@(bus_if.cb);
bus_if.cb.valid <= 1'b1;
bus_if.cb.addr <= 8'h01;
@(bus_if.cb);
$display("data = %0h", bus_if.cb.data);
end
endmodule@(bus_if.cb) waits for the clocking block's next clocking event — cleaner than writing @(posedge bus_if.clk) directly, and it's what actually makes the input/output skews take effect. Assignments to clocking block signals conventionally use <=, matching how they behave: scheduled, not immediate.
This is exactly the pattern almost every real UVM driver and monitor uses in practice — reading and writing through vif.cb.signal instead of vif.signal directly. This track's own running mux2 example stays purely combinational all the way through the UVM track, so it never actually needs a clocking block to avoid a race — but production testbenches built around a clocked DUT essentially always have one.
Summary
interfacebundles a group of related signals into a single unit that can be passed as one port once instantiated, avoiding hand-written per-signal port lists; when the protocol changes, only theinterfacedefinition itself needs updating.- Signals inside an
interfacehave no direction restriction by default — anyone can read or write them, which risks multi-driver conflicts. modportdeclares each signal's input/output direction from a specific perspective; a module specifies which view it uses viainterfaceName.modportName, and any access that violates the declared direction is flagged at compile time instead of surfacing only in simulation.clocking block(clocking cb @(posedge clk); ... endclocking, declared inside aninterface) gives testbench signals an explicit, race-free timing relationship to a clock edge:input #1stepsamples just before the edge,output #Ndrives just after it — needed once the DUT is sequential, not combinational.- A
modportcan expose just a clocking block (modport tb_mp (clocking cb);) instead of individual signals, so testbench code can't bypass the timing contract; wait for the next clocking event with@(interfaceName.cb), and assign clocking block signals with<=.
What's the main benefit of using an interface to connect a DUT and testbench, compared to hand-wiring each signal in the port list?
Which statement about modport is correct?
Inside an interface, which keyword declares a group of signals' input/output direction from a specific perspective? (lowercase)
Why does simple_bus_if need a clocking block once dut becomes sequential (always_ff), when the earlier combinational version never needed one?
In default input #1step output #1;, what do the #1step and #1 skews actually guarantee?