Real buses come in families: one protocol at several widths, with payloads that change per project. An interface takes value parameters for widths and parameter type for payloads, exactly like a module.
Each parameterization elaborates into its own type. bus_if#(32) and bus_if#(64) are different types, so code that names the interface type, such as a class's virtual interface handle in the OOP part, must name the exact specialization. A module port is different: a plain bus_if or bus_if.dut port takes the parameters of whichever instance is connected.
interface bus_if #(
parameter int ADDR_W = 32,
parameter int DATA_W = 64,
parameter type meta_t = logic [3:0] // type parameter
)(
input logic clk
);
timeunit 1ns; timeprecision 1ps;
logic req, gnt;
logic [ADDR_W-1:0] addr;
logic [DATA_W-1:0] wdata, rdata;
meta_t meta;
// generate: structure follows the parameters
generate
if (DATA_W > 32) begin : g_wide
logic [DATA_W/8-1:0] byte_en; // byte enables only when wide
end
endgenerate
clocking cb @(posedge clk);
default input #1step output #2;
output req, addr, wdata, meta;
input gnt, rdata;
endclocking
endinterfaceThe DUT repeats the same width parameters. Feed both sides from one package of constants, so the two sets cannot diverge at any instantiation.
An interface port cannot take parameter values. A plain bus_if.dut port inherits the connected instance's widths, not the defaults, so lock the two sides with an elaboration check that compares $bits(bus.addr) with the DUT's own parameter.
The next listing is a sketch and is not run: its dut has no body and it relies on a bus_if with a dut modport, like the one in the Testbench section.
// not-run: sketch; needs a bus_if with a dut modport (see the Testbench listing)
package soc_cfg_pkg;
parameter int ADDR_W = 40;
parameter int DATA_W = 128;
endpackage
module tb_top;
timeunit 1ns; timeprecision 1ps;
import soc_cfg_pkg::*;
logic clk = 0; always #5 clk = ~clk;
// one source of truth for BOTH sides:
bus_if #(.ADDR_W(ADDR_W), .DATA_W(DATA_W)) bus (.clk(clk));
dut #(
.ADDR_W (ADDR_W), // same package constants:
.DATA_W (DATA_W) // interface and DUT cannot diverge
) u_dut (.bus(bus));
endmodule
// A plain interface port: the widths come from the connected instance.
module dut #(
parameter int ADDR_W = 32,
parameter int DATA_W = 64
)(
bus_if.dut bus
);
// lock-step check: elaboration fails if the instance and the DUT disagree
if ($bits(bus.addr) != ADDR_W || $bits(bus.wdata) != DATA_W)
$error("bus_if widths do not match the dut parameters");
endmoduleExact specialization matters for handles: a virtual bus_if #(...) must name the exact widths of the instance it points at. That rule belongs to the virtual interfaces topic.
Tasks inside the interface see the bus signals natively. Pack the drive, wait, and sample sequence once, and every caller shares the same timing-correct choreography.
Declare every task automatic. A static task shares one set of locals across concurrent calls, so two callers corrupt each other.
The read and write tasks sit in the interface at the top of the Testbench listing below, where they run.
The interface owns the pin-accurate, mechanical layer: drive and sample tasks written against the clocking block. Directed tests call these tasks directly. Class-based drivers, in the OOP part, call the same tasks through a virtual interface and add sequencing, randomization and error injection on top.
automatic. What breaks?Both calls share one set of locals, so they corrupt each other. Declare interface tasks automatic.
The BFM interface, a small memory DUT, and a directed test that writes a word and reads it back through the tasks.
interface bus_if #(parameter int ADDR_W = 32, parameter int DATA_W = 64) (input logic clk, input logic rst_n);
timeunit 1ns; timeprecision 1ps;
logic req, gnt, we;
logic [ADDR_W-1:0] addr;
logic [DATA_W-1:0] wdata, rdata;
clocking cb @(posedge clk);
default input #1step output #2;
output req, we, addr, wdata;
input gnt, rdata;
endclocking
modport dut (input req, we, addr, wdata, output gnt, rdata, input clk, rst_n);
modport tb (clocking cb, input clk, rst_n);
// BFM method: full handshake, written once (note the synced deassert)
task automatic read(input logic [ADDR_W-1:0] a, output logic [DATA_W-1:0] d);
@(cb);
cb.req <= 1'b1; cb.we <= 1'b0; cb.addr <= a;
@(cb iff cb.gnt); // grant seen: the DUT has the address
@(cb); // one more cycle: the DUT places rdata
d = cb.rdata;
@(cb);
cb.req <= 1'b0;
endtask
task automatic write(input logic [ADDR_W-1:0] a, input logic [DATA_W-1:0] d);
@(cb);
cb.req <= 1'b1; cb.we <= 1'b1; cb.addr <= a; cb.wdata <= d;
@(cb iff cb.gnt);
@(cb);
cb.req <= 1'b0;
endtask
endinterface
module dut #(parameter int ADDR_W = 32, parameter int DATA_W = 64)
(bus_if.dut bus); // widths come from the instance
logic [DATA_W-1:0] mem [4];
assign bus.gnt = bus.req; // grant follows request
always_ff @(posedge bus.clk or negedge bus.rst_n) begin
if (!bus.rst_n) bus.rdata <= '0;
else if (bus.req && bus.we) mem[bus.addr[1:0]] <= bus.wdata;
else if (bus.req && !bus.we) bus.rdata <= mem[bus.addr[1:0]];
end
endmodule
module tb_top;
timeunit 1ns; timeprecision 1ps;
logic clk = 0, rst_n = 0;
always #5 clk = ~clk;
bus_if #(.ADDR_W(32), .DATA_W(64)) bus (.clk(clk), .rst_n(rst_n));
dut #(.ADDR_W(32), .DATA_W(64)) u_dut (.bus(bus));
// directed bring-up test: no classes, full protocol correctness
initial begin : smoke
logic [63:0] d;
int errors = 0;
repeat (4) @(posedge clk);
rst_n <= 1'b1; // nonblocking: no race with the DUT flop
bus.write(32'h1000, 64'hDEAD_BEEF_CAFE_F00D);
bus.read(32'h1000, d);
if (d !== 64'hDEAD_BEEF_CAFE_F00D) errors++;
if (errors != 0) $fatal(1, "FAIL: readback differed");
$display("PASS: write then read returned the written word");
$finish;
end
endmodulePASS: write then read returned the written wordOne way to write it. This is PacketFIFO's first full bench: the OOP part replaces the direct task calls with classes.
// pkt_if with a clocking block per role and the two BFM tasks.
interface pkt_if (input logic clk, input logic rst_n);
timeunit 1ns; timeprecision 1ps;
logic valid, ready, last;
logic [7:0] data;
modport src (output valid, data, last, input ready, clk, rst_n);
modport snk (input valid, data, last, clk, rst_n, output ready);
clocking src_cb @(posedge clk); // the testbench as a source
default input #1step output #2;
output valid, data, last;
input ready;
endclocking
clocking snk_cb @(posedge clk); // the testbench as a sink
default input #1step output #2;
output ready;
input valid, data, last;
endclocking
task automatic send_pkt(input logic [7:0] bytes [$]);
@(src_cb);
foreach (bytes[i]) begin
src_cb.valid <= 1'b1;
src_cb.data <= bytes[i];
src_cb.last <= (i == bytes.size() - 1);
@(src_cb iff src_cb.ready); // this edge took the byte
end
src_cb.valid <= 1'b0;
src_cb.last <= 1'b0;
endtask
task automatic recv_pkt(output logic [7:0] bytes [$]);
bytes = {};
@(snk_cb);
snk_cb.ready <= 1'b1;
do begin
@(snk_cb iff snk_cb.valid); // this edge delivered a byte
bytes.push_back(snk_cb.data);
end while (!snk_cb.last);
snk_cb.ready <= 1'b0;
endtask
endinterface
// DEPTH is a power of two of at least 2, so PTR_W is at least 1.
module fifo #(parameter int DEPTH = 16, parameter int DATA_W = 32) (
input logic clk, rst_n, wr_en, rd_en,
input logic [DATA_W-1:0] wdata,
output logic [DATA_W-1:0] rdata,
output logic full, empty
);
timeunit 1ns; timeprecision 1ps;
localparam int PTR_W = $clog2(DEPTH);
logic [DATA_W-1:0] mem [DEPTH];
logic [PTR_W:0] wr_ptr, rd_ptr;
assign empty = (wr_ptr == rd_ptr);
assign full = (wr_ptr[PTR_W] != rd_ptr[PTR_W]) &&
(wr_ptr[PTR_W-1:0] == rd_ptr[PTR_W-1:0]);
assign rdata = mem[rd_ptr[PTR_W-1:0]];
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
wr_ptr <= '0;
rd_ptr <= '0;
end else begin
if (wr_en && !full) begin
mem[wr_ptr[PTR_W-1:0]] <= wdata;
wr_ptr <= wr_ptr + 1;
end
if (rd_en && !empty) rd_ptr <= rd_ptr + 1;
end
end
endmodule
// PacketFIFO v0 with pkt_if on both sides.
module pkt_fifo (pkt_if.snk in, pkt_if.src out);
timeunit 1ns; timeprecision 1ps;
logic full, empty;
logic [8:0] rdata;
fifo #(.DEPTH(4), .DATA_W(9)) u_f (
.clk(in.clk), .rst_n(in.rst_n),
.wr_en(in.valid), .rd_en(out.ready),
.wdata({in.last, in.data}), .rdata(rdata), .full(full), .empty(empty));
assign in.ready = !full;
assign out.valid = !empty;
assign {out.last, out.data} = rdata;
endmodule
packet 1: '{'h22, 'ha5, 'h3c, 'hbb}
packet 2: '{'h51, 'h7, 'h56}
PASS: send_pkt and recv_pkt moved both packets through PacketFIFO v0 unchangedRun with Verilator 5.048 (--binary --timing) on 2026-10-18; every bench printed the PASS line shown, with no warnings. Icarus 13 cannot run these benches: it rejects interface ports and clocking blocks.
The PacketFIFO bench was also run with a planted bug (the read pointer stepping by 2); it failed with both packets wrong, and its watchdog stops the run if bytes never arrive.