Part 6 · Testbench Architecture · Intermediate

Hooking Coverage Into the TB

Coverage class subscribing to the monitor stream, sampling observed transactions, env wiring, and monitor-embedded vs separate coverage.

Coverage rides the monitor stream

Functional coverage answers "what did the DUT actually see and do?" — so it must sample the monitor's observed transactions, never the generator's intent. The DUT may drop, transform, or error stimulus; coverage credited from the driven side claims scenarios that never reached the design. In a hand-built TB the wiring is simply another consumer mailbox on the monitor's broadcast list.

diagram
COVERAGE WIRING IN A HAND-BUILT TB

  generator ──mbx──► driver ──► DUT ──► output pins
                                  │
        sampling here is WRONG ───┘
        (intent, not behavior)
                                          input pins / output pins
                                                  │
                                                  ▼
                                              monitor
                                            (observes)
                                          ┌─────┴─────┐
                                     mbx_scb       mbx_cov
                                          │             │
                                          ▼             ▼
                                     scoreboard    bus_coverage
                                     (checking)    cg.sample(t)
                                                        │
                                                        ▼
                                                coverage database

A coverage collector class

systemverilog
class bus_coverage;
  mailbox #(bus_txn) mbx_cov;     // fed by the monitor
  bus_txn            tr;          // covergroup samples through this handle
  bit                in_reset;
  int unsigned       sample_count;

  covergroup cg;
    option.per_instance = 1;
    option.name         = "bus_cov";

    cp_op : coverpoint tr.op {
      bins add_op = {OP_ADD};
      bins sub_op = {OP_SUB};
      bins logical[] = {OP_AND, OP_XOR};
    }
    cp_len : coverpoint tr.len {
      bins single = {1};
      bins small  = {[2:4]};
      bins large  = {[5:16]};
    }
    cp_resp : coverpoint tr.resp {
      bins okay  = {0};
      bins error = {[1:3]};
    }
    x_op_len : cross cp_op, cp_len;
  endgroup

  function new(mailbox #(bus_txn) mbx_cov);
    this.mbx_cov = mbx_cov;
    cg = new();                    // forget this → null covergroup crash
  endfunction

  task run();
    forever begin
      mbx_cov.get(tr);
      if (in_reset) continue;      // reset-phase txns pollute closure
      cg.sample();
      sample_count++;
    end
  endtask

  function void report();
    $display("[COV] %s: %0.1f%% (%0d samples)",
             cg.option.name, cg.get_inst_coverage(), sample_count);
  endfunction
endclass

Env wiring

systemverilog
class env;
  bus_monitor  mon;
  scoreboard   scb;
  bus_coverage cov;
  mailbox #(bus_txn) mbx_scb = new();
  mailbox #(bus_txn) mbx_cov = new();

  function void build(virtual bus_if.MON vif);
    mon = new(vif, mbx_scb, mbx_cov);   // monitor broadcasts to both
    scb = new(/* exp mbx */ mbx_exp, mbx_scb);
    cov = new(mbx_cov);
  endfunction

  task run();
    fork
      mon.run();
      scb.run();
      cov.run();
    join_none
  endtask
endclass

Mailbox vs direct call

  • Mailbox subscription (shown): coverage runs in its own thread, monitor never blocks, and the collector can be dropped from an env without touching monitor code. Preferred default.

  • Direct function call (mon calls cov.sample_txn(t)): simpler, zero threading — but couples the monitor to the coverage class and every new consumer means editing the monitor.

  • Either way, sampling stays a function-level concern: get the txn, gate on reset, cg.sample().


Where should coverage live?

Embedding the covergroup inside the monitor works for tiny benches but ages badly: the monitor stops being reusable (every project wants different bins), compile-time grows on the hot path, and disabling coverage for a debug run means editing observation code. Keep the monitor a pure observer and put covergroups in a separate collector class subscribed to its stream — the same separation of concerns the scoreboard already follows.

  • Separate class (default): per-project bins without touching the monitor; collectors can be added or removed per test.

  • Inside the monitor (acceptable): protocol-universal coverage that every user of this interface wants — e.g., handshake-stall-length bins that are part of the protocol itself.

  • Never in the driver or generator: that is intent coverage — the classic wrong-stream mistake.

Interview angle

Expect "Where do you sample functional coverage and why?" — answer: from the monitor's observed stream, once per completed transaction, gated during reset; never from the driver because the DUT may transform or reject stimulus. Bonus points for naming the per-instance option and the zero-sample sanity check (a collector that sampled nothing should fail the test, mirroring the empty-scoreboard trap).

Key takeaways

  • Coverage samples observed transactions from the monitor — driven intent is the wrong stream.

  • A separate collector class subscribed via mailbox keeps the monitor reusable and coverage optional.

  • Gate sampling during reset and count samples — zero samples should fail the test.

  • Construct the covergroup in the class constructor — cg = new() or it is a null handle at first sample.

Common pitfalls

  • Sampling in the driver — coverage credits scenarios the DUT may never have seen.

  • Forgetting cg = new() in the constructor — null covergroup crash at first sample.

  • Sampling per beat instead of per completed transaction — inflated hit counts, no new scenarios.

  • Covergroup hard-wired inside the monitor — bins can't change per project without editing observation code.