ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

System Verilog实战经验:从RTL设计到验证环境的关键要点

System Verilog实战经验:从RTL设计到验证环境的关键要点 写System Verilog的实战经验其实是个挺需要勇气的事。这门语言覆盖面太大从RTL设计到验证方法学从断言到覆盖率任何一个方向挖下去都是无底洞。我给自己挖这个坑是因为这些年确实在项目里踩过不少坑也积累了一些看着不起眼、但关键时刻真能救命的经验。我会持续把这些东西整理出来保持一个不断更新迭代的状态毕竟验证技术和工具链本身也在不停演进。这篇文章先不急着谈语法我想先聊聊从Verilog过渡到System Verilog时最容易让人栽跟头的几个思维转变和实操细节。如果你是从C语言、软件验证转来做数字IC验证的或者刚把System Verilog当“高级Verilog”在写那这部分内容对你的帮助会非常大。1. 从Verilog到System Verilog不只是语法多了几行很多刚开始接触System Verilog的人最大的误区就是把它当成Verilog的“超集”觉得只要会Verilog再查查语法手册就能上手。实际上System Verilog的定位是硬件描述与验证语言它在Verilog的基础上引入了大量面向对象、随机约束、断言和覆盖率的语法结构。你在写代码时候的思路如果还停留在“设计硬件电路”的层面写出来的验证组件大概率是四不像。1.1 语言思维的第一重转变从“连线”到“对象”Verilog的核心抽象是module和wire它描述的是电路的连接关系和时序行为。而System Verilog的验证部分核心抽象是class它描述的是数据的属性和操作。这个思维转变如果没有完成你会在写testbench时遇到非常别扭的情况。比如你有一个总线接口用Verilog写是这样的module tb_top; reg [31:0] addr; reg [31:0] wdata; reg we; reg clk; // 初始化信号 initial begin addr 32h0; wdata 32h0; we 0; // 開始激勵 end dut u_dut ( .clk(clk), .addr(addr), .wdata(wdata), .we(we) ); endmodule这套写法在简单的模块级验证里完全没问题。但是当你的设计规模变大要验证的功能点从几十个涨到几百个需要构造的事务transaction类型有几十种再用这种“全局信号名”的方式去操控总线代码很快就会失控。到处都是tb_top.addr、tb_top.wdata的引用一个小改动可能牵动十几个文件的修改。System Verilog的class机制解决的就是这个问题。你可以把一笔总线交易transaction定义成一个对象属性包括地址、数据、读写方向、延时等方法包括随机化、打印、比较等。class bus_transaction; rand bit [31:0] addr; rand bit [31:0] wdata; rand bit we; rand int delay; constraint c_addr { addr inside {[32h0000_0000 : 32h0000_FFFF]}; } constraint c_delay { delay inside {[1:10]}; } function void display(); $display(addr0x%0h, wdata0x%0h, we%0b, delay%0d, addr, wdata, we, delay); endfunction endclass写完这个class之后你在driver或者sequence里只需要通过对象的句柄去访问和操作数据而不是去操作一根根“线”。这个抽象层级的提升是System Verilog验证方法学的基石也是我建议所有刚接触这门语言的人要刻意训练的第一件事。1.2 数据类型的细节毒打bit、logic与reg的边界Verilog里有reg和wire的严格区分到了System Verilog里新引入了logic类型很多人一开始想当然地认为“logic就是reg反正都是变量”。这个理解在大部分场景下没问题但有几个边界情况会坑得你怀疑人生。logic类型不能用于多驱动场景。logic只能有一个driver如果你在testbench里用了两个initial块同时给同一个logic信号赋值编译器会报错误。而wire或者System Verilog里的wire logic支持多驱动适合描述总线这类可能存在多个设备驱动的信号。还有一位的数据类型bit无符号默认值是0和logic杂用的时候容易被忽略。比如bit a; logic b; initial begin a 1b1; b 1bz; // 允许logic可以表示高阻态 // a 1bz; // 错误bit不能表示高阻态 end这些类型细节看起来不起眼但在实际综合或仿真过程中可能导致你的验证环境和RTL设计行为不一致等到芯片回来再发现代价就大了。我的建议是验证环境里尽量用logic替代reg和wire除非你明确知道需要多驱动或者要处理四态逻辑值再针对性选择类型。1.3 接口interface的价值接线方式的革命用Verilog做比较大的系统验证时最让人头疼的是DUT的端口列表。几十上百个端口信号对的时候对到眼花缭乱牵一发动全身。interface是System Verilog里特别实用的一个概念它把相关的一组信号封装到一个结构里端口列表瞬间就干净了。interface axi4_lite_if(input logic clk, input logic rst_n); logic [31:0] awaddr; logic [2:0] awprot; logic awvalid; logic awready; logic [31:0] wdata; logic [3:0] wstrb; logic wvalid; logic wready; logic [1:0] bresp; logic bvalid; logic bready; logic [31:0] araddr; logic [2:0] arprot; logic arvalid; logic arready; logic [31:0] rdata; logic [1:0] rresp; logic rvalid; logic rready; modport master( output awaddr, awprot, awvalid, wdata, wstrb, wvalid, bready, araddr, arprot, arvalid, rready, input awready, wready, bresp, bvalid, arready, rdata, rresp ); modport slave( input awaddr, awprot, awvalid, wdata, wstrb, wvalid, bready, araddr, arprot, arvalid, rready, output awready, wready, bresp, bvalid, arready, rdata, rresp ); endinterface这里有个容易忽略的细节modport的方向是相对使用方而言的。你可以把master和slave的方向定义得非常明确然后在driver或monitor里例化时选择对应的modport。这样做的好处有两个一是代码可读性极高二是从语法层面避免方向接反的低级错误。我用这个接口方式重构过一个老的AHB验证环境端口连接的错误率降低了一个数量级。2. 可综合子集与RTL设计的边界写System Verilog的人和写验证环境的人最大的分水岭就是可综合这三个字。很多人用仿真工具的默认编译模式写了一段花哨的代码仿真结果也正确可一到综合阶段工具直接报错或者综合出不可预期的电路。搞清楚System Verilog里哪些语法能综合哪些不能综合是RTL工程师的基本功。2.1 能用的常用可综合结构System Verilog确实对可综合设计有不少增强比如always_comb和always_ff这两个关键词本身就是设计意图的声明。always_comb代替了Verilog时代用来写组合逻辑的always (*)它在语义上强制要求块内不能有存储单元并且敏感列表是自动推断的。这比手写敏感列表要安全得多减少了一类“忘了加信号导致仿真和综合行为不一致”的经典bug。always_comb begin if (sel) begin data_out data_a; end else begin data_out data_b; end endalways_ff (posedge clk or negedge rst_n)则明确表示这是一个时序逻辑块工具可以据此检查你的代码是否符合时序逻辑的规则。相比普通always (posedge clk)它的语义更严格也更容易被EDA工具优化。枚举类型enum也是可综合的而且比用parameter定义状态机更清晰、更具可读性。用枚举类型定义状态寄存器时工具可以自动做编码优化代码也更不容易出错。typedef enum logic [1:0] { IDLE 2b00, READ 2b01, WRITE 2b10, DONE 2b11 } state_t; state_t state, next_state;不过要注意即使这些结构综合工具支持不等于是好的电路设计。比如有些综合工具对enum类型做状态编码的优化策略和你想的不一样导致状态寄存器布局不是你预期的。对于要求可控性强的设计我建议还是手动指定状态编码不要图省事依赖工具。2.2 仿真专用结构心里要有数另一边有几类System Verilog的语法是明确不可综合的但它们对验证环境的构建极其重要。常见的有class、program、fork join_any、wait、assert property、covergroup等。这里遇到最多的问题是把不可综合的语句误放进RTL design里。比如一个同事可能图省事在DUT内部加了个fork join_none来控制某个时序行为仿真跑得很好综合阶段直接卡住。这种问题的排查往往很费劲因为错误信息经常是综合工具抱怨“unexpected token”之类指向根本不明确。我现在的习惯是在项目代码目录里严格区分rtl和tb两个根目录并且在编译脚本里把两个目录的编译选项和参数范围分开。RTL代码里绝不出现任何class、program、断言等语法一旦CI检查碰到这类关键字直接报错打回。这种规则前置的思路能省掉后面大量排错时间。2.3 风格统一比技巧重要写RTL是一条漫长的路在团队协作时代码风格的重要性往往被低估。System Verilog允许的语法糖非常多同一个功能可以用十种不同方式写出来。但最后在综合、布局布线、验证各个环节不同风格带来的工具兼容性差异、可维护性差异是真实存在的。我的建议是团队内建立自己的编码规范文档明确什么场景用什么结构。比如组合逻辑用always_comb时序逻辑用always_ff状态机用枚举类型模块接口用interface或明确的logic列表。这些规范不需要多复杂但要严格执行。实战中你会发现统一风格后的代码评审效率高很多因为评审者只需要关注逻辑本身而不是先去理解你的书写习惯。3. 验证环境构建从零搭建一个可复用的testbench如果说RTL的可综合子集是System Verilog的“设计面”那验证环境的构建就是它的“验证面”也是这门语言被大规模使用的主战场。我见过很多验证工程师写testbench仍然在用Verilog时代的套路——把所有激励写在一个大的initial块里然后用#delay层层延时。这在模块级小验证里勉强可以但一旦有几十上百个用例复杂度就会爆炸。3.1 分层验证环境的基本结构一个标准的System Verilog验证环境通常遵循分层的思想最底层是信号接口中间是driver和monitor再往上是agent、env、test最上层是sequence和sequence item。每一层的职责边界要画清楚。我以一个简单的SPI设备验证为例说下环境搭建的几个关键步骤。signal interface定义物理接口信号driver根据sequence提供的transaction把信号按SPI协议时序驱动到接口上monitor反过来从接口上采样信号打包成transactionscoreboard比较预期的transaction和monitor采集到的transaction判断设计是否工作正确。class spi_driver extends uvm_driver #(spi_transaction); uvm_component_utils(spi_driver) virtual spi_if vif; function build_phase(uvm_phase phase); super.build_phase(phase); if (!uvm_config_db#(virtual spi_if)::get(this, , vif, vif)) begin uvm_fatal(NOVIF, virtual interface not found) end endfunction task run_phase(uvm_phase phase); spi_transaction req; forever begin seq_item_port.get_next_item(req); drive_transaction(req); seq_item_port.item_done(); end endtask task drive_transaction(spi_transaction req); // 按照SPI协议将req中的data和cs信号驱动到vif上 // 这部分直接操作vif的时序 endtask endclass这套结构的好处是强隔离性每个组件只负责自己那部分逻辑修改driver不会影响scoreboard新增一个sequence不需要改env。最早的验证环境我几乎把所有代码堆在几个大文件里维护成本很高后来按分层重写之后新用例的开发速度提升了一半不止。3.2 约束与随机化验证效率的倍增器随机约束验证CRV是System Verilog相对Verilog最大的优势之一。通过随机化的数据配合约束求解器可以在有限的时间内自动生成海量测试场景覆盖手工写用例难以触碰的边界条件。class spi_transaction; rand bit [7:0] data; rand int delay_cycles; rand bit [3:0] cs_num; constraint c_delay { delay_cycles inside {[0:15]}; } constraint c_cs_num { cs_num dist {0 : 4, [1:3] : 1}; } constraint c_data_corner { data inside {8h00, 8hFF, 8h55, 8hAA}; solve data before delay_cycles; } endclass这里有几个细节值得展开说。dist约束可以把权重分配得比较细上面这个例子里cs_num等于0的概率是其他值的4倍能高效生成更多选中片选0的场景。solve before这个约束会影响求解器的求解顺序进而影响随机数的分布。如果不加约束data和delay_cycles的取值组合基本是平均分布的加上solve data before delay_cycles之后每次都是先固定data的值再随机delay_cycles这在某些验证场景下能让边界数据的覆盖率收敛得更快。随机化还有一个比较容易忽略的问题——随机稳定性。如果你的验证环境里有多个随机源而它们的求解顺序不确定那么同一个seed跑出来的结果可能不同这对回归测试的可复现性是个巨大打击。解决办法是保证随机数产生器的调用顺序固定或者干脆使用std::randomize()配合process::self().set_randstate()来精确控制每个进程的随机状态。我习惯在环境里的top测试用例层统一管理seed避免底层多个组件各自随机化时出现不可控的交互。3.3 覆盖率驱动验证到底验够了没有要回答“验证做完了吗”这个问题覆盖率是核心依据。System Verilog提供了三类覆盖率代码覆盖率由工具自动收集、功能覆盖率由covergroup和coverpoint人工定义、断言覆盖率由SVA属性隐含。代码覆盖率不用自己写但功能覆盖率必须根据设计规格来建模。覆盖组的定义说难不难说简单也容易流于表面。最容易犯的错误是把覆盖率当成“写几个coverpoint就完事”的任务结果跑完回归发现功能覆盖率100%了但设计的关键交互场景一个都没测到。真正有效的覆盖率建模要求你深入理解设计规格里每一个功能点思考“什么叫做这个功能被验证过了”。covergroup spi_cov (posedge vif.clk); option.per_instance 1; cp_data: coverpoint tx_data { bins zero {8h00}; bins ff {8hFF}; bins others default; } cp_cs: coverpoint cs { bins cs0 {0}; bins cs1 {1}; bins cs2 {2}; bins cs3 {3}; } cp_addr_phase: coverpoint addr_phase { bins short_phase {[0:3]}; bins long_phase {[4:15]}; } cross_cs_data: cross cp_cs, cp_data; endgroupcross交叉覆盖率的价值在于捕捉组合场景。比如单看cs每个值都覆盖到了单看data每个值也覆盖到了但cs2且data0xFF的组合可能一次都没出现这个场景就可能藏着bug。覆盖率报告里那些“0%”的交叉bin往往是验证团队最需要花时间挖掘的地方。覆盖率驱动的另一个要点是要把覆盖率目标和回归测试结合。可以在回归开始时清空之前的覆盖率数据库跑完全部testcase后自动merge再输出整体报告。如果某个功能覆盖率一直上不去先别急着加用例去分析原因——是约束范围设窄了还是sequence根本没有生成相关激励这些分析过程才是验证工程师真正的日常。4. 断言SVA与调试技巧让Bug无处遁形System Verilog AssertionSVA是强大但常常被人忽略的工具。很多团队验证主要靠波形调试等到仿真挂了再打开波形一根根信号去查效率很低。而断言的作用是在第一时间发现协议或时序上的违规并且直接把报错信息打在log里即使不开波形也能定位问题。4.1 断言的基本写法与黄金参考断言的核心是序列sequence和属性property。序列描述事件发生的先后次序属性是对序列逻辑关系的规定而assert property则是让仿真器检查这条规则。以一个简单握手机制为例// req拉高后最多等10个周期ack必须拉高 property p_req_ack; (posedge clk) req | ##[1:10] ack; endproperty assert property (p_req_ack) else $error(REQ asserted but ACK not received within 10 cycles);这段代码的意思很清楚当req在状态为高时从下一个周期开始最多往后数10个周期你至少能看到一次ack拉高。如果超时没看到仿真就会报错。SVA的重点不只是写对语法更在于设计出哪些值得断言的属性。好的断言应该直接对应协议规范中的关键规则例如总线请求与响应、FIFO读写指针关系、状态机状态跳转合法性。不要为了凑断言数量而写一堆鸡毛蒜皮的检查那样的断言只是代码量上的充实起不到应有的质量保障作用。4.2 调试技巧从波形驱动到断言驱动我自己的调试经验里断言在定位问题上有一个好处它能把“出了问题”和“根源在哪”直接挂钩。比如一个AXI总线验证跑挂了你打开log发现某条断言报错再去看它对应的时序波形问题的大方向基本就定了。这比漫无目的地在海量波形里翻找要高效很多。断言还能用来做协议规则收敛。记得有一次验证一个总线协议环境跑了很多用例功能覆盖率也很好但一直没有发现一个页错误的场景。我把协议手册里的相关规则写成断言后发现其中一条关于地址边界对齐的断言从第一条用例开始就一直没通过只是当时的log级别没打开根本没人注意到。后来修正了激励的约束把这条场景补上果然立刻暴露一个RTL地址计算逻辑的边界问题。从那以后我给团队的规矩就是所有新开发的验证环境必须把协议关键检查点写成断言作为用例的守护神。4.3 断言的调试环境配置SVA调试也和仿真工具配置相关。常用的仿真器如VCS、Questa都支持-assert相关选项来开启断言使能和报告。如果你用的是VCS可以加-assert enable_diag打开诊断信息用Questa的话要注意在vsim时添加-assertcover来使能基于断言的覆盖率收集。这些工具选项记不住也没关系在脚本里写好模板反复复用即可。另外有个容易被忽视的坑断言的采样和重置。assert property里如果没处理好异步复位可能在复位释放时就误报违规。比如上面那个req | ack的例子如果ack在复位期间不受约束工具会按原属性检查最终在log里拉出一堆无意义的错误。规范做法是在属性里加一个disable iff (!rst_n)的判断或者用rst_n做门控。property p_req_ack_rst; (posedge clk) disable iff (!rst_n) req | ##[1:10] ack; endproperty这样在复位期间断言不会启动检查等复位释放后才会生效。5. 仿真性能与代码优化验证工程做到一定规模仿真性能就成了硬指标。一套UVM环境跑一个用例可能只要几分钟但要跑几千个回归用例整体时间可能长达几十小时。节约仿真时间就是节约人力成本和服务器资源。5.1 大数组与队列的仿真开销System Verilog的队列queue、动态数组dynamic array用起来很方便但在仿真器的内存管理和性能上有非常大的开销。如果要建立很大的存储模型比如模拟几MB的缓存或寄存器组动态数组会频繁触发内存分配和释放仿真速度会急剧下降。一个常见优化方法是使用定长数组或者关联数组associative array。定长数组的内存布局是连续的访问效率高关联数组按key存储适合做地址稀疏的模型。对于需要大量随机读写的数据模型还可以考虑用std::randomize直接操作底层数据结构减少中间对象复制。// 不推荐大量使用queue做存储模型 logic [31:0] storage_q[$]; // 推荐定长数组精确控制内存 logic [31:0] storage_arr[0:1023]; // 推荐关联数组稀疏存储 logic [31:0] storage_aa[int];5.2 UVM环境中减少不必要的拷贝UVM组件之间传递transaction时经常会发生对象拷贝。每一次copy()和clone()操作都会创建新对象海量的事务流下这会造成大量内存分配和释放。优化的思路是尽量复用对象比如在driver里可以这样spi_transaction req; forever begin seq_item_port.get_next_item(req); // 直接使用req对象而不是clone一个副本 drive_transaction(req); seq_item_port.item_done(); end这个例子虽然简单但体现了“减少无谓拷贝”的核心思想。很多UVM代码里都有req req.clone()之类的做法其实大多数情况完全没必要。合理的使用对象句柄传递比频繁clone要高效得多而且能保证数据的唯一性避免多个组件操作同一份数据时的同步问题。5.3 仿真的加速思路除了代码层面的优化仿真环境的搭建方式也会影响性能。可以考虑关闭不必要的波形dump减少记录信号的数量和深度开启编译器的优化选项比如VCS的-debug_accessall虽然方便调试但也会拖慢仿真速度调试时用详细调试选项回归时报全面波形或者完全不dump波形只保存log往往能显著提速。另外大型环境里用uvm_top.print_topology()打印UVM树也是有开销的尤其在每个用例都打印的情况下。可以把打印开关做成测试用例的参数只有在debug用例里才打开。这些都是细节但积累起来节省的时间非常可观。6. 常见问题速查与排查实录这个部分我会持续更新把我自己团队在日常开发中踩过的坑和对应的解决方案记录下来。也给刚开始用System Verilog的读者提供一些可以直接“抄作业”的排查路线。6.1 编译错误篇编译错误是最常见的一类问题。很多System Verilog编译错误其实来自类型不匹配或者语法表达不符合SV标准。以下是我见过频率最高的几个类型。错误类型现象描述典型原因处理建议Unresolved type某个类型名找不到定义忘记include包或类定义顺序不对检查文件的编译顺序先编译存在类型的文件Illegal assignment to wire给wire类型信号赋值把logic信号写成wire或者试图在always里给wire赋值检查信号类型定义always里赋值应使用logicNon-constant index索引表达式不是常量综合工具不支持用变量索引某些数组或是仿真工具对动态数组的编译限制查阅所用工具的User Guide确认对动态数组的语法支持程度Cannot find implementation of methodUVM环境报某个虚方法没有实现没有重写父类的纯虚方法检查类的继承关系确认需要override哪些virtual task/function6.2 仿真行为错误篇编译通过但仿真行为不对排查起来比编译错误更费神。一些典型场景包括时钟和复位时序没对齐、interface里的信号没有正确连接、race condition等。我遇到最多的是经典Verilog仿真竞态组合逻辑和时序逻辑在同一时钟沿采样导致读出旧值还是新值不稳定。System Verilog提供了always_ff和always_comb这种语义更明确的块配合nonblocking赋值能减轻这一问题。但如果你在testbench里粗暴地用#1来调整时序只能是治标不治本。还有一类经典的坑是interface和DUT端口连接方向错误。比如总线接口里把input写成了output仿真结果虽然能出波形但数据的语义可能被完全反转。这种情况用上面说的modport规范后基本能从语法层面避免但老代码里仍然常有排查时要把接口连接方向作为第一优先级检查。6.3 覆盖率与断言问题篇覆盖率收集结果和预期对不上的情况也很常见。比如定义了covergroup但跑完用例发现覆盖率为0%。大概率原因是你的covergroup事件没有在正确的采样时钟沿触发。检查你的采样事件是(posedge clk)还是某个信号沿或者有没有忘记把covergroupsample()函数调用到合适的位置。断言的误报也是一大坑。刚写完断言跑仿真满屏的error别急着怀疑RTL先检查自己的属性表达式是不是漏了时序层次。比如req |- ack和req | ack就差一个时钟周期语义误用之后断言结果会完全相反。建议在写关键断言时先在简单的测试用例上验证一下断言本身的时序是否符合预期再拿它去检查复杂场景。7. 后续更新与经验迭代写到这里这篇“System Verilog实战经验”算是在这个方向上开了个头。内容目前覆盖了从RTL设计到验证环境、再到断言和调试的几个主要侧面但System Verilog的边界远不止这些。之后我计划继续更新的方向包括UVM高级用法寄存器模型RAL的深度使用、sequence的启动与调度细节覆盖率进阶如何设计真正的交叉覆盖目标如何进行覆盖率的收敛分析和报告解读基于断言的随机化验证策略如何让断言和随机约束产生正向协同多时钟域CDC验证中System Verilog的实践经验各主流仿真工具VCS、Questa、Xcelium在System Verilog上的特有选项和脚本化技巧这些内容我会结合自己实际项目中遇到的问题持续补充。也欢迎同行在评论区分享你们在System Verilog上踩过的独特难点互相交流才能把验证这条路走得更稳。
返回列表