SystemVerilog验证平台连接设计:interface、clocking block与virtual interface实战

发布时间:2026/10/13 5:13:50
SystemVerilog验证平台连接设计:interface、clocking block与virtual interface实战 1. 为什么连接才是验证平台真正的分水岭写了几年SystemVerilog我越来越觉得语法本身不是门槛真正卡住大多数人的是**怎么把设计和测试平台接起来**这件事。你单独写一个DUTDesign Under Test待测设计没问题单独写一个driver、monitor、scoreboard也没问题可一旦要把它们连成一个能跑起来、能自动比对、能报错的完整环境问题就全冒出来了信号怎么传时钟谁来驱动接口怎么定义driver什么时候把数据推给DUTmonitor又该在哪个时刻采样这一篇我就专门聊这个主题——连接设计和测试平台。它解决的核心问题是让一个静态的、孤立的RTL模块变成一个可以被自动化激励、自动化观测、自动化判定的可验证对象。适合谁看如果你已经能写基本的SystemVerilog类、知道class、interface、program这些概念但一到搭环境就不知道从哪下手那这篇就是给你准备的。如果你是完全的新手也能看懂因为我会把每个为什么都讲透。我个人的经验是验证平台的复杂度80%来自连接20%才来自激励和检查逻辑本身。很多人写激励写得花里胡哨结果连不上DUT或者连上了但时序错位采样采到的是X态最后debug的时间全花在为什么我的scoreboard一直报错上。所以这一篇我打算从设计思路、接口定义、连接方式、时序对齐、常见坑这几个维度把这件事彻底讲清楚。2. 连接设计的整体思路与方案选型2.1 先想清楚谁连谁验证平台的拓扑结构在动手写代码之前我习惯先在纸上画一张拓扑图。一个典型的验证平台连接关系大致是这样的测试用例test产生激励配置sequencer管理激励序列driver把激励转换成信号级动作推给DUTmonitor从DUT的接口上采样实际信号scoreboard拿monitor采到的数据和参考模型比对最后给出通过或失败的结论。这张图里DUT是唯一的物理实体其他都是围绕它构建的软件组件。连接的本质就是让这些软件组件能够看到和驱动DUT的引脚。这里有个关键认知软件世界是瞬时的、事件驱动的而硬件世界是时序的、周期驱动的。连接设计要做的就是在这两个世界之间架一座桥。我见过不少初学者一上来就想着把所有东西塞进一个initial块里用#10这种延时去驱动信号。这样写小demo能跑但一旦DUT变复杂时序就完全失控。所以正确的思路是用接口interface封装物理连接用时钟块clocking block解决时序对齐用虚接口virtual interface把硬件信号引入软件世界。这三样东西是连接设计的三大支柱。2.2 为什么优先选interface而不是直接连信号有人会问我直接用reg和wire连DUT不行吗当然行但代价很大。假设你的DUT有32位数据总线、地址总线、读写使能、片选、中断等十几个信号如果你在每个组件里都单独声明一遍这些信号那么一旦DUT接口变了你要改十几个地方。这就是典型的散弹式修改。用interface的好处是把一组相关信号打包成一个逻辑单元。DUT的端口直接声明成interface类型测试平台里所有组件通过这个interface访问信号。接口变了只改interface定义一处其他组件几乎不用动。这就是所谓的高内聚、低耦合。更重要的是interface里可以放clocking block而clocking block能帮你解决采样和驱动的时序问题。这一点后面会详细讲。所以我的建议很明确只要DUT端口超过5个信号就老老实实用interface。别图省事省下来的时间后面都会加倍还回去。2.3 连接方式的三种典型方案对比在实际项目中连接设计和测试平台主要有三种方案我整理成表格方便你对照选择方案连接方式优点缺点适用场景直接信号连接用wire/reg逐个连简单直观无额外抽象信号多时维护困难时序易错极小模块信号少于5个interface连接用interface打包信号维护性好支持clocking需要额外定义interface绝大多数RTL验证事务级连接用TLM端口传递事务抽象层次高复用性强需要额外的事务转换层复杂SoC、UVM环境我个人的选择逻辑是模块级验证用interface系统级验证用TLM。这两者不是对立的而是可以叠加的——interface负责物理层连接TLM负责事务层连接中间用driver和monitor做转换。理解了这一点你就理解了验证平台分层的本质。3. 接口定义与时钟块的核心细节3.1 interface的基本写法与信号分组先看一个我常用的interface模板。假设DUT是一个简单的同步FIFO有写数据、写使能、读使能、读数据、满标志、空标志这几个信号interface fifo_if (input logic clk, input logic rst_n); logic wr_en; logic [7:0] wr_data; logic rd_en; logic [7:0] rd_data; logic full; logic empty; modport dut ( input wr_en, wr_data, rd_en, output rd_data, full, empty ); modport tb ( output wr_en, wr_data, rd_en, input rd_data, full, empty ); endinterface这里有几个细节值得说。第一clk和rst_n作为interface的端口传入而不是在interface内部声明这样时钟和复位可以由外部统一管理多个interface可以共享同一个时钟源。第二modport定义了不同角色的信号方向——DUT看到的是输入输出测试平台看到的是相反的方向。这个方向定义非常重要它能在编译期就帮你抓出方向接反的错误。我踩过的一个坑是modport的方向写反了仿真时信号一直是X。因为方向反了之后driver驱动的是一个被声明为input的信号工具不会报错但信号根本传不出去。所以每次定义完modport我都会对着DUT的端口列表逐个核对方向。3.2 clocking block解决时序对齐的关键clocking block是interface里最容易被忽视、但最重要的部分。它的作用是定义一个相对于时钟边沿的采样和驱动时序。看这个例子interface fifo_if (input logic clk, input logic rst_n); logic wr_en; logic [7:0] wr_data; logic rd_en; logic [7:0] rd_data; logic full; logic empty; clocking drv_cb (posedge clk); default input #1step output #0; output wr_en, wr_data, rd_en; input rd_data, full, empty; endclocking clocking mon_cb (posedge clk); default input #1step; input wr_en, wr_data, rd_en, rd_data, full, empty; endclocking endinterfacedefault input #1step output #0这行是精髓。它的意思是采样发生在时钟沿之前的1个时间步即采样的是时钟沿到来前的稳定值驱动发生在时钟沿之后的0个时间单位即立即驱动。这样做的效果是driver在时钟沿驱动信号DUT在下一个时钟沿采样monitor在时钟沿采样到的是DUT更新后的稳定值整个时序就对齐了。如果你不写clocking block直接用interface的信号那么采样和驱动的时刻就取决于你代码里写的延时非常容易出错。我见过太多人因为采样时刻不对导致monitor采到的数据比driver驱动的数据晚一拍或早一拍scoreboard就疯狂报错。clocking block就是用来消除这种时序歧义的。3.3 虚接口把硬件信号引入软件世界interface是硬件世界的概念而driver、monitor是软件世界的类。类里面不能直接声明interface类型的成员必须用virtual interface。写法是这样的class fifo_driver; virtual fifo_if vif; function new(virtual fifo_if vif); this.vif vif; endfunction task drive_write(byte data); (vif.drv_cb); vif.drv_cb.wr_en 1b1; vif.drv_cb.wr_data data; (vif.drv_cb); vif.drv_cb.wr_en 1b0; endtask endclass注意这里用的是vif.drv_cb.wr_en 1b1这种非阻塞赋值而不是。在clocking block里驱动信号必须用非阻塞赋值这是语法要求也是时序正确性的保证。用阻塞赋值的话驱动时刻会变得不确定。还有一个关键点virtual interface必须在顶层用实际interface实例赋值。通常是在一个initial块或者program块里做这件事module top; logic clk 0; logic rst_n; always #5 clk ~clk; fifo_if dut_if (.clk(clk), .rst_n(rst_n)); fifo_dut u_dut ( .clk(clk), .rst_n(rst_n), .wr_en(dut_if.wr_en), .wr_data(dut_if.wr_data), .rd_en(dut_if.rd_en), .rd_data(dut_if.rd_data), .full(dut_if.full), .empty(dut_if.empty) ); initial begin fifo_driver drv; drv new(dut_if); // 后续激励... end endmodule这里new(dut_if)就是把实际的interface实例传给driver的virtual interface。这一步如果忘了做driver里的vif就是null一访问就报空指针错误。这是新手最常见的错误之一。4. 完整实操流程从零搭一个可跑的连接环境4.1 第一步定义DUT并确认端口我习惯先写一个最简单的DUT把端口固定下来。还是用FIFO举例一个8位深、16深度的同步FIFOmodule fifo_dut ( input logic clk, input logic rst_n, input logic wr_en, input logic [7:0] wr_data, input logic rd_en, output logic [7:0] rd_data, output logic full, output logic empty ); logic [7:0] mem [0:15]; logic [4:0] wr_ptr, rd_ptr, count; assign full (count 16); assign empty (count 0); always_ff (posedge clk or negedge rst_n) begin if (!rst_n) begin wr_ptr 0; rd_ptr 0; count 0; end else begin if (wr_en !full) begin mem[wr_ptr[3:0]] wr_data; wr_ptr wr_ptr 1; end if (rd_en !empty) begin rd_data mem[rd_ptr[3:0]]; rd_ptr rd_ptr 1; end case ({wr_en !full, rd_en !empty}) 2b10: count count 1; 2b01: count count - 1; default: count count; endcase end end endmodule端口确认清楚后interface的定义就照着端口来一个不多一个不少。这里我要强调一个经验interface的信号名最好和DUT端口名保持一致这样在顶层连接时不容易搞混。有些人喜欢在interface里用简写结果连的时候对不上号白白浪费时间。4.2 第二步写driver把事务转成信号driver的职责是把上层来的事务比如写一个字节0x5A转换成信号级的动作。我通常会把driver写成带mailbox或带seq_item_port的形式但为了讲清楚连接本身这里先用一个简化版class fifo_driver; virtual fifo_if vif; mailbox #(byte) wr_mbx; mailbox #(byte) rd_mbx; function new(virtual fifo_if vif); this.vif vif; wr_mbx new(); rd_mbx new(); endfunction task run(); fork drive_write(); drive_read(); join endtask task drive_write(); byte data; forever begin wr_mbx.get(data); (vif.drv_cb); vif.drv_cb.wr_en 1b1; vif.drv_cb.wr_data data; (vif.drv_cb); vif.drv_cb.wr_en 1b0; end endtask task drive_read(); forever begin rd_mbx.get(); (vif.drv_cb); vif.drv_cb.rd_en 1b1; (vif.drv_cb); vif.drv_cb.rd_en 1b0; end endtask endclass这里用fork...join让读写两个驱动任务并行运行各自从自己的mailbox取任务。(vif.drv_cb)是等待clocking block的下一个时钟沿这样驱动就和时钟同步了。注意写操作占两个时钟周期第一个周期拉高wr_en并给数据第二个周期拉低wr_en。这是为了模拟真实的写时序让DUT有足够的时间采样。4.3 第三步写monitor采样实际信号monitor的职责是从接口上采样信号还原成事务送给scoreboard。写法如下class fifo_monitor; virtual fifo_if vif; mailbox #(byte) wr_obs_mbx; mailbox #(byte) rd_obs_mbx; function new(virtual fifo_if vif); this.vif vif; wr_obs_mbx new(); rd_obs_mbx new(); endfunction task run(); fork mon_write(); mon_read(); join endtask task mon_write(); forever begin (vif.mon_cb); if (vif.mon_cb.wr_en !vif.mon_cb.full) begin wr_obs_mbx.put(vif.mon_cb.wr_data); end end endtask task mon_read(); forever begin (vif.mon_cb); if (vif.mon_cb.rd_en !vif.mon_cb.empty) begin rd_obs_mbx.put(vif.mon_cb.rd_data); end end endtask endclassmonitor用的是mon_cb只采样不驱动。采样条件是wr_en !full这和DUT内部的写条件一致保证采到的都是有效写入。这里有个细节monitor采样rd_data时采到的是DUT在时钟沿更新后的值因为clocking block的input #1step保证了采样的是时钟沿前的稳定值而DUT的rd_data是在时钟沿更新的所以monitor采到的正好是本次读出的数据。这个时序关系如果搞错scoreboard就会一直报错。4.4 第四步写scoreboard做参考模型比对scoreboard里放一个参考模型用队列模拟FIFO行为然后和monitor采到的数据比对class fifo_scoreboard; mailbox #(byte) wr_obs_mbx; mailbox #(byte) rd_obs_mbx; byte ref_q[$]; int err_cnt 0; function new(mailbox #(byte) wr_mbx, mailbox #(byte) rd_mbx); this.wr_obs_mbx wr_mbx; this.rd_obs_mbx rd_mbx; endfunction task run(); fork check_write(); check_read(); join endtask task check_write(); byte data; forever begin wr_obs_mbx.get(data); ref_q.push_back(data); end endtask task check_read(); byte exp, act; forever begin rd_obs_mbx.get(act); if (ref_q.size() 0) begin $error(Read from empty FIFO, got 0x%0h, act); err_cnt; end else begin exp ref_q.pop_front(); if (exp ! act) begin $error(Mismatch: exp0x%0h act0x%0h, exp, act); err_cnt; end end end endtask endclass参考模型用ref_q这个队列模拟FIFO的存储写的时候push_back读的时候pop_front。比对用!而不是!因为要能抓出X态。这一点很重要如果DUT输出X用!比对可能不会报错用!才能抓到。4.5 第五步顶层连接把所有组件串起来最后在顶层把DUT、interface、driver、monitor、scoreboard连起来module top; logic clk 0; logic rst_n; always #5 clk ~clk; fifo_if dut_if (.clk(clk), .rst_n(rst_n)); fifo_dut u_dut ( .clk(clk), .rst_n(rst_n), .wr_en(dut_if.wr_en), .wr_data(dut_if.wr_data), .rd_en(dut_if.rd_en), .rd_data(dut_if.rd_data), .full(dut_if.full), .empty(dut_if.empty) ); fifo_driver drv; fifo_monitor mon; fifo_scoreboard scb; initial begin drv new(dut_if); mon new(dut_if); scb new(mon.wr_obs_mbx, mon.rd_obs_mbx); fork drv.run(); mon.run(); scb.run(); join_none rst_n 0; #20 rst_n 1; // 发起激励 for (int i 0; i 10; i) begin drv.wr_mbx.put(i); end #200; for (int i 0; i 10; i) begin drv.rd_mbx.put(0); end #500; if (scb.err_cnt 0) $display(TEST PASSED); else $display(TEST FAILED, err_cnt%0d, scb.err_cnt); $finish; end endmodule这里的关键是fork...join_none让driver、monitor、scoreboard三个组件的run任务并行启动然后主流程继续做复位和激励。如果写成fork...join主流程会一直等这三个任务结束而它们是forever循环永远不会结束仿真就卡死了。这是新手非常容易犯的错误。5. 常见问题与排查技巧实录5.1 信号一直是X或Z怎么排查这是最常见的问题。排查顺序我一般是这样的先看DUT的输入信号有没有被驱动再看interface的连接有没有接错最后看clocking block的时序对不对。具体来说如果driver驱动了信号但DUT端口还是X八成是modport方向写反了或者顶层连接时信号名对不上。如果DUT输出是X先确认DUT内部有没有复位再看monitor采样时刻是不是太早。我整理了一个速查表现象可能原因排查方法DUT输入一直Xdriver没驱动/modport方向反检查driver是否启动核对modportDUT输出一直X复位没做/DUT内部逻辑问题检查rst_n时序单独仿真DUTmonitor采到X采样时刻早于DUT更新检查clocking block的input skewscoreboard一直报错采样晚一拍或早一拍对比driver和monitor的时序仿真卡死fork...join用错改用fork...join_none空指针错误virtual interface没赋值检查new()时是否传入interface5.2 采样时刻错位最隐蔽的坑采样时刻错位是最难查的问题因为信号值看起来都是对的就是比对不上。我遇到过一次monitor采到的rd_data总是比预期晚一拍。查了半天发现是clocking block里写成了input #0导致采样发生在时钟沿的同一时刻而DUT的rd_data是在时钟沿更新的所以采到的是更新前的旧值。改成input #1step后问题就解决了。这个经验告诉我clocking block的input skew一定要用#1step不要用#0。#1step表示采样发生在时钟沿之前的一个时间步能采到稳定值#0表示在时钟沿同一时刻采样会和DUT的更新产生竞争。这个细节在文档里往往一笔带过但实际影响巨大。5.3 driver驱动多个信号时的竞争问题如果driver在一个时钟周期里驱动多个信号比如同时驱动wr_en和wr_data一定要用非阻塞赋值并且放在同一个clocking block的驱动里。如果分开写比如先vif.drv_cb.wr_en 1再vif.drv_cb.wr_data data这两个赋值会在同一个时间步生效没问题。但如果中间插了(vif.drv_cb)就会变成两个时钟周期时序就错了。我的习惯是一个事务的所有信号驱动写在同一个时钟沿的驱动块里中间不插入任何等待。这样能保证信号的同步性。5.4 复位释放时刻与第一个激励的配合复位释放和第一个激励之间的时序也很讲究。如果复位刚释放就立刻发激励DUT可能还没稳定第一个事务容易出错。我的做法是复位释放后至少等2到3个时钟周期再发第一个激励。在代码里就是#20 rst_n 1;之后再加几个(posedge clk);。这个等待时间不长但能避免很多莫名其妙的错误。5.5 独家避坑技巧汇总最后分享几个我踩坑总结出来的技巧。第一interface里所有信号都用logic类型不要用wire或reglogic在interface里更灵活。第二clocking block的命名要有区分度driver用drv_cbmonitor用mon_cb不要混用。第三mailbox的深度要设合理默认是0表示无限深但实际项目中最好设个上限避免仿真时内存爆掉。第四scoreboard的比对要用!能抓X态。第五顶层连接时用命名端口连接不要用位置连接否则端口顺序一变就全乱了。这些技巧看起来都是小事但每一个都是我在实际项目中花时间debug换来的。验证平台这东西写对了跑得飞快写错了一个小细节能让你查一整天。所以我的建议是连接设计阶段多花时间把interface、clocking block、virtual interface这三样东西搞扎实后面写激励和检查逻辑就会顺很多。后续如果要把这个环境扩展成UVM风格可以把driver、monitor、scoreboard都继承自UVM的基类用TLM端口替代mailbox用sequence替代直接往mailbox里put。但那是另一个话题了连接设计的核心逻辑是不变的——接口封装物理连接clocking block对齐时序virtual interface打通软硬件世界。这三句话记住了连接设计和测试平台这件事就抓住了根本。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询