尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SystemVerilog实战指南:从语法到覆盖率,提升芯片验证效率

SystemVerilog实战指南:从语法到覆盖率,提升芯片验证效率 1. System Verilog 到底改变了什么干验证这一行的人对 System Verilog 应该都不陌生。但说实话我刚从 Verilog 切到 SV 的那阵子心里是有点嘀咕的——不就是给 Verilog 加了点面向对象的壳子吗直到真正拿它做起来才发现这个认知错得离谱。System Verilog 核心解决的是验证方法学层面的问题。它把硬件描述语言和硬件验证语言合到了一起让你能在同一个仿真环境里同时处理设计代码和测试代码。Verilog 时代你要做约束随机、要做功能覆盖率、要搭复杂的测试平台基本得靠 external 工具或者手写一堆繁琐的逻辑到了 SV这些全部变成语言内建能力。可以这样理解Verilog 给了你一支笔能画电路SV 给了你一套绘图工具集不仅能画电路还能自动检查你画得对不对、覆盖率够不够。另外一个容易忽略的点是SV 不是要你丢掉原来的 Verilog 功夫。恰恰相反它完整向下兼容。你写过的always(posedge clk)、assign、wire和reg在 SV 里照常能用。这意味着项目的迁移成本是可控的——你可以一部分模块继续用老写法新模块逐步上 SV 的特性而不是某天一觉醒来必须推倒重来。谁最适合读这篇文章如果你正在做 IC 验证、FPGA 验证或者即将从 Verilog 过渡到 SV想在真实的测试平台搭建、约束随机、断言和覆盖率收集上少走弯路那这篇实战经验总结就是给你准备的。下面的内容不是语言手册的复述而是我在多个项目里踩过坑、改过 bug、优化过仿真效率之后沉淀下来的东西。2. 核心语法与数据结构的实战选型2.1 logic、reg 与 wire别再用错了很多从 Verilog 转过来的人第一个卡住的点就是logic到底和reg、wire是什么关系。简单说logic是一种通用的数据类型它既能被连续赋值驱动也能被过程块always、initial驱动。在绝大多数场景下你不需要再纠结这个信号到底该声明成reg还是wire直接用logic就完了。但有一个例外而且这个例外在实际项目中很常见多驱动源的情况。比如你在验证环境里挂了两个 master 模型去驱动同一条总线或者某个三态信号既可能被 A 模块拉高也可能被 B 模块拉低。这时候logic就不能用了因为logic只允许一个驱动源多个driver往上顶编译直接报错或者仿真变成 X 态。解决办法还是老老实实声明成wire配合supply0、supply1或pullup、pulldown来处理。再补充一点logic在 0、1、X、Z 四态之外还支持 0 和 1 的二态语义。如果你确定某个信号只会在二态环境下存在比如纯功能仿真中的内部节点你可以用bit类型仿真内存占用会小一点跑长回归的时候速度也能快一些。实际项目里我一般只在存储大量数据的队列元素或关联数组的值上使用bit其他地方统一logic也方便 RTL 对接。2.2 数组、队列与关联数组选型决定了性能SV 里数据容器的选择直接影响到你写测试平台的效率也影响仿真速度。我自己总结了一套选型逻辑如果你需要频繁地在头部或尾部插入删除用queue。int q[$]这种写法非常顺手配合push_front、push_back、pop_front、pop_back很灵活。注意访问队列中间元素的复杂度是 O(n)不适合做随机索引访问频繁的场景。如果你需要一个按地址索引、但地址非连续的空间比如模拟一个稀疏的内存模型用associative array。int mem[string]或者byte mem[longint]这种。它不会像定宽数组那样预分配所有空间在模拟超大地址空间时省内存。如果你的数据是固定大小、固定结构的比如一个 16 项的配置寄存器表直接用定宽数组reg_cfg_t arr[16]访问效率最高也最容易综合。实际遇到过的坑是在循环里大规模使用queue的delete()操作之后再连续插入仿真速度会肉眼可见地下降。后来排查的时候发现时队列删除后不会自动缩容底层的存储块一直占着。解决方法是定期重建队列或者用索引标记无效项而不是物理删除仿真效率立刻上来了。这类经验你从语言手册里是读不到的。2.3 struct、union 与 enum让代码有语义刚写验证代码的人喜欢把所有的信号拆成零散的logic和int一个事务包动辄十几个参数传函数的时候参数列表长得像卷草纸。其实 SV 早就给你准备好了结构化的套路。struct可以把一个事务的所有字段打包到一起。比如一个 AXI 写事务你声明一个typedef struct packed { logic [7:0] id; logic [31:0] addr; logic [3:0] len; logic [31:0] data[]; } aw_trans_t;整个事务的构造、比较、拷贝都方便多了。注意packed这个关键字它保证了结构体按位紧凑排列方便做位操作也方便随机化时设定$urandom范围。enum则推荐用在状态机或者事务类型的定义上。typedef enum logic [2:0] {IDLE, RUN, DONE, ERROR} state_e;这样写的好处是波形里可以直接显示状态名调试效率比看一堆 3b101 高得多。注意直接用enum做比较之前先想想是否需要显式做类型转换。SV 是强类型语言enum和int之间不能无声地互相赋值不转换就编译报错这其实是保护你避免把非法值写进状态机。通过合理地使用结构体和枚举你的测试平台会变得更像业务代码语义清晰新成员接手也快。团队协作的时候代码的可读性比炫技重要得多。3. 接口、类与测试平台的结构设计3.1 interface 和 modport组件之间的通信契约在验证环境里DUT 和验证组件之间要有一堆信号需要连接。Verilog 时代的做法是把每个端口信号都列一遍连到 testbench 顶层信号一多就全是复制粘贴和找错。SV 的interface就是来解决这个痛点的。你定义好一组信号和方向之后在测试平台里把它当作一个端口来传整个环境瞬间清爽。更关键的是interface还可以包含modport来区分不同角色的视图。比如对 master driver 来说地址和数据是输出方向对 monitor 来说这些统统都是输入方向。通过modport声明清楚任何一端用错了方向编译器都会帮你抓出来。接口里还可以内嵌时钟块clocking block。我用 clocking 块最大的感受是它把时序细节封装起来了在 driver 里写(cb)或者cb.addr addr;不用再手写一堆(posedge clk); #1;的过程。这一方面减少了笔误另一方面也让时序关系一致——所有驱动都同步到同一组时钟事件上不容易出现某个信号打拍多了、某个信号驱动时刻不对的诡异问题。不过要提醒的是clocking block里的信号驱动默认是有#1step的 skew 的这个在仿真里是模拟真实时序的需要。但如果你写的是用于形式化验证的属性时钟块反而可能带来额外的复杂度建议区分场景使用。3.2 类、继承与工厂模式测试平台的核心是类。一个标准的验证环境至少会有 transaction 类、driver 类、monitor 类、scoreboard 类和 reference model 类。这些类之间通过继承和句柄互相组织。继承要克制不要为了看起来面向对象而强行多层继承。我见过有人把 base transaction 继承了三层、每层还加一堆字段最后随机化一个包的时候约束冲突排查到怀疑人生。我的经验是继承的深度控制在两层以内父类只放所有子类都用得到的公共字段具体的扩展字段放子类里这样定位问题快得多。工厂模式在 UVM 里是一个绕不开的概念但它的本质其实很朴素你注册了某个类的制造方法后续可以通过配置字符串去动态创建对应类型的实例而不需要在代码里写死new。这个机制最大的价值在测试用例的可重用性——你想把一个 driver 替换成带错误注入功能的子类只要在 test 里set_type_override_by_type一下整个环境无需改动就换上了新 driver非常优雅。真正实操的时候记得多态函数要声明virtual。virtual function和virtual task是动态绑定的基础漏了virtual子类重写的函数根本不会被调用到这个问题非常隐蔽不查代码只看仿真结果容易绕很久。3.3 测试平台的分层别把代码堆在一个文件里验证环境最忌讳的就是一个文件从头写到尾几千行代码里既有 driver 又有 monitor还有 scoreboard 的计算逻辑和覆盖率收集。表层上它跑起来没问题但稍微要扩展一点立刻就会觉得牵一发动全身。我常用的分层是这么拆的stimulus 层负责产生事务包括 sequence 和 sequencer。驱动层driver把事务里的字段按协议时序驱动到 DUT 的接口上。观测层monitor从接口上采集信号恢复成事务级数据。比对层scoreboard把 monitor 上报的数据和 reference model 的预期输出做比较。环境层env把上面这些组件用 factory 和 config 串起来开箱即用。层与层之间通过 TLM 端口通信比如analysis_port、blocking_put_port。这套结构不是 UVM 专有的即使你暂时用不了 UVM 全家桶只靠原生的 SV 类也能照这个思路搭一个轻量级环境。这样做的好处是任何一层想替换、想复用都能以类为单位进行而不是打开文件做手术。4. 约束随机化实践让测试向量学会自己找茬4.1 约束块的基本面与变量选择SV 最有杀伤力的能力之一就是约束随机化。你可以让一个事务的地址在合法范围内随机跳动同时保证对齐、保证某些字段之间的依赖关系。这种能力让验证的测试向量不再是写死的而是像侦察兵一样去试探 DUT 的边界。写约束有个基本顺序先决定哪些变量参与随机化。使用rand修饰的变量会在randomize()调用时被重新赋值只用randc修饰的会在所有可能取值都出过一遍之后才允许重复特别适合产生全遍历的枚举型配置。举个实际的例子验证一个 AXI 从设备地址的约束是地址必须 4KB 对齐长度字段 len 的取值和地址的低位有绑定关系。那么约束可以这样写rand bit [31:0] addr; rand bit [3:0] len; constraint c_addr_aligned { addr % 4096 0; } constraint c_len_legal { len inside {[1:16]}; (len * 4) (4096 - (addr % 4096)); }第二个约束保证了 burst 不会跨越 4KB 边界这在 AXI 协议验证里是必须要覆盖到的点。实战中我发现把约束写成这种不变量形式比在 sequence 里一层层 if-else 控制要可靠得多——约束求解器会主动找到一个满足所有条件的组合而不是靠你自己枚举。4.2 处理约束冲突收集不可能满足的报错信息约束冲突是随机化最容易遇到的问题。报错信息千篇一律地提示No solution exists但到底是谁和谁冲突往往需要你自己去挖。我常用的处理流程是第一步把冲突嫌疑大的约束块逐个注释掉然后重新跑随机化看问题是否消失。这种二分手动排查方式最笨但最可靠。第二步用soft约束处理那些非硬性要求。soft约束在与其他约束冲突时会被自动解决适合做默认值设置。第三步养成在randomize()之后立刻检查返回值assert(randomize())的习惯而不是放任随机失败继续往后跑。实际操作中rand变量如果被赋予了不合法的初始值有时会直接导致约束无解。比如你给len赋了一个 0同时约束里写了len inside {[1:16]}求解器照样报失败。解决办法是在randomize()之前用std::randomize或者手动重新赋一个合理值。4.3 约束要覆盖的是设计的不变量不是应用的某个场景写约束时最重要的思维转变是不要只想着某个测试用例需要什么样的数据而要思考 DUT 在协议层面对输入数据有哪些硬性限制。这些硬限制在验证里就是不变量。变量与变量之间的关系、边界值、非法值在什么时候被拒绝把这些约束齐全了你跑的每一轮随机其实都是在替设计做压力测试。我自己踩过的坑是早期写约束总是往宽松方向写总觉得随机范围大一点覆盖率就高一点。实际正好相反约束太宽松大量样本落在中间地带边界值和非法值反而长期覆盖不到。后来改成把约束拆成合法数据约束和错误注入约束两套由不同 sequence 拉高或拉低权重覆盖率提升很明显。5. 断言与覆盖率从仿真跑完到验证完备5.1 SVA 断言用属性描述协议的期望断言SVA是 SV 里另一块重要武器它让你可以用声明性的方式描述协议行为的期望。仿真运行期间断言失败会立刻暴露问题而不是等 scoreboard 比对半天之后才回过味来。最常用的是时序属性。比如验证一个握手协议要求req拉高后ack必须在 1 到 4 个时钟周期内拉高。写成断言就是这样property p_req_ack; (posedge clk) disable iff (!rst_n) req |- ##[1:4] ack; endproperty assert property (p_req_ack) else $error(req-ack handshake timeout);这里|-是蕴含算子表示当左边成立时右边必须在指定窗口内成立。窗口写法##[1:4]表示 1 到 4 拍。个人建议所有 SVA 都加上disable iff的复位条件否则复位期间仿真总是在报无意义的断言失败。断言还能检测时序窗口的稳定性比如某个信号在多拍内必须保持稳定可以用$stablereq |- $stable(addr)[*2]。这类断言在总线协议、流控信号、FIFO 指针的验证中非常实用。5.2 covergroup 与 coverpoint覆盖率是验证的度量尺SV 的覆盖率收集用的是covergroup。一个 covergroup 里可以包含多个coverpoint一个 coverpoint 里可以有多个bins。覆盖率指标的意义在于它把我们测过哪些情况变成了硬数据而不再是我觉得差不多测完了。一个典型的 covergroup 长这样covergroup cg_write_burst (posedge clk); coverpoint burst_len { bins len_1 {1}; bins len_2_4 {[2:4]}; bins len_5_8 {[5:8]}; bins len_9_16 {[9:16]}; bins max_len {16}; } coverpoint burst_addr_align { bins align_1KB {[0:1023]}; bins align_4KB {[0:4095]}; } cross burst_len, burst_addr_align; endgroup注意到我设置了cross它统计的是两个 coverpoint 的组合覆盖情况。组合覆盖可以帮你发现那些单看长度覆盖到了、单看地址覆盖到了但两者组合没有出现的场景。覆盖率分组设计别贪多coverpoint 之间关联性强的才做 cross否则 bins 数量爆炸仿真跑完覆盖率也上不了 90%心态容易崩。5.3 从覆盖率数据倒推功能缺口跑完一轮回归所有的覆盖率数据导出后我一般会按以下顺序查看首先看功能覆盖率优先处理 0% 的 bin。0% 通常意味着某个协议场景根本没被激发大概率是 sequence 写漏了。其次看 cross 覆盖率里那些单点覆盖到了但 cross 为空的组合这往往意味着两个参数之间存在未预料的耦合。最后再看代码覆盖率。代码覆盖率低的分支比功能覆盖率低更难定位需要结合波形逐步排查。实际项目中我遇到过一类很尴尬的情况功能覆盖率上了 95%但代码覆盖率里有一段分支始终是 0。后来查了波形发现是约束把地址范围限制得太窄DUT 里跟高位地址相关的 branch 从未被走到。这说明功能覆盖率和代码覆盖率是互补的不能只盯其中一个。6. 仿真调试与常见问题排查实录6.1 X 态传播仿真中最难缠的 bug 来源做 SV 验证遇到 X 态未知状态的传播问题几乎是家常便饭。一个信号因为初始化不够变成 X然后顺着组合逻辑传播开来整个波形大片大片的红色看起来像出了灵异事件。排查 X 态的手段经验上最有效的是打开仿真器的-xprop或者类似选项让 X 态传播时能自动打印第一次出现 X 的时间点和位置。在检测到 X 时用$error通知并停止仿真避免后续大量无效波形进一步干扰定位。我自己调试时会在关键接口上挂一段临时代码always (posedge clk) begin if ($isunknown(axi_addr)) $error(AXI addr contains X at time %t, $time); endX 态的根源大多出在复位不完整、异步信号未同步、或者某个变量在 initial 里没有赋初值。FPGA 上仿真的话还要注意reset释放时刻和时钟沿之间的建立/保持关系设计里没有同步释放逻辑的话xprop 很容易抓到问题。6.2 时间精度timeunit 与 timeprecision 引发的大坑SV 里timeunit和timeprecision这两个声明影响的是这个模块或类里所有延时语句的时间刻度。不同模块之间如果精度不一致你写#1到底是 1ns、1ps 还是 1fs完全取决于声明。真实踩过的坑driver 模块里声明的是timeunit 1ns / timeprecision 1ps而参考模型里声明的是timeunit 1ps / timeprecision 1ps结果 monitor 采集到的数据时序上出现了细微错位scoreboard 比对总是失败。后来统一到同一个时间精度才解决。建议是整个验证环境的公共 base 类里统一声明timeunit 1ns / timeprecision 1ps。虽然 UVM 里通常建议使用uvm自带的时间精度但在裸 SV 环境下全工程统一时间刻度是避免诡异时序 bug 的最便宜手段。6.3 性能优化长回归跑不动怎么办验证环境越到项目后期回归测试集越大每跑一轮全量回归的时间成本也越高。性能优化不能只靠升级机器代码层面的几个做法实测效果很明显合理使用仿真器优化选项在编译和仿真时开启acc1或类似选项级别的控制避免不必要的层次信息记录。大量使用二态类型bit、int替代四态类型减少仿真器处理 X/Z 的开销。尽量避免顶层 testbench 里到处$display调试信息用宏封装起来生产回归时把打印级别关掉。如果 sequence 里存在大量随机等待尽量用事件触发而不是repeat(random_delay)轮询减少无效调度。另一个容易被忽视的点是queue的大量增长。随着仿真进行scoreboard 里的队列如果没及时清理仿真器内存会持续膨胀最终拖慢整个回归。我在环境里专门加了一个 watchdog定期检查队列深度超阈值就自动打印警告帮忙提前发现泄漏。7. 测试平台编写心得好的环境是改出来的回顾这些年接触过的验证项目有一个体会特别深一个良好的验证环境不是一开始设计得多完美而是能不能在项目的不同阶段被低成本地修改和扩展。SV 提供了很多方便改动的机制但前提是你在一开始就留好扩展的口子。具体来说事务类的字段宁可初期多留几个也别后期到处加引用。因为事务类一旦被多个组件引用新增字段时所有随机化约束、比较函数、print 函数都会受影响。我习惯在一开始就给事务类提供copy、compare、print这些基础方法即使最开始只有两个字段要用也不省掉。等后面字段多了再补远比一开始就写全麻烦得多。另外断言和覆盖率不要留到环境写完了才回头补那样大概率会漏掉一些关键时序点和边界组合。我现在的做法是在写 driver 和 monitor 的同时就把断言挂上、covergroup 定义好哪怕一开始只有一个 coverpoint也要先有骨架。随着调试的深入覆盖点自然会被慢慢补全。这样最终交付的覆盖率数据才是可信的。最后分享一个维护上的习惯每次修完一个 bug 或者新增一个测试场景我都会在代码里留下注释说明这个测试用例是复现哪个 issue 的、核心关注点是什么。这个习惯在项目后期非常救急——回到几个月前写的 sequence没有这些注释你根本想不起来当初为什么要在那个地方加一个constraint_mode(0)的调用。
返回列表