1. 从天天手敲vcs命令到makefile:我为什么最终换了思路
做数字IC验证的人,对下面这串东西一定不陌生:
vcs -full64 -sverilog -debug_access+all -timescale=1ns/1ps \ -f scripts/filelist.f \ -o simv \ +define+DUMP_FSDB \ -l comp.log刚开始我觉得无所谓,复制粘贴改一改就行。但等验证环境里的testbench多起来以后,这套手动流程开始变得非常难受:编译选项越堆越长,每次换一个tb要改半天文件名;波形路径乱了,Verdi打不开fsdb时根本分不清是编译问题还是dump问题;同事传过来的工程,每个人敲的命令都不一样,连log命名都是自由发挥。
后来我把编译、仿真、开波形这三件事全部收进一个makefile,日常操作就变成了三条命令:
make comp # 编译 make sim # 仿真 make verdi # 打开Verdi看波形这篇文章就是把我自己整理这套流程时踩过的坑、反复确认过的细节、以及最终沉淀下来的模板完整写出来。内容包括环境准备、目录规划、makefile核心写法、多用例回归扩展,以及五个让我浪费过时间的实际问题排查。无论你刚接触SystemVerilog验证,还是已经写了一段时间vcs命令想找个更顺手的组织方式,这篇都可以直接参考。
2. 动手前先把环境摸清:目录结构、filelist与工具版本
2.1 vcs与verdi版本匹配:第一步就卡人的地方
很多人第一步就栽在版本匹配上。我遇到过的情况是:vcs和verdi来自不同版本,或者license特性不完整,导致编译没问题,但Verdi打开后看不到源码,或者fsdb波形加载进nWave后信号是空的。
先说最稳妥的做法:用同一个工具版本下的vcs和verdi。在终端先各看一眼版本:
vcs -ID verdi -version两个版本号最好处于同一个大版本周期内。旧版本的verdi去读新版本vcs产生的fsdb,偶尔会出现波形兼容问题;反过来倒是相对少见,但也没必要冒这个险。
还有一个容易忽略的点:编译时务必加-debug_access+all。这个选项会生成Verdi调试需要的数据库。早期我图省事,编译只加了-debug_access,结果simv能跑,波形也能dump,但Verdi里双击信号想trace到RTL源码时,经常跳不过去,最后排查下来就是debug数据库不完整。
另外,如果你在服务器上远程跑,Verdi开图形界面还要确认DISPLAY环境变量和license的Verdifeature都正常。这个不起眼,但确实是新手最容易卡住的地方。
2.2 一套能长期用的目录结构
目录结构没有绝对标准,但至少要能把“源码”和“生成物”分开。我长期用的结构是这样的:
project_root/ ├── rtl/ # 设计文件 ├── tb/ # testbench文件 ├── scripts/ # filelist、makefile等脚本 ├── sim/ # 编译与仿真产生的文件 │ ├── comp.log │ ├── sim.log │ └── wave.fsdb └── makefile这个结构最大的好处是:sim/目录可以随时整个删掉,重新编译不会污染其他目录;rtl/、tb/、scripts/只放源文件,方便代码管理。
有一个细节值得注意:所有源文件路径不要带空格,也尽量不要有中文目录名。vcs和verdi对这类路径的兼容性虽然一直在改善,但排查路径问题的成本远高于你改目录名的成本。我在两个项目里吃过这个亏,后来所有工程一律使用全英文无空格的路径。
2.3 filelist.f:让编译和调试读同一份文件
filelist是整个流程的中枢。编译要用它,Verdi打开源码也要用它。最省心的做法是同一份filelist,两个工具共用,而不是为Verdi再维护一份“查看用列表”。
我的filelist长这样:
rtl/adder.sv rtl/multiplier.sv tb/tb_top.sv +incdir+tb这里有一个非常重要的经验:filelist里写相对路径时,尽量以项目根目录为基准,而不是以当前执行命令的目录为基准。因为makefile里经常会有cd sim && ...这类操作,一旦切换了工作目录,filelist里相对路径的解析就会出错。统一以项目根目录为基准后,无论在哪个目录发起编译,只要makefile里能正确定位到scripts/filelist.f,里面列的路径就都能对上。
Verdi使用时同样用这一份filelist:
verdi -f scripts/filelist.f -top tb_top -ssf sim/wave.fsdb &它负责两件事:加载RTL和tb的源码,以及加载fsdb波形。源码路径和波形路径在同一个命令里能看到,方便多了。
3. 核心makefile拆解:编译、仿真、一键开波形
3.1 变量区:把变化的东西裸露出来
makefile里最重要的不是target,而是变量设计。你要让“经常变化的东西”集中在文件顶部,一眼就能看到;不太变的东西放进编译选项里,沉到下面。
我习惯把这类变量放最上面:
TB ?= tb_top SEED ?= 1 FINISH ?= 100us SIM_DIR := sim FILELIST := scripts/filelist.f这里用?=而不是=,原因后面专门讲。它的效果是:makefile里给了一个默认值,但你在命令行执行make sim TB=tb_adder时,会优先用命令行传入的值。这个特性是我们后面做多用例回归的基础。
3.2 comp target:把编译选项沉淀下来
我的核心makefile长这样:
VCS := vcs VERDI := verdi VCS_OPTS := -full64 -sverilog \ -debug_access+all \ -timescale=1ns/1ps \ -f $(FILELIST) \ -o $(SIM_DIR)/simv \ +define+DUMP_FSDB \ -l $(SIM_DIR)/comp.log .PHONY: comp sim verdi clean comp: @mkdir -p $(SIM_DIR) $(VCS) $(VCS_OPTS) sim: comp cd $(SIM_DIR) && ./simv +ntb_random_seed=$(SEED) +vcs+finish+$(FINISH) -l sim.log verdi: $(VERDI) -f $(FILELIST) -top $(TB) -ssf $(SIM_DIR)/$(TB).fsdb & clean: rm -rf simv* csrc *.daidir *.fsdb comp.log $(SIM_DIR)逐个解释一下关键点。
-o $(SIM_DIR)/simv指定可执行文件输出到sim/目录。这是很多简单教程里不会写但实际非常实用的选项。默认情况下,vcs会把simv、csrc、simv.daidir全堆在当前目录,跑几次仿真后根目录乱七八糟。指定-o之后,simv本体到了sim目录,但csrc和simv.daidir仍会留在执行目录,所以clean target里要把这些也都带上。
+define+DUMP_FSDB这里要和testbench配合。典型做法是让fsdb dump代码受宏控制:
`ifdef DUMP_FSDB initial begin $fsdbDumpfile("wave.fsdb"); $fsdbDumpvars(0, tb_top); end `endif这样设计有两个好处:平时如果需要跑长仿真,不想产生超大波形文件,编译时不加+define+DUMP_FSDB即可;需要调试时再加上。波形文件名不用改代码,直接在makefile里控制switch就行。
-timescale=1ns/1ps指定了testbench默认时间单位和精度。如果你的tb文件顶部已经写了timescale,那这个选项加不加都行,但统一在命令行指定可以避免“有人写了有人没写”的混乱。
3.3 sim target:seed、结束时间、额外参数怎么传
sim: comp这行是关键依赖关系。它保证了你直接执行make sim时,如果simv还没编译,会先自动编译;如果已经编译好了,且没有动的源文件,就不会重复编译。
仿真时的核心参数有三个:
+ntb_random_seed=$(SEED) # 随机种子 +vcs+finish+$(FINISH) # 强制结束时间 EXTRA_OPTS # 其他临时参数随机种子放在变量区,每次回归可以传不同seed。+vcs+finish+100us的意思是仿真器运行到100us后自动$finish,单位支持ns、us、ms。如果你的testbench里自己写了$finish,仿真会在那个点结束;这个选项相当于一道保险,防止tb忘了结束导致仿真挂死。
还有一个很实用的做法,是提供一个EXTRA_OPTS占位变量:
SIM_OPTS := +ntb_random_seed=$(SEED) +vcs+finish+$(FINISH) $(EXTRA_OPTS)然后命令行就可以这样用了:
make sim EXTRA_OPTS="+UVM_TESTNAME=my_smoke_test"这样不用改makefile,就能临时给simv传任意运行时选项。我在移植UVM环境时经常靠这个方式快速切换testcase。
3.4 verdi target:打开源码和fsdb波形的正确姿势
Verdi的启动target是三件事里最简单但也最容易写错的:
verdi: $(VERDI) -f $(FILELIST) -top $(TB) -ssf $(SIM_DIR)/$(TB).fsdb &-f $(FILELIST)加载源码,-top $(TB)指定顶层,-ssf指定fsdb路径。注意我专门用了$(SIM_DIR)/$(TB).fsdb,因为仿真时simv是在sim/目录里运行的,所以产生的波形文件默认落在sim/目录。如果你在项目根目录启动verdi,就必须写这个路径。
这里有一个关键细节:verdi窗口是在后台启动的,所以命令末尾有一个&。如果不加,这个target会一直占用终端,直到你关掉Verdi。加了&之后,终端能立刻返回,可以继续操作。
另外,启动信息里可以加-nologo去掉启动时的logo弹窗,界面会干净一些:
verdi: $(VERDI) -nologo -f $(FILELIST) -top $(TB) -ssf $(SIM_DIR)/$(TB).fsdb &提示:如果你的波形文件名固定叫
wave.fsdb而不是$(TB).fsdb,那-ssf路径要对应写清楚。我更建议用$(TB).fsdb,这样不同用例的波形不会互相覆盖。
4. 从单用例到回归:让makefile随验证环境一起长大
4.1 参数化tb:命令行覆盖默认变量
当工程里出现第二个testbench时,固定写法就开始捉襟见肘了。比如你有tb_top和tb_timer两个用例,它们可能编译的源文件都一样,只是顶层不同。
利用?=变量,可以做到这样:
make sim TB=tb_timer SEED=42 FINISH=500us对应的编译脚本需要把-top也参数化。我实际用的编译target是这么写的:
VCS_OPTS := -full64 -sverilog \ -debug_access+all \ -timescale=1ns/1ps \ -f $(FILELIST) \ -top $(TB) \ -o $(SIM_DIR)/simv \ +define+DUMP_FSDB \ -l $(SIM_DIR)/comp.log-top $(TB)的作用是显式指定仿真顶层。这里有一个容易踩的坑:如果两个tb的源码都同时在filelist里,切换顶层时必须重新编译,否则simv还是上一次编译时确定的顶层。VCS不会因为你换了-top变量就自动重新生成simv,这是很多回归异常的直接原因。
4.2 批量回归:一个target串起多条用例
跑回归是验证工作的日常。写一个regresstarget,把所有用例串起来:
REGRESS_TBS := tb_top tb_timer tb_sram regress: @for tb in $(REGRESS_TBS); do \ echo "===== run $$tb ====="; \ $(MAKE) sim TB=$$tb SEED=$$RANDOM; \ done注意这里用$(MAKE)而不是直接写make。原因是如果你在命令行用了make -j4之类的并行参数,递归调用make时必须把同样的参数传递下去,$(MAKE)会自动继承父进程的make参数。
跑回归的日志会按用例名分开吗?上面这个写法用的是-l sim.log,所有用例会写同一个log文件,这就不好了。更好的方式是把log也参数化:
sim: comp cd $(SIM_DIR) && ./simv $(SIM_OPTS) -l $(TB).log这样make sim TB=tb_top产生sim/tb_top.log,make sim TB=tb_timer产生sim/tb_timer.log。回归完成后,按log名就能轻松区分结果,排查失败用例时效率高很多。
4.3 扩展:覆盖率收集与自定义选项
当验证环境需要收覆盖率时,makefile还可以继续扩展。VCS的常用做法是编译时加-cm选项:
VCS_OPTS := ... -cm line+cond+branch+tgl仿真跑完后,用urg工具汇总覆盖率数据,再让Verdi打开查看。我习惯把它做成独立的cov target:
cov: urg -dir $(SIM_DIR)/simv.vdb -reportdir $(SIM_DIR)/coverage_report $(VERDI) -cov -f $(FILELIST) -top $(TB) &-cov会让Verdi以覆盖率浏览模式打开,配合simv.vdb数据,可以直接看哪行代码没跑到。这个target我一般不会放进默认流程,只有需要做覆盖率分析时才调用。
还有一个细节:不要轻易在makefile里用-j并行编译。VCS编译本身很吃内存,并行编译经常导致服务器内存被打满。如果真要加速过程,可以把回归里的for循环拆成后台任务,但前提是机器资源足够。我实际跑回归时,宁可按用例串行执行,也不愿意为了省几分钟把服务器搞到OOM。
5. 踩坑实录:我在makefile+vcs+verdi组合里遇到的几个坑
5.1 comp.log路径报错,却说找不到文件
有一次我执行make comp,vcs报错:
Can't open log file: sim/comp.log但sim/目录明明已经用mkdir -p创建了。反复确认后发现,问题出在target执行顺序上。我的写法是这样的:
comp: $(VCS) $(VCS_OPTS) # 顺序错了 @mkdir -p $(SIM_DIR)-l sim/comp.log是vcs打开log文件,而mkdir -p sim要等vcs执行完才创建目录,顺序反了自然打不开。解决办法很简单,把mkdir放在vcs命令之前:
comp: @mkdir -p $(SIM_DIR) $(VCS) $(VCS_OPTS)这个坑看起来蠢,但几乎每个刚开始用makefile组织vcs流程的人都可能踩一次。记住一个原则:所有依赖某个目录存在的动作,都要先确保目录被创建。
5.2 改完代码,仿真结果却“没变”
这个坑最隐蔽,也最耗时间。现象是:我修改了RTL文件里的某个逻辑,重新执行make sim,但仿真结果和之前一模一样,像是没编译到新代码。
原因归根结底是VCS增量编译的依赖跟踪在某些场景下不太可靠。VCS确实支持增量编译,但如果你修改的是include文件、filelist内容,或者跨目录源文件,它的依赖分析有时不能正确触发重编译。
我的解决方案很直接:涉及关键改动时,不依赖增量编译,先clean再comp:
make clean comp sim如果你觉得每次clean太慢,至少要做到“修改filelist、修改include文件、切换TB”这三类操作后必须clean重编。只改单个RTL文件的时候,增量编译大部分情况下是正常的,但一旦结果看起来“不对劲”,第一反应就该是clean之后重新编译一次,而不是拿着波形反复怀疑自己。
5.3 波形文件总是不生成,Verdi打开一片黑
这是我被问过最多的问题。makefile编译正常,simv也跑完了,sim/目录下就是没有wave.fsdb,或者Verdi打开后没有任何波形信号。
排查顺序基本是固定的:
testbench里有没有调用$fsdbDumpfile和$fsdbDumpvars。只加
+define+DUMP_FSDB但tb里没有对应的initial block,波形当然不会生成。编译时有没有加
+define+DUMP_FSDB。这个宏的作用是把dump代码“打开”。有时候改代码时不小心把+define+DUMP_FSDB从VCS_OPTS里删了,tb里的ifdef DUMP_FSDB就不会生效。simv的实际运行目录和波形路径是否匹配。我的makefile里
simtarget先cd $(SIM_DIR)再执行simv,所以波形生成在sim/目录。如果你在根目录直接执行./sim/simv,波形反而会出现在根目录。这个差异很容易让人误以为“波形没生成”。有没有开debug_access。没有
-debug_access+all时,虽然VCS也能跑,但Verdi从fsdb反向trace源码的能力会大打折扣。我遇到过波形能看,但信号和源码完全对不上号的情况,最后发现是debug数据库不完整。
我把这个排查过程整理成了一张表:
| 现象 | 优先检查 | 解决方向 |
|---|---|---|
| 没有fsdb文件 | tb里的dump调用是否执行 | 检查DUMP_FSDB宏和initial block |
| fsdb文件存在但内容为空 | $fsdbDumpvars参数 | 确认dump层级范围 |
| 波形能开但源码trace不上 | 编译时debug_access选项 | 改为-debug_access+all |
| Verdi启动后报找不到波形 | -ssf路径与simv运行目录不一致 | 统一使用$(SIM_DIR)/$(TB).fsdb |
5.4:=和?=用错,命令行参数覆盖失效
makefile变量赋值看起来简单,实际用起来有讲究。我最初写的赋值是:
TB := tb_top执行make sim TB=tb_timer时,命令行参数覆盖了makefile里的值,看起来没问题。但等我把回归target加进去后:
regress: @for tb in $(REGRESS_TBS); do \ $(MAKE) sim TB=$$tb; \ done这里的TB=$$tb传给了子make,但子makefile里的TB := tb_top优先级问题导致覆盖失败。原因在于:命令行变量只有在makefile里没有用override类语句强制赋值时才生效;而:=在这种场景下的行为很容易让人困惑。
后来我把所有这类参数统一改成?=:
TB ?= tb_top SEED ?= 1 FINISH ?= 100us?=的含义是“如果变量未被赋值,则赋默认值;如果已经被赋值(包括命令行赋值),则保持原值”。完美契合“默认值+命令行覆盖”的使用场景。现在我的makefile里,所有需要经常改动的参数全部用?=,不经常改的编译选项用:=。
5.5+vcs+finish+100us没生效,仿真一直跑
最后一个坑是单位问题。VCS的+vcs+finish选项支持的时间单位写法是有讲究的。我见过有人写成+vcs+finish+100,期望跑100us,结果仿真器按100个时间单位执行了——到底多久取决于timescale设置。
我的习惯是显式带上单位:
+vcs+finish+100us +vcs+finish+200ns +vcs+finish+1ms这样含义明确,不依赖timescale的默认值。另外,命令行传参时如果FINISH=100us写错成FINISH=100 US(中间有空格),会被shell解析成两个参数,silently忽略掉。这个问题用FINISH=100us这样一个紧凑写法就能避免。
最后分享一点我的个人习惯
makefile这套流程我已经用了不少项目,最大的收获不是“少打了几条命令”,而是让验证流程变成显性资产。新同事接手工程,先看make help、再跑一条make sim、最后make verdi看波形,整个过程不需要我解释“编译选项为什么要这么写”。它把容易出错的手动操作固化成了可复现、可审查、可沟通的流程。
有一点我一直建议别人也这么做:从最小用例开始。先写死一个tb_top、一个filelist、一个编译选项,跑通makefile三个target,再逐步添加参数化、多用例和回归。不要一上来就写一个几百行的通用makefile,那样调试成本远高于收益。
如果你现在还在手动敲那串长长的vcs命令,真的建议花一下午整理一次。等makefile跑通的那一刻,你就会理解为什么所有老工程师都强调“流程自动化不是写代码,是省时间”。之后可以顺手把UVM、覆盖率、多seed回归这些能力一点点加到同一个文件里,你的验证环境会越来越顺手。