DV Methodology

Chapter 2 of 15

Functional Coverage in Depth: bins, Crosses, and Options

The covergroup/coverpoint syntax ch1 deferred, in full: explicit bins, illegal_bins, ignore_bins, transition bins, cross coverage with illegal combinations, option.at_least/weight/goal, and get_coverage() vs. get_inst_coverage() -- built into a real coverage model for axil_regfile's address space and response codes.

Chapter 1 showed just enough covergroup/coverpoint to recognize the shape and deferred everything else: "nothing about how to bucket those values (that's bins), nothing about combinations across multiple fields (that's cross)." This chapter is where both arrive, built directly against axi_txn (uvm-advanced ch2) and axil_regfile's real address decode and response logic (uvm-advanced ch1) — no throwaway example, the same DUT this whole module committed to.

Bins: from one bin per value to buckets that mean something

A coverpoint with no bins clause at all — exactly what ch1's cp_addr: coverpoint txn.addr; was — auto-generates one bin per distinct value the field can take. For an 8-bit address that's up to 256 bins, each one requiring its own hit to reach 100%. That's not a coverage model, it's an accident: nobody decided those buckets meant anything, and 100% is nearly unreachable by construction.

Explicit bins fix that by naming buckets that map onto something real — in this case, the four addresses axil_regfile actually decodes:

