代码覆盖率与功能覆盖率:四类指标直觉与 verilator --coverage 实跑导读
更新时间:2026-10-28。本文是「集成电路大类 · 芯片设计」入门层(beginner)的一篇实验文。验证这个话题在 芯片验证总览 里有全景图,功能覆盖率与代码覆盖率的区别 一篇做过概念辨析;概念读完之后的自然问题是:这些覆盖率数字到底长什么样、从哪来、怎么读。本篇不空谈,直接用 Verilator 的
--coverage开关把一个 8 位计数器真跑一遍,把真实的覆盖数据库拆开给你看——包括一个反直觉的结论:这个实验的行覆盖率是 100%,但测试显然没测全。
本文要回答的问题
- 行覆盖、翻转覆盖、FSM 覆盖、功能覆盖这四类指标,各自在数什么东西?
verilator --coverage从编译到出报告的完整流程长什么样,报告怎么读?- 为什么说「行覆盖 100%」不等于「测全了」?真实数据里的证据在哪一行?
- 覆盖率驱动的验证收敛,工程上是怎样一个闭环?
先把四类覆盖率的分工摆上来,这张图是全篇的地图:
读这张卡抓住上下两排的分工:上面四组(蓝、绿两色)是「结构性」指标——行、分支、翻转、FSM 四类全是工具从 RTL 结构里自动提取的,你一行验证代码不写它们也存在;最下面紫色一组是「意图性」指标,covergroup 里每个 coverpoint 都是验证工程师对「什么场景必须发生」的显式声明。两派回答的问题不同:代码覆盖回答「哪些代码从来没跑过」,功能覆盖回答「该发生的行为发生了吗」。底部的结论就是本篇要用真实数据证明的主张。
一、四类覆盖率各数什么:直觉先于定义
行覆盖(line coverage) 最直白:仿真跑完,每一行可执行语句被执行过几次。一次没跑过的行就是赤裸裸的「测试根本没碰到这段逻辑」——死代码、接线漏掉的模块实例、被常量宏关掉的特性,都会在这里现形。它的问题也同样直白:一行 if (a && b) 只要整体执行过就算覆盖,至于 a=0 提前短路和 b=0 提前短路两条路径是不是都走过,行覆盖不管——那是分支覆盖的事。工程实践里行覆盖是收敛的入场券:行覆盖都不到 90% 谈别的没意义,但行覆盖到了 100% 也只值一句「哦,然后呢」。
翻转覆盖(toggle coverage) 换了个维度:不看代码看信号。每个信号的每一个 bit,从 0 变到 1、从 1 变到 0,各发生了没有。一个高位寄存器如果整个回归下来从没翻过,说明测试激励根本没把它推到那个取值区间——这类空洞行覆盖完全看不见,因为代码行都执行了,只是数据没走到那儿。翻转覆盖还特别擅长抓「没用的输入」:某根输入引脚翻了不翻对内部毫无影响,往往是模块例化时端口接错或者干脆悬空,翻转覆盖报告里该信号关联的逻辑会呈现一片死寂。
FSM 覆盖(state/transition coverage) 是工具自动从 RTL 里识别出状态机之后,统计每个状态到达过没有、每条状态转移走过没有。状态机是数字设计的控制中枢,「状态都到过」和「转移都走过」是两回事——五个状态全亮但某条异常恢复转移从没走,一样藏着没验证的行为;现实中大量芯片召回级别的 bug,恰恰躺在「正常流程永远走不到」的那几条转移上。状态机本身的设计方法见 状态机入门。
功能覆盖(functional coverage) 是唯一的「人写」指标。工具不知道你的计数器「load 之后立即 enable 计数」是个必须验证的场景,除非你用 SystemVerilog covergroup 把它声明成一个 coverpoint。功能覆盖率的本质是验证计划的机器可执行化:计划里写了多少功能点、每个点被命中几次,全部量化。它也因此有一个结构性指标没有的风险——计划本身就漏了某个场景时,功能覆盖率照样 100%,这是覆盖率方法论里著名的「计划盲区」,缓解手段是评审验证计划而不是迷信数字。covergroup 的分桶与交叉写法在 SystemVerilog covergroup 交叉覆盖 里有专篇,本篇聚焦前三个「免费」的结构性指标。
二、实验对象:一个 8 位计数器,四种行为路径
被测设计是一个带 load 和加减控制的 8 位计数器, deliberately 简单,简单到每行代码都能数过来:
module counter (
input wire clk,
input wire rst_n,
input wire en,
input wire up_down, // 1=up 0=down
input wire load,
input wire [7:0] load_val,
output reg [7:0] count,
output wire carry
);
assign carry = en && ((up_down && count == 8'hff) || (!up_down && count == 8'h00));
always @(posedge clk) begin
if (!rst_n)
count <= 8'h00;
else if (load)
count <= load_val;
else if (en) begin
if (up_down) count <= count + 1;
else count <= count - 1;
end
end
endmodule测试激励用一个 C++ harness(Verilator 的标准玩法,RTL 编译成 C++ 类,激励在 main 里写):复位两拍,向上数 300 拍(故意超过 255,让环绕和 carry 分支都走到),切向下数 20 拍,load 一个 0x42,最后让 en 交替抖动 10 拍。这套序列覆盖了 reset / load / en 加 / en 减 / 环绕五条路径——看上去挺全,后面会看到「挺全」只是错觉。
harness 里和覆盖率直接相关的只有两处:编译命令里加 --coverage,以及 main 结束前调用 VerilatedCov::write("coverage.dat") 把覆盖率数据库落盘。前者让 Verilator 在生成的 C++ 模型里自动插入探针——每一行语句的执行计数、每个信号 bit 的 0→1 与 1→0 跳变计数,全部在仿真过程中顺带累加,对仿真速度的影响在这个规模上几乎不可感知;后者决定数据写到哪个文件,文件名自由,惯例是 coverage.dat。多次回归的多个 .dat 可以用 verilator_coverage 合并成一份总报告,大项目里每晚回归几十上百个测试,靠的就是这个合并机制累积覆盖。
这里有一个设计选择的说明:实验用 0x42(二进制 0100_0010)做 load 值不是随手挑的。它的 8 个 bit 里只有 bit1 和 bit6 是 1,其余六位是 0——后文翻转覆盖报告里,这个选择会变成一个非常扎眼的证据。
三、verilator --coverage 全流程实录
整个流程五步:编译时加 --coverage 让 Verilator 在生成的 C++ 模型里埋覆盖率探针,make 出仿真可执行文件,跑仿真(结束前提一句 VerilatedCov::write("coverage.dat") 把数据库落盘),最后用 verilator_coverage 工具把数据库转成可读报告。注意 4.038 还没有 --build 选项,编译和 make 要拆成两条命令。
这份实录分两段读。第一段是仿真本身的三行输出:count after load = 0x42 证明 load 路径走通(载入值确实进了寄存器),final count = 0x47, carry = 0 说明后续又向上数了 5 拍(0x42 + 5 = 0x47,en 抖动的 10 拍里只有一半使能有效),SIM_DONE 收尾。第二段是 coverage.info——lcov 格式的行覆盖数据,DA:行号,执行次数 一行一条:DA:20,20 是 count <= count + 1 被执行 20 次,DA:21,20 是减法分支 20 次,DA:18,1 是 load 赋值恰好 1 次。数一遍就会发现:counter.v 里全部 14 个可执行行,执行次数都大于零——行覆盖率 100%。
coverage.info 全文不长,原样贴出来(上面 termshot 卡片里截取的是其中一段):
TN:verilator_coverage
SF:counter.v
DA:3,667
DA:4,1
DA:5,11
DA:6,3
DA:7,2
DA:8,2
DA:9,646
DA:10,2
DA:14,668
DA:15,4
DA:16,2
DA:17,2
DA:18,1
DA:19,6
DA:20,20
DA:21,20
end_of_recordDA:9,646 与 DA:14,668 是 always 块里每拍都走的赋值与判断,667/668 这种量级对应仿真的总拍数;DA:4,1 是只释放过一次的复位分支——每个数字都能在 testbench 的激励序列里找到出处,这就是行覆盖数据「可回放」的价值。
如果被这 100% 说服了,实验就白做了。接下来看翻转覆盖说了什么不一样的话。
四、翻转覆盖拆台:100% 行覆盖下的三个空洞
coverage.dat 里的翻转覆盖段,按信号逐 bit 记录了跳变次数。把关键数字整理成表(全部出自本次真实运行的数据库):
| 信号 | 翻转次数 | 说明 |
|---|---|---|
| clk | 667 | 时钟,必翻 |
| count[0] | 325 | 最低位每拍都翻 |
| count[3] | 40 | 中位正常递减 |
| count[7] | 2 | 最高位全程只翻过 2 次 |
| rst_n | 1 | 复位只释放过一次 |
| load | 2 | 拉起、撤下各一次 |
| load_val[1] / load_val[6] | 1 | 0x42 里为 1 的两位 |
| load_val[0,2,3,4,5,7] | 0 | 这六位从没变过 |
| carry | 2 | 环绕瞬间拉起一次 |
count 低五位的翻转次数顺带展示了一个漂亮的规律:325、163、81、40、20——每一位的翻转次数恰好是低一位的一半。这正是二进制计数的数学本质,bit_n 每 2^n 拍翻一次。仿真数据符合这个规律,反过来也说明计数器核心逻辑没毛病:覆盖率数据除了找空洞,还能当行为指纹用,哪一位的比例不对,计数逻辑多半就有问题。
这张表里至少藏着三个「行覆盖 100% 完全看不见」的空洞。第一,load_val 有六个 bit 翻转次数为 0——load 这行代码执行过了(行覆盖记着 1 次),但载入值的四分之三 bit 位从没被激励过,如果 RTL 里 load_val 的高六位接线接反了,这次回归照样全绿。第二,count[7] 只翻了 2 次,说明计数器绝大部分时间工作在低半区,高位相关的逻辑(比如 carry 判断里的 count == 8'hff)只被真正考验了一瞬间。第三,rst_n 只翻过 1 次——释放复位之后就再也没复位过,「工作中途来一个异步复位」这种经典场景完全没测。
这就是「100% 行覆盖 ≠ 测全」的实证版:行覆盖度量的是代码的脚印,翻转覆盖度量的是数据的行程,而功能覆盖(本实验没有 covergroup,Verilator 4.x 也不支持,需要商用仿真器或换用 SystemVerilog 验证环境)度量的才是场景的清单。三把尺子各量一段,任何一把满分都不代表另外两把及格。顺带一提,FSM 覆盖在本例报告里是空的——计数器里没有可提取的状态机。这本身也是个提醒:读覆盖报告时「某项为空」要问一句是设计里真没有,还是工具没识别出来,两者对收敛决策的含义完全相反。
还有一个读报告时的剂量问题值得说:翻转覆盖在真实项目里几乎不可能也不必要到 100%。地址总线的高位、配置寄存器的保留位、只在调试模式才用的引脚,这些信号翻不翻不影响功能正确性,硬追 100% 会浪费大量验证资源。工程上常见的做法是翻转覆盖设一个分级目标——数据通路关键位 100%、控制信号 100%、杂项信号按风险评审豁免,把精力留给真正会咬人的 bit。
五、覆盖率驱动的收敛闭环
把上面的教训推广到工程实践,覆盖率不是跑完看一眼的奖状,而是一个迭代闭环的仪表盘:
读这张图抓住红色判定节点的三叉分流:蓝色是机械动作(跑、合并、分析),红色「判定空洞性质」是全程唯一需要人脑的环节,它把空洞分成三类——激励不足的补测试(橙色,回到起点重跑)、验证计划漏项的补 coverpoint(同样是橙色,但改的是 covergroup 不是 testbench)、确认结构上不可达的走豁免(绿色,必须评审留痕,不许偷偷 waive)。紫色归档是终点:数字达标且豁免清单评审通过后锁定基线,之后每次回归只许涨不许跌。本实验计数器的 load_val 六位零翻转,走的就是「激励不足 → 补测试」这条路:给 load 序列加几个高低位混合的载入值,翻转覆盖立刻补上。
实验环境
- 机器/系统:云主机 2C4G / Ubuntu 22.04(eda-golden-v1 镜像)
- 工具版本:Verilator 4.038(2020-07-11,镜像基线);g++ 随镜像
- 日期:2026-10-28
- 完整命令:
verilator --cc --exe --coverage -Wno-fatal counter.v sim_main.cpp -o counter_sim
make -C obj_dir -f Vcounter.mk counter_sim # 4.038 无 --build,拆两步
./obj_dir/counter_sim # 仿真结束写 coverage.dat
verilator_coverage coverage.dat # 文本汇总报告
verilator_coverage --write-info coverage.info coverage.dat # 转 lcov 格式代码获取:本篇全部源码(counter.v、sim_main.cpp)与实跑产物在公开仓 https://github.com/derekzhuo/geek-doc.cn/tree/main/experiments/coverage-types-verilator,装好 Verilator 后按上面五条命令即可复现。
这一篇在知识体系里的位置
本篇是覆盖率主题的「实跑入门」:概念辨析看 功能覆盖率与代码覆盖率的区别,covergroup 的写法进阶看 SystemVerilog covergroup 交叉覆盖,验证方法学全景在 芯片验证总览。工具侧,Verilator 的另一面——Lint 静态检查——在 Verilator Lint 报错速查 有实测;同属「RTL 小实验真跑」系列的还有 同步 FIFO 的 Verilog 仿真实验,更多可动手实验归拢在 RTL/CDC/FIFO 实验枢纽。
一句话总结
覆盖率有四类——行、分支/翻转、FSM 是工具从代码结构里免费提取的「脚印与行程」,功能覆盖是验证工程师用 covergroup 显式声明的「场景清单」;本篇用 Verilator 4.038 实跑 8 位计数器证明:行覆盖 100% 的同时 load_val 六个 bit 零翻转、最高位只动过两次,所以任何单一指标满分都不等于测全,覆盖率驱动收敛的正确姿势是「跑回归 → 读报告 → 分流空洞 → 补测试或补声明 → 评审豁免 → 锁定基线」的闭环。