做数字IC验证的同学,大概率都经历过这样的一幕:为了跑一次仿真,在终端里敲出一长串VCS编译命令,几十个文件路径、一堆编译选项,手一抖漏掉一个-debug_access+all,波形里全是unknown,又得重新敲一遍。这种重复劳动,浪费的不仅是时间,还特别容易出错。
用Makefile来管理VCS和Verdi的仿真流程,是我认为投入产出比极高的一次改造。编译命令、仿真参数、波形加载逻辑全部固化到一个文件里,跑回归只需一条make命令,看波形只需make verdi,新人接手项目也不需要翻文档找半天。这篇文章就把我在工程里实际用的这套Makefile方案拆开讲清楚,从整体设计思路到每一条命令的含义,再到完整跑通一次仿真的实操步骤和排坑实录,希望能给同样在VCS/Verdi仿真上花过时间的同学一些参考。
1. 为什么要把VCS和Verdi的启动流程交给Makefile
1.1 手敲仿真命令的三个痛点
先说说我在刚开始做验证那会儿的状态。那时候跑仿真基本是"复制粘贴大法":从某个旧测试用例的脚本里复制一条vcs命令,改改文件列表,回车,等编译,再跑simv,跑完再用verdi打开波形。这个流程有三个问题特别明显:
第一个是命令太长、太容易出错。VCS的编译命令动辄一两百个字符,涉及-I包含路径、-y库目录、+incdir+头文件路径、-f文件列表、-debug_access、-timescale、-assert等一堆选项。手敲或者复制时漏掉一个选项,轻则编译报错,重则编译过了但仿真结果不对,排查起来非常痛苦。
第二个是增量编译没有真正落地。VCS本身支持增量编译,但前提是编译参数和文件列表没变。如果靠"人肉记忆",今天在这个测试用例里加了-include,明天在另一个用例里忘了,VCS会重新编译整个设计,一次全量编译在稍微大一点的项目里要跑十几分钟到半小时,时间就白白浪费了。
第三个是团队协作成本高。每个人跑仿真的习惯不一样,有人用VCS的交互模式,有人喜欢先编译再跑simv,还有人习惯直接把波形打开。命令不统一,队友之间的命令分享和问题复现都很费劲。我见过最夸张的情况是,同事仿出来的波形是VPD格式,我这边Verdi配置读的是FSDB格式,两个人的结果对不上,定位了一个小时才发现是工具用法的差异。
1.2 Makefile给仿真流程带来什么
Makefile本质上是一个基于"目标-依赖"关系的构建调度器。它最擅长的就是两件事:一是判断哪些目标过期了需要重新构建,二是把复杂的命令序列封装成简单可靠的接口。
在VCS仿真这个场景里,Makefile能把上面的问题一次性解决掉:
- 命令标准化:编译、仿真、看波形、清理,每个动作都对应一个明确的make目标,想干什么就make什么;
- 增量编译自然生效:文件列表和编译选项作为依赖的一部分,只有依赖变化时才触发全量重编,仿真可执行文件本身没变就直接复用,省掉大量重复编译时间;
- 参数可配置:用变量抽出文件名、选项、路径,换测试用例、换设计版本只需要改变量,脚本本身不用动;
- 团队共享:一个Makefile放进工程仓库里,所有人都用同一套流程,新人上来跑一次make就能干活。
用生活化的类比来说,手敲命令就像每次出门前都临时整理行李箱,Makefile就是固定的打包清单和行李架,虽然第一次配置花点时间,但以后每次出门都是"按清单取物",省心得多。
1.3 整体方案长什么样
我用的这套方案的完整工作流分三段:
- 编译阶段:make comp,把RTL文件、测试平台文件、仿真选项交给VCS,产出可执行的simv;
- 仿真阶段:make sim,运行simv并生成FSDB格式波形文件,同时打印仿真日志;
- 调试阶段:make verdi,用Verdi打开FSDB波形和设计源代码,进行信号分析和调试。
每一条命令都不长,背后做的事却是一整套经过设计的流程。下面我先把Makefile的必备基础过一遍,再逐行拆解可以直接使用的模板。
2. 环境准备与Makefile基础速览
2.1 你需要准备好的东西
开始写Makefile之前,确认环境里有以下几样东西:
- VCS仿真工具:负责RTL的编译和仿真,输出simv可执行文件和FSDB波形;
- Verdi波形调试工具:负责打开波形、分析信号、追踪逻辑;
- GNU Make工具:Linux环境下一般自带,用make -v确认版本即可;
- 仿真工程源文件:RTL设计文件(.v/.sv)、测试平台文件(testbench)、文件列表(.f文件),以及一个存放仿真输出的run目录。
这里特别提一下License的问题。VCS和Verdi都需要有效的License环境,启动前检查一下License是否正常。我在实际操作中遇到过几次"VCS启动报错但License其实没掉"的情况,多半是环境变量(如VCS_HOME、PATH、LD_LIBRARY_PATH)没有正确source,后面排坑部分会详细讲。
2.2 Makefile的核心机制
Makefile的基本结构是三要素:目标(target)、依赖(prerequisites)和命令(recipe)。
target: prerequisites recipe commandmake在执行时会先检查依赖是否存在,再判断依赖是否比目标新。如果目标不存在,或者任何一个依赖比目标新,就执行命令;否则跳过。这个机制正是增量编译的基石。
还要弄明白几个常用概念:
- 变量:用=或:=赋值,用$(变量名)引用,用来集中管理命令选项;
- 自动变量:$@表示目标,$^表示全部依赖,$<表示第一个依赖,在规则里直接用能省不少事;
- 伪目标:用.PHONY声明不生成文件的目标,比如clean、comp、sim这些,避免同名的真实文件干扰执行。
对仿真场景来说,最常用到的其实就这三板斧。语法不用多,会表达"依赖关系"和"命令执行"就够用了。后续模板里涉及到的每一条语法我都会尽量解释清楚,不太熟Makefile的同学也能跟着搭起来。
2.3 一个最小可跑的Makefile雏形
先用一个最简的例子感受一下:
# 最小VCS仿真Makefile TOOL_PATH := /eda/synopsys/vcs VCS := $(TOOL_PATH)/bin/vcs SIMV := ./simv RTL_FILES := src/tb_top.sv src/dut.sv comp: $(VCS) -sverilog -debug_access+all \ $(RTL_FILES) -o $(SIMV) sim: comp $(SIMV) -l sim.log .PHONY: comp sim这个例子把编译和仿真分成了两个目标,sim依赖comp,所以make sim会自动先编译再仿真。虽然简陋,但整个体系已经立起来了。接下来我在这个基础之上,把实际工程里用到的完整版本逐行拆开。
3. 逐行拆解一版可直接用的Makefile模板
3.1 变量定义区:先把所有可改的东西集中起来
我在实际工程里的Makefile,最上面永远是变量定义区。这样后面换设计、换版本、加文件,只需要改最上面几行,不会动到规则本身。
# ===== 工具路径配置 ===== VCS_HOME := /eda/synopsys/vcs VERDI_HOME := /eda/synopsys/verdi VCS := $(VCS_HOME)/bin/vcs VERDI := $(VERDI_HOME)/bin/verdi # ===== 工程配置 ===== TOP_MODULE := tb_top FILE_LIST := ./filelist.f TIMESCALE := 1ns/1ps DEBUG_OPTS := -debug_access+all -debug_region+cell # ===== 波形与输出配置 ===== RUN_DIR := ./run FSDB_NAME := $(RUN_DIR)/dump.fsdb LOG_DIR := ./logs为什么要用文件列表filelist.f而不是直接在Makefile里列出源文件?原因有两个:一是RTL文件多的时候,Makefile本身会变得臃肿;二是文件列表文件可以直接被VCS的-f选项读取,和Tcl脚本、其他工具也能共享同一份文件清单,避免维护多份不一致的列表。这是我在多个项目里实践出来的选择,强烈建议按这个思路组织。
3.2 编译目标的实现:这些VCS选项到底在做什么
编译目标是整个Makefile里最核心的环节。我在工程里长期使用的一版关键命令是这样:
comp: mkdir -p $(RUN_DIR) mkdir -p $(LOG_DIR) $(VCS) -sverilog \ -timescale=$(TIMESCALE) \ -f $(FILE_LIST) \ +incdir+./rtl +incdir+./tb \ +define+$(SEED_DEFINE) \ -debug_access+all \ -assert svaext \ -l $(LOG_DIR)/comp.log \ -o $(RUN_DIR)/simv逐个说说这些选项的作用:
- -sverilog:告诉VCS编译SystemVerilog代码,几乎所有现代验证环境都得带;
- -timescale=1ns/1ps:默认时间尺度,设计里没写timescale的模块都用这个设置,避免时间精度不一致导致的仿真时序问题;
- -f filelist.f:指定文件列表,列表里按行写源文件路径,VCS会按顺序编译这些文件;
- +incdir+路径:添加include目录,代码里include头文件时按这个路径搜索;
- +define+宏:定义编译宏,可以用来切换测试模式、选择约束,我常用它来区分不同seed或配置;
- -debug_access+all:打开VCS的调试访问权限,后续Verdi才能读出完整的信号状态和寄存器数据;
- -assert svaext:打开SystemVerilog断言(SVA)编译支持,验证环境里有断言就必带;
- -l 日志:把编译输出写到指定日志文件,排查问题全靠它;
- -o 输出名:指定仿真可执行文件的名字,默认是simv,我习惯改成run目录下的simv,把中间产物集中管理。
这里有个非常容易踩的坑:-debug_access+all不能随便拿掉。很多同学为了编译速度把调试选项去掉,编译确实快了,但Verdi打开波形后要么看不到信号,要么只能看到顶层信号,内部信号全是空的。就是因为VCS编译时没有把调试信息写进可执行文件。省这么点时间不值得,后面后悔的功夫更大。
3.3 仿真阶段:跑simv只是第一步
编译完成后,仿真阶段的核心是把simv跑起来,同时保证三样东西能顺利产出:正确的仿真日志、可用于后续调试的FSDB波形、以及明确的结束标记。
sim: comp cd $(RUN_DIR) && ./simv +ntb_random_seed=$(SEED) \ -l $(LOG_DIR)/sim_$(SEED).log很多教程会建议直接./simv,不传参数。但我在实际工程里几乎都会做两件事:一是通过+ntb_random_seed指定随机种子,让随机约束用例在回归时每条用例的种子可控可复现;二是用-l把日志写到logs目录下,和编译日志分开管理。这样出问题时,logs目录里积累的每一次运行记录都能对照查找。
那FSDB波形在哪里生成?关键点在于FSDB由Verdi的dumplib机制产生,VCS编译时已经通过-debug_access+all把对FSDB的支持包含进去,仿真代码里还需要调用$fsdbDumpfile和$fsdbDumpvars这两个系统任务。这两个调用一般放在initial块里:
initial begin $fsdbDumpfile("dump.fsdb"); $fsdbDumpvars(0, tb_top); end这里有个小细节:$fsdbDumpfile的路径要和Makefile里FSDB_NAME变量保持一致,路径不一致时Verdi那边加载波形文件就会找不到。我在多个项目里见过这种低级但特别耗费时间的错误,写Makefile时务必统一。
$fsdbDumpvars的第一个参数0表示把所有层级、所有信号都dump出来。如果只关心某些模块,也可以写模块名,缩小波形文件体积、加快仿真速度。回归测试时我经常只dump顶层,快速跑完;定位bug时再dump全层次,波形会大很多,但信息也全得多。
3.4 启动Verdi:把打开波形也变成一条make命令
Verdi的打开命令,我见过好几种写法。最简单的是直接verdi,然后手动在界面上加载设计、加载波形。但项目一旦大了,这种方式很浪费时间,而且Verdi的GUI配置每次都要重新做一遍。
我推荐的写法是把设计文件和波形文件都在命令行里传进去:
verdi: comp sim $(VERDI) -f $(FILE_LIST) +incdir+./rtl +incdir+./tb \ -top $(TOP_MODULE) \ -ssf $(FSDB_NAME) \ -nologo &关键选项说明:
- -f和+incdir和编译时保持一致,确保Verdi能加载到同一套源代码和头文件;
- -top指定顶层模块,Verdi用它做层次结构的入口;
- -ssf指定FSDB波形文件路径,Verdi启动后直接加载波形;
- 结尾的&表示后台启动GUI,终端不会被占住。
我在实际使用中还会在verdi命令里加-nologo参数,目的是跳过Verdi启动时的logo动画,省那几秒钟的等待。另外要注意,verdi目标依赖comp和sim,含义是"只要想打开波形,就确保编译和仿真都已经完成"。但这里有个隐患:如果编译和仿真已经执行过,而make verdi却把它们又重复跑了一遍,说明时间戳判断出了问题,这个在排坑章节细讲。
3.5 clean与其他辅助目标
仿真工程跑久了,run目录里堆满临时文件、中间变量和旧日志,磁盘越来越满,而且旧的simv和新的文件列表混在一起还会造成编译结果不可信。所以我习惯维护一个clean目标:
clean: rm -rf $(RUN_DIR)/* $(LOG_DIR)/*再追加一个常用的reg目标,把所有测试用例以回归形式跑一遍:
SEEDS := 1 2 3 4 5 reg: comp @for s in $(SEEDS); do \ cd $(RUN_DIR) && ./simv +ntb_random_seed=$$s -l ../$(LOG_DIR)/reg_$$s.log || exit 1; \ done这里用shell的for循环把不同的随机种子轮询跑一遍。两条细节值得注意:第一,@前缀是取消make默认的"回显命令"行为,不然每次运行所有命令都会打到终端上,日志会很乱;第二,循环体里用了$$s而不是$s,因为在Makefile中$是特殊字符,要传给shell的变量必须写成$$。这个坑我踩过一次之后印象特别深,新手尤其容易在这里反复报错。
4. 实操记录:从零到跑通一次完整仿真
4.1 构造一个可以跟着练的迷你工程
为了让整个流程可复现,我用一个超级简单的例子来做演示:一个小型计数器模块,外加一个简单的测试平台,跑一次仿真实测。工程目录结构如下:
proj/ ├── Makefile ├── filelist.f ├── rtl/ │ └── counter.sv └── tb/ └── tb_counter.svcounter.sv内容是一个带load使能的同步计数器,tb_counter.sv里做时钟激励、复位、load操作,然后在initial块里调用$fsdbDumpfile和$fsdbDumpvars。filelist.f里写:
./rtl/counter.sv ./tb/tb_counter.sv我的Makefile在模板基础上做了适配:TOP_MODULE=tb_counter,不额外加define,SIM_TIME设为2us(实际上用initial里的$finish控制结束)。这样一个工程虽然简单,但把Makefile、编译、仿真、波形生成、Verdi加载整条链路都覆盖了。
4.2 编译阶段:现场输出怎么看
在工程根目录执行make comp,终端上看到的第一屏是VCS的版本信息、编译引擎启动的信息。重点要关注的是最后几行,通常是类似下面这种输出:
Portability check passed. Elaboration time = 2.1s Making 64-bit portable simulation executable ... Chisel compilation completed successfully.看到completed successfully就说明编译过了,run目录下会多出一个simv可执行文件。如果哪一行报error,这时候第一件事是去logs/comp.log里搜error关键词,不要只看终端屏幕,因为终端有时被VCS的banner刷掉了一部分内容。
编译阶段最常出现的两类error是文件路径写错、语法错误。文件路径错误在VCS输出里一般会直接打出找不到文件的完整路径,照着filelist.f一行行核对就行。SystemVerilog语法错误会显示行号和错误类型,同样要先从第一个error开始修,因为编译器经常被第一个错误带偏,后续一长串error往往都是误报。
4.3 仿真阶段:确认波形真的生成了
make comp通过之后,执行make sim。simv跑起来后会打印时间推进的进度,通常每个单位时间会输出一个点号,跑到$finish或$stop就停下来。结束之后,第一件事去run目录确认波形文件存在:
$ ls -la run/ drwxr-xr-x ... simv -rw-r--r-- ... dump.fsdb看到dump.fsdb存在,文件大小不为0,说明FSDB波形生成成功。如果文件不存在,大概率是仿真没跑到dump的系统任务就退出了,或者在tb代码里根本没有调用$fsdbDumpvars。$finish放在太靠前的位置也会导致波形没来得及写盘,我把这个检查点放在"波形缺失"排查的最前面,因为它的发生率真的不低。
日志方面,sim.log里如果看到类似"Simulation finished"的信息,并且没有任何Error或Fatal,基本可以认定这一轮的仿真结果是可信的。退出码方面,simv正常结束时exit code一般是0;如果是非0,务必去日志末尾看有没有Fatal或UVM_ERROR。
4.4 用Verdi打开波形:一个完整的排查现场
仿真跑完、dump.fsdb就位后,执行make verdi。Verdi的GUI启动后,你会看到源代码窗口、层次树、波形窗口三个主要区域。层次树里点开tb_counter,能找到counter实例;在源代码窗口选中某个信号,右键选择添加信号到波形窗口,就能在波形窗口看到时序变化。
在真实项目里,我拿到一个失败用例后的排查路径一般是这样的:
- 先看FSDB里被检查信号的实际波形,和期望值做对照;
- 波形异常时,往上游信号一层层回推,把相关模块的中间信号都拉进波形窗口;
- 确认是哪个时间点逻辑开始偏差,再回到RTL源码里打点检查状态机、计数器或组合逻辑。
Verdi还有两个我特别依赖的功能:一个是波形窗口里的信号值搜索功能,在超长仿真时间里快速定位特定数值出现的时间点;另一个是层次树的"只看关键信号"过滤,可以显著减少波形窗口里无关信号的干扰。这些功能用熟之后,定位问题的效率比纯看日志要高一到两个数量级。
5. 我踩过的坑与排查技巧实录
5.1 常见报错速查表
我把这几年在Makefile+VCS+Verdi组合下遇到的高频问题整理成一张表,按症状、常见原因、解决办法三列排布:
| 症状 | 常见原因 | 解决办法 |
|---|---|---|
| make: vcs: command not found | PATH环境变量没配置或工具路径写错 | source环境脚本,核对VCS_HOME和PATH |
| 编译报错Unknown option | 选项顺序或写法不对,或者用了老版本不支持的选项 | 对照VCS版本文档查选项,同团队统一版本 |
| 仿真日志全空 | simv退出太快,$finish写在initial最前头 | 检查tb代码结构,把dump和finish的顺序理顺 |
| FSDB文件不存在 | tb里漏掉$fsdbDumpfile / $fsdbDumpvars | 在initial块补上系统任务,并确认路径一致 |
| Verdi打开信号全是X/Z | 编译时没加-debug_access+all | 编译目标补上调试选项,重新编译再仿真 |
| make sim总是重复编译 | 依赖关系少写了或者clean不彻底 | 确认sim依赖comp,检查simv和源文件时间戳比较逻辑 |
| VCS报License expired | License服务状态不稳或环境变量指向错误 | 自查License环境,确认服务状态正常 |
这个表里的问题覆盖了我遇到的九成情况。重要的是,每一种问题都对应着一个明确的操作动作,排查时按表格一步步走,基本都能快速定位。
5.2 增量编译不生效的排查:时间戳和文件列表
Makefile构建系统底层的判断逻辑是时间戳:如果目标文件(例如simv)比所有依赖都新,make就认为不需要重新编译;否则就触发重编。所以增量编译不生效,原因集中在几个方向:
- 依赖列表没把真正的源文件写全:filelist.f里少了某个文件,改了这个文件但make不知道要重编;
- clean目标太激进:每次make comp之前先clean,等于放弃了增量能力,跑回归时间全花在编译上;
- 编译选项改变了但make没有感知:比如你改了-timescale,但目标文件simv的时间戳没变,make不会自动重编。
我处理这类问题的经验是:不需要把清理做得很勤,反而是要让Makefile的依赖关系尽量完整。把filelist.f里所有文件都作为comp目标的依赖,用$(shell cat filelist.f)动态获取文件列表再拼到依赖里,这样VCS编译选项变了、文件列表变了,make都能感知到。增量编译能带来的提速非常可观,值得专门花点时间调好。
5.3 波形文件打不开或内容不对的处理
FSDB波形是调试环节的生命线,波形出问题,整个调试就断了。我遇到过的波形问题主要有三个场景:
场景一:Verdi提示找不到FSDB文件。这种多半是路径不一致,Verdi里-ssf传的参数和仿真时实际生成的路径对不上。解决方法是把FSDB_NAME定义成Makefile和tb代码都能引用的同一个相对路径,不要让两边各自维护一套路径。
场景二:能打开但波形没有数据或者全是X态。通常是编译时调试选项不全,或者$fsdbDumpvars第一个参数写了0但后面跟的模块名写错,导致dump范围超出了实际层次。解决办法是核对tb里的dump调用和RTL层次是否一致,必要时把参数改成0全量dump,确认波形正常后再缩小范围。
场景三:Verdi启动后没有加载任何源码。这通常是因为-f文件列表或+incdir的路径没找对。Verdi和VCS虽然是同一家公司工具,但加载代码的解析逻辑不完全一样,有的路径在VCS下能用,到Verdi这就不认了。我的习惯是给Verdi和VCS传一模一样的文件列表和include路径,不让两边各自维护一份,差异自然会消失。
5.4 让回归更省力的小改进
仿真规模一大,单条make命令已经不够用,我后来做了几个小改进,效果非常显著:
第一个是并行跑多seed。用make -j 4 reg这种并行方式,一个目标里的多个seed用例可以用多个核同时跑,前提是Makefile里每个seed的输出文件不能互相覆盖。我习惯把日志、波形都按seed编号命名,这样不仅并行安全,事后排查也能直接对应到具体seed。
第二个是给Makefile加usage目标。运行make(不带参数)时打印所有可用目标,每个目标一行注释,效果相当于内置了命令手册。团队里新人上手快,自己隔两个月再回来也不会忘。
第三个是把Verdi打开后加载的配置(比如常用信号的显示分组)存成rc文件,在verdi命令里加-rc选项加载。这样打开波形时,常见的关注信号都已经按分组排好,省下大量重复操作。
这些改进单独看都是小事情,合在一起的效果是:整个仿真调试过程中最耗时的"等待编译+重复配置"被压到最低,留出更多时间真正去分析设计问题。我在实际使用中最深的体会是,脚本的真正价值不在于逻辑多复杂,而在于它能让团队里每个人都用同样的方式处理同样的事,让"跑仿真"这件事从需要记忆和口口相授的琐碎操作,变成一条条所有人都能闭眼执行的标准动作。每当看到新同事用make comp、make sim、make verdi跑通第一轮仿真的时候,我都会想,这几条短命令背后省下的时间,真的够写好几篇文章了。