cp_addr: coverpoint txn.addr {
  bins ctrl     = {8'h00};  // ADDR_CTRL
  bins status   = {8'h04};  // ADDR_STATUS
  bins data     = {8'h08};  // ADDR_DATA
  bins count    = {8'h0C};  // ADDR_COUNT
  bins unmapped = default;
}

Four named bins, one per mapped register, plus a fifth: default isn't "no bins declared," it's an explicit instruction to collect every value not claimed by an earlier bin into a single bucket. One address 8'h10, one address 8'h55, and one address 8'hFF all land in the same unmapped bin — which is the right call here, since this coverage model cares about "did we ever hit the decoder's fallback path," not which specific unmapped address did it.

illegal_bins vs. ignore_bins: two different ways to say "not counted"

Both keywords remove a value from ever contributing toward 100% — the interview-common confusion is that they do it for opposite reasons:

  • ignore_bins says "this value can legitimately occur, but I don't care about it — don't ask me to cover it." A value here is silently dropped from the model.
  • illegal_bins says "this value must never occur — if it does, that's a bug, not a coverage gap." Sampling an illegal_bins value fires a runtime error (UVM_ERROR-severity by default), the same way an assertion firing means something is wrong, not merely untested.

axil_regfile's 2-bit response field is the concrete case: AXI4-Lite only ever produces three of the four possible 2-bit values (OKAY, SLVERR, DECERRuvm-advanced ch1). The fourth, 2'b01 (EXOKAY in full AXI4, meaningless here since AXI4-Lite drops exclusive accesses), can never come out of this DUT by construction. Demanding coverage of a value that structurally cannot occur would make 100% permanently unreachable for the wrong reason — so it's ignore_bins, not left uncovered and not illegal_bins either, since seeing it wouldn't be a bug in this DUT, just impossible:

cp_resp: coverpoint txn.resp {
  bins okay            = {2'b00};
  bins slverr           = {2'b10};
  bins decerr           = {2'b11};
  ignore_bins reserved  = {2'b01};
}

Transition bins: did a sequence happen, not just a value

A plain bin asks "did this value occur." A transition bin asks "did this value occur right after that one" — coverage over consecutive samples, not individual ones. Syntax: bins name = (val_set_1 => val_set_2);. Added to cp_resp above, this asks whether the DUT was ever hit with two error responses back to back — a genuinely interesting stress scenario a plain per-value bin can't express at all:

bins back_to_back_err = ({2'b10, 2'b11} => {2'b10, 2'b11});

This bin covers only when one sample lands in {SLVERR, DECERR} and the very next sample also lands in {SLVERR, DECERR} — four consecutive-OKAY transactions never touch it, and neither does a single isolated error surrounded by OKAYs.

Cross coverage: combinations, and the combinations that should never happen

A cross asks about combinations of two or more coverpoints — not "was status hit" and separately "was slverr hit," but "was status hit together with slverr, specifically." axil_regfile's decode logic makes several address/response combinations structurally impossible, and a cross's own illegal_bins is exactly where to encode that — turning knowledge that currently lives only in the RTL's case statement (uvm-advanced ch1) into something the coverage model actively checks:

addr_x_resp: cross cp_addr, cp_resp {
  // CTRL and DATA are readable and writable -- the decoder guarantees
  // OKAY on every access to either one, never an error response.
  illegal_bins rw_regs_never_error =
    binsof(cp_addr) intersect {8'h00, 8'h08} &&
    binsof(cp_resp) intersect {2'b10, 2'b11};
 
  // STATUS and COUNT are mapped -- a decode miss (DECERR) can never
  // land there, only OKAY (read) or SLVERR (write, since both are
  // read-only).
  illegal_bins ro_regs_never_decerr =
    binsof(cp_addr) intersect {8'h04, 8'h0C} &&
    binsof(cp_resp) intersect {2'b11};
 
  // an unmapped address can never come back OKAY or SLVERR -- the
  // decoder either finds a register there or it doesn't.
  illegal_bins unmapped_never_ok_or_slverr =
    binsof(cp_addr.unmapped) &&
    binsof(cp_resp) intersect {2'b00, 2'b10};
}

If any of these three ever sampled, it would mean the coverage model's understanding of the decoder disagrees with what the decoder actually does — worth an error, the same way illegal_bins was worth one on cp_resp. Sampling unmapped crossed with decerr, on the other hand, is exactly what a passing run should eventually do — that combination is legal and simply isn't pre-excluded.

Options: at_least, weight, and goal

Three options worth knowing, all used above or in the model below:

  • option.at_least — how many times a bin must be hit before it counts as covered. The default is 1; setting it higher (as below, on cp_resp) says a single lucky error response isn't enough to call that response code "tested" — it has to recur.
  • option.weight — how heavily a coverpoint or cross counts toward the covergroup's rolled-up percentage. The default is 1 for everything; a cross with, say, nine legal bins can otherwise swamp two coverpoints with four or five bins each when the tool averages them together, so bumping its weight (or the individual coverpoints') is how you keep the rollup meaningful rather than accidentally lopsided.
  • option.goal — the percentage a bin, coverpoint, or covergroup needs to reach before the tool calls it "covered" in a summary report. The default is 100; lowering it for a specific bin is a deliberate, documented decision that some combination is real but rare enough not to be worth chasing to literal completion. Chapter 6 covers when that judgment call is the right one — here, all bins keep the default.
cp_resp: coverpoint txn.resp {
  bins okay             = {2'b00};
  bins slverr           = {2'b10};
  bins decerr           = {2'b11};
  ignore_bins reserved  = {2'b01};
  bins back_to_back_err = ({2'b10, 2'b11} => {2'b10, 2'b11});
  option.at_least        = 2;
}

Wiring it up: sampling from the monitor's existing analysis port

None of this needs a new environment. axi_monitor already broadcasts every completed axi_txn over its analysis_port (uvm-advanced ch2); a coverage model just needs to be another subscriber, the exact uvm_analysis_imp/write() shape irq_watcher already used in regfile_env (uvm-advanced ch3):

class regfile_coverage extends uvm_component;
  `uvm_component_utils(regfile_coverage)
 
  uvm_analysis_imp #(axi_txn, regfile_coverage) imp;
 
  covergroup axi_cg with function sample(axi_txn txn);
    option.per_instance = 1;
 
    cp_addr: coverpoint txn.addr {
      bins ctrl     = {8'h00};
      bins status   = {8'h04};
      bins data     = {8'h08};
      bins count    = {8'h0C};
      bins unmapped = default;
    }
 
    cp_resp: coverpoint txn.resp {
      bins okay             = {2'b00};
      bins slverr           = {2'b10};
      bins decerr           = {2'b11};
      ignore_bins reserved  = {2'b01};
      bins back_to_back_err = ({2'b10, 2'b11} => {2'b10, 2'b11});
      option.at_least        = 2;
    }
 
    addr_x_resp: cross cp_addr, cp_resp {
      option.weight = 2;
      illegal_bins rw_regs_never_error =
        binsof(cp_addr) intersect {8'h00, 8'h08} &&
        binsof(cp_resp) intersect {2'b10, 2'b11};
      illegal_bins ro_regs_never_decerr =
        binsof(cp_addr) intersect {8'h04, 8'h0C} &&
        binsof(cp_resp) intersect {2'b11};
      illegal_bins unmapped_never_ok_or_slverr =
        binsof(cp_addr.unmapped) &&
        binsof(cp_resp) intersect {2'b00, 2'b10};
    }
  endgroup
 
  function new(string name, uvm_component parent);
    super.new(name, parent);
    imp    = new("imp", this);
    axi_cg = new();
  endfunction
 
  function void write(axi_txn txn);
    axi_cg.sample(txn);
  endfunction
 
  function void report_phase(uvm_phase phase);
    `uvm_info("REGFILE_COV",
      $sformatf("axi_cg coverage: %0.1f%%", axi_cg.get_coverage()), UVM_LOW)
  endfunction
endclass

regfile_env (uvm-advanced ch3) picks it up with one new member and one new connect_phase line, unchanged otherwise:

class regfile_env extends uvm_env;
  // ... axi_agt, irq_agt, irqw unchanged ...
  regfile_coverage cov;
 
  function void build_phase(uvm_phase phase);
    super.build_phase(phase);
    // ... existing creates unchanged ...
    cov = regfile_coverage::type_id::create("cov", this);
  endfunction
 
  function void connect_phase(uvm_phase phase);
    super.connect_phase(phase);
    irq_agt.mon.ap.connect(irqw.imp);
    axi_agt.mon.ap.connect(cov.imp);
  endfunction
endclass

covergroup ... with function sample(axi_txn txn) takes the transaction as an argument directly, rather than stashing it in a member variable first — the coverpoints reference txn.addr/txn.resp right off the argument, and axi_cg.sample(txn) in write() is the only place sampling happens: once per completed transaction, driven entirely by what the monitor actually saw.

Querying coverage from code: get_coverage() vs. get_inst_coverage()

report_phase above calls axi_cg.get_coverage() — and it's worth being precise about what that returns, since get_coverage() and get_inst_coverage() answer different questions that happen to look identical in this chapter's example:

  • get_inst_coverage() returns the coverage of this one instance of the covergroup — regfile_coverage's single axi_cg object, and nothing else.
  • get_coverage() returns the coverage rolled up across every instance of this covergroup type that has ever existed in the simulation.

regfile_env creates exactly one regfile_coverage, which creates exactly one axi_cg — so here, the two calls return the same number, and it's easy to conclude they're interchangeable. They aren't: the moment a covergroup is instantiated more than once — one per agent in a multi-agent testbench, one per entry in an array of scoreboards, anywhere the same coverage model gets reused — get_coverage() blends all of them into one aggregate number while get_inst_coverage() still isolates just one. Reaching for the wrong one when there's more than one instance is exactly the kind of mistake that silently makes a coverage report far more (or less) optimistic than it should be.

A verification-feasibility reminder

Same limitation ch1 flagged: Icarus Verilog doesn't implement covergroup at all, so nothing in this chapter has been run through a simulator here — the code above is the same standard, unremarkable covergroup/bins/cross syntax every commercial simulator supports, checked against axil_regfile's actual decode logic by reading it carefully rather than by executing it. Run it for real on EDA Playground with a commercial-grade simulator (Aldec Riviera-PRO, say) if you want to see the bins and the illegal-bin errors fire.

Summary

  • A coverpoint with no bins clause auto-generates one bin per distinct value — usually not what you want. Explicit bins name buckets that mean something; default collects everything else into one bucket, not one bin per leftover value.
  • ignore_bins removes a value that can legitimately occur but isn't worth tracking. illegal_bins flags a value that should never occur at all — sampling it is an error, not a coverage gap.
  • Transition bins ((a => b)) cover sequences across consecutive samples, not single values — useful for scenarios like "did two errors happen back to back."
  • cross coverage asks about combinations of coverpoints together, not each one separately; a cross's own illegal_bins encodes combinations that should be structurally impossible, catching a mismatch between the coverage model and the RTL's actual behavior.
  • option.at_least (hits needed before a bin counts as covered), option.weight (how heavily something counts toward a rollup), and option.goal (the percentage that counts as "done") tune how a coverage model reports, without changing what it measures.
  • get_coverage() aggregates across every instance of a covergroup type; get_inst_coverage() isolates one instance. They look identical with exactly one instance and diverge the moment there's more than one.

A value that the DUT is structurally guaranteed to never produce should be handled with which construct?

Why does this chapter's coverage model cross cp_addr with cp_resp instead of only covering each one separately?

A covergroup is instantiated once per agent in a multi-agent testbench. What's the difference between calling get_coverage() and get_inst_coverage() on one of those instances?