验证方法论

第 3 章 · 共 15 章

Code Coverage:Line 与 Branch

一份真实的 line/branch coverage 报告到底在统计什么,精确到每一条语句和每一个 case 分支——建立在 axil_regfile 真实的写响应逻辑和这个项目自己真实的测试历史之上,其中包含一个真实的、至今仍然存在的缺口:写路径上两种错误响应从来都没有被测过。

第 1 章那份模拟出来的报告只是一眼看过去的四个百分比。这一章把它打开:工具说"line"的时候到底数的是什么,说"branch"的时候到底数的是什么,以及为什么一份报告会同时给出两者,而不是只算其中一个更省事的。这里用的例子不是编出来的——就是 axil_regfile 自己的写响应逻辑(uvm-advanced 第 1 章),标注的依据是第 1 章自己那个定向测试真实发出过的那一串写操作。

"Line" 到底数的是什么

Line coverage 工具数的不是文本行——它数的是可执行语句,给每一条语句关联一个绑定到源码行号的命中计数器。声明、注释、空行、块分隔符(begin/endendcase)都不会有计数器——那里没有什么可以被执行的东西。大部分 RTL,包括 axil_regfile,恰好每一行只写一条语句,这也是为什么这份报告能跟源码对得这么整齐;一个足够精确的工具遇到一行里挤了两条语句时,会把它们当成两个独立的计数器来追踪,即便它们共享同一个行号。

下面是 axil_regfile 的写响应代码块(行号从这个 module 自己的声明开始算作第 1 行,跟一份真正按文件统计的报告会用的编号方式一样),标注的命中次数正是第 1 章那个定向测试实际发出过的那一串操作:axi_write(CTRL, ENABLE=1)、四次 axi_write(DATA, ...),然后 axi_write(CTRL, ENABLE=1, IRQ_CLR=1)——一共六次写,两次写 CTRL,四次写 DATA,别的地方一次都没写过:

Line Coverage Report -- axil_regfile.sv (write-response block)
================================================================
Line  Hits  Source
----  ----  ----------------------------------------------------
 97      6  if (write_fire) begin
 98      6    aw_have      <= 1'b0;
 99      6    w_have       <= 1'b0;
100      6    s_axi_bvalid <= 1'b1;
101      6    unique case (aw_addr_eff)
102      2      ADDR_CTRL: begin
103      2        ctrl_enable <= w_data_eff[0];
104      2        if (w_data_eff[1]) count_reg <= '0;
105      2        s_axi_bresp <= RESP_OKAY;
106      2      end
107      4      ADDR_DATA: begin
108      4        data_reg <= w_data_eff;
109      4        if (ctrl_enable) count_reg <= count_reg + 1;
110      4        s_axi_bresp <= RESP_OKAY;
111      4      end
112      0      ADDR_STATUS, ADDR_COUNT: s_axi_bresp <= RESP_SLVERR;
113      0      default:                 s_axi_bresp <= RESP_DECERR;
114      6    endcase

有两行数字是 0:第 112 行和第 113 行。这不是渲染出来的假象——是真的。去这个项目自己的示例代码里搜一下就能对上:uvm-advanced 第 1 到第 6 章里,每一次 axi_write 调用打的地址都是 8'h00CTRL)或者 8'h08DATA)。这个项目从头到尾,从来没有写过 STATUSCOUNT,或者任何一个未映射地址。专门用来报告"这次写失败了"的两个响应码——SLVERR("这个寄存器存在,但不支持写")和 DECERR("这个地址压根没有寄存器")——在写路径上,在任何一章里,一次都没有被真正触发过。

"Branch" 到底数的是什么

Line coverage 只告诉你一条语句执行过。它不告诉你一个判断走的是哪一边——那是 branch coverage 的工作,它存在于每一个控制流分叉的地方:if/elsecase 的每一个分支、三元表达式。axil_regfile 的写 case 语句是四个分支(CTRLDATASTATUS/COUNTdefault);第 104 行的 if (w_data_eff[1]) 又是另外两个(清零发生了,或者没发生)——这跟这个 if 语句所在的那一行有没有执行过是完全独立的两件事:

Branch Coverage Report -- axil_regfile.sv, write-response case (line 101)
============================================================================
Branch                                      Hits   Status
-------------------------------------------  ----  ------------
case (aw_addr_eff)
  ADDR_CTRL                                    2   covered
  ADDR_DATA                                    4   covered
  ADDR_STATUS, ADDR_COUNT                      0   NOT COVERED
  default                                      0   NOT COVERED

if (w_data_eff[1])  (line 104)
  true                                         1   covered
  false                                        1   covered

