回归测试 Regression:每次改动都批量重跑,让旧 bug 死灰不能复燃
更新时间:2026-09-24。本文是「集成电路大类 · 芯片设计」**进阶层(intermediate)**验证工程化的一篇。手工修一个模块、跑一个用例、看一眼波形,这是单人调试的节奏;但一颗芯片有几十万个断言、上百条接口场景,任何一行 RTL 改动都可能在看不见的角落里把旧功能改坏。回归测试(regression test) 解决的就是这个问题:把一整套用例的执行、判定、留证全部交给脚本和调度器,每次改动后自动重跑。它在流程上承接验证计划,在数据上与代码与功能覆盖率和覆盖率收敛流程挂钩。
本文要回答的问题
- 回归测试到底在「防」什么?为什么改一处要重跑全部?
- testlist 怎么组织?seed、参数化怎么让同一条用例变出上百个场景?
- 几百条仿真怎么批量提交到机器集群,pass/fail 由谁判定?
- 用例半夜跑挂了,第二天凭什么还原现场?
- nightly 回归、覆盖率回归和发布门禁之间是什么关系?
- 随机回归失败了,怎么用同一个 seed 精确复现?
一、回归的本质:防的是「旧 bug 死灰复燃」
regression 这个词直译是「倒退」。回归测试防的倒退有两种:一是已经修掉的 bug 因为重构、合并、参数调整又回来了;二是 A 模块的改动意外打坏了看似无关的 B 模块——比如改了仲裁器的优先级,DMA 的带宽时序跟着变,下游 FIFO 溢出。这类问题靠 code review 很难看出来,只有让 B 的用例在 A 改动之后真正跑一遍才会暴露。
手工跑用例有三个死穴:慢,一个中等规模 SoC 的 nightly 展开后动辄三五千个作业、累计几十万仿真小时,没人愿意手动敲;漏,人会下意识只跑自己改的模块,跨模块交互永远是盲区;判定飘,肉眼翻日志,今天心情好多看两行、明天跳着看。自动化回归把这三件事全部固化:脚本按 testlist 提交、按退出码和日志关键字判定、失败自动归档。人的精力只留给一件事——分析真正的失败。
还有一笔经济账值得点明。仿真集群按机时计费,一次全量回归可能烧掉几千核小时;但一个流片后才发现的 bug,代价是重新流片的几百万人民币和两到三个月的市场窗口。回归的本质,是用确定性的、便宜的机时,去换不确定性的、昂贵的硅后返工。理解了这笔账,就理解了为什么验证团队宁可让集群夜里满载,也不愿在白天「省着点跑」。
二、testlist、seed 与参数化:用例是怎么组织的
回归的输入是一份 testlist(用例清单),最简单的形态就是一个文本文件,每行一条用例名加上它的参数:
# smoke.list —— 冒烟用例
smoke_reset seed=1
smoke_axi_basic seed=1
smoke_uart_loopback seed=1
# nightly.list —— 夜间全量回归
axi_random_wr_rd seed=1..40
dma_sg_transfer seed=1..20 LEN=64,256,1500
cache_coherence seed=1..30 CORES=4
interrupt_stress seed=1..10seed=1..40 表示同一条约束随机(constrained-random)测试用 40 个不同种子各跑一次。种子是随机激励生成器的初值,seed 不同,产生的地址、长度、时序间隔就不同,一条用例因此变成 40 个不同场景。参数化则覆盖确定性维度:LEN=64,256,1500 把短包、边界包、长包都跑到,CORES=4 固定多核拓扑。
组织 testlist 的原则是按反馈速度分层、按风险加权:冒烟层只放十几条几分钟跑完的基础用例;快速层覆盖各模块主路径;全量层包含全部定向用例与多 seed 随机用例;覆盖率回归则进一步加大 seed 数、打开功能覆盖率采集。新增高风险特性时,把它的用例在清单里多放几份 seed,等稳定后再降下来。
testlist 在工程上通常不是一个文件,而是一组带 include 关系的清单:base.list 列全量用例,smoke.list 和 nightly.list 用标签或引用从中挑选子集,模块负责人维护自己模块的分片,主清单只负责聚合。这样新增一条用例只需在一个地方登记,各层回归自动继承,避免出现「用例写了但从没进过回归」的尴尬。清单文件和 RTL 一样进 git 管理,谁在什么时候加了哪条用例、seed 范围改过几次,全部可追溯。
跑回归的入口往往就是一行命令,脚本把清单里的紧凑写法翻译成每个作业的 plusarg(传给仿真器的命令行参数):
./run_reg.sh -list nightly.list -j 200 -dump_on_fail
# -j 200:最多 200 个作业并发;-dump_on_fail:失败才转存波形
# 单个作业实际执行(脚本自动生成):
# vsim -c work.top +TESTNAME=axi_random_wr_rd +SEED=17 +LEN=1500 +CORES=4+SEED、+LEN 这类 plusarg 在 testbench 里用 $value$plusargs 取回,因此同一份编译产物可以服务所有作业——编译只做一次,这正是下一节要讲的流水线能省大钱的关键。
三、批量提交与自动判定:从一条命令到一封结果邮件
跑回归的人只敲一条命令,背后是一整条流水线,如下图。
读这张图抓住两点。第一,冒烟是一道闸门:十几条基础用例几分钟内出结果,编译不过、复位失败这种低级错误立刻挡住,后面成百上千个作业根本不会提交,集群时间不被浪费。第二,判定不依赖人:每个仿真作业内部有自检机制——记分板(scoreboard)比对数据、属性检查(assertion)监控协议、结束时检查是否收到 TEST_PASS 标记;作业结束后,包装脚本再看退出码和日志里的 FATAL/ERROR 关键字,任何一项不对就记为 fail。
失败作业必须自动留证。仿真启动时统一加上波形参数,但波形文件很大,常规做法是失败才转存:仿真先开轻量的事务日志,检测到失败或异常退出时,脚本自动把该作业的 FSDB/VCD 波形、完整 log、seed、编译版本号(git hash)、配置文件打包到失败目录,并在报告里给出可直接重跑的命令行。第二天工程师不用问「昨晚怎么挂的」,打开归档目录即可。
展开后的作业列表可能有几千行,调度器(LSF 的 bsub、Grid Engine 或云上的容器队列)负责把它们摊到几百台机器上,-j 200 这样的并发上限防止把集群占满、饿死别的项目。工程上有两个必做的优化:一是编译一次、运行多次,所有作业共享同一份仿真编译产物,编译时间从几千次降到一次;二是超时看门狗,每个作业设预期时长的 1.5 倍为上限,死锁的仿真不会把一个队列槽位挂到天荒地老。
所有作业收尾后,汇总脚本扫描每个作业目录里的状态文件,生成一张机器可读、也人眼可读的总表,邮件和看板都消费同一份数据:
testname seed status cycles note
axi_random_wr_rd 17 FAIL 2_301_552 assertion A_ARB_TIMEOUT
dma_sg_transfer 3 PASS 8_114_002
cache_coherence 22 PASS 5_902_771
interrupt_stress 8 TIMEOUT - watchdog kill
----------------------------------------------------------------
total 3200 | pass 3197 | fail 2 | timeout 1 | pass_rate 99.91%汇总时有个细节:作业被机器抢占、license(仿真器许可证)掉线导致的中断不能算 fail,要自动重排队并标注 retry,否则基础设施的抖动会被误报成设计缺陷。
四、nightly 回归与分层:本地、CI 与夜间的分工
不同时机跑不同规模的回归,是迭代速度和信心之间的平衡术。
| 回归类型 | 触发时机 | 用例规模 | 典型耗时 | 目的 |
|---|---|---|---|---|
| 冒烟 smoke | 每次提交 / push | 十几条基础用例,seed=1 | 几分钟 | 快速确认主干没被改坏 |
| 快速回归 | 合入主干前 | 各模块主路径,少量 seed | 半小时到 1 小时 | 合并前的功能把关 |
| 全量 nightly | 每夜定时 | 全部定向 + 随机,多 seed | 数小时(并行) | 深度随机探索,抓边角 bug |
| 覆盖率回归 | 每周 / 里程碑前 | 超大 seed 数,开覆盖率采集 | 一夜到数夜 | 补覆盖洞,产出合并覆盖率 |
本地开发和 CI/nightly 也有明确分工:
| 维度 | 工程师本地 | CI / nightly |
|---|---|---|
| 跑什么 | 与改动相关的少量用例 | 冒烟、全量、覆盖率回归 |
| 什么时候跑 | 改完随时 | 提交触发 / 定时触发 |
| 判定 | 人看波形确认 | 脚本自动判定并留证 |
| 覆盖面 | 当前模块 | 全芯片跨模块交互 |
| 失败归属 | 自己马上修 | 按模块/提交自动分发责任人 |
本地追求快,跑相关用例确认自己的改动;CI 追求全,替每个人记住那些「我以为不受影响」的模块。主干必须保持绿色:冒烟挂了的提交要么当天修掉,要么回退,绝不能让红灯过夜——红灯一旦堆积,所有人都会对回归邮件麻木,真正的新失败就混在旧失败里被忽略。
当 nightly 报红时,定位「哪次提交引入」有个顺手的技巧:CI 保留了每个绿色提交的记录,对失败用例在相邻的绿、红两个版本之间做二分(bisect),自动在中间提交上重跑这一条用例,五到六轮就能从上百次提交里锁定元凶。这要求提交足够小而频繁——一次提交混杂十个模块的改动,二分即使锁定了也没人敢回退。所以「小步提交」不只是代码卫生,它直接决定了回归失败的排查速度。
nightly 的时间窗也是要设计的资源。假设机器每晚可用八小时、集群并发 200,那么单作业平均 10 分钟时,每晚容量约为 200 × 48 ≈ 9600 个作业。testlist 展开后的作业数必须压在这条线下,超了就要么砍 seed、要么给用例分级排队——保底用例凌晨两点前必须出结果,探索性的大 seed 作业跑不完可以顺延。把 nightly 当成有明确容量约束的系统来规划,而不是「能跑多少跑多少」。
五、覆盖率回归、看板趋势与 seed 复现
nightly 回答「昨天的改动有没有改坏东西」,覆盖率回归回答的是另一个问题:「我们离验证完还有多远」。它打开代码覆盖率和功能覆盖率采集,把几十上百个 seed 的结果合并(merge)——单个 seed 可能只命中覆盖率点的一小片,合并之后才看得出整块场景是否被覆盖。覆盖率不升反降也是一种失败,往往意味着约束改动把合法激励封死了。合并与收敛的具体打法见覆盖率收敛流程。
合并是在统一的覆盖率数据库里做的:每个作业结束吐出自己的覆盖率快照,收集器按 testname 和 seed 去重后依次并入总库,行覆盖率看哪些 RTL 行从未被执行,功能覆盖率里每个 coverpoint 的各个 bin、cross(交叉覆盖)的命中次数都一目了然。覆盖率回归还有个副产品——它能发现「种子浪费」:如果 seed 30 到 seed 80 合并进来的新 bin 数为零,说明激励分布已经饱和,继续加 seed 只是在烧机时,这时该做的是调约束或换测试结构,而不是机械地把 seed 翻倍。
只看一次回归的通过率没有意义,趋势才是管理工具。下图把「随时间追踪」和「失败分流」画在一起。
这张图的读法是:看板上的曲线不是用来欣赏的,拐点才是信号——通过率骤降对应某次坏提交,覆盖率连续数日走平说明随机激励撞上了约束天花板,flaky(时过时不过)数量抬头说明环境稳定性在恶化。失败用例先分流再处理:RTL 缺陷进 issue 系统,testbench 和参考模型问题归验证环境,疑似 flaky 的用例进复现回路。
复现回路是随机回归的命根子。任何随机失败,归档里都必须带着 seed 和 git hash;工程师拉取对应版本,执行一条带固定 seed 的命令,激励就应当逐比特重演。如果同一 seed 重跑有时过、有时不过,它就是 flaky——根源通常是未初始化变量、跨进程文件竞争、时序敏感的 wait 语句。flaky 用例最伤团队信心:它让真失败混进「老毛病,忽略吧」的噪音里,所以处理原则是先于一切新功能修复,修不好就移出回归集隔离,绝不带病运行。失败定位的完整方法论见验证失败的排查与分流。
六、发布门禁:数字达标才允许签核
回归最终要服务于决策。流片或里程碑前的 gate(门禁)通常写死四条:全量回归通过率 100%;合并后的功能覆盖率达到验证计划规定的目标,未覆盖点逐条 waiver(豁免)并有评审记录;无未关闭的严重 bug;冒烟连续若干天保持绿色。门禁不看任何人的口头保证,只看回归系统产出的数据。这让「我觉得差不多了」变成可审计的结论,也是回归工程化的最终价值。
waiver 不是「这洞我不补了」的便条,而是一条带责任人和有效期的正式记录:写清未覆盖的具体 bin、为什么不可达或风险可接受(比如某种外部接法在本封装上物理不存在)、由谁评审批准。门禁审计时逐条核对 waiver 与覆盖率库的对应关系,过期的豁免自动重新亮红——没有时效的 waiver 等于把窟窿永久藏进地毯下,这是很多团队流片前评审翻车的老地方。
这一篇在知识体系里的位置
- 上游:回归跑什么,由验证计划定义;随机激励怎么写,属于受约束随机验证的方法学。
- 数据侧:通过率背后的质量度量是代码与功能覆盖率,覆盖率回归的打法见覆盖率收敛流程。
- 下游:回归跑出失败之后怎么分流和定位,见验证失败的排查与分流。
总结
回归测试 regression 用 testlist 加 seed 参数化组织分层用例,靠 CI 与 nightly 把批量仿真、自动判定、失败留波形全程自动化,以通过率与合并覆盖率的趋势看板管理质量、用硬性门禁决定能否签核;随机失败必须凭保存的 seed 稳定复现,flaky 用例优先消灭、主干保持常绿,才能让迭代既快又安全。