SystemVerilog 基础

第 13 章 · 共 16 章

接口与 Modport:信号的打包与组织

理解为什么需要 interface 来连接 DUT 和 testbench,用 modport 限制不同视角下信号的读写方向让工具在编译期就能发现连接错误,以及 clocking block 如何给时序 DUT 的信号一套精确、不产生竞争的时序约定。

前面几章的例子里,DUT 和 testbench 之间的连接都是逐个信号手写端口的——信号一多,端口列表就变得又长又容易出错。这一章介绍 interface:把一组相关信号打包成一个整体,连接时只需要传一个接口句柄,而不是逐个连线。

没有 interface 时的痛点

假设一个简单总线有 clkvalidaddrdata 四个信号,DUT 和 testbench 都要用到它们:

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)
  );
  // ...
endmodule

四个信号还能接受,但真实总线协议往往有十几甚至几十个信号——端口列表逐个手写不仅繁琐,连接顺序或名字对不上也很容易出错;更麻烦的是,只要协议改动(加一个信号),DUT 的端口列表、testbench 里的信号声明、例化时的连接,三个地方都要同步修改。

interface:把一组信号打包成一个整体

interface simple_bus_if;
  logic       clk;
  logic       valid;
  logic [7:0] addr;
  logic [7:0] data;
endinterface

和第 5 章的 struct 类似,interface 把相关的信号组织在一起;不同的是,interface 专门用来在模块之间传递一整组信号,例化后可以直接当作一个端口使用:

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();   // 例化接口
  dut u_dut (bus_if);        // 只需要传这一个接口句柄
 
  initial begin
    bus_if.valid = 1'b1;
    #10 $display("data = %0h", bus_if.data);
  end
endmodule

dut 的端口列表从四个信号变成了一个 simple_bus_if;以后总线协议加信号,只需要改 interface 定义本身,不用再去逐个模块的端口列表里同步修改。

modport:限制不同视角下的读写方向

上面的写法还有一个问题:interface 里的信号默认谁都能读也能写——DUT 和 testbench 都能直接给 bus.data 赋值,如果不小心两边都驱动了同一个信号,就会产生冲突,而且编译器不会主动提醒你哪一端"应该"只读、哪一端"应该"只写。modport 就是用来解决这个问题的:它从某个特定视角出发,声明这组信号里哪些是输入、哪些是输出:

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
  );
endinterface

dut_mptb_mp 是同一组信号的两种"视角":站在 DUT 的角度,clk/valid/addr 是输入、data 是输出;站在 testbench 的角度正好相反。模块声明端口时指定自己用哪个视角:

module dut (simple_bus_if.dut_mp bus);
  always_comb begin
    bus.data = bus.valid ? 8'hAA : 8'h00;  // 合法:data 在 dut_mp 里是 output
    // bus.addr = 8'h01;                   // ✗ 编译错误:addr 在 dut_mp 里是 input
  end
endmodule

一旦 dut 通过 dut_mp 视角去访问接口,试图给 addr(在这个视角里是 input)赋值就会直接报编译错误——这和第 8 章"用 unique case/always_comb 让工具在开发阶段就发现问题"是同一种思路:与其等到仿真出现信号冲突才排查,不如让工具在编译期就替你把关。

clocking block:给时序 DUT 一套精确的时序约定

到目前为止,simple_bus_ifdut 都是纯组合逻辑(always_comb)。换成一个真正的时序 DUT——第 4 章 D 触发器例子已经预告过的那种(always_ff 里的 q <= d<=/= 的区别留到第 8 章展开)——就会冒出一个新问题:data 只在 posedge clk 才更新,那 testbench 到底该在相对这个边沿的哪个时间点驱动 valid/addr、采样 data,就不再是无关紧要的细节了。

module dut (simple_bus_if.dut_mp bus);
  always_ff @(posedge bus.clk) begin
    bus.data <= bus.valid ? 8'hAA : 8'h00;
  end
endmodule

如果 testbench 在(或者太接近)posedge clk 同一个仿真时刻执行 bus_if.valid = 1'b1;,DUT 的 always_ff 在这个边沿到底看到的是 valid 的旧值还是新值,取决于 testbench 完全无法直接控制的仿真事件调度细节——这是两个独立进程(testbench 和 DUT)恰好都关心同一个时钟边沿时产生的竞争条件(race condition)