第 104 行的 if 其实是个不错的信号:第 1 章第一次写 CTRLENABLE=1,第 1 位是 0)走的是 false 分支,第二次(ENABLE=1, IRQ_CLR=1,第 1 位是 1)走的是 true 分支——这个判断的两边都被测到了,纯粹是那个定向测试碰巧这么做了的副作用,不是谁刻意计划出来的。Case 语句最下面这两个分支,情况正好相反:这是一个真实的、至今仍然存在的缺口,不是假设出来的。

为什么一份报告要同时给出两者,而不是只给一个

Line coverage 和 branch coverage 在这里逐个分支都对得上,只是因为 axil_regfile 的每个 case 分支恰好各自占一行。这是排版上的巧合,不是什么保证。把同样的逻辑——行为完全一样,写法差一点——改成两个分支挤在一行:

ADDR_STATUS, ADDR_COUNT: s_axi_bresp <= RESP_SLVERR; default: s_axi_bresp <= RESP_DECERR;

一份只看 line coverage 的报告会显示这一整行有非零的命中次数,只要任何一个分支跑过一次——这会给人一种两个分支都测过的错误印象。Branch coverage 完全不会被源码排版骗到:不管这两个分支挤在几行里,它依然把 ADDR_STATUS, ADDR_COUNTdefault 当成两个独立的条目分别追踪,还是会正确地显示一个覆盖了、一个没有。这正是一份报告要同时带上两个指标、而不是只算那个更省事的指标的具体原因。

那个经典陷阱,说精确一点

第 1 章用一句话点了这个陷阱:一行代码可以在值不对的情况下执行,依然算"覆盖到了"。值得说精确的是它到底什么时候会真正咬人——就用这一章里这个真实的缺口来当例子。现在,第 112、113 行显示的是 0——一个诚实的、看得见的缺口。假如第 113 行有个笔误——写成了 default: s_axi_bresp <= RESP_OKAY;,而不是 RESP_DECERR——今天这份报告并不会把它藏起来;它依然会在那一行准确地显示 0 次命中,这本身就是一个清清楚楚被报出来的缺口,不管有没有 bug。

这个陷阱只会在缺口被补上之后才咬人。一旦将来某个测试真的写了一个未映射地址,write_fire 的 default 分支就会执行,它的命中次数从 0 变成某个正数,报告就会变绿——line 和 branch 都会显示"covered"。如果那个笔误是真的,一次本该以 RESP_DECERR 失败的写操作会返回 RESP_OKAY,而两个覆盖率指标都不会注意到任何异常:它们只问一行有没有执行过,从来不问它算出来的值对不对。抓这种问题是 scoreboard 或者断言的工作(uvm-advanced 第 2、5 章)——跟第 1 章画的是同一条边界,只不过现在能在一份报告里亲眼看到它当下正诚实地在说哪些东西还没测过。

关于验证可行性的提醒

跟这个模块每一章一样的限制:Icarus Verilog 完全没有 code coverage 统计功能(第 1 章说过,这本来就是商业工具才有的特性),所以上面这些报告是示意性的,不是仿真器真正跑出来的——是靠对照真实的 RTL、追踪第 1 章定向测试真实发出过的那一串写操作构建出来的,跟这一章其他数字的来路一样:靠核对,不靠编造。要看到一个真正的工具跑出长这样的报告,得去 EDA Playground 上换一个商业级仿真器(比如 Aldec Riviera-PRO)。

小结

  • Line coverage 数的是可执行语句,不是文本行——声明、注释、空行永远不会有命中计数器,一行里挤了两条语句的话,即便共享同一个行号,也会被当成两个独立的计数器分别追踪。
  • Branch coverage 数的是每一个控制流分叉的地方——每一个 case 分支(包括 default)、每一个 if 的两边,跟它们占了几行源码没关系。
  • 只要源码排版一变,即便行为完全一样,这两个指标也可能分道扬镳——line coverage 可能被挤在一行的两个分支骗到;branch coverage 不会。
  • axil_regfile 的写响应 case 语句有一个真实的、可以核实的缺口:这个项目从头到尾的示例代码里,从来没有测过写 STATUSCOUNT,或者任何未映射地址,所以 SLVERRDECERR 在写路径上都从来没有真正触发过。
  • 一行代码 100% 的 line/branch coverage 不代表它算出来的值被检查过——这个陷阱只会在一个缺口被补上、而补上它的测试又没有检查正确性的时候才会咬人;一行诚实地显示 0 次命中,是一个看得见的缺口,不是一个藏起来的 bug。

Line coverage 工具会给下面哪一种东西关联一个命中计数器?

根据上面的 branch 报告,这个项目的 uvm-advanced 示例代码里,哪一件事从来都没有真正被测过?

如果 axil_regfile 的 STATUS/COUNT 分支和 default 分支被改写成挤在同一行源码里,而不是两行,在只有 STATUS/COUNT 分支被真正跑到过之后,两个覆盖率指标会分别显示什么?

第 113 行(default 分支)现在显示 0 次命中。这个 0 本身是不是已经体现了“覆盖了但值不对”这个经典陷阱?