说实话,能一路写到第九篇学习笔记的人,已经不算是SystemVerilog的新手了。前几篇我们聊完了数据类型、接口、类、随机化约束、线程之间的通信,基础的验证环境搭建也跑通过几个小例子。但从“会写验证代码”到“能证明设计是对的”,中间其实还隔着两道大槛:一个是覆盖率,另一个是断言。
这篇笔记我就把这两个东西放在一起讲。为什么放一起?因为它们在验证流程里本来就是配套出现的——断言负责告诉你“错在哪”,覆盖率负责告诉你“测够了没”。没有断言,你的仿真挂了可能还要排查半天;没有覆盖率,你可能测了一整天都觉得虚,根本不知道哪些分支从来没走到过。这篇内容适合已经能独立搭建UVM或简易验证环境、想进一步把验证做扎实的朋友。我尽量用直白的语言讲清楚原理,再给一个能直接跑起来的完整小例子。
1. 为什么学完基础后必须啃下覆盖率和断言
1.1 覆盖率模型的本质:给验证工作装上“仪表盘”
你可以把功能覆盖率想象成开车时的仪表盘。你踩油门、打方向盘,车到底跑了多远、还剩多少油,仪表盘会告诉你。验证工作也是一样:你写了激励、跑了仿真,但你不能只盯着“仿真有没有报错”这一项指标。testbench跑完、没有报错,只能说明现有激励没有触发致命问题,这离“验证完备”还差得很远。
SystemVerilog里的功能覆盖率(Functional Coverage)就是一种给验证过程安装仪表盘的手段。它通过covergroup、coverpoint、cross这些关键词,把你关心的信号取值、信号组合、状态跳转等情况统计出来。仿真跑完后,覆盖率报告会明确告诉你:
- 哪些信号取值被覆盖到了,哪些没有被覆盖到
- 哪些信号之间的交叉组合被覆盖到了,哪些从来没发生过
- 哪些状态机的状态跳转从来没进入过
有了这些数据,你就能针对性地补测试用例或调约束,而不是盲目地加大仿真时间。我个人的经验是,覆盖率收集不一定要等到环境全部搭好才做。从第一版testbench能跑通开始,就把全局覆盖率开着,哪怕只统计几个关键信号,也能帮你尽早发现问题信号。等到UVM环境完整了之后,再慢慢把covergroup补充完整也不迟。
1.2 SVA断言在验证中的定位:实时“裁判员”而不是事后“纪检委”
断言(SVA,SystemVerilog Assertion)和覆盖率解决的问题完全不同。覆盖率回答的是“测到了吗”,断言回答的是“设计行为对不对”。
在实际验证里,很多bug是时序性的。比如一个握手信号,拉高之后必须在指定周期内被应答,否则就视为超时;又比如一个FIFO,写使能和满信号绝对不能同时为高。这类bug如果你想通过检查最终输出来发现,通常要花费大量时间追波形,而且很可能被中间信号的变化掩盖掉。SVA的定位就是在仿真过程中实时监控这些信号的时序关系,违反协议的那一刻立刻报错,并且打印出具体的时间戳和信号值。
我经常把断言比作球场上的裁判员。设计在被测的时候,裁判员一直在场上盯着,任何犯规动作都逃不过他的眼睛。相比之下,只在仿真结束后检查打印日志的方式,就像赛后看录像回放,能发现问题但效率低太多了。更关键的是,SVA不止能用断言(assert)来报错,还能用覆盖(cover)来记录某个时序是否发生,这本身就是功能覆盖率的一种补充。因此,成熟验证环境里,SVA和覆盖率从来不分家。
2. SystemVerilog功能覆盖率的核心机制
2.1 covergroup的编排思路:把验证目标翻译成机器能统计的语言
covergroup是SystemVerilog中收集功能覆盖率的容器。你需要把“我想验证什么”翻译成covergroup里具体的coverpoint(覆盖点)和cross(交叉覆盖)。
举个例子,假设你要验证一个AXI-Lite从机的写通道,你关心两个信号:写地址AWADDR的高两位取值,以及写有效AWVALID的跳变情况。那么你的验证目标就变成两类coverpoint:
- AWADDR[1:0]的取值,四种情况00、01、10、11是否都发生过
- AWVALID从0到1、从1到0的跳变是否都发生过
- 这两个信号之间的交叉组合情况
代码上很简单,先定义covergroup类型,再在需要的位置实例化:
covergroup cg_axi_lite_write @(posedge clk); awaddr_cp : coverpoint awaddr[1:0]; awvalid_cp : coverpoint awvalid; awaddr_x_awvalid : cross awaddr_cp, awvalid_cp; endgroup cg_axi_lite_write cg_inst = new();这里有个很重要的可选参数——采样事件。上面的写法用了@(posedge clk),也就是每个时钟上升沿采样一次。但实际验证中,你可能更希望在特定事件发生时采样,比如读写事务完成时。那就可以把采样事件改成:
covergroup cg_axi_lite_write @(event ev_sample); ... endgroup然后在你认为合适的时刻,通过->ev_sample来触发采样。这种按事件采样的方式,比单纯按时钟采样更精准,能显著减少无效数据量,我强烈建议你在实际项目里用起来。
covergroup可以定义在module里、interface里,也可以定义在class里。在class里用的最多,尤其是在UVM环境中,通常把covergroup定义在monitor或scoreboard的类中。但要注意一点:如果你在class里定义了covergroup,实例化时必须调用new()函数,而且covergroup的采样对象必须是class里能访问到的信号,或者是通过new函数的参数传进去的虚接口。这个细节新手经常栽跟头,编译报错说找不到信号,其实不是信号不存在,而是covergroup的采样作用域没写对。
2.2 仓bin的进阶写法:自动分箱和自定义分箱
coverpoint的取值域默认会被自动分成bin。比如一个4比特信号,默认会分成16个bin,每个bin对应一个值。这种自动分箱可以快速上手,但真实场景中通常需要手动指定bin,不然覆盖率报告会非常碎,而且有些组合你觉得不重要,也会被当成未覆盖项列出来。
手动指定bin有两种常见写法。第一种是直接列出关心的值:
coverpoint cmd { bins cmd_read = {READ}; bins cmd_write = {WRITE}; bins cmd_idle = {IDLE}; }第二种是用数组或范围来生成bin:
coverpoint addr { bins low = {[0:15]}; bins mid = {[16:31]}; bins high = {[32:47]}; bins rest = default; }这里我要多说一句default这个特殊的bin。它会捕获所有没有明确列出来的取值,通常用来保证覆盖率统计的完备性。但同时default bin在报告里一般不计入“未覆盖”的统计,因此能有效避免一些无关取值把覆盖率数字拉低。不过,使用default也要心里有数——如果default占比特别高,说明你的bin划分策略可能没覆盖到设计的主要工作模式,需要回头审视一下验证计划。
还有一种常见需求是忽略某些取值。比如一个信号只有复位后才会出现某些特殊值,而你不关心复位阶段的状态,那就可以用ignore_bins来过滤掉:
coverpoint mode { ignore_bins reset_mode = {MODE_RESET}; }ignore_bins和default的区别在于:ignore_bins是完全从覆盖率统计中排除这些取值,连“未被覆盖”的状态都不算;default则是不让它把覆盖率拉低。两者本质不同,用的时候要注意区分场景。很多时候项目里为了拉高覆盖率数据好看,滥用ignore_bins,把关键场景都滤掉了——这其实是在自欺欺人,验证的初衷是发现bug,不是刷一个漂亮的百分比数字。
2.3 交叉覆盖率:发现组合爆炸里的隐藏盲区
交叉覆盖率(cross)是用来统计多个覆盖点之间组合情况的工具。它解决的核心痛点是:单个覆盖点各自都覆盖到了,但它们的组合可能从来没发生过。
继续用AXI的例子。假设你分别收集了AWLEN(突发长度)和AWSIZE(传输大小)的覆盖点,单独看两个覆盖点都达到100%覆盖,但AWLEN=8且AWSIZE=4(即8拍传输且每拍4字节,共32字节)这个组合可能一次都没发生。如果不做交叉覆盖,这个问题完全不会被发现;而组合恰恰是很多功能bug的高发区。
交叉覆盖的写法很简单:
cross awlen_cp, awsize_cp;但有个重要细节:如果你不想统计所有组合,而是只关心某些特定组合,可以用binsof和intersect来过滤:
cross awlen_cp, awsize_cp { ignore_bins unexpected = binsof(awlen_cp) && !binsof(awsize_cp) intersect {1, 2, 4}; }这段代码的含义是:当awlen_cp的任意取值与awsize_cp的取值1、2、4组合时,都要排除掉。说白了,你对某些组合不感兴趣,那就让它们从统计里消失。
这里必须提个醒:交叉覆盖的数量是呈几何增长的。两个各有16个bin的覆盖点做cross,会生成256个交叉bin。如果三个覆盖点各16个bin,那就是4096个交叉bin。在设计覆盖率模型时,无脑cross会让覆盖率报告膨胀到你根本不想看,而且仿真性能也会受影响。我的建议是只对你真正关心的功能点做交叉覆盖。如果你自己都说不清楚这个交叉组合要验证什么行为,那就先别写。
2.4 覆盖率选项与收集流程:从数据到决策的闭环
除了基本的coverpoint和cross,SystemVerilog覆盖率还有几个常用选项,这些选项用好了能让覆盖率数据更符合你的验证目标。
第一个是weight选项。每个coverpoint或cross都可以设置权重,默认权重是1。如果你希望某个关键组合在总覆盖率中占比更高,可以把权重调大。比如:
cp_high_priority : coverpoint sig_a { weight = 3; } cp_low_priority : coverpoint sig_b { weight = 1; }第二个是goal选项。默认覆盖率目标是100%,但实际项目中有些点确实很难达到100%覆盖。你当然可以努力去补激励,但有些是设计本身就不会出现的情况,这时可以显式设置goal为90%或95%,并配套ignore_bins做说明。不过这个操作要慎重,默认不推荐乱改goal,因为管理层看覆盖率数字时,100%和90%的说服力完全不同。
第三个是option.per_instance。当同一个covergroup被多个实例化时,默认覆盖率是合并统计的。但你有时需要单独看某一个实例的覆盖情况,比如有多个相同通道的FIFO,你想确认是不是某个通道从来没被充分测试过。这时设置per_instance=1即可:
covergroup cg_fifo @(posedge clk); option.per_instance = 1; ... endgroup覆盖率收集的工作流一般是:定义covergroup并实例化 -> 跑仿真 -> 生成覆盖率数据库(通常在仿真结束时通过$coverage_save或工具自动生成) -> 用工具打开覆盖率报告 -> 分析未覆盖项 -> 补充或调整激励 -> 重跑仿真。当你发现覆盖率数据涨不上去了,先别急着加激励,看看那些未覆盖bin是不是ignore_bins没设对,或是验证环境里某些限制条件导致激励根本不可能产生这些值。找到根因,调整起来就事半功倍。
3. SVA断言:从属性到验证
3.1 断言的底层逻辑:属性、序列、成功与失败
SVA的基础概念其实不多,但初次接触时容易绕晕。核心就三个:序列(sequence)、属性(property)、断言(assert)。
序列是最底层的时间行为描述。比如“信号a拉高后的下一个周期,信号b会拉高”,这个行为描述就叫序列:
sequence s_req_ack; @(posedge clk) req ##1 ack; endsequence这里##1表示延迟一个时钟周期。req ##1 ack的意思就是:当前周期req=1,下一个周期ack=1。如果这个序列匹配成功,那属性就对这个序列进行进一步判别。属性是比序列更高一层的概念,它可以包含蕴涵操作符。最常见的是|->和|=>,分别表示“如果前件成立,则当前周期后件必须成立”和“如果前件成立,则下一个周期后件必须成立”:
property p_req_ack; @(posedge clk) req |-> ##1 ack; endproperty assert property (p_req_ack);第三行就是断言本身。把属性和断言语句结合起来,仿真器在每个时钟上升沿检查这个属性是否成立。如果前件req=1但后件ack没有在下一周期拉高,断言失败并报错。
好多人刚开始写SVA时容易混淆sequence和property。我的理解方式是:sequence管的是“信号之间如何随时间展开”,property管的是“这个展开过程需要满足什么整体要求”。sequence可以嵌套在property里,但property不能反过来嵌到sequence里(至少在基本用法上是这样)。等你会灵活使用sequence重命名、局部变量后,对这两者的边界感会更深。
3.2 时序控制与常用操作符:从##1到$past,覆盖最常见的协议场景
SVA里的时间控制符是理解断言语义的重中之重。除了##1、##2这种固定延迟外,还有一种延迟范围,比如##[1:3],表示延迟1到3个周期之内后件成立即可:
property p_req_ack_range; @(posedge clk) req |-> ##[1:3] ack; endproperty##[1:3]在真实协议验证里非常常用。比如很多总线的应答信号,协议规定必须在请求后2到4个周期内返回,这种场景用固定延迟就没法写了,只能用延迟范围。
还有一个高频操作符是$past()。它可以取信号在前几个周期的值。比如你想验证“当ready拉高时,data信号相比上一周期发生了变化”,就可以写:
property p_data_change; @(posedge clk) ready |-> (data != $past(data)); endproperty$past函数有几个可选参数,最常用的是$past(signal, n),表示当前周期之前第n个周期的值,n默认是1。还有一个需要注意的参数是clock gating,但实际项目中我用得不多,通常默认即可。
另外一个比较常用的是throughout和within,用来描述“某个条件在整个时间段内一直成立”。比如写使能拉高期间,读使能必须一直保持低电平:
property p_no_read_during_write; @(posedge clk) write_en throughout (write_valid ##1 write_valid); endproperty实际操作中我发现,新人最容易踩坑的是时钟域的问题。SVA默认是单时钟域的,如果你在时钟a的always块里采样了时钟b域的信号,很容易出现竞争。正规做法是断言也按信号所属时钟域来写,不要图省事。跨时钟域的断言不是不能用,但有专门的CDC验证方法学,那个是另一个大坑,这里按下不表。
3.3 断言的复用与层次化:在interface里封装,在模块里调用
断言的复用性非常重要。一个项目里可能有好几个模块都有类似的协议要求,比如所有生成write_valid的模块都要求write_valid不能和write_ready同时为高,或者握手信号拉高后必须在一个窗口内完成。如果你在每个模块里各写一遍断言,后期维护成本很高,而且很容易出现同一协议在不同模块里写了不同版本的问题。
推荐的做法是把断言封装在interface里。interface本来就是用来描述模块之间通信协议的,把和协议相关的时序断言放在里面再自然不过。比如:
interface axi_lite_interface(input logic clk, input logic rst_n); logic [31:0] awaddr; logic awvalid; logic awready; // 协议断言:awvalid与awready不能同时与复位冲突 property p_awvalid_no_assert_during_reset; @(posedge clk) disable iff (!rst_n) not (awvalid && awready); endproperty assert property (p_awvalid_no_assert_during_reset); endinterface在模块顶层例化这个interface后,断言会自动随接口的例化而生效。这样做的好处是:验证环境里用的接口信号驱动方式、DUT里实际的时序行为,都被统一的断言约束住了。改协议时只改interface,不用满工程找断言。
另一个复用技巧是把断言封装成bind模块。当你不想修改被测模块的RTL代码时,可以用bind语句把断言模块“绑”到目标模块内部。这在验证第三方IP时特别有用:
bind dut_module protocol_checker checker_inst( .clk(clk), .rst_n(rst_n), .req(req), .ack(ack) );bind语句就像是“外挂”了一组断言到设计模块上,不会改动原RTL,但仿真时这些断言对所有例化dut_module的实例都生效。第一次用bind的时候可能觉得有点“魔法”,但它确实是SVA复用的一把利器。
4. 实操:一个总线协议检查的完整实例
4.1 设计需求与验证目标
纸上谈兵说了这么多,我拿一个简化的SRAM接口协议来做完整演示。假设接口信号只有四个:
- req:请求信号,高电平有效
- ack:应答信号,高电平有效
- wr:读写选择,1表示写、0表示读
- addr:地址总线,宽度8位
- data:写数据总线,宽度8位
协议要求:
- req拉高后,ack必须在2到4个周期内拉高
- 在req和ack同时为高的周期,wr、addr、data必须保持稳定
- 复位期间不允许出现req拉高
- 一次事务完成后(req=1且ack=1的下一拍),req必须拉低至少一拍,不允许连续发起事务
现在的验证目标:设计一个验证环境,收集上述协议行为的覆盖率,同时用SVA监控协议是否被遵守。
4.2 覆盖率模型实现:定义关键覆盖点和交叉覆盖
针对上面的协议要求,可以设计如下的覆盖率模型。我把covergroup定义在interface里,方便直接采样接口信号:
interface sram_if(input logic clk, input logic rst_n); logic req; logic ack; logic wr; logic [7:0] addr; logic [7:0] data; covergroup cg_sram_protocol @(posedge clk); option.per_instance = 1; // 请求和应答的相位关系 cp_req_ack : coverpoint {req, ack} { bins idle = {2'b00}; bins pending = {2'b10}; bins completed = {2'b11}; bins illegal = {2'b01}; // 没有req就出现ack,正常协议中不该出现 } // 读写类型 cp_wr : coverpoint wr; // 地址分区:低地址、中地址、高地址、边界值 cp_addr : coverpoint addr { bins low = {[0:63]}; bins mid = {[64:191]}; bins high = {[192:254]}; bins max = {255}; } // 读写操作与地址分区交叉 cross_wr_addr : cross cp_wr, cp_addr; // 事务完成后的下一拍行为,需要配合采样时刻 cp_req_ack_next : coverpoint {req, ack}; endgroup covergroup cg_sram_turnaround @(posedge clk); // 采集事务结束后req拉低的情况 cp_turnaround : coverpoint req { bins low_after_done = {0}; } endgroup cg_sram_protocol cg_protocol = new(); cg_sram_turnaround cg_turnaround = new(); // 用事件触发采样事务完成后的状态 event ev_transaction_done; covergroup cg_after_done @(ev_transaction_done); cp_req_after : coverpoint req; cp_wr_after : coverpoint wr; endgroup cg_after_done cg_after = new(); // 在事务完成时触发采样事件 always @(posedge clk) begin if (req && ack) ->ev_transaction_done; end endinterface这段代码演示了几个关键点:
第一,coverpoint的表达式可以是信号拼接{req, ack},这样能直接把组合状态当成一个覆盖点来统计。2'b01被标记为illegal bin,因为协议不允许在没有请求的情况下出现应答。这个bin如果在覆盖率报告里出现了计数,那基本意味着设计有bug。
第二,用cross把读写选择和地址区间做了交叉覆盖。地址分四个区间,读写分两种情况,所以这个cross一共有8个bin。数量不大,有意义,适合演示。
第三,cg_after_done这个covergroup使用了自定义事件ev_transaction_done来触发采样。只有在req和ack同时为高的下一周期才会采样req和wr的状态,用来检查“事务完成后req是否成功拉低、wr是否还保持原值”。
这里有个仿真时序细节要强调:在always块里用->ev_transaction_done触发事件,这个事件的触发时刻是在当前time step的NBA阶段(如果事件来自阻塞赋值)还是可能提前,不同仿真器行为会有微小差异。稳妥做法是如果需要采样“事务完成后的下一拍”,你可以在事务完成事件触发的地方加一个#1step或使用clocking block的采样边沿。但那样写出来例子会太复杂,这里只是展示思路,实际项目中要根据仿真器行为确认采样点。
4.3 断言检查实现:把协议规则变成可执行的约束
覆盖率负责统计“测到哪些场景”,断言负责检查“这些场景有没有违反协议”。针对协议的四条要求,我写四个断言:
// 断言1:req拉高后,ack必须在2到4个周期内拉高 property p_ack_within_2_to_4; @(posedge clk) disable iff (rst_n == 1'b0) $rose(req) |-> ##[1:3] ack; // 注意:这里用了[1:3],配合下次采样,相当于req拉高后的第2到第4个周期 endproperty assert property (p_ack_within_2_to_4) else $error("req raised but ack not asserted within 2-4 cycles"); // 断言2:req=1且ack=1时,wr、addr、data必须保持稳定,即下一拍与当前拍值相同 property p_stable_during_transfer; @(posedge clk) disable iff (rst_n == 1'b0) (req && ack) |=> ($stable(wr) && $stable(addr) && $stable(data)); endproperty assert property (p_stable_during_transfer) else $error("Control signals changed during transfer"); // 断言3:复位期间不允许req拉高 property p_no_req_during_reset; @(posedge clk) disable iff (rst_n == 1'b1) not ($rose(req)); endproperty assert property (p_no_req_during_reset) else $error("req asserted during reset"); // 断言4:一次事务完成后,req必须拉低至少一拍 // 检测req和ack同时为高的上升沿,下一拍req必须为0 property p_req_low_after_done; @(posedge clk) disable iff (rst_n == 1'b0) (req && $rose(ack)) |=> !req; endproperty assert property (p_req_low_after_done) else $error("req did not deassert after transaction completion");逐个解释一下:
第一个断言的$rose(req)是检测req的上升沿,也就是请求发出的时刻。|->后面的##[1:3] ack表示之后的1到3个周期内ack必须为高。为什么写[1:3]而不是[2:4]?这里涉及时间原点的理解:$rose(req)是在上升沿那一拍被检测到的,这一拍属于周期0。##[1:3]表示的是“从下一拍开始算起的1到3拍内”。如果你的协议要求的是“req拉高的那一拍不算,第2到第4拍要看到ack”,那写法就是##[2:4] ack。这两种写法在周期计数上非常容易混淆,我一开始也经常搞错,建议写完之后在仿真波形里专门盯几个pass用例确认一下。
第二个断言的|=>符号表示下一拍成立。$stable(signal)是SVA内置的系统函数,当信号与上一个采样周期的值相同时返回真。这里用( req && ack ) |=> $stable(...)检查的是:在req和ack同时为高的这一拍之后的下一拍,wr、addr、data的值不发生变化。为什么这么写?因为我们通常关心的是“数据传输的那一拍各信号要保持稳定”,而且要注意握手完成的那一拍信号值会被接收方采走,下一拍即使变了也不影响本次传输。这个断言的语义是:本轮传输完成后的下一拍,这三个控制信号保持了原值。如果你希望检查的是“在req&&ack为高的那一拍”,信号必须等于前一拍的值,那应该写成|-> $stable(...)配合$past。这两种需求经常被混在一起,写之前务必想清楚你要验证的是“传输前保持”还是“传输后保持”。
第三个断言的disable iff (rst_n == 1'b1) 和前面的思路相反,表示当rst_n为高时,整个断言被禁用。这其实是错误的写法!注意看这个断言,我要检查的是“复位期间不允许req拉高”,因此必须在复位拉低(即rst_n==0)时使能断言,但disable iff应该是使能条件的反。在这个仿真实例里,如果你写disable iff (rst_n == 1'b1),当rst_n为0时,断言工作,检查有没有$rose(req)。看起来似乎可行。但更标准、语义更清晰的写法是:
property p_no_req_during_reset; @(posedge clk) if (!rst_n) not ($rose(req)); else $pass; endproperty或者更常见的写法是直接用disable iff把复位条件排除掉,但注意排除的是“复位状态”,不是“非复位状态”。比如你想在复位期间禁止断言,很自然地写disable iff(rst_n),意思是复位时整个断言不使能。上面我写的disable iff (rst_n == 1'b1)就是这个意思。但要注意这和你预期的“复位期间检查”是冲突的——复位时断言都关了,还怎么检查复位期间的行为?所以如果协议明确要求复位期间req不能拉高,你应该把断言写成:不复位时检查肯定行为(如req不能与ack同时拉高过久),而不是在复位状态里加not。因为绝大多数SVA断言库的设计思路都是“功能阶段才检查时序”,复位阶段直接禁用。你非要检查复位期间的行为,可以用单独的always块配合if (!rst_n)来做,不要硬塞进SVA断言里。这里我保留这个例子,是为了提醒大家:思考disable iff条件时,一定要先想清楚“我要在哪个时间窗口内做检查”。
第四个断言用来防止连续发起事务。协议要求事务完成后req必须拉低至少一拍。所以用( req && $rose(ack) )作为前件,检测ack上升沿且req为高,这表示事务完成。紧接着的下一拍要求req为0。这样能保证两次事务之间至少隔一拍空档。
4.4 仿真运行与结果分析:如何用覆盖率报告反推测试缺口
上面这些断言和covergroup写在一个interface里,仿真时把它和DUT、testbench顶层连好。顶层验证环境的示意代码大致长这样:
module tb_top; logic clk; logic rst_n; initial begin clk = 0; forever #5 clk = ~clk; end // 复位序列 initial begin rst_n = 0; #20; rst_n = 1; end // 实例化interface sram_if u_sram_if( .clk(clk), .rst_n(rst_n) ); // 驱动激励:用简单task发几个事务 initial begin wait(rst_n == 1); // 第一个事务:地址0x10,写操作 drive_write(8'h10, 8'hAB); // 第二个事务:地址0x99,读操作 drive_read(8'h99); // 第三个事务:地址0xFF,写操作 drive_write(8'hFF, 8'h12); // 完结 #100; $finish; end task drive_write(input [7:0] addr, input [7:0] data); @(posedge clk); u_sram_if.req <= 1; u_sram_if.wr <= 1; u_sram_if.addr <= addr; u_sram_if.data <= data; do @(posedge clk); while (!u_sram_if.ack); u_sram_if.req <= 0; endtask task drive_read(input [7:0] addr); @(posedge clk); u_sram_if.req <= 1; u_sram_if.wr <= 0; u_sram_if.addr <= addr; do @(posedge clk); while (!u_sram_if.ack); u_sram_if.req <= 0; endtask endmodule跑完之后,我模拟一下覆盖率报告里可能出现的情况。假设只有上面三个简单事务,那么:
- cp_req_ack覆盖点:idle和completed可能被覆盖到,pending不一定。如果每个事务的ack都在req拉高后的第2拍出现,那你的覆盖率只覆盖了“请求保持2拍得到应答”的场景,没有覆盖“请求保持3拍”或“4拍才应答”的场景。这就是一个典型测试缺口。
- cp_addr覆盖点:0x10落在low区间,0x99落在mid区间,0xFF落在max区间,但mid区间虽然被0x99访问到了,其他地址区间如high区间(192到254)没有事务访问过。所以cp_addr会有部分覆盖率缺口。
- cross_wr_addr覆盖点:写操作只访问了low和max区间,读操作只访问了mid区间。也就是说,“写+mid”“读+low”“读+high”“读+max”这些组合都缺失。
- 断言方面,如果DUT的时序正确,四条断言都应该被pass。但你可以故意在drive_read里把ack行为改乱,比如在req拉高后第5拍才拉高ack,断言1就会立刻报错。这就是SVA在仿真中的实时监控能力。
分析法不是跑完就结束的。拿到这份覆盖率报告,下一步应该做两件事:一是增加激励让未覆盖的场景出现,比如把地址改成0xC0、0x0F等;二是在testbench里做一个随机延迟的ack生成器,让ack在2到4拍之间随机返回,从而把应答时序范围拉开。如果Ack时序是靠DUT内部状态机产生的,那就要去检查状态机的设计,看是不是存在某些状态无法进入的问题——这往往是RTL缺陷的先兆。
5. 踩坑记录与排查技巧
5.1 覆盖率一直是0%,先别怀疑工具
很多人第一次把covergroup写好、跑完仿真,打开覆盖率窗口看到0%的时候,第一反应是工具出问题了。以我的经验,绝大多数情况下是代码写错了。
最常见的原因是covergroup没有实例化。covergroup和class不同,你没有定义变量并调用new(),它不会自动创建。在module或interface里写covergroup定义,但没有实例化,仿真器不会报编译错误,但覆盖率收集路径根本没有建立。解决办法就是在initial块或声明处显式new()。
第二个常见原因是采样信号写错了作用域。在class里定义的covergroup,如果采样信号是tb_top顶层信号,你需要通过虚接口或构造函数传参进去;如果你直接用全局信号名,很容易产生编译问题或者采到的始终是x态。在UVM里,我习惯在monitor里用analysis port把事务对象发给scoreboard,然后在scoreboard里对事务字段做覆盖。这样既能采样到事务级数据,又不需要关心时序采样点。
第三个原因是采样事件没有触发。比如你把covergroup的采样事件绑定到了某个soft event,但这个事件在仿真过程中从未被触发过,那覆盖率自然一直是0%。你可以用$increment_coverage或仿真器的覆盖率窗口来查看哪些covergroup完全没有采样记录,然后从这些covergroup入手排查。
第四个不太显眼但很容易踩的坑:在循环中实例化covergroup,但没有为每个实例保存引用。比如你在for循环里连续new了10个covergroup,但没有把每个实例保存在数组里,前面的实例会被垃圾回收或覆盖,导致你实际只收集到了最后一个实例的数据。这在分析per-instance覆盖率时尤其致命,很多人只看总覆盖率没发现,但看每个实例的覆盖率才发现一大片是零。
5.2 断言误报的常见来源:信号还没稳定就下结论
断言写好后,最头疼的问题之一就是误报。明明协议上设计是对的,断言却报了error。根据我的经验,误报大多出在以下几个场景:
第一,复位释放后的头几个周期,信号可能还没稳定。SVA断言通常在复位期间通过disable iff关闭,但复位释放瞬间,用$rose、$fell检测信号边缘时,仿真器可能因为x态或初始值而产生误判。建议在断言里统一加上disable iff (rst_n == 1'b0),给信号留出确定的建立时间。
第二,采样时刻和赋值时序不匹配。如果你在时钟上升沿的阻塞赋值区(阻塞赋值)更新了信号,而断言也在同一个上升沿采样,那么断言看到的值可能是旧值,也可能是新值,取决于仿真器的调度顺序。为了避免这种不确定性,推荐使用clocking block来采样,或者直接在班子里用非阻塞赋值驱动信号。UVM里大家对非阻塞赋值都很熟,但到了写断言时就容易忽略采样同步问题。
第三,时钟门控和异步复位嵌套带来的问题。如果你的设计里有门控时钟,SVA的@(posedge clk)是采样不到门控打开后的边沿的,这时你需要用$clk_gate_check这类专门的时钟门控断言或把时钟信号改成门控后的有效时钟。异步复位吃掉请求信号的情况更复杂,简单用disable iff处理复位会导致断言窗口被截断。这在异步设计中是个深坑,只能说尽量在验证计划里提前定义好复位和断言的交互策略。
5.3 性能优化:覆盖率收集和断言监控的开销不能忽视
覆盖率收集和断言监控都是有性能代价的,尤其在跑回归仿真时。覆盖率收集的核心开销在cross。十个以上的覆盖点做全交叉,很容易让仿真速度慢一倍以上。断言则更多体现在属性评估的复杂度上。如果断言里包含复杂的时间窗口,比如##[1:100],仿真器在100个周期内都要跟踪这个属性实例,开销会显著增大。
常见的优化手段包括:
- 减少不必要的cross,只交叉真正有关联的场景。
- 将covergroup的采样频率降低。如果采样事件从每个时钟改成事务完成时才采一次,覆盖率收集开销会大幅下降。
- 用ifdef控制断言和覆盖率编译开关。比如
ifdef ASSERT_ON ...endif,在性能验证或功耗仿真时关掉。 - 批量处理时,可以用多个仿真批次分别收集不同覆盖率,然后合并覆盖率数据库,而不是一次仿真收集所有覆盖率。
还有一点很实用:如果你在跑超大规模SoC级别的验证,覆盖率数据库文件动不动几个GB,建议在covergroup里配置option.coverage_goal和option.weight,减少不必要的bin。毕竟没人会认真看一个有十万个bin的覆盖率报告。
5.4 多周期断言与性能仿真的“不兼容”问题
这个话题在项目量产阶段尤其值得注意。多周期路径(Multicycle Path)在静态时序分析里经常出现,性能仿真(Power-Aware Simulation)也可能让时钟、复位行为变得非常复杂。在这些场景下,你精心调好的断言很可能因为时序变化而产生大量误报。我的建议是在做综合后仿真或低功耗验证时,对时序类断言做一次专门的审查,该关掉的关掉,该加disable条件的加上。断言是验证工具,不要让它们变成仿真回归里的“狼来了”噪音;报错的断言如果没人第一时间处理,后面大家就会忽略所有断言,就起不到实时监控的作用了。
覆盖率和断言这套东西,刚上手时确实容易写成“为了覆盖率而覆盖率”“为了断言而断言”。我自己的体会是,最好的切入点是从协议规范出发:你拿到的接口协议文档里,每一句话几乎都能翻译成一条断言或一个覆盖点。协议说“信号X必须保持稳定直到Y事件”,那就是$stable的断言;协议说“允许的X状态有A、B、C”,那就是三个bin的覆盖点。把一个接口协议翻译成十几条断言和四五个covergroup,这个过程的收获,远比最后看到“覆盖率100%、断言全pass”的数字要大。到下一篇笔记,我打算动笔搭建一个完整的UVM验证环境,把这些断言和覆盖率全部嵌进去,做成一个能直接用于项目的开源模板。