clocking block 就是 SystemVerilog 给出的答案:一个声明在 interface 内部的块,给它管理的每一个信号一套明确、有保证的、相对某个时钟边沿的时序关系,而不是交给运气。

动手试之前,先说一句仿真器的事:Icarus Verilog——这个系列从头到尾用的免费仿真器——压根没有实现 clocking 块(这是实测确认过的:哪怕最简单的 clocking ... endclocking 都编译不过)。这一节的例子请在 EDA Playground 上换个仿真器:在 Tools & Simulators 里选一个商业级选项,比如 Aldec Riviera-PRO——EDA Playground 免费提供这个仿真器的使用权限,不需要你自己的授权(没有公司/学校邮箱的话,用 Google 或 Facebook 账号登录就行),它完整支持 SystemVerilog 的全部特性,包括 clocking block。这个系列其余部分继续用 Icarus 完全没问题,只有这一节需要换。

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);
endinterface
  • clocking cb @(posedge clk); 把这个块绑定到一个特定的时钟边沿——它列出的每个信号都相对这个边沿被采样或驱动,而不是相对 testbench 恰好执行到某条语句的那个时刻。
  • default input #1step output #1; 定下了时序约定:input #1step 在时钟事件发生一个仿真步(simulation step)采样,保证拿到的是边沿之前已经稳定下来的值——绝不会和 DUT 自己在同一个边沿的 always_ff 更新抢时间。output #1 让驱动的值在时钟事件发生 1 个时间单位才真正生效,给它足够的时间在下一个边沿到来之前稳定下来。
  • output valid, addr; input data; 列出这个 clocking block 管理哪些信号,方向是从 testbench 一侧描述的(这个 cb 正是 tb_mp 暴露出去的东西)。
  • modport tb_mp (clocking cb);——modport 也可以只暴露一个 clocking block,而不是逐个信号。一个用 tb_mp 的模块只能通过 cb 去够 valid/addr/data,摸不到原始信号,这套时序保证也就不会被意外绕开。

用起来是这样的:

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) 等待这个 clocking block 的下一次时钟事件——比直接写 @(posedge bus_if.clk)更清晰,而且正是它才能让输入/输出的时序偏移真正生效。给 clocking block 信号赋值习惯上用 <=,这和它们的行为是一致的:延后生效,而不是立即生效。

这正是几乎所有真实 UVM driver、monitor 的写法——通过 vif.cb.signal 而不是 vif.signal 来读写信号。这个系列自己那条贯穿全程的 mux2 例子,一路到 UVM track 都保持纯组合逻辑,所以从来用不上 clocking block 来避免竞争——但真实项目里,只要 testbench 面对的是一个时序 DUT,clocking block 基本是标配。

小结

  • interface 把一组相关信号打包成一个整体,例化后可以作为单个端口传递,避免逐个信号手写端口列表;协议改动时只需要修改 interface 定义本身。
  • interface 里的信号默认没有方向限制,谁都能读写,容易产生多方驱动冲突。
  • modport 从特定视角声明信号的输入/输出方向;模块通过 接口名.modport名 的形式指定自己使用哪个视角,一旦访问方向不符会在编译期直接报错,而不是留到仿真阶段才发现。
  • clocking blockclocking cb @(posedge clk); ... endclocking,声明在 interface 内部)给 testbench 的信号一套明确、不产生竞争的时钟边沿时序关系:input #1step 在边沿之前采样,output #N 在边沿之后驱动——DUT 从组合变成时序之后就需要它。
  • modport 可以只暴露一个 clocking block(modport tb_mp (clocking cb);)而不是逐个信号,这样 testbench 代码就没法绕开这套时序约定;用 @(接口名.cb) 等下一次时钟事件,给 clocking block 信号赋值用 <=

相比逐个信号手写端口连接,使用 interface 连接 DUT 和 testbench 的主要好处是什么?

关于 modport,以下哪项描述正确?

在 interface 内部,用哪个关键字声明一组信号从某个特定视角看的输入/输出方向?(英文小写)

为什么 dut 变成时序电路(always_ff)之后 simple_bus_if 就需要 clocking block 了,而之前纯组合逻辑的版本从来不需要?

default input #1step output #1; 里的 #1step 和 #1 具体保证了什么?