Chapter 3 of 15
Code Coverage: Line and Branch
What a real line/branch coverage report actually shows, down to individual statements and case arms -- built against axil_regfile's write-response logic and this site's own real test history, including a genuine, still-open gap: neither error response has ever been tested on the write path.
Chapter 1's mocked report showed code coverage at a glance — four percentages, one per metric. This chapter opens that up: what a tool actually counts when it says "line," what it counts when it says "branch," and why a report shows both rather than picking one. The example isn't invented — it's axil_regfile's own write-response logic (uvm-advanced ch1), annotated against the real sequence of writes chapter 1's own directed test actually issued.
What counts as a "line"
A line coverage tool doesn't count text lines — it counts executable statements, and assigns each one a hit counter tied to a source line number. Declarations, comments, blank lines, and block delimiters (begin/end, endcase) don't get one — there's nothing there to execute. Most RTL, including axil_regfile, happens to write one statement per line, which is why a report reads cleanly against the source; a tool encountering two statements crammed onto one line tracks them as two separate hit counters even though they share a line number.
Here's axil_regfile's write-response block (line numbers counted from the module declaration itself as line 1, the way a real per-file report would number it), annotated with hit counts from exactly the sequence chapter 1's own directed test issued: axi_write(CTRL, ENABLE=1), four axi_write(DATA, ...) calls, then axi_write(CTRL, ENABLE=1, IRQ_CLR=1) — six total writes, two to CTRL, four to DATA, none anywhere else:
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
Two lines read 0: line 112 and line 113. That's not a rendering artifact — it's real. Grep this site's own example code and the fact checks out: every axi_write call across uvm-advanced ch1 through ch6 targets 8'h00 (CTRL) or 8'h08 (DATA). Nothing, anywhere in this project's history, has ever written to STATUS, COUNT, or an unmapped address. The two response codes that exist specifically to report a failing write — SLVERR for "this register exists but doesn't take writes," DECERR for "no register lives here at all" — have never once fired on the write path, in any chapter.
What counts as a "branch"
Line coverage tells you a statement executed. It doesn't tell you which way a decision went — that's branch coverage's job, and it exists at every point control forks: if/else, each arm of a case, a ternary. axil_regfile's write case is four branches (CTRL, DATA, STATUS/COUNT, default); line 104's if (w_data_eff[1]) is two more (the increment happens or it doesn't), entirely separate from whether the if statement's line executed:
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
The if on line 104 is a genuinely good sign: chapter 1's first CTRL write (ENABLE=1, bit 1 clear) took the false branch, and the second (ENABLE=1, IRQ_CLR=1, bit 1 set) took the true branch — both sides of that decision got exercised, purely as a side effect of what the directed test happened to do, not because anyone planned it that way. The case statement's bottom two branches are the opposite story: a real, still-open gap, not a hypothetical one.
Why a report shows both, not just one
Line and branch coverage happen to agree here, arm for arm, only because axil_regfile's case arms are each written on their own source line. That's a coincidence of formatting, not a guarantee. Rewrite the same logic — same behavior, worse style — with two arms sharing one line:
ADDR_STATUS, ADDR_COUNT: s_axi_bresp <= RESP_SLVERR; default: s_axi_bresp <= RESP_DECERR;A line-coverage-only report would show that single line with a nonzero hit count the instant either arm ran — a false impression that both were exercised. Branch coverage isn't fooled by source formatting at all: it tracks ADDR_STATUS, ADDR_COUNT and default as two separate items regardless of how many lines they're crammed onto, and would still correctly show one covered, one not. That's the concrete reason a report carries both metrics instead of picking the cheaper one to compute.
The classic trap, precisely
Ch1 named the trap in one sentence: a line can execute with the wrong value and still count as covered. Worth being exact about when that actually bites, using this chapter's own real gap as the example. Right now, lines 112–113 read 0 — an honest, visible gap. If line 113 had a typo — default: s_axi_bresp <= RESP_OKAY; instead of RESP_DECERR — today's report wouldn't hide it; it would still show 0 hits on that exact line, which is itself a glaring, correctly-reported gap, bug or no bug.
The trap only bites after the gap gets closed. The moment a future test writes to an unmapped address, write_fire's default arm executes, its hit count goes from 0 to something positive, and the report turns green — line and branch, both "covered." If the typo were real, RESP_OKAY would come back on a write that should have failed with RESP_DECERR, and neither coverage metric would notice a thing: they only ask whether the line ran, never whether the value it produced was right. Catching that is a scoreboard's or an assertion's job (uvm-advanced ch2, ch5) — the same boundary ch1 drew, now visible in a report that's currently, correctly, telling the truth about what hasn't been tested yet.
A verification-feasibility reminder
Same limitation as every chapter in this module: Icarus Verilog has no code coverage collection at all (a commercial-tool feature to begin with, per ch1), so the reports above are illustrative, not simulator output — built by tracing the real sequence of writes chapter 1's directed test issues against the real RTL, the same way this chapter's numbers were checked rather than invented. A commercial simulator on EDA Playground (Aldec Riviera-PRO, say) is where to see an actual tool produce a report shaped like these.
Summary
- Line coverage counts executable statements, not text lines — declarations, comments, and blank lines never get a hit counter, and two statements sharing a source line get tracked separately even though they share a line number.
- Branch coverage counts every point control forks — each
casearm (includingdefault) and each side of everyif, independent of how many source lines they occupy. - The two metrics can diverge the moment source formatting changes, even with identical behavior — line coverage can be fooled by two branches sharing a line; branch coverage can't.
axil_regfile's write-response case has a real, checkable gap: across this entire site's example code, no test has ever written toSTATUS,COUNT, or an unmapped address, so neitherSLVERRnorDECERRhas ever fired on the write path.- 100% line/branch coverage of a line doesn't mean its value was checked — the trap only bites once a gap is closed by a test that doesn't verify correctness; an honest 0-hit line is a visible gap, not a hidden bug.
A line coverage tool assigns a hit counter to which of the following?
Based on the branch report above, what has genuinely never been tested anywhere in this site's uvm-advanced example code?
If axil_regfile's STATUS/COUNT and default case arms were rewritten to share one source line instead of two, what would happen to each coverage metric once only the STATUS/COUNT arm ran?
Line 113 (the default arm) currently shows 0 hits. Does that 0 already demonstrate the classic 'covered but wrong' trap?