
VCS 的编译选项和仿真选项平时都埋在上百行的 Makefile 或者脚本里跑得顺的时候没人会多看一眼一旦出问题就得花大半天去猜每一个带减号、带加号的开关到底是干什么的。我接手过好几个别人交接的验证环境最典型的情况就是编译选项堆了三四十个能跑但状态很微妙——改一个define就得全量重编一编十几分钟换台机器跑波形又出不来。后来我干脆把这些选项按“编译期”和“仿真期”重新梳理了一遍环境才真正变得可控。这篇就把我日常用得最多、也最容易踩坑的那些编译选项和仿真选项逐个讲清楚它们分别在哪个阶段生效、底层在做什么、为什么这么写以及什么情况下会把人坑住。刚入行看脚本一头雾水的、做了几年但一直照抄同事选项没系统梳理过的都能从里面找到对得上号的东西。1. VCS 的两段式流程选项必须先分清“编译”还是“仿真”很多人对 VCS 的困惑根源其实不在选项本身而在于没意识到 VCS 是两段式的。你敲的那条vcs命令做的事是“把一堆*.v、*.sv文件翻译并链接成一个可执行程序”真正跑仿真的是它生成出来的那个叫simv的可执行文件。这两个阶段是完全割裂的各自有一批只属于自己的选项。把编译选项写到simv后面或者把仿真选项写到vcs后面轻则被静默忽略重则直接报错退出。1.1 vcs 和 simv 各自负责什么vcs阶段的核心产物是simv以及一个叫simv.daidir的目录。前者是可执行的仿真内核后者是编译过程中产生的中间数据库增量编译、Verdi 的 KDB 库、覆盖率的一些元信息都放在这里面。这个阶段要解决的是语法解析、宏展开、模块例化关系建立、UVM 库链接、代码优化等一整套“翻译”工作所以文件列表、宏定义、头文件路径、语言标准、优化等级这类选项全都属于编译期。simv阶段做的事就纯粹是“运行”。它读入外部的激励、驱动时序、跑 testbench、dump 波形、记录日志。随机种子、仿真时间控制、波形文件名、UVM 测试名、覆盖率数据输出路径这些全都是运行时才确定的东西所以放在simv后面。理解这条分界线之后你会发现很多“选项不生效”的问题其实都源于放错了位置。1.2 选项放错阶段会发生什么最隐蔽的一种情况是选项被静默忽略。比如你在vcs后面写了ntb_random_seed123VCS 编译时看到它不认识但它可能只是当普通参数吞掉不报错等仿真跑起来随机种子根本没生效每次跑出来的结果都不一样你还以为是自己代码里有随机没约束好。另一种是-l这种日志选项编译和仿真都可以用但输出的日志是两码事编译日志记录的是解析过程仿真日志记录的是运行时打印混在一起看会误导排查方向。实际排查这类问题的办法很简单在vcs命令和simv命令各自的末尾都加上-l logfile让两个阶段的日志分开落盘。当某个选项“看起来没起作用”时先去对应阶段的日志里 grep 一下这个选项名看它有没有被识别、有没有被忽略的告警。这一步能省掉大量瞎猜的时间我个人几乎是条件反射式地先做这件事。2. 编译期选项代码是怎么被翻译成 simv 的编译期选项决定了 VCS 怎么理解你的代码。同一个文件列表选项不同编译出来的simv行为可能完全不一样。这一部分按“文件怎么找、宏怎么传、性能怎么提、调试怎么开”四条线来讲基本覆盖了实际环境里最常出现的那批选项。2.1 语言标准、文件列表与库搜索文件列表是编译的入口。最常见的写法是-f filelist.f或者-F filelist.f两者看着一样差别在网络化路径解析上-f里写的相对路径是相对于你当前执行 vcs 命令的工作目录来解析的-F里的相对路径是相对于这个 filelist 文件自己所在的目录来解析。这个差异在大型项目里非常关键。如果你的目录结构是多层的脚本在某一层调用 filelist而 filelist 里写的是../rtl/xxx.v这种相对路径用-f和-F得到的结果可能完全不同。我吃过这个亏本地跑得好好的换到 CI 上路径就全错最后发现是-f依赖工作目录导致的。语言标准方面-sverilog是老环境里几乎必写的选项用来开启 SystemVerilog 支持。现在多数 VCS 版本默认就支持 SV脚本里保留它更多是历史习惯写了也不会有坏处。真正的老古董是v2k专门用来按 Verilog-2001 解析现在基本只在维护非常老的 RTL 时才会遇到。库搜索相关的两个选项是-y和libext配合使用。-y dir告诉 VCS“去这个目录里找模块定义”libext.v.sv告诉它“只找这些扩展名的文件”。这两个选项在 RTL 代码分散、没有统一 filelist 的场景下很有用但现代工程基本都用完整的 filelist反而很少用到。值得一提的是-y搜索是惰性的——只有某个模块没有在 filelist 里被显式列出来时VCS 才会去-y目录里找这个机制我建议保留在 code review 清单里避免出现“某个文件没加进 filelist但因为-y撞上了另一个同名文件”这种很难查的隐蔽问题。2.2 宏、头文件路径与时间精度宏定义用defineMACRO或defineMACROVALUE等价于在代码最前面加了一行define。这个选项是环境里改动最频繁的因为它经常被用来做条件编译比如ifdef FSDB_GEN控制要不要 dump 波形、ifdef DEBUG控制打印等级。也正因如此它是增量编译失效的头号元凶——改一个宏理论上会影响所有引用了这个宏的文件VCS 只能保守地把相关文件全部重编。头文件搜索路径用incdirdir也可以写成-incdirdir作用是让include能在指定目录里找到文件。多个路径可以连写incdirabc。这里的一个实操心得是把自己工程用到的头文件目录统一放在一个变量里编译时一次性追加而不是让不同文件各自去拼路径。否则头文件名一旦重复比如两个 IP 都有自己的defines.svh谁先被搜到就取决于路径顺序很容易出现“改了 A 文件B 模块行为变了”的诡异现象。时间精度是新手最容易忽略的一环。如果 RTL 和 testbench 里都没有显式声明timescaleVCS 会使用一个默认值不同版本默认值可能不同这会导致延迟计算和波形对不上。稳妥的做法是编译时统一用-timescale1ns/1ps指定全局默认再在少数需要特殊精度的文件里单独声明。注意-timescale只是个默认值文件里显式的timescale优先级更高所以不会覆盖掉你精心调过的模块。2.3 编译性能与工程组织大型项目全量编译动辄十几分钟性能相关选项的价值就体现出来了。-j N开启并行编译N 一般设为机器核数的 1 到 1.5 倍左右比如 8 核机器用-j 8。这个选项在 RTL 文件上千个的项目里提速非常明显但要注意它和增量编译的配合——并行编译本身不感知文件依赖第一次全量编译用它是赚的之后的日常编译更应该靠增量。增量编译的核心是-Mupdate。第一次正常编译之后VCS 会在simv.daidir里留下依赖信息之后再编译时加上-MupdateVCS 只会重新解析那些被改动过的文件其余沿用上次的结果。这个选项能把日常改一个 testcase 的编译时间从十几分钟压到几十秒。它的坑在于-Mupdate对宏改动、头文件改动、filelist 顺序改动都相当敏感一旦触发“保守重编”你会觉得它时快时慢。所以我的习惯是把“改 RTL/改宏”和“只改 testbench”两类编译分开后者才用-Mupdate前者干脆老老实实全量避免出现增量结果和全量结果不一致这种最难查的问题。-o name用来指定输出可执行文件名默认是simv。当你要同时维护多个不同配置的编译产物时这个选项很有用比如-o simv_cov和-o simv_fast并存。-l logfile则把编译日志写到指定文件。2.4 调试能力与波形预处理编译期还有一个大类是调试相关。-debug_accessall是比较现代、也比较推荐的写法它开启完整的调试访问能力是使用 UCLI 交互式调试、以及在 Verdi 里看信号层次的前提。老脚本里常见的-debug_all、-debug_pp是更早的写法其中-debug_pp能力较弱基本可以理解为只是开了个“能 dump 波形”的口子。如果你的环境里混着这两种写法建议统一到-debug_accessall功能更全也避免不同版本行为差异。调试访问有一个容易被忽视的代价开得越全编译越慢、simv 越大、运行越慢。-debug_accessall会保留大量符号信息一个中等规模的设计可能会让 simv 体积翻倍。所以很多团队会准备两套编译配置——日常回归用轻量版只有需要定位问题时才切到全调试版本。这个取舍没有标准答案取决于你的回归时长和问题定位频率。和 Verdi 联动还需要在编译期加上-kdb -lca。-kdb让 VCS 生成 Verdi 需要的 KDB 知识数据库-lca是 Limited Customer Availability 授权相关的开关很多增强特性需要它配合。这两个选项不加后面verdi -ssf打波形时可能连信号层次都看不到只能看到一堆扁平的名字。我见过不少人把波形打不开归咎于波形文件本身折腾半天才发现是编译期没加-kdb。另外-assert enable_diag这类断言相关选项也属于编译期用来增强 SVA 的调试信息排查断言失败时值得加上。3. 仿真期选项时间、随机、日志与运行时行为编译产出的simv只是一个“通用引擎”真正让它按你的意图跑起来的是仿真期选项。这一部分的东西大部分不是通过vcs传而是跟在./simv后面或者通过 testbench 里的系统函数触发。搞混这两个阶段前面说过是很多问题的根源。3.1 时间尺度与延迟模式仿真期和时序相关的选项最常打交道的三个是maxdelays、mindelays、typdelays。它们决定仿真读入 SDF标准延迟格式文件时选择最大值、最小值还是典型值来建模延迟。做功能验证时一般用典型值或零延迟做时序相关的后仿时才会在最大/最小之间切换。这几个选项配合-sdf min|max|typ:module:sdf_file使用SDF 文件路径在仿真期指定因为同一份网表可以搭配不同工艺角的 SDF。notimingcheck是另一个高频选项作用是关闭时序检查让仿真不去检查 setup/hold。它经常被用在“我只关心功能对不对不关心时序”的场景能显著提速。但它的名字有迷惑性——notimingcheck关掉的是时序检查不是延迟延迟该有的还是有。如果连延迟都不想要那是delay_mode_zero这类选项的活。这几个选项很容易被当成一回事实际含义差别很大我建议在脚本里对它们的用途写一行注释免得半年后自己都忘了为什么加。3.2 随机种子与回归复现ntb_random_seedN是我在仿真期选项里最看重的一个。它给$urandom这类随机函数设定种子决定这一轮仿真的随机激励序列。回归时最怕的就是“某个 case 偶尔失败”如果没有固定种子你根本没法复现。所以标准做法是每一个回归用例的日志和波形都要记录它这一轮用的种子值失败时用同一种子重跑问题就能稳定重现。ntb_random_seed_automatic让 VCS 自动生成种子适合大批量随机回归能保证每轮种子不同但它和“复现”是矛盾的所以通常只在探索性回归里用。这里有个细节值得强调UVM 里除了ntb_random_seed还有各组件自己的get_seed和UVM_TESTNAME配合的随机化两套种子体系是独立的。只固定了ntb_random_seed不代表整个环境完全确定——如果 testbench 里用了$random而不是$urandom它走的是另一套种子。我踩过一次这样的坑明明固定了种子结果还是每次不一样排查半天才发现是某段老代码里用了$random。3.3 日志、license 与超时控制仿真日志用-l sim.log落到文件里。除此之外$display和uvm_info的输出等级由UVM_VERBOSITYlevel在仿真期控制这个是 UVM 环境里调打印的常规手段比改代码里的uvm_info参数方便得多。要注意UVM_LOW和UVM_HIGH的差别日常回归一般用UVM_LOW或者UVM_MEDIUM只有当你要定位某个uvm_info到底有没有执行、执行顺序对不对时才临时开到UVM_DEBUG否则日志会大到没法读。vcslicwait处理的是 license 排队问题。当多个仿真并发跑、license 不够时不加这个选项拿不到 license 的仿真会直接退出加上它仿真会等待直到拿到 license 再开始。这个选项在回归脚本里几乎是必加的否则半夜跑回归可能一半任务因为 license 抢占而失败。相似的还有UVM_TIMEOUTtime,override用来设置 UVM 的全局超时防止某个 case 挂死导致回归卡住。设超时的时候有个经验值参考设为正常跑完时间的 2 到 3 倍太短会误杀慢 case太长起不到保护作用。3.4 运行时波形控制UCLI 与 $fsdbDumpfile波形的生成方式大致有两类。一类是在 testbench 代码里直接调用系统函数典型的如$fsdbDumpfile(wave.fsdb)配合$fsdbDumpvars(0, tb_top)属于代码控制。另一类是编译时开好调试访问运行时用 UCLI 脚本控制写法是./simv -ucli -i dump.tcl其中dump.tcl里写fsdbDumpfile、fsdbDumpvars、run这些命令。UCLI 方式的好处是波形控制从代码里解耦出来改波形范围不用重新编译调试期非常灵活。我个人的习惯是日常回归跑精简波形或者干脆不 dump只有调试特定 case 时才用 UCLI 精细地 dump 相关层次。原因很简单全量 dump 一个复杂 SoC 的波形文件几个 G 起步磁盘和 IO 都会被拖垮。UCLI 里还可以配合-ucli的交互模式和fsdbDumpoff/fsdbDumpon只在特定时间段或特定事件前后打开 dump这个技巧在处理“跑了很久才出问题”的 case 时特别好用。需要提醒的是$fsdbDumpfile这类系统函数依赖 Verdi 提供的 PLI环境和库路径没配好时会报undefined system task这是另一类需要单独排查的配置问题。4. 覆盖率与 Verdi 联动验证收尾阶段绕不开的选项功能验证做到后期绕不开两件事覆盖率收集和波形查看。这两块各自有一批固定搭配的选项写错了要么覆盖率收不上来要么 Verdi 打不开波形。这一章单独拎出来讲因为它们和前面的常规编译仿真选项在使用节奏上有明显区别——覆盖率是“编译时使能、运行时收集、结束后合并”三段式的。4.1 覆盖率编译选项-cm 与 -cm_dir / -cm_name覆盖率的采集类型由-cm指定常见组合是-cm linecondfsmtglbranchassert分别对应行覆盖率、条件覆盖率、状态机覆盖率、翻转覆盖率、分支覆盖率和断言覆盖率。这个选项编译期和仿真期都要写因为编译时要插入对应的统计逻辑运行时才知道要收集哪些。只写了编译期没写仿真期数据是空的只写仿真期没写编译期VCS 会在运行时提示覆盖率模块未编译进去。输出目录和用例命名用-cm_dir和-cm_name。典型写法是运行./simv -cm linecondfsmtglbranch -cm_dir ./cov.vdb -cm_name tc_smoke_001。这里的关键在于-cm_name要唯一它决定了这份数据在合并时是一个独立的用例。如果回归脚本里所有 case 都用了同一个名字合并出来的覆盖率会互相覆盖你根本分不清是哪个 case 贡献的。我在脚本里见过有人把-cm_name写死成一个常量结果几十个 case 合并后覆盖率奇低查了很久才发现是命名冲突导致的互相覆盖。后来又有人用-cm_dir每个 case 一个目录来规避也可以但目录会很多占用空间大需要定期清理。还有个-cm_hier config_file选项用来指定覆盖率收集的层次范围通过配置文件精确控制哪些模块收集、哪些不收集。在大型设计里这个很有必要因为把整个 SoC 所有层次都收满仿真会慢到无法接受。通常只对 DUT 收覆盖率testbench 和第三方 IP 排除在外。4.2 从 VCS 到 Verdi-kdb -lca 到 verdi -ssf生成波形之后查看波形一般走 Verdi。前面编译期提到的-kdb -lca是第一步它让 VCS 在simv.daidir里生成 KDB 库这是 Verdi 能识别信号层次、模块结构的基础。第二步是仿真期生成 FSDB 波形也就是前面$fsdbDumpfile或 UCLI 那套。第三步才是打开 Verdiverdi -dbdir simv.daidir/kdb -ssf wave.fsdb 其中-dbdir指向 KDB 库-ssf加载波形文件。这三步缺一不可而且顺序上 KDB 是编译期产出的波形是仿真期产出的两者必须来自同一次编译运行否则信号层次对不上。常见的“波形打开了但看不到信号”问题多数是-kdb没加或者verdi加载的 KDB 和波形不是同一次运行的。还有一种情况是命名空间问题如果设计里用了-top显式指定顶层Verdi 的层次树会和 testbench 里的引用方式一致如果没指定VCS 自动推断顶层有时会多出一层包装让人找不到信号。-top module这个编译选项就是为了明确顶层模块建议在环境里显式写出来避免自动推断带来的不确定性。4.3 覆盖率合并与查看urg 的基本用法收集到一堆cov.vdb之后用urg合并查看。基本写法是urg -dir ./cov.vdb -dir ./cov2.vdb -report ./urgReport把多个目录或文件合并成一个 HTML 报告。这个工具里比较有用的选项是-format both同时生成文本和 HTML和-metric linecondfsmtglbranch只报告关心的指标。真正实操里最容易出问题的不是 urg 本身而是前面-cm_name命名和-cm_dir组织没做好导致合并结果不可信。这里分享一个实际经验回归里的覆盖率数据建议按天或者按 run 批次分目录存放而不是所有历史数据堆在一个大目录里。原因是一旦某个 case 的覆盖率数据损坏仿真中途被 kill、磁盘写满等合并时 urg 可能报错退出把整个目录的数据都带崩。分批存放至少能保证大部分数据可用。另外增量回归时可以先合并历史数据、再合并增量urg支持多次-dir叠加这个用法在长时间项目里能省掉重复合并的开销。5. 一套可复用的 Makefile 组织方式与选项速查讲完单个选项最终还是要落到“怎么把它们组织起来”。我见过的最乱的环境是所有选项平铺在一行改一个参数要在一百多字符里找位置。比较舒服的组织方式是把编译段和运行段彻底分开各自维护一份选项变量再用目标target来组合。5.1 编译段和运行段分开写参考结构大致如下。注意这里只是展示组织思路具体值要按你的设计规模调整。# 编译期选项 VCS_COMPILE_OPTS -full64 -sverilog -timescale1ns/1ps \ -debug_accessall -kdb -lca \ -f filelist.f \ defineFSDB_GEN \ incdir./include \ -ntb_opts uvm-1.2 \ -l compile.log # 仿真期选项 SIM_RUN_OPTS UVM_TESTNAME$(TEST) \ UVM_VERBOSITYUVM_LOW \ ntb_random_seed$(SEED) \ vcslicwait \ -l sim.log compile: vcs $(VCS_COMPILE_OPTS) -o simv run: ./simv $(SIM_RUN_OPTS) cov_compile: vcs $(VCS_COMPILE_OPTS) -cm linecondfsmtglbranch -cm_dir ./cov.vdb -o simv_cov cov_run: ./simv_cov $(SIM_RUN_OPTS) -cm linecondfsmtglbranch -cm_dir ./cov.vdb -cm_name $(TEST)这种写法的好处是一目了然VCS_COMPILE_OPTS里全是编译期的东西SIM_RUN_OPTS里全是仿真期的。当有人问“这个ntb_random_seed为什么不生效”你一眼就能看出它有没有被放到正确的变量里。同时TEST、SEED这类参数用$(...)变量传递回归脚本调用时从外部注入不用改 Makefile 本身。5.2 常用选项速查表下面这张表是我按“阶段 用途 常见写法”整理的高频选项可以直接当作速查用。阶段选项用途常见写法示例编译-sverilog开启 SystemVerilog 支持-sverilog编译-f/-F指定文件列表-f rtl.f/-F tb.f编译define宏定义defineFSDB_GEN编译incdir头文件搜索路径incdir./include编译-timescale默认时间单位/精度-timescale1ns/1ps编译-j并行编译-j 8编译-Mupdate增量编译-Mupdate编译-debug_accessall开启完整调试-debug_accessall编译-kdb -lca生成 Verdi KDB-kdb -lca编译-cm覆盖率类型-cm linecondfsmtglbranch仿真ntb_random_seed固定随机种子ntb_random_seed123仿真UVM_TESTNAME指定 UVM 测试名UVM_TESTNAMEmy_test仿真UVM_VERBOSITY打印等级UVM_VERBOSITYUVM_LOW仿真UVM_TIMEOUT全局超时UVM_TIMEOUT1000000,NO_OVERRIDE仿真vcslicwait等待 licensevcslicwait仿真maxdelays选最大延迟maxdelays仿真notimingcheck关闭时序检查notimingcheck仿真-cm_dir覆盖率输出目录-cm_dir ./cov.vdb仿真-cm_name覆盖率用例命名-cm_name tc_001仿真-ucli -iUCLI 脚本控制-ucli -i dump.tcl这张表里最容易被写错阶段的就是-cm和UVM_*系列-cm两阶段都要UVM 相关的都在仿真期。5.3 几个反直觉的踩坑记录第一个坑增量编译 宏改动。前面提过这里再强调一次。有一次同事改了一个define的值用-Mupdate编译结果仿真行为和改之前完全一样。查到最后是增量编译没有正确感知宏变化。结论改动任何define或者被广泛引用的头文件之后务必全量重编一次别指望增量。第二个坑-debug_accessall和性能。一个跑 40 分钟的 case去掉全调试访问之后 25 分钟就跑完了。原因是调试信息保留了太多信号的可访问性仿真内核需要维护更多状态。这个代价在回归阶段是纯损失所以日常回归的编译配置和调试配置建议物理分开。第三个坑-cm_name在并行回归里冲突。并行跑 20 个 case如果并行度是 8同时有 8 个仿真往同一个-cm_dir里写且-cm_name有重复数据会互相覆盖。正确做法是让-cm_name带上唯一的用例 ID 或进程号或者每个并行任务用独立的-cm_dir。6. 版本差异与那些“文档里不写”的细节VCS 的选项在不同版本之间有一些细微差异尤其是调试和覆盖率相关的那批。-debug_pp之类的老选项在新版本里还能用但行为和-debug_accessall不完全等价建议新环境一律用带_access的新写法。UVM 库版本通过-ntb_opts uvm-1.2指定这个选项要和环境里实际用的 UVM 源码版本对上否则可能出现接口不兼容的编译错误。有些团队会同时维护两套环境一套用 VCS 自带的 UVM 库一套链接外部 UVM 源码切换时最容易忘记改的就是这个选项。关于数字 IC 用哪套仿真器这件事实际工作中 VCS、Xcelium、Questa 各有使用场景同一个团队也可能因为项目历史原因混用。不同工具的同名选项语义未必一致比如固定随机种子这件事VCS 的ntb_random_seed对应到别的工具是完全不同的选项名。这也是为什么我在前面反复强调要理解“选项在做什么”而不是背选项名——理解了随机的种子机制换个工具你也能快速找到对应的开关。最后再分享一个我自己踩过、也见过别人反复踩的细节-l指定的日志文件如果编译和仿真用了同一个路径后一次会覆盖前一次。排查问题时如果只保留了仿真日志编译期的告警信息就丢了而很多隐蔽问题恰恰藏在编译告警里比如隐式线网、位宽不匹配。我的习惯是编译日志固定叫compile.log、仿真日志固定叫sim.log两个都保留出问题时一起看。另外VCS 编译时的-notice、lint、-suppress这类告警控制选项也值得花点时间去配把真正有价值的告警留下、把噪音压掉长期看能挡掉不少后期才发现的设计问题。