ARTICLE DETAIL

资讯详情

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

iverilog 开源 Verilog 仿真:从安装到工程化实战

iverilog 开源 Verilog 仿真:从安装到工程化实战 iverilog 这个工具在我的日常工作里出现频率相当高尤其是需要快速验证一个 RTL 小模块、写个测试激励看看时序对不对、或者在没有授权仿真器可用的机器上跑一次冒烟测试的时候它几乎是第一选择。它属于开源 Verilog 仿真工具一行命令编译一行命令运行配合波形查看器就能把电路的行为看得清清楚楚。它解决的核心问题很直接不用装几 GB 的商业工具也不用折腾许可证几秒钟就能让一段 Verilog 代码动起来。这篇文章适合三类人看刚学数字逻辑、手里只有一台普通笔记本的学生做 FPGA 或 ASIC 前端、需要一个轻量回归环境的工程师以及想把 Verilog 仿真塞进自动化流水线的开发者。下面我按实际使用顺序把安装、编译、仿真、看波形、排错、工程化这几件事一次讲透。1. 先把iverilog的定位搞清楚再动手1.1 它到底是个什么东西很多人第一次接触它会把它和 ModelSim、VCS 这类商业仿真器放在一起比较然后问它能替代吗。我的答案是分场景。iverilog 的全称是 Icarus Verilog它的工作方式是把 Verilog 源代码编译成一个中间格式再由一个叫 vvp 的运行时引擎去执行整个链路和编译型语言非常像——先编译再运行。这个设计带来的直接好处是编译期就能把语法错误、未声明信号、模块端口不匹配这类问题全部暴露出来而不是等到仿真跑起来才报错。它支持的语法标准覆盖 IEEE 1364 的 1995、2001、2005 三个版本从 11.0 版本开始又通过-g2012选项支持了一部分 SystemVerilog 特性比如logic类型、always_comb、always_ff、typedef、struct、enum以及部分interface语法。但它对高级验证特性基本没有支持比如 SVA 断言、covergroup、class这一套面向对象的东西几乎用不了UVM 更不要想。所以它最适合的场景是功能仿真和模块级验证而不是大规模随机验证平台。我一般这样划分模块级的功能验证、算法原型验证、教学演示、CI 流水线里的快速冒烟这些交给 iverilog需要覆盖率、断言、事务级建模的大工程还是得上商业工具。认清这条边界后面用起来就不会拧巴。1.2 为什么值得花时间学它最直观的理由是启动速度。一个中等规模的模块iverilog 从零编译到出波形往往两三秒就结束了这个反馈循环短到你可以边改代码边看结果思路不会被打断。商业工具动辄几十秒的编译加优化在调试初期其实很拖节奏。第二个理由是它天然适合做自动化。iverilog 和 vvp 都是纯命令行程序退出码清晰输出可以直接重定向到文件很容易塞进 Makefile 或者 Python 脚本里做批量回归、结果比对。我在做参数化模块验证的时候会用脚本扫一遍不同的参数组合每个组合编译仿真一次把日志收集起来自动判断通过与否整套流程几十行代码就能搞定。第三个理由是它的错误信息足够直白。它不会给你一堆晦涩的内部报错多数时候直接告诉你哪一行、哪个信号出了问题。对新手来说这种反馈比商业工具友善得多。当然它也有短板比如对某些边界语法的处理比较宽松、警告不够细致这些后面单独讲。2. 环境搭建十分钟从零到跑通第一个波形2.1 各平台的安装方式和版本选择Linux 下最省事一条命令的事。Ubuntu 或 Debian 系直接sudo apt install iverilog gtkwave装完之后iverilog -V看一眼版本号。macOS 用 Homebrewbrew install icarus-verilog波形工具如果不想装 GTKWave也可以导出 VCD 之后用其他查看器打开。Windows 上的情况稍微麻烦一点官方有预编译的安装包可以直接下载安装装好后需要把安装目录下的bin加进 PATH 环境变量如果不喜欢这种方式用 WSL 或者 MSYS2 里的包管理器装也是一样的效果而且和 Linux 侧的脚本兼容性更好。版本方面建议至少用 11.0 以上。原因很简单11.0 之前的版本对 SystemVerilog 的支持非常有限-g2012这个选项也是从这个大版本开始才真正可用。当前比较常见的稳定版本是 12.0很多发行版的仓库里还是 10.3 或者 11.0如果你需要-g2012的完整特性可以自己从源码编译一份步骤也不复杂就是标准的configure、make、make install三步。我个人的经验是如果只是写纯 Verilog-2001 的代码发行版自带的版本完全够用不必折腾。注意从源码编译时别忘了同时装上bison、flex、gperf和readline的开发包这几个是构建 iverilog 的硬依赖缺任何一个都会在 configure 阶段报错而且报错信息不太直观容易让人误以为是源码问题。2.2 最小可运行示例我不喜欢一上来就讲理论先把东西跑起来最重要。假设你有一个最简单的计数器模块和一个激励文件目录结构这样安排proj/ ├── src/ │ └── counter.v ├── tb/ │ ── tb_counter.v └── Makefilecounter.v的内容是一个四位计数器timescale 1ns/100ps module counter ( input wire clk, input wire rst_n, input wire en, output reg [3:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 4d0; else if (en) cnt cnt 1b1; end endmodule激励文件tb_counter.vtimescale 1ns/100ps module tb_counter; reg clk; reg rst_n; reg en; wire [3:0] cnt; counter u_counter ( .clk (clk), .rst_n (rst_n), .en (en), .cnt (cnt) ); initial begin clk 1b0; rst_n 1b0; en 1b0; end always #5 clk ~clk; initial begin $dumpfile(wave.vcd); $dumpvars(0, tb_counter); #20 rst_n 1b1; #10 en 1b1; #200; $display(final cnt %0d at time %0t, cnt, $time); $finish; end endmodule编译和运行就两步iverilog -g2012 -o simv src/counter.v tb/tb_counter.v vvp simv gtkwave wave.vcd 第一次跑通这条链路你会看到终端打印出最终的计数值同时目录下多了个wave.vcd文件用 GTKWave 打开就能看到波形。这里有个细节值得说编译时我没有指定顶层模块iverilog 会自己分析模块之间的层次关系找到那个没有被例化的模块当作顶层。但如果文件里有多个候选顶层行为就不确定了所以工程化的时候一定要显式加-s tb_counter指定顶层这一点后面还会提到。3. 命令行参数拆解编译期和运行期分别在做什么3.1 iverilog编译阶段的关键参数iverilog 这个命令本身只负责编译它把.v文件解析后生成一个可执行的仿真文件默认名字是a.out。理解这一点很重要因为它意味着语法错误在编译期就暴露了而运行期只处理时序逻辑和赋值行为。常用的参数我整理成一张表方便对照参数作用典型用法-o file指定输出文件名-o simv-g2012启用 SystemVerilog 子集语法-g2012-I dir添加include搜索路径-I ./include-y dir添加模块库搜索目录-y ./lib-s top显式指定顶层模块-s tb_top-Dmacro定义宏-DDEBUG1-Wall打开全部警告-Wall-E只做预处理输出展开后的源码调试宏时用-c file从文件读命令行参数参数太多时用-Wall这个选项我强烈建议一直带着。它会把隐式声明的 wire、位宽不匹配、端口悬空这类问题都报出来。刚开始用的时候你会觉得警告很多很烦但这些东西里藏着真正的 bug。我印象最深的一次是某根信号名打错了一个字母如果没有-Walliverilog 会默默把它当成一根新的隐式 wire 处理仿真能跑波形却是错的查了半天才发现。加上这个选项之后编译阶段就会直接告诉你哪个信号被隐式声明了。-I和-y的区别也值得说清楚。-I是给include指令用的相当于头文件搜索路径适用于把参数定义、公共宏集中放在一个文件里的场景。-y是给模块例化用的当你的代码里例化了一个不在当前文件列表中的模块时iverilog 会去-y指定的目录下找同名文件。这两个机制配合起来可以把工程拆得很干净编译命令却保持简短。3.2 vvp运行阶段你必须知道的细节编译产出的simv不是普通的可执行文件它需要 vvp 来驱动。直接运行vvp simv就能开始仿真但有几个参数非常实用。-n的作用是抑制版本信息和版权声明输出。默认情况下 vvp 会在开头打印几行工具信息如果你要把日志收集起来做自动比对这几行会污染输出加上-n就干净了。-l file可以把仿真输出写到指定日志文件比用 shell 重定向更规范。还有一个是我经常用的技巧把测试结果打印成固定格式然后用 shell 的grep去匹配比如激励里用$display(TEST_PASS)或者$display(TEST_FAIL)运行完直接查日志配合退出码就能做自动化判断。vvp -n simv -l run.log if grep -q TEST_FAIL run.log; then echo regression failed exit 1 fi这里有个非常容易踩的坑vvp 运行时的工作目录和编译时不一定相同。$dumpfile(wave.vcd)生成的文件是相对于运行时的当前目录而不是编译时的目录。如果你在工程的根目录编译然后切到别的目录去运行波形文件就会出现在你想不到的地方。$readmemh读初始化文件也是同样的逻辑。我在 CI 脚本里统一规定必须在工程根目录执行 vvp就是为了避开这种路径混乱。3.3 GTKWave看波形的高效用法波形本身没什么可讲的但看波形的效率差距很大。GTKWave 打开 VCD 之后默认所有信号都在一个分组里几百根信号堆在一起根本没法看。我一般会先在激励里用$dumpvars(0, tb_top)把整个层次 dump 出来然后在 GTKWave 里用模块层次树把信号拖进波形窗口右键另存成一个.gtkw配置文件。下次打开只需要gtkwave wave.vcd my.gtkw信号分组、颜色、进制全都在省掉大量重复操作。另一个提速的点是限制 dump 范围。$dumpvars的第一个参数是层级深度0表示 dump 全部1表示只 dump 指定模块这一层。如果工程很大全量 dump 会让 VCD 文件膨胀到几百 MB打开都卡。只 dump 你关心的那几层文件大小能降下来一个数量级这对每天跑很多次的回归流程来说意义很大。4. Testbench写作中真正会出问题的地方4.1 timescale、时钟与复位timescale是新手最容易忽略、后果又最严重的东西。它的写法是timescale 时间单位/时间精度比如timescale 1ns/100ps表示延时数字的单位是 1ns仿真器内部最小分辨是 100ps。问题在于如果不同文件里的timescale不一致或者有的文件压根没写那#10到底是 10ns 还是 10 个默认单位就完全取决于编译顺序结果不可预测。我踩过的最典型的一次是模块里写了#5想表示 5ns 延时但这个文件没有timescale实际跑出来延时缩水了几百倍波形上根本看不到还以为是逻辑问题。所以我的习惯是每个文件的第一行都写timescale全工程统一用一套或者干脆在编译命令里用-D传一个统一的宏。这样至少保证所有文件在同一时间基准上。时钟的产生最常见写法是always #5 clk ~clk;配合timescale 1ns/100ps就是 10ns 周期100MHz。这里有个坑如果clk在initial块里没有赋初值仿真开始时它是x取反之后还是x时钟永远起不来。所以一定要在initial里给它一个确定的初值。复位信号的释放时机也要注意我一般让复位至少维持几个时钟周期并且在时钟的下降沿附近释放避免和上升沿产生竞争。4.2 阻塞赋值与非阻塞赋值的实际差异教科书上讲这个区别讲得很抽象我说个实际场景你就懂了。假设一个两级移位寄存器// 写法一都用阻塞赋值 always (posedge clk) begin a din; b a; end // 写法二都用非阻塞赋值 always (posedge clk) begin a din; b a; end写法一跑出来的结果是a和b在同一拍都变成了din两级寄存器退化成了一级因为b a读到的是本拍已经更新过的a。写法二才是正确的两级延迟b拿到的是上一拍的a。iverilog 对这两种写法的处理完全符合标准它不会帮你纠正所以写时序逻辑时必须统一用非阻塞赋值这是铁律。那什么时候用阻塞赋值组合逻辑。always (*)块里用因为组合逻辑是即时求值的用非阻塞反而会引入意外的延迟。我把这条规则简化为一句口诀时序用组合用同一个always块里不要混着用。混用带来的仿真结果和综合结果的差异是那种最让人抓狂的 bug——仿真过了上板就废。注意iverilog 不会对阻塞、非阻塞混用给出警告哪怕开了-Wall也不一定报。这个只能靠代码规范和 review 来防。4.3 波形dump与自检机制$dumpfile和$dumpvars这对组合很多人只会在initial块里调一次。实际上它们可以更灵活地用$dumpon和$dumpoff可以在仿真过程中动态开关记录$dumpall可以强制记录当前所有信号的快照。我做过一个数据量很大的仿真只关心某一段窗口内的行为就用$dumpoff把前面无关的部分全部跳过VCD 文件小了一个数量级。自检机制比波形更值得投入。波形只能告诉你发生了什么自检能告诉你对不对。最简单的做法是写一个参考模型用$display或者断言式的if判断比较always (posedge clk) begin if (cnt ! expected_cnt) begin $display(MISMATCH at %0t: got %0d, exp %0d, $time, cnt, expected_cnt); $finish; end end注意这里用的是!而不是!。这个区别很关键!在操作数含x或z时结果是x会被if当作假处理等于漏掉了未知态的检查!是精确比较x和x会被判定为相等未知态和确定值比较会被判定为不等。验证代码里应该优先用和!这样才不会漏掉x传播这类问题。5. 把它塞进工程流Makefile和脚本化5.1 用Makefile把重复命令收拢每次都手敲一长串 iverilog 命令是很低效的。一个够用的 Makefile 长这样SIM : simv TOP : tb_counter IVFLAGS : -g2012 -Wall -I./include -I./tb SRCS : $(wildcard src/*.v) $(wildcard tb/*.v) .PHONY: all build run wave clean all: run build: iverilog $(IVFLAGS) -s $(TOP) -o $(SIM) $(SRCS) run: build vvp -n $(SIM) -l run.log wave: gtkwave wave.vcd wave.gtkw clean: rm -f $(SIM) wave.vcd run.log几个设计取舍解释一下。用wildcard自动收集源文件好处是新增文件不用改 Makefile坏处是文件顺序不确定如果模块之间有依赖顺序要求就会出问题。iverilog 本身对文件顺序不敏感所以这个做法是安全的。-s $(TOP)显式指定顶层避免自动推断出错。run目标依赖build所以make run会自动先编译改完代码直接跑一次就行。-l run.log把日志落盘方便事后比对。我还会在run里加一个后处理检查日志里有没有失败标记run: build vvp -n $(SIM) -l run.log ! grep -q MISMATCH\|TEST_FAIL run.log || (echo FAILED; exit 1)这样 CI 里只要make run的退出码非零就知道这次回归挂了不需要人工翻日志。5.2 参数化扫描与批量回归真正体现效率的地方在于批量跑。假设你的模块有个WIDTH参数你想验证 4、8、16、32 四种配置手改代码太笨了。我的做法是用-D在编译期传入参数for w in 4 8 16 32; do iverilog -g2012 -Wall -DWIDTH$w -s tb_top -o sim_$w src/*.v tb/*.v vvp -n sim_$w -l log_$w.txt done模块里用ifdef WIDTH或者直接把宏传给参数module dut #( parameter WIDTH WIDTH ) ( ... );这套组合的威力在于它把改参数、重新编译、跑一遍、看结果这个循环完全自动化了。我曾经用它一次性扫了几十个参数组合半小时跑完如果手动做可能要一整天。要提醒的一点是-D定义的宏只在编译期生效模块里如果用了$value$plusargs那种运行期参数就要在 vvp 命令后面加PARAMvalue的形式这两套机制别搞混。6. 常见报错与排查实录6.1 编译期错误速查我把这些年遇到的高频编译错误整理成一张表基本覆盖了八成情况报错信息实际原因解决办法error: Unable to bind wire/reg/memory信号未声明或模块端口名字打错检查拼写加-Wall定位隐式声明error: syntax error缺分号、括号不匹配、关键字用错看报的行号往往问题在上一行warning: implicit definition of wire使用了未声明的信号补声明或检查是否拼写错误error: Module ... is not defined模块文件没加进编译列表用-y指定库目录或补全文件列表error: port ... is not a port of ...例化端口名和定义不一致核对端口名注意大小写error: Constant expression required用了变量做位选或范围改成常量或换用 shift 运算特别说一下syntax error。iverilog 报的行号经常是出错位置的下一行因为它是读到下一个 token 才发现前面的语句不完整。比如漏了个分号它可能报在下一行开头。所以看到这个错误先往上看一到两行多数时候能找到真正的问题。Unable to bind这个错误在新手里出现率极高本质上是名字对不上。可能是 wire 没声明可能是模块名写错也可能是include路径没配对导致某个定义文件根本没被读到。我一般会先用-E生成预处理后的文件看看宏展开和 include 的实际结果一眼就能看出问题。6.2 运行期诡异现象排查编译过了但行为不对这类问题更费时间因为没有任何报错。我总结几个典型的仿真瞬间结束什么都没跑。多半是激励里的延时写在了$finish之后或者initial块里所有的#延时都被优化掉了。检查一下第一个initial块里有没有立即执行的$finish。波形全是直线或者全是 x。如果是常量说明时钟没起来检查clk有没有初值。如果是x说明信号没有被驱动可能是模块没例化、端口没连、或者复位没释放。我之前遇到过一次是复位信号rst_n在激励里写成了rst_n 0然后忘了置 1整个仿真从头到尾都停在复位状态。波形缺了一段。检查$dumpvars的层级参数。如果写的是$dumpvars(1, u_dut)那只会记录u_dut这一层它内部的子模块信号全都没有。改成0就会递归 dump 全部层次。时间刻度对不上。表现为时钟周期是你预期的几百倍或者几千分之一。这几乎一定是timescale缺失或者不一致。用iverilog -E看看预处理结果里每个文件的 timescale 是什么问题立刻就现形。死循环仿真跑不完。两种可能一种是always块里没有延时也没有时钟边沿形成零延时无限循环iverilog 会直接卡死另一种是激励里忘了写$finish仿真会一直跑到内部的时间上限。养成习惯每个测试激励的最后都要有明确的结束条件。6.3 几个能救命的调试技巧第一个是用$monitor代替一堆$display。$monitor会在参数表中任意信号变化时自动打印一行格式统一不需要你手动在每个时序点插入打印语句。用一次$monitor(t%0t clk%b rst%b cnt%0d, $time, clk, rst_n, cnt);就能得到一份完整的行为日志配合波形看非常高效。要注意的是$monitor同一时间只能有一个生效后设置的会覆盖前面的。第二个技巧是打印十进制和十六进制同时来。位宽比较宽的信号只看十进制不好判断哪一位出了问题我会同时打印%b和%0d或者用%h。iverilog 的格式化字符串支持标准的 C 风格格式符%0d会去掉前导空格输出更紧凑。第三个技巧是在关键位置插入$display加上文件名和行号。iverilog 有个编译选项-pfileline1可以在运行时把每个语句的源文件位置带出来排查到底哪一行触发了这类问题时特别有用。代价是输出量会变大只在需要的时候开。注意$stop和$finish不一样。$finish直接结束仿真$stop会进入交互模式让仿真暂停等待继续指令。如果你在 CI 环境里用了$stop脚本会挂住不返回这一点要特别小心。7. 把iverilog用得更深一点7.1 参数化、宏和generate的组合用法iverilog 对generate语句的支持是比较完整的for和if两种 generate 都能用。这让它可以写出参数化程度很高的代码。一个常见的模式是用generate for批量例化用define或者-D在顶层控制参数genvar i; generate for (i 0; i LANES; i i 1) begin : gen_lane lane_unit #(.WIDTH(WIDTH)) u_lane ( .clk (clk), .din (din[i*WIDTH : WIDTH]), .dout(dout[i*WIDTH : WIDTH]) ); end endgenerate这里用到了位选切片:语法它是 Verilog-2001 引入的iverilog 支持得很好。相比手写din[i*WIDTH WIDTH - 1 : i*WIDTH]:的好处是起始位置可以是变量宽度必须是常量语法更安全也更清晰。不过要注意iverilog 对某些generate嵌套和localparam在 generate 块内的作用域处理有一些历史遗留的小问题如果遇到奇怪的报错可以尝试把参数声明提到 generate 外层通常能绕过去。宏的用法上我建议用define定义可配置项用ifdef做条件编译。比如调试打印可以包在一块ifdef DEBUG里编译时不加-DDEBUG就不会有任何打印开销。这套机制在参数扫描的时候特别有用因为你可以通过命令行完全控制代码的分支而不需要改任何源码。7.2 和其他工具配合起来用iverilog 单独用已经够强了但它真正的价值在于它是一个可以编程调用的组件。最常见的一种配合是把它接进 Python 脚本或者 CI 流水线做成自动回归。另一种是和其他开源工具串起来先用 iverilog 做功能仿真验证通过了再用综合工具做映射看资源和时序。这个流程在小规模设计的快速验证里非常顺畅。如果你希望用 Python 写激励而不是 Verilog可以考虑 cocotb 这类框架它的底层可以对接 iverilog 作为仿真后端。用 Python 写测试的好处是数据结构、循环、文件处理这些都很方便适合做随机激励和复杂比对。代价是调试链变长出了问题要同时理解 Python 层和 Verilog 层。我的建议是先用纯 Verilog 把功能验证做扎实等代码稳定了再引入 Python 层做扩展验证。还有一个隐蔽但实用的点iverilog 支持 VPI 接口可以用 C 语言写扩展模块直接访问仿真内部信号。这个能力一般用不到但在需要和外部软件做联合仿真、或者想把仿真数据实时喂给另一个程序的时候它是唯一的通路。编译 VPI 模块需要用到 iverilog 的头文件安装包里一般都有编译成.vpi共享库之后用vvp -m ./myvpi加载即可。这条路我走得不多因为多数场景用文件交互就够了但知道有这么个能力关键时刻能救场。最后分享一个我在实际使用中养成的小习惯每次跑完仿真不管结果看起来多正常我都会先扫一眼日志开头的时间刻度和最终的时间戳确认仿真真的跑到了预期的时间点。有太多次表面通过的测试其实是因为激励提前结束或者根本没启动日志里那行最终时间才是判断仿真有效性的第一道防线。另外把常用的编译参数组合和波形配置存成模板工程新项目直接复制改改就能用比每次重新搭环境省下的时间远超你的想象。
返回列表