Part 6 · Testbench Architecture · Intermediate

End-of-Test Checks & Drain

Quiescence checks, drain-time pattern, timeout watchdog, final PASS/FAIL banner, and the zero-compare sanity trap.

Quiescence: proving the TB is actually done

A test is not finished when stimulus stops — it is finished when every consequence of that stimulus has been observed and checked. That condition is quiescence : all expected transactions consumed, all mailboxes and queues empty, no in-flight activity in the DUT. Ending the simulation earlier silently discards pending compares — including, possibly, the failing one.

diagram
END-OF-TEST SEQUENCE

  stimulus done ──► drain window ──► quiescence checks ──► report
                     │                  │
                     │                  ├─ expected_q empty?
   in-flight txns    │                  ├─ expected[id] array empty?
   flush through ────┘                  ├─ all mailboxes num() == 0?
   the DUT                              ├─ compare count > 0 ?  ◄── sanity!
                                        └─ mismatch count == 0 ?
                                                  │
                              all yes ────────────┴──── any no
                                 │                        │
                                 ▼                        ▼
                            PASS banner              FAIL banner
                                                (leftovers listed)

  watchdog (parallel, whole test): #MAX_TIME  $fatal("TB hung")
systemverilog
class env;
  // ... generator, driver, monitors, model, scoreboard handles ...

  task run_test();
    fork : tb_threads
      begin                                // main flow
        gen.run_all_stimulus();            // returns when last txn sent
        drain();                           // wait for in-flight to flush
        scb.final_check();
        report();
      end
      watchdog();                          // global hang protection
    join_any
    disable tb_threads;
  endtask

  // Drain: wait until nothing has moved for DRAIN_IDLE cycles
  task drain();
    int idle = 0;
    int unsigned last_seen = mon_out.txn_count;
    while (idle < 100) begin
      @(posedge vif.clk);
      if (mon_out.txn_count != last_seen) begin
        last_seen = mon_out.txn_count;
        idle = 0;                          // activity → restart window
      end else idle++;
    end
  endtask

  task watchdog();
    #1ms;
    $fatal(1, "[ENV] WATCHDOG: test exceeded 1ms — TB or DUT hung");
  endtask
endclass
  • Activity-based drain (restart the idle window on every observed txn) beats a fixed #delay — it adapts to back-pressure and deep pipelines.

  • The watchdog runs for the whole test in a parallel thread; a hung handshake otherwise stalls the regression slot forever.

  • join_any plus disable kills the watchdog on normal completion and kills the main flow on timeout.


Final checks and the report banner

systemverilog
class scoreboard;
  bus_txn      expected_q[$];
  mailbox #(bus_txn) mbx_act;
  int unsigned match_count, mismatch_count;
  int unsigned errors;

  function void final_check();
    // 1. leftovers: predicted but never observed
    if (expected_q.size() != 0) begin
      errors++;
      $error("[SCB] %0d expected txns never observed:", expected_q.size());
      foreach (expected_q[i])
        $display("    leftover[%0d]: %s", i, expected_q[i].convert2string());
    end
    // 2. unconsumed actuals stuck in the mailbox
    if (mbx_act.num() != 0) begin
      errors++;
      $error("[SCB] %0d actual txns never compared (mailbox not drained)",
             mbx_act.num());
    end
    // 3. THE ZERO-COMPARE TRAP: empty scoreboard must not mean PASS
    if (match_count + mismatch_count == 0) begin
      errors++;
      $error("[SCB] ZERO comparisons performed — test exercised nothing");
    end
    if (mismatch_count != 0) errors++;
  endfunction

  function void report();
    $display("=======================================================");
    $display(" matches=%0d mismatches=%0d leftovers=%0d",
             match_count, mismatch_count, expected_q.size());
    if (errors == 0)
      $display("            *** TEST PASSED ***");
    else
      $display("            *** TEST FAILED (%0d errors) ***", errors);
    $display("=======================================================");
  endfunction
endclass

The empty-scoreboard trap

The most dangerous regression result is a PASS that checked nothing. A broken generator, a monitor watching the wrong interface, or a config typo that disabled the driver all produce the same outcome: zero transactions, zero compares, zero mismatches — and a naive if (mismatch_count == 0) PASS banner. The zero-compare check converts that silent hole into a loud failure. Stronger variants: require a per-test minimum compare count , or have the test specify how many transactions it intends to send and check the scoreboard against that number.

Interview angle

Two staples: "How do you know when a test is done?" (quiescence — not when stimulus ends; describe drain plus leftover checks plus watchdog) and "How could a test pass while verifying nothing?" (zero-compare trap — name it and give the counter-check). These separate candidates who have shipped a TB from those who have only read about them.

Key takeaways

  • End of test = quiescence proven, not stimulus finished — drain, then check leftovers.

  • Use activity-based drain windows, not fixed delays — they adapt to back-pressure.

  • Always run a watchdog in parallel; a hung handshake must kill the test, not the regression slot.

  • PASS requires compare_count > 0 — an empty scoreboard is a failure, not a success.

Common pitfalls

  • Ending at "stimulus sent" — in-flight transactions are never checked, late mismatches vanish.

  • Fixed #10us drain delay — passes at low back-pressure, truncates checking at high back-pressure.

  • PASS banner gated only on mismatch_count == 0 — the zero-compare hole ships a broken test as green.

  • No leftover dump — "3 expected txns never observed" without printing them forces a full re-run.