ARTICLE DETAIL

资讯详情

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

Verilator实战:从RTL到C++模型的高效仿真与波形生成

Verilator实战:从RTL到C++模型的高效仿真与波形生成 1. 为什么我最终把仿真主力从商业工具换成了Verilator如果你写过几年Verilog大概率经历过这样的场景改一行RTL打开重量级仿真器编译、精化、跑仿真一套流程下来几分钟过去了波形还没看到。项目小的时候还能忍一旦设计规模上去或者需要跑成千上万个随机测试向量这种等待就变成了折磨。我最初接触Verilator就是被它的编译速度吸引的——它把Verilog/SystemVerilog先翻译成C或SystemC模型再用本地编译器编译成可执行文件跑起来几乎是原生程序的性能。Verilator的定位和传统的HDL仿真器不太一样。它本质上是一个周期精确cycle-accurate的编译器而不是事件驱动event-driven的仿真器。这个区别很关键事件驱动仿真器会精确模拟每个信号在任意时刻的变化包括门级延迟、惯性延迟这些而Verilator把设计当作一个在每个时钟边沿统一求值的组合逻辑时序逻辑网络它不模拟任意时间点上的信号跳变只关心每个时钟周期结束时的稳定状态。所以Verilator跑得快但它不适合做带精确时序的模拟仿真比如模拟PLL锁定过程、异步复位毛刺这类场景。那它适合什么我的经验是三类场景最合适第一纯数字逻辑的功能验证比如CPU核、总线控制器、状态机、协议栈第二需要和C/SystemC测试平台深度耦合的场景比如你要在测试平台里调用C算法库、做软硬件协同仿真第三大规模回归测试因为编译出来的可执行文件可以并行跑配合CI流水线非常顺手。关键词里提到的“HDL到C模型”说的就是Verilator的核心工作方式它读入.v或.sv文件生成一堆.cpp和.h文件这些文件里包含了你的模块对应的C类。你在自己的C测试平台里实例化这个类驱动时钟、施加激励、读取输出整个流程就像在写一个普通的C程序。波形生成也不是仿真器内置的而是通过编译时打开--trace选项在运行时调用VerilatedVcdC类来dump VCD文件再用GTKWave这类工具查看。这篇文章我会从零开始把Verilator的安装、RTL准备、C测试平台编写、编译脚本、波形生成、常见坑点全部串一遍。不管你是刚接触数字设计的在校学生还是想给现有验证流程提速的工程师都能照着复现。我踩过的坑、绕过的弯路都会在对应章节里说清楚。2. Verilator的安装与版本选择别小看这一步2.1 包管理器安装 vs 源码编译Verilator在主流Linux发行版上都有包Ubuntu下直接apt install verilator就能装。但我要提醒一句发行版自带的版本往往偏旧。比如Ubuntu 20.04默认给的是4.038而较新的5.x版本在SystemVerilog支持、编译速度、错误信息友好度上都有明显提升。如果你只是跑个简单例子旧版本够用但如果你的RTL里用了较多SystemVerilog特性比如interface、struct、enum建议直接上5.x。源码编译也不复杂官方推荐流程是git clone https://github.com/verilator/verilator cd verilator autoconf ./configure make -j$(nproc) sudo make install编译前需要确保系统里有g、make、autoconf、flex、bison这些基础工具。我第一次编译时卡在flex版本太旧上报了一堆语法错误后来升级flex到2.6以上就过了。另外如果你打算用--trace生成波形编译Verilator时需要确保--enable-trace相关依赖主要是zlib已经装好否则后面dump VCD会报错。2.2 验证安装是否成功装完之后跑一句verilator --version能打印出版本号就说明基本OK。再跑一个官方自带的小例子验证一下verilator --binary -j 0 -Wall --top-module top examples/top.v如果没报错并生成了obj_dir目录说明工具链完整。这里--binary是5.x版本引入的便捷选项它会自动生成一个可执行的仿真程序不需要你手写C测试平台。对于快速验证RTL功能这个选项非常省事。2.3 版本差异带来的坑不同版本之间有几个差异值得注意。4.x时代--trace和--trace-vcd是分开的5.x里统一成了--trace。另外5.x对--timing的支持更好如果你的设计里有#delay这类延时语句4.x可能直接忽略或报错5.x在开启--timing后能部分支持。我个人的建议是新项目直接用5.x老项目如果RTL里用了大量非综合延时先确认版本支持情况再迁移。还有一个容易被忽略的点Verilator默认把顶层模块名当作C类名如果你的模块名和C关键字冲突比如叫class、new编译会失败。这时候需要用--prefix选项给生成的类名加前缀或者直接改模块名。我遇到过一次模块名叫interface折腾了半天才发现是关键字冲突。3. 从RTL到C模型Verilator到底做了什么3.1 编译流程拆解当你执行verilator --cc top.v --exe sim_main.cpp时背后发生了这些事第一步解析。Verilator读入所有.v/.sv文件构建抽象语法树AST。这一步会做基本的语法检查如果RTL里有语法错误会在这里报出来。第二步精化elaboration。Verilator展开所有模块实例、解析参数、计算位宽、确定信号连接关系。这一步会做大量的优化比如常量传播、死代码消除、公共子表达式提取。这也是Verilator跑得快的原因之一——它在编译期就把很多逻辑简化了。第三步生成C代码。每个Verilog模块对应生成一个C类类名通常是V模块名。类里有成员变量对应RTL里的信号有eval()方法对应组合逻辑求值有clk相关的时序逻辑处理。生成的代码在obj_dir目录下文件名形如Vtop.cpp、Vtop.h、Vtop___024root.cpp等。第四步编译链接。Verilator调用g把生成的C代码和你的测试平台代码一起编译链接成可执行文件。这一步的编译时间取决于设计规模但通常比事件驱动仿真器的精化过程快得多。3.2 生成代码的结构打开obj_dir/Vtop.h你会看到类似这样的结构class Vtop { public: VlUnpackedCData, 8 counter; VlUnpackedCData, 1 clk; VlUnpackedCData, 1 rst_n; // ... void eval(); void final(); };VlUnpacked是Verilator定义的类型用来表示宽位信号。CData是单比特类型SData是有符号类型IData是32位无符号类型QData是64位无符号类型。这些类型定义在verilated_types.h里你写测试平台时需要包含这个头文件。eval()方法是核心每次调用它会重新计算所有组合逻辑并在时钟边沿更新时序逻辑。你的测试平台需要自己控制时钟翻转比如top-clk 0; top-eval(); top-clk 1; top-eval();这样就完成了一个时钟周期的推进。注意Verilator不会自动帮你翻转时钟时钟完全由测试平台控制。这既是灵活性的来源也是新手容易困惑的地方——很多人第一次跑Verilator发现波形不动就是因为忘了在测试平台里翻转时钟。3.3 组合逻辑与时序逻辑的处理差异Verilator对组合逻辑和时序逻辑的处理方式不同。组合逻辑在每次eval()时都会重新计算而时序逻辑只在时钟边沿更新。这意味着如果你的RTL里有组合环路combinational loopVerilator会在编译时报错因为它在求值时无法收敛。事件驱动仿真器可能会通过迭代求解来处理组合环路但Verilator直接拒绝。这个特性其实是个好事——组合环路在真实硬件里本来就是危险的设计Verilator帮你提前发现了。我遇到过几次组合环路都是因为assign语句里不小心形成了反馈Verilator的报错信息会直接指出环路路径比仿真器跑出X态再回头查要高效得多。另外Verilator默认把always (posedge clk)块当作时序逻辑把always (*)块当作组合逻辑。如果你的RTL里用了always (posedge clk or negedge rst_n)这种异步复位Verilator也能正确处理但需要注意复位信号的初始化顺序。4. 手写C测试平台从零跑通第一个仿真4.1 最小测试平台的结构一个最小的Verilator测试平台通常包含这几部分包含Verilator生成的头文件、实例化顶层模块、初始化信号、驱动时钟、施加激励、读取输出、释放资源。下面是一个完整例子假设RTL是一个简单的8位计数器#include Vcounter.h #include verilated.h #include iostream int main(int argc, char** argv) { Verilated::commandArgs(argc, argv); Vcounter* top new Vcounter; // 初始化 top-clk 0; top-rst_n 0; top-eval(); // 复位保持几个周期 for (int i 0; i 3; i) { top-clk 1; top-eval(); top-clk 0; top-eval(); } top-rst_n 1; // 跑20个周期 for (int i 0; i 20; i) { top-clk 1; top-eval(); top-clk 0; top-eval(); std::cout cycle i count (int)top-count std::endl; } top-final(); delete top; return 0; }这段代码里Verilated::commandArgs用来处理Verilator自己的命令行参数比如verilatordebug。top-eval()是核心每次调用都会重新求值。注意时钟翻转的顺序先拉高、eval、再拉低、eval这样每个循环对应一个完整时钟周期。4.2 时钟生成与激励施加的常见写法实际项目里测试平台通常会更复杂。我习惯把时钟生成封装成一个函数或类避免在每个测试里重复写翻转逻辑。比如void tick(Vcounter* top, int n 1) { for (int i 0; i n; i) { top-clk 1; top-eval(); top-clk 0; top-eval(); } }激励施加则根据测试场景不同而变化。如果是随机测试可以用C的random库生成随机输入如果是定向测试直接赋值即可。我建议把激励生成和结果检查分开写这样测试平台结构更清晰也方便后续扩展。还有一个细节Verilator生成的信号类型可能是CData、SData、IData等赋值时需要注意类型匹配。比如给一个8位信号赋值可以直接写top-data 0xFFVerilator会自动截断或扩展。但读取时如果信号是CData单比特直接std::cout会输出字符而不是数字需要强制转换成int。4.3 编译脚本的编写手写g命令太麻烦通常用Makefile或CMake。下面是一个简单的MakefileVERILATOR verilator TOP counter SRC counter.v SIM sim_main.cpp all: sim sim: $(VERILATOR) --cc $(SRC) --exe $(SIM) --trace --build -j 0 \ --top-module $(TOP) -o sim run: sim ./obj_dir/sim clean: rm -rf obj_dir--build选项让Verilator自动调用make编译生成的C代码-j 0表示用所有可用核心并行编译。--trace打开波形支持-o sim指定输出可执行文件名。跑make run就能编译并运行。如果你用CMake可以写一个CMakeLists.txt用find_package(verilator)找到Verilator然后verilate()函数处理RTL。CMake的好处是跨平台、依赖管理方便适合大型项目。我现在的项目基本都用CMake因为测试平台代码多了之后Makefile维护起来很痛苦。5. 波形生成VCD dump的完整配置5.1 编译时打开trace选项波形生成的第一步是在Verilator编译时加--trace。这个选项会让生成的C代码里包含波形dump相关的逻辑。如果不加运行时调用VerilatedVcdC会报错或什么都不输出。--trace之外还有几个相关选项--trace-structs会把SystemVerilog的struct也dump出来--trace-depth N限制dump的层次深度--trace-underscore会dump以下划线开头的信号。我一般用--trace就够了如果设计层次很深加--trace-depth 3避免VCD文件过大。5.2 运行时dump VCD的代码模板在测试平台里需要包含verilated_vcd_c.h创建VerilatedVcdC对象绑定到顶层模块然后在每个时钟周期调用dump()。完整模板如下#include Vcounter.h #include verilated.h #include verilated_vcd_c.h int main(int argc, char** argv) { Verilated::commandArgs(argc, argv); Vcounter* top new Vcounter; Verilated::traceEverOn(true); VerilatedVcdC* tfp new VerilatedVcdC; top-trace(tfp, 99); // 99是层次深度 tfp-open(wave.vcd); // 初始化 top-clk 0; top-rst_n 0; top-eval(); tfp-dump(0); // 仿真循环 vluint64_t sim_time 0; for (int i 0; i 100; i) { top-clk 1; top-eval(); tfp-dump(sim_time); sim_time 5; top-clk 0; top-eval(); tfp-dump(sim_time); sim_time 5; } tfp-close(); delete tfp; top-final(); delete top; return 0; }几个关键点Verilated::traceEverOn(true)必须在创建VerilatedVcdC之前调用否则trace不生效。top-trace(tfp, 99)里的99是层次深度设大一点没关系Verilator会自动处理。tfp-dump(sim_time)里的时间参数是仿真时间单位是时间精度通常设为5或10的倍数对应半个时钟周期。5.3 用GTKWave查看波形生成的wave.vcd可以用GTKWave打开gtkwave wave.vcdGTKWave是开源工具Linux下apt install gtkwave就能装。打开后在左侧层次树里找到信号拖到波形窗口即可。我习惯把时钟、复位、关键状态信号放在最上面方便观察。如果VCD文件太大GTKWave加载会很慢。这时候可以用--trace-depth限制dump深度或者只dump关键信号。Verilator还支持FST格式--trace-fstFST比VCD压缩率高、加载快GTKWave也支持。我现在基本都用FSTVCD只在需要和其他工具交换数据时才用。5.4 波形生成中的常见问题最常见的问题是波形里信号全是X或Z。这通常是因为复位没做好或者测试平台没有正确初始化信号。Verilator默认把所有信号初始化为0但如果你的RTL依赖复位来初始化状态机而测试平台忘了拉低复位状态机就会停在初始状态。另一个问题是波形时间轴不对。tfp-dump(sim_time)里的时间参数需要你自己维护如果每个周期加的时间不一致波形看起来会很奇怪。我建议用一个变量统一管理仿真时间每次dump时递增固定值。还有一个坑如果你在eval()之前调用dump()dump出来的是上一次求值的结果。正确的顺序是先eval()再dump()这样波形反映的是当前周期的稳定状态。6. 踩坑实录那些让我熬夜的Verilator问题6.1 组合环路报错从报错信息定位问题Verilator报组合环路时错误信息通常长这样%Error: Internal Error: counter.v:15: Combinational loop detected它会指出环路所在的文件和行号但有时候环路跨多个模块定位起来还是麻烦。我的经验是先看报错行附近的assign语句和always (*)块检查是否有信号自己赋值给自己或者A赋值给B、B又赋值给A的情况。如果环路跨模块可以用--debug选项生成更详细的中间文件或者临时把可疑模块注释掉逐步缩小范围。有一次我遇到一个隐蔽的环路状态机的next_state逻辑里某个条件分支漏写了默认赋值导致next_state在某些输入下保持原值而原值又影响条件判断形成了环路。Verilator的报错指向了always块但真正的问题在条件覆盖不全。后来我养成了习惯所有always (*)块开头先给所有输出赋默认值避免锁存器和环路。6.2 信号位宽不匹配导致的静默错误Verilator对位宽的处理比仿真器严格但有些情况下它会自动截断或扩展不报错。比如你把一个8位信号赋值给4位信号Verilator会截断低4位不报警告。这在调试时很坑因为波形看起来正常但数值不对。避免这个问题的方法是打开-Wall选项Verilator会报出大部分位宽不匹配的警告。另外在RTL里显式写位宽比如assign out in[3:0]而不是assign out in。我现在的项目里-Wall是标配CI流水线里如果出现警告直接失败强制大家修掉。6.3 时钟域 crossing 与 eval 顺序Verilator是周期精确的它不关心时钟域之间的亚稳态但多时钟域设计在Verilator里需要特别注意eval()的调用顺序。如果你的设计有两个时钟clk_a和clk_b测试平台需要分别控制它们的翻转并在每次翻转后调用eval()。如果两个时钟同时翻转Verilator会按代码顺序求值可能导致和真实硬件不一致的结果。我的做法是多时钟域设计里尽量让时钟错开翻转比如先翻clk_a、eval、再翻clk_b、eval。如果必须同时翻转在RTL里加同步器确保跨时钟域信号经过至少两级寄存器同步。Verilator不会帮你检查这些需要你自己保证。6.4 内存泄漏与性能调优Verilator生成的代码本身没有内存泄漏但测试平台里如果频繁new/delete对象可能会有性能问题。我见过一个测试平台在每个测试用例里都重新创建顶层模块跑几千个用例后内存暴涨。后来改成复用同一个顶层模块只在用例之间复位性能提升了好几倍。另外eval()的调用次数直接影响仿真速度。如果测试平台里做了大量无关的eval()调用会拖慢整体速度。我建议只在必要时调用eval()比如时钟翻转后、读取输出前。如果只是检查组合逻辑可以在激励施加后调用一次eval()不需要每个周期都调。还有一个性能技巧如果设计里有很多调试信号但测试时不需要可以用--x-assign 0和--x-initial 0选项让Verilator把X态当作0处理减少求值时的分支判断。这个选项在功能验证时很有用但做X态传播测试时不能开。7. 把Verilator接入日常验证流程的几点体会7.1 和CI流水线的配合Verilator编译出来的可执行文件是原生程序非常适合放进CI流水线。我的做法是每次代码提交后CI自动跑Verilator编译和回归测试测试结果用XML或JSON格式输出方便集成到Jenkins或GitLab CI。因为Verilator编译快整个回归通常几分钟就能跑完比事件驱动仿真器快一个数量级。回归测试的并行化也很简单把测试用例分成多组每组跑一个可执行文件实例用xargs -P或GNU parallel并行执行。我试过在16核机器上跑1000个用例总时间从单核的半小时降到两分钟。7.2 覆盖率收集的替代方案Verilator本身不提供代码覆盖率收集但可以通过--coverage选项生成覆盖率相关的C代码再配合gcov或lcov收集行覆盖和分支覆盖。不过这种方式收集的是C代码的覆盖率不是RTL的覆盖率映射回RTL需要额外处理。我的经验是如果项目对覆盖率要求高还是用商业仿真器做覆盖率收集Verilator主要负责快速功能回归。两者结合既保证了速度又满足了覆盖率要求。如果预算有限可以用Verilator做功能测试用开源的verilator_coverage工具做基本的行覆盖统计虽然不如商业工具精细但聊胜于无。7.3 和SystemC/C算法模型的联合仿真Verilator最强大的场景之一是和C算法模型联合仿真。比如你在做一个图像处理芯片C里有参考算法Verilator里有RTL实现你可以在同一个测试平台里同时调用两者逐像素对比输出。这种软硬件协同验证方式比纯RTL仿真效率高得多。具体做法是把C算法编译成静态库在测试平台里链接进去RTL的输出和算法的输出做比较。如果结果不一致dump波形定位问题。我做过一个通信基带的项目用这种方式把验证周期从几周缩短到几天。7.4 一些零散但实用的建议第一保持RTL可综合。Verilator虽然支持部分非综合语法但为了后续能上FPGA建议RTL里避免使用#delay、initial块里的复杂逻辑、force/release这些语句。第二测试平台代码也要版本管理。很多人只把RTL放进Git测试平台随便放。实际上测试平台和RTL一样重要需要一起管理、一起review。第三善用--lint-only。这个选项只做语法检查和lint不生成C代码适合在写RTL阶段快速检查。我通常在编辑器里保存文件后自动跑一次--lint-only有问题立刻发现。第四波形文件定期清理。VCD/FST文件动辄几百MB跑几次回归磁盘就满了。我一般在CI脚本里设置波形文件大小上限超过就只保留最近几次的。第五多读Verilator的警告信息。Verilator的警告信息非常详细很多问题它都提前告诉你了只是大家习惯性忽略。我现在的习惯是编译时出现的每个警告都查一遍确认无害后再用-Wno-xxx屏蔽而不是直接全局关警告。Verilator不是万能的它不能替代事件驱动仿真器做时序验证也不能替代形式验证做穷举证明。但在功能验证这个环节它的速度和灵活性确实让人上瘾。我从最初抱着试试看的心态到现在大部分项目都用它做主力回归工具中间踩了不少坑但也实实在在感受到了效率的提升。如果你还在犹豫要不要学我的建议是找一个周末照着这篇文章跑一遍感受一下从RTL到C模型再到波形的完整流程大概率你会和我一样回不去了。
返回列表