ARTICLE DETAIL

资讯详情

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

Verilator仿真入门:从环境搭建到C++测试平台实战

Verilator仿真入门:从环境搭建到C++测试平台实战 1. 为什么我最终选择了Verilator这条路线搞数字电路设计的人迟早会撞上一个绕不开的问题手写的Verilog代码怎么在电脑上快速跑起来验证功能对不对。早期我也是老老实实打开商业仿真器建工程、配库、跑仿真、看波形一整套流程走下来小模块还好一旦设计规模上去编译时间动辄几分钟起步迭代一次心态就崩一次。后来接触到Verilator才算是真正把“验证”这件事从重资产变成了轻资产。Verilator的核心定位和传统事件驱动仿真器有本质区别。它不做逐时间步的事件调度而是把可综合的Verilog HDL代码翻译成等价的C模型再借助宿主机的C编译器编译成原生可执行文件。这个思路带来的直接好处就是仿真速度极快官方给出的数据是比传统仿真器快10到100倍我自己实测一个带流水线的UART收发模块同样的测试激励商业仿真器跑了将近40秒Verilator编译出来的可执行文件不到1秒就跑完了。这个差距在需要跑大量随机测试或者长时间回归的场景下是决定性的。但速度快是有代价的。Verilator本质上是把HDL“编译”成软件它对Verilog的支持是有取舍的。它主要面向可综合的子集那些依赖精确时序控制、延迟建模、非综合构造的代码它要么不支持要么行为跟事件驱动仿真器不一致。所以你得清楚Verilator适合做功能验证、算法验证、大批量回归测试不适合做带精确时序的后仿或者模拟电路混合仿真。这个边界心里要有数不然踩坑了会怀疑人生。这套流程适合谁我觉得三类人收益最大。第一类是FPGA/IC设计工程师日常需要快速验证RTL功能逻辑第二类是做算法硬件化的同学比如通信基带、图像处理需要把C参考模型和HDL实现做逐周期比对第三类是想学数字设计但不想被商业工具授权卡住的学生和爱好者Verilator完全开源配合开源工具链能搭出一套零成本的验证环境。接下来我会把从环境搭建、HDL代码约束、C测试平台编写、波形生成到问题排查的完整链路拆开讲都是我自己反复踩坑之后沉淀下来的东西你照着做基本能少走很多弯路。2. 环境搭建与工具链选型的关键决策2.1 安装方式的选择与理由Verilator的安装有好几条路包管理器直接装、源码编译、或者用预编译二进制。我建议分场景选。如果你只是想快速上手跑个demoLinux下直接apt install verilator或者macOS下brew install verilator最省事。但要注意包管理器里的版本往往偏旧可能缺少一些新特性比如对SystemVerilog某些断言的支持。我自己在Ubuntu上装过仓库版本结果发现--timing相关的部分行为跟文档对不上后来换成源码编译才解决。源码编译其实不复杂关键是依赖要装全。核心依赖是git、make、g或者clang、perl如果要生成波形还得装gtkwave来看。编译流程大致是这样git clone https://github.com/verilator/verilator cd verilator autoconf ./configure make -j$(nproc) sudo make install编译过程大概五到十分钟取决于机器性能。装完之后用verilator --version确认一下。这里有个细节如果你系统里有多个g版本configure的时候最好显式指定比如./configure CXXg-11避免默认版本太老导致C17特性编译失败。Verilator较新版本对C标准要求是C14起步部分特性需要C17。提示源码编译时如果报perl模块缺失通常是libperl-dev或者类似包没装按报错提示补上即可不要跳过。2.2 配套工具链的搭配Verilator本身只负责翻译和生成C它不提供波形查看器也不提供测试平台语言。所以完整的验证环境还需要几样东西。波形查看我用GTKWave开源免费能直接读Verilator生成的VCD或FST文件。FST格式体积更小、加载更快我一般优先用FST。生成波形需要在编译时加--trace或者--trace-fst选项这个后面会细讲。测试平台的编写语言就是C这也是Verilator的一大特点——你用C写激励、写检查、写参考模型跟HDL模型通过生成的接口交互。这意味着你可以直接复用现有的C算法库比如做通信仿真时把Matlab生成的C代码直接拿来当参考模型非常方便。构建系统方面小项目我直接用MakefileVerilator官方也提供了--exe配合Makefile的用法。项目大了之后可以考虑CMake把Verilator生成的源文件纳入CMake的编译目标。我个人的习惯是中小规模用Makefile因为Verilator生成的依赖关系它自己会处理Makefile写起来反而直观。2.3 一个最小可运行环境的验证装完环境别急着上大工程先拿一个最简单的例子验证整条链路通不通。我习惯用一个带寄存器的计数器做冒烟测试module counter ( input wire clk, input wire rst_n, input wire en, output reg [7:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 8d0; else if (en) cnt cnt 8d1; end endmodule对应的C测试平台#include Vcounter.h #include verilated.h #include cstdio int main(int argc, char** argv) { Verilated::commandArgs(argc, argv); Vcounter* dut new Vcounter; // 初始化 dut-clk 0; dut-rst_n 0; dut-en 0; dut-eval(); // 释放复位 dut-rst_n 1; dut-en 1; for (int i 0; i 20; i) { dut-clk 0; dut-eval(); dut-clk 1; dut-eval(); printf(cycle %2d: cnt %d\n, i, dut-cnt); } dut-final(); delete dut; return 0; }编译命令verilator --cc counter.v --exe sim_main.cpp --build -j跑完如果看到cnt从0递增到20说明环境没问题。这个冒烟测试很重要因为Verilator的报错有时候比较隐晦先用最小例子确认工具链正常后面出问题才好定位是环境问题还是代码问题。3. HDL代码的约束与改造要点3.1 Verilator不支持的语法清单这是新手最容易翻车的地方。Verilator对Verilog的支持是“可综合子集优先”很多在仿真器里能跑的写法它直接报错或者行为异常。我整理了一份高频踩坑清单语法/构造Verilator行为替代方案#delay延迟默认忽略或报错用时钟周期计数替代initial块中的时序控制部分支持行为受限改用复位逻辑fork/join不支持拆成状态机精确的timescale不建模用周期计数非阻塞赋值混合阻塞赋值可能告警严格区分real类型综合有限支持定点化处理动态数组、队列部分支持用固定数组最典型的就是延迟语句。事件驱动仿真器里写#10 a b;表示10个时间单位后赋值Verilator根本不认这套因为它是周期精确模型没有时间概念。你得把延迟改写成基于时钟的计数逻辑。3.2 时钟与复位的建模方式Verilator生成的模型是周期精确的时钟和复位都由C侧驱动。这意味着你的HDL里不需要也不应该自己产生时钟。我见过有人在HDL里写always #5 clk ~clk;这在Verilator里直接报错。正确的做法是把clk和rst_n作为模块输入端口由C测试平台控制翻转。复位的处理也有讲究Verilator不会自动帮你做上电复位你必须在C侧显式拉低复位若干周期再释放。我一般会封装一个reset()函数void reset(int cycles 5) { dut-rst_n 0; dut-eval(); for (int i 0; i cycles; i) { tick(); } dut-rst_n 1; tick(); }这里的tick()就是翻转一次时钟并调用eval。复位周期数不能太少一般3到5个周期足够让所有寄存器进入确定状态具体看设计里复位树的深度。3.3 组合逻辑环与锁存器的处理Verilator对组合逻辑环combinational loop非常敏感一旦检测到就会报错。事件驱动仿真器可能容忍某些环但Verilator会直接拒绝。如果你的设计里有意为之的环比如某些异步电路得重新设计。锁存器是另一个坑。HDL里if-else没写全导致综合出锁存器Verilator会给出LATCH告警。这个告警我建议当错误处理因为锁存器在周期精确模型里行为容易出问题。解决办法就是补全else分支或者用默认赋值。// 有锁存器风险 always (*) begin if (sel) y a; // sel为0时y保持综合出锁存器 end // 修正 always (*) begin y 1b0; // 默认值 if (sel) y a; end3.4 参数化与generate的注意事项Verilator对参数化模块和generate块的支持总体不错但有几个细节要注意。generate里的for循环变量必须是genvar且循环边界必须是编译期常量。如果边界依赖运行时输入Verilator会报错。另外参数覆盖在C侧是通过生成的类名体现的。比如module fifo #(parameter DEPTH16)Verilator会生成Vfifo类但参数值在编译时确定运行时改不了。如果你需要不同深度的fifo得编译多个版本或者用运行时配置端口。注意Verilator的--lint-only模式可以在不生成C的情况下做语法和规则检查我强烈建议在正式编译前先跑一遍能提前发现大部分问题。4. C测试平台的编写与波形生成实操4.1 测试平台的整体结构设计一个规范的Verilator测试平台我一般分成几个部分时钟驱动、复位控制、激励生成、输出采样、结果比对。这跟UVM那套思路类似只是用C实现。时钟驱动最简单就是翻转clk然后调eval。但要注意eval的调用时机Verilator的eval会计算所有组合逻辑和更新寄存器但寄存器的更新是在eval内部完成的。标准的时钟周期操作是void tick() { dut-clk 0; dut-eval(); // 下降沿组合逻辑稳定 dut-clk 1; dut-eval(); // 上升沿寄存器更新 }这个顺序很重要。先拉低再eval让组合逻辑基于低电平稳定再拉高eval触发时序逻辑。如果顺序反了采样到的值可能不对。激励生成我习惯用一个独立的类或者函数把测试向量组织成序列。对于复杂设计我会写一个参考模型C实现每个周期把DUT输出和参考模型输出比对一旦不一致立即报错并打印上下文。4.2 波形生成的完整配置波形是调试的命根子。Verilator生成波形需要三步编译时加trace选项、代码里调用trace初始化、运行时控制dump。编译时选项verilator --cc top.v --exe sim_main.cpp --build -j \ --trace-fst --trace-structs --trace-params --trace-max-array 1024--trace-fst生成FST格式比VCD小很多。--trace-structs让结构体也能被追踪--trace-params追踪参数--trace-max-array设置数组追踪的最大深度默认值可能不够大数组会被截断。C侧需要包含trace头文件并初始化#include verilated_fst_c.h VerilatedFstC* tfp nullptr; void init_trace() { Verilated::traceEverOn(true); tfp new VerilatedFstC; dut-trace(tfp, 99); // 99是追踪深度 tfp-open(wave.fst); } void dump_trace(int time) { if (tfp) tfp-dump(time); }然后在每个时钟周期调用dump_trace时间参数用周期计数乘以周期长度。最后在仿真结束时tfp-close()。这里有个性能陷阱如果每个周期都dump大设计的波形文件会非常大仿真速度也会明显下降。我的做法是加一个开关只在需要调试的时间窗口dump或者用--trace-depth限制追踪层级。4.3 用GTKWave查看波形的技巧GTKWave打开FST文件后我一般会先保存一个信号布局文件.gtkw下次直接加载省得每次重新拖信号。几个实用操作用CtrlShiftA添加所有信号用CtrlG分组用Shift滚轮缩放时间轴。对于总线信号右键可以设置数据格式十六进制、十进制、有符号等。对于状态机信号可以设置枚举显示把状态编码映射成名字看起来直观很多。提示如果波形里信号显示为红色X说明该信号在仿真中从未被赋值通常是复位没做好或者信号没连接。这是排查问题的第一线索。4.4 一个完整的UART收发验证实例拿UART举例因为它是FPGA入门经典也能体现Verilator的完整流程。假设有一个uart_rx模块输入串行数据线输出接收到的字节和有效标志。测试平台的核心逻辑是按照波特率周期数逐位驱动rx线模拟一帧数据的发送然后在接收完成后检查输出字节是否正确。// 波特率周期数假设时钟50MHz波特率115200 const int CLK_PER_BIT 434; void send_byte(uint8_t data) { // 起始位 dut-rx 0; for (int i 0; i CLK_PER_BIT; i) tick(); // 8位数据LSB先发 for (int b 0; b 8; b) { dut-rx (data b) 1; for (int i 0; i CLK_PER_BIT; i) tick(); } // 停止位 dut-rx 1; for (int i 0; i CLK_PER_BIT; i) tick(); }接收检查部分在每个周期采样rx_valid一旦有效就读出rx_data跟发送值比对。这个例子里CLK_PER_BIT的计算很关键它是时钟频率除以波特率取整会有误差误差累积超过半个位周期就会采样错位。实际工程里要么用更精确的分数分频要么在测试平台里补偿这个误差。5. 常见问题排查与性能调优实录5.1 编译期报错速查Verilator的报错信息有时候不够友好我整理了一份高频错误对照表报错关键词含义解决方向Unsupported: ...用了不支持的语法查手册换写法%Error: ... LATCH推断出锁存器补全条件分支%Error: ... BLKANDNBLK阻塞非阻塞混用统一赋值风格%Error: ... COMBDLY组合逻辑有延迟去掉延迟语句%Error: ... MULTIDRIVEN信号多驱动检查驱动源%Warning: ... WIDTH位宽不匹配显式位宽转换WIDTH告警我建议不要忽略位宽不匹配在Verilator里可能导致意外的截断或扩展跟事件驱动仿真器的行为可能不同。用$signed、$unsigned或者显式位选来消除。5.2 仿真结果与预期不符的排查思路仿真跑起来了但结果不对这是最耗时的环节。我的排查顺序是这样的第一步确认复位是否生效。在波形里看所有寄存器在复位释放后是否处于确定值。如果还是X说明复位逻辑有问题。第二步检查时钟域。如果设计有多时钟域确认每个域的时钟都在正确翻转跨域信号有没有做同步。第三步逐周期比对。把DUT输出和参考模型输出在每个周期打印出来找到第一个不一致的周期然后往前追溯几个周期看信号变化。第四步缩小范围。如果设计很大把可疑模块单独拿出来做单元测试隔离问题。我踩过的一个典型坑是C侧驱动输入信号后没有调eval就直接读输出导致读到的是上一周期的值。Verilator的eval是显式调用的不像事件驱动仿真器会自动传播。所以每次改输入后必须eval读输出前也要确保eval过。5.3 仿真速度的进一步优化Verilator本身就快但大设计还是能再压榨。几个有效手段第一减少波形dump。波形IO是主要瓶颈之一只在必要时开trace。第二用-O3优化编译。Verilator生成的C代码用-O3编译能明显提速Makefile里加上OPT_FAST-O3。第三多线程。Verilator支持--threads N把设计分成多个线程并行对大规模设计有效但小设计反而因为线程开销变慢要实测。第四减少不必要的eval。如果某个周期输入没变可以跳过eval但这需要仔细分析容易出错我一般不用。第五用--x-assign fast和--x-initial fast。默认情况下Verilator对X的处理比较保守改成fast能提速但会牺牲一些X传播的准确性。功能验证阶段可以用sign-off阶段要谨慎。5.4 与C参考模型联合验证的经验这是Verilator相比商业仿真器的一大优势。做算法硬件化时我通常先用C或者Python写一个黄金参考模型验证算法正确性然后把它移植成C在Verilator测试平台里跟HDL实现逐周期比对。关键点是时序对齐。参考模型的输出要跟HDL在同一个周期对齐这需要仔细分析HDL的流水线延迟。我的做法是先跑一遍把两边输出都dump出来人工对齐一次确定延迟周期数然后在比对逻辑里加这个偏移。另一个经验是随机化测试。用C的随机数生成器产生大量随机激励跑几千上万次比手写几个测试用例覆盖率高得多。配合覆盖率工具Verilator支持--coverage能看到哪些分支没被覆盖到针对性补测试。注意随机测试要设置种子并可复现一旦发现失败用例用相同种子重跑能复现问题这是调试的基础。6. 从单模块验证到系统级仿真的扩展单模块跑通之后自然会想验证更大的系统。Verilator支持多模块层次化编译把顶层和所有子模块一起翻译。这时候要注意模块间的接口匹配以及顶层端口的C映射。系统级仿真里我习惯把测试平台也模块化每个子模块有对应的C验证类顶层测试平台负责协调。这样某个子模块出问题可以单独把它拎出来测不用每次跑整个系统。对于带总线的系统比如AXI或者Wishbone可以写总线功能模型BFM来产生事务级激励而不是手动翻转每个信号。BFM把事务转换成周期级的信号序列测试平台写起来清爽很多。跨模块的信号追踪在系统级仿真里尤其重要。我会在关键路径上埋一些调试信号通过$display或者直接映射到顶层端口方便在波形里观察。Verilator对$display的支持是有的但输出会混在C的stdout里我一般用VL_PRINTF宏或者直接在C侧打印。最后说一个实际项目里的体会Verilator的验证环境一旦搭好迭代速度是质变。改一行HDL重新编译加跑测试几秒钟出结果这种即时反馈对设计效率的提升是巨大的。前期花时间把环境搭扎实后面省下的时间远超投入。我现在的习惯是任何新模块动手写RTL之前先把Verilator的测试框架搭起来边写边验比写完再验效率高得多。
返回列表