The DMA's coverage review looks finished. The clear covergroup reads 100%: DONE cleared, ERR cleared. Then the designer asks which test produced an ERR for that clear to act on. Nobody can name one, because none did. Every job in the regression ended with DONE, and the test helper run_job writes 0x6 to STATUS after every job, setting both write-1-to-clear bits whether or not they were set. Writing 1 to a bit that is already 0 does nothing in the hardware, and the coverage model counted it anyway.
That bug is real: it was found while building this course's bench. It is the cleanest illustration of the question this lesson answers. How do you design a coverage model that measures what the DUT did, rather than what the testbench typed?
Code coverage tells you which RTL lines, branches and toggles were exercised. It is collected automatically and knows nothing about intent. Functional coverage tells you which promises in the spec were exercised, and it exists only if you write it. The document that lists those promises is the testplan: one row per feature, each with the stimulus that exercises it, the check that proves it worked, and the coverage item that proves the stimulus really happened. A coverage item (or plan item) is a named thing to observe, such as "a job with LEN = 0". In SystemVerilog it usually becomes a bin of a coverpoint inside a covergroup.
Think of a driving test. The examiner's sheet lists manoeuvres: parallel parking, a hill start, an emergency stop. A box is ticked only when the examiner saw the manoeuvre, never because the learner says they practised it. A coverage model is that sheet. The three rules that follow from the analogy carry the whole lesson: every element traces to a plan row; every sample comes from observed behaviour, not stimulus intent; and every sample is taken at the moment the fact becomes true.
The decision in the middle is the design work. Asking "where is this fact visible?" before writing any code prevents the two most common model bugs: sampling a transaction-level covergroup for something that only exists between cycles, and sampling a value from the stimulus because it was convenient.
Walk the spec (The DMA you will verify) and sort each promise into a category. The categories are a checklist that stops whole areas from being forgotten.
Category | On the DMA | Plan items in dma_coverage |
|---|---|---|
Configuration | LEN value classes, IRQ_EN | len=0, len=1, len=2-4, len=5-15, len=16, len>16, irq_en=0, irq_en=1 |
Stimulus shape | Gaps between input beats; a packet shorter than LEN | gap=0, gap=1-3, gap=4+, tlast=on-time, tlast=early |
Responses | How the job ended, as read from STATUS | outcome=done, outcome=err |
Errors | Early TLAST; an unmapped address (PSLVERR) | tlast=early, outcome=err; PSLVERR has no item yet |
Register side effects | W1C clears of bits that were actually set | clear=done, clear=err |
Timing and backpressure | Output stalls; TVALID held while TREADY is low | None: a |
Interrupts | irq rises only with IRQ_EN; the level holds until cleared | irq_en via the GO write only. The level is a plan gap today: it needs two cover properties, irq rising while IRQ_EN is set and irq falling only after a STATUS write that clears DONE or ERR (SVA cover property vs covergroups shows the shape) |
The last column is the honest state of the bench. Seventeen plan items exist as code. Several promises in the spec (backpressure, PSLVERR, KEY or LEN written while busy, ERR surviving GO) have no item at all. A promise with no item can never appear as a hole, which is worse than a hole: the report goes green without ever having measured it.
dma_coverage keeps its plan items as a list of strings. The covergroups measure; the list gives every item a name that a log line can print.
// snippet: from dma_coverage in the course DMA bench
string plan[$] = '{"len=0", "len=1", "len=2-4", "len=5-15", "len=16", "len>16",
"irq_en=0", "irq_en=1", "outcome=done", "outcome=err",
"clear=done", "clear=err", "gap=0", "gap=1-3", "gap=4+",
"tlast=on-time", "tlast=early"};Each name reads like a testplan cell, so a hole in the log (plan holes: len=0 ...) maps back to a plan row without a lookup table. The lesson on crosses and traceability shows how these names map to bins.
Sampling site | Good for | DMA example |
|---|---|---|
Class covergroup fed by a monitor, with | One fact per transaction |
|
Covergroup in the interface, on the clock | Per-cycle states and transitions | TVALID/TREADY handshake states, stall-then-transfer |
| Sequences over several cycles | TVALID held while TREADY is low for 3 cycles; an APB wait state |
Register coverage through the register model | Which fields were written and read, with which values | Covered in the register layer section |
Most of the DMA's plan is visible in single transactions: a GO write, a STATUS read, a beat. That is why dma_coverage is one component with four transaction covergroups. Timing facts are the exception, and they get their own lesson.
For "how did the job end", two sources are available. The test's intent: it sent a 3-word packet to an 8-word job, so it expects ERR. Or the STATUS read the APB monitor observed. Only the second proves the DUT reached that state; a DUT that ignored TLAST would still have "covered" ERR under the first. The bench makes the observed choice everywhere:
job_cg is sampled when the APB monitor sees the GO write, using the LEN value latched from the last observed LEN write. Accesses that ended with PSLVERR are skipped.
The outcome comes from STATUS read data, never from what the test expected.
A clear counts only for a bit the last STATUS read showed as set (the fix for the opening bug).
Gaps between beats are derived from the times the input monitor saw each beat, not from the driver's idle knob, which is a request, not a measurement.
dma_check_test runs three random jobs with the checking environment, which adds dma_coverage. From the real run (Verilator with the open-source UVM library; times in ps; random values differ by simulator and seed):
+UVM_TESTNAME=dma_check_test
UVM_INFO dma_tb.sv(731) @ 0: uvm_test_top [JOB] key=90fdd8fd len=10 irq_en=1
UVM_INFO dma_tb.sv(731) @ 645000: uvm_test_top [JOB] key=0e564b57 len=1 irq_en=1
UVM_INFO dma_tb.sv(731) @ 885000: uvm_test_top [JOB] key=5a1793f4 len=3 irq_en=1
UVM_INFO dma_tb.sv(1528) @ 1305000: uvm_test_top.env.cov [COV] covergroups: job 0.0%, status 0.0%, clear 0.0%, stream 0.0%
UVM_INFO dma_tb.sv(1530) @ 1305000: uvm_test_top.env.cov [COV] plan items hit: 9 of 17
UVM_INFO dma_tb.sv(1531) @ 1305000: uvm_test_top.env.cov [COV] plan holes: len=0 len=16 len>16 irq_en=0 outcome=err clear=err gap=0 tlast=early
UVM_INFO dma_tb.sv(411) @ 1305000: uvm_test_top.env.sb [SB] 14 words matched, 0 mismatched
UVM_INFO dma_tb.sv(1639) @ 1305000: uvm_test_top.env [CMP] in-order comparator: 14 matches, 0 mismatches
UVM_ERROR : 0
UVM_FATAL : 0covergroups: ... 0.0% is Verilator ignoring coverpoints. On a commercial simulator these four numbers are real.
plan items hit: 9 of 17 is the tally: LEN classes 1, 2-4 and 5-15, irq_en=1, outcome=done, clear=done, two gap classes and an on-time TLAST.
plan holes lists the eight items no job reached. Each has a different cause; the closure lesson works through them one by one.
The first version of the clear covergroup sampled the written bits directly. It looked reasonable: STATUS bits 1 and 2 are W1C, so a write with those bits set "clears" them.
// bad-example: counts the write, not the clear
if (t.write && t.addr == STATUS && t.data[2:1] != 0)
clear_cg.sample(t.data[2:1]); // 0x6 after a DONE-only job "covers" clear_errrun_job writes 0x6 after every job, so every DONE-only job also counted a clear of ERR. The symptom was a covergroup at 100% and a plan row signed off with no ERR ever produced. The root cause is a model that sampled the testbench's action instead of the DUT's state change. The fix tracks what the last STATUS read showed and counts only bits that were set:
// snippet: from dma_coverage in the course DMA bench
// A clear counts only for a bit that was actually set: writing 1 to an
// already-clear W1C bit exercises nothing.
if (t.write && t.addr == STATUS) begin
bit [1:0] cleared = t.data[2:1] & {err_seen, done_seen};
if (cleared != 0) begin
clear_cg.sample(cleared);
if (cleared[0]) hit("clear=done");
if (cleared[1]) hit("clear=err");
end
done_seen &= !t.data[1];
err_seen &= !t.data[2];
endThe plain-SystemVerilog model below replays the three jobs of dma_check_test through both versions. Only the fixed one reports clear_err as the hole it really is.
// The W1C coverage bug: counting a clear from the write data alone, versus
// counting only bits that the last STATUS read showed as set.
class clear_cov;
bit naive; // 1: sample the written bits as they are
bit done_seen, err_seen; // as last read from STATUS
int unsigned clear_done, clear_err;
function new(bit naive);
this.naive = naive;
endfunction
function void status_read(bit [2:0] rdata);
done_seen = rdata[1];
err_seen = rdata[2];
endfunction
function void status_write(bit [2:0] wdata);
bit [1:0] cleared = naive ? wdata[2:1] : (wdata[2:1] & {err_seen, done_seen});
if (cleared[0]) clear_done++;
if (cleared[1]) clear_err++;
done_seen &= !wdata[1];
err_seen &= !wdata[2];
endfunction
endclass
module tb_w1c_cov;
int errors = 0;
clear_cov naive_cov, fixed_cov;
initial begin
naive_cov = new(1);
fixed_cov = new(0);
// run_job, three times: every job ends with DONE, and the test writes 0x6
// to clear DONE and ERR whether or not they are set.
repeat (3) begin
naive_cov.status_read(3'b010); fixed_cov.status_read(3'b010);
naive_cov.status_write(3'b110); fixed_cov.status_write(3'b110);
end
if (naive_cov.clear_err != 3) errors++; // "clear ERR covered" with no ERR ever set
if (fixed_cov.clear_err != 0) errors++; // the honest answer: a hole
if (fixed_cov.clear_done != 3) errors++;
$display("naive model: clear_err hit %0d times; fixed model: %0d times",
naive_cov.clear_err, fixed_cov.clear_err);
if (errors == 0) $display("PASS: only the fixed model reports clear_err as a hole");
else $display("FAIL: %0d W1C coverage checks wrong", errors);
$finish;
end
endmoduleThe general lesson: for every bin, name one test that should hit it and one that should not. A DONE-only regression must leave clear=err empty. If it does not, the model is wrong, however good the number looks.
job_cg straight from the test's dma_job_cfg after randomize(). What could that coverage claim that the DUT never did?It would record the job the test meant to run. If the LEN or GO write failed, or the LEN write was ignored because the DMA was still busy, the DUT ran a different job or none, and the cfg-based coverage would still claim the LEN class. Sampling the observed GO write ties coverage to bus traffic. Note that dma_coverage latches LEN from every successful LEN write it observes, so a LEN write while busy would fool it too: the same model gap the spec page points out for the scoreboard.
dma_coverage, and why is that worse than a hole?Backpressure on the output, PSLVERR on unmapped addresses, KEY/LEN/GO written while busy, set-beats-clear on STATUS, and ERR surviving GO. A hole is visible: the tally lists it. A promise with no item never appears anywhere, so the tally can reach 17 of 17 while those promises were never measured.
outcome=err sampled from a STATUS read rather than when the test sends a short packet?The short packet is stimulus intent; the STATUS read is evidence that the DUT set ERR. If the DUT ignored the early TLAST, intent-based coverage would still record the error outcome and hide the bug from the coverage review.
Next: the plan is written down. Coverage Components and Sampling builds the component that listens to the monitors and feeds the covergroups.