Part 6 · Testbench Architecture · Intermediate

End-of-Test: The Objection Pattern by Hand

From naive #delay endings through transaction-count endings to a full raise/drain objection-counter class with watchdog.

Three generations of end-of-test

Every bench has to answer "when do we stop?" and the answer evolves the same way on every project. Generation one is a #1ms; $finish; — it truncates slow runs and wastes time on fast ones, and every stimulus change means re-tuning a magic number. Generation two ends when a transaction count is reached — better, but it hard-codes one component's view: it cannot express "the interrupt handler is still busy" or "agent B has follow-up traffic." Generation three lets any component hold the test open while it has outstanding work: the objection pattern, which is exactly what UVM's uvm_objection formalizes.

diagram
END-OF-TEST MECHANISMS, WORST TO BEST

  GEN 1: #delay            #1ms; $finish;
         └─ too short  truncated checks; too long  wasted cycles
            breaks on every stimulus or back-pressure change

  GEN 2: txn count         wait (scb.compared == N); $finish;
         └─ one component's view only; N must be known up front;
            reactive/interrupt traffic has no fixed N

  GEN 3: objections        any component: raise() while busy
         (UVM-style)                      drop() when idle
         └─ test ends when count == 0 (after drain time)
            every component votes; no magic numbers

  Always paired with: watchdog timeout (the safety net under all three)

An objection counter class

systemverilog
class objection;
  local int unsigned count;
  local string       holders[$];        // who raised — for hang debug
  local time         drain_time = 100ns;

  function void raise(string who);
    count++;
    holders.push_back(who);
  endfunction

  function void drop(string who);
    if (count == 0)
      $fatal(1, "[OBJ] drop('%s') with zero outstanding objections", who);
    count--;
    foreach (holders[i]) if (holders[i] == who) begin
      holders.delete(i);
      break;
    end
  endfunction

  function void set_drain_time(time t);
    drain_time = t;
  endfunction

  // Blocks until count==0 AND stays 0 through a full drain window.
  task wait_for_done();
    forever begin
      wait (count == 0);
      #(drain_time);
      if (count == 0) return;     // nobody re-raised during drain → done
    end
  endtask

  function void dump(string prefix = "[OBJ]");
    $display("%s outstanding=%0d", prefix, count);
    foreach (holders[i]) $display("%s   held by: %s", prefix, holders[i]);
  endfunction
endclass

Why the drain re-check loop matters

The subtle part is wait_for_done(): after the count hits zero it waits a drain time and then re-checks. This handles the gap pattern — generator drops its objection, and only some cycles later does the monitor see a response and raise its own. Without the re-check loop the test ends inside that gap. UVM's objection drain-time exists for exactly this reason.


Wiring it into the bench

systemverilog
class generator;
  objection obj;
  task run();
    obj.raise("generator");
    repeat (n_items) begin
      bus_txn t = new();
      void'(t.randomize());
      mbx.put(t);
    end
    obj.drop("generator");        // accepted ≠ driven: driver holds its own
  endtask
endclass

class driver;
  objection obj;
  task run();
    forever begin
      bus_txn t;
      mbx.get(t);
      obj.raise("driver");        // busy only while actually driving
      drive_one(t);
      obj.drop("driver");
    end
  endtask
endclass

class env;
  objection obj = new();

  task main_phase();
    fork : main_threads
      drv.run(); mon_in.run(); mon_out.run(); mdl.run(); scb.run();
    join_none
    gen.run();

    fork : eot
      obj.wait_for_done();                       // normal end
      begin                                       // watchdog
        #1ms;
        obj.dump("[WATCHDOG]");                   // who is hanging?
        $fatal(1, "[ENV] timeout — objections never drained");
      end
    join_any
    disable eot;
    disable main_threads;
  endtask
endclass

Design notes

  • raise/drop take a name string — when the watchdog fires, dump() tells you which component hung instead of leaving you to bisect.

  • The driver raises per item, not for the whole test — a forever loop holding one permanent objection would never let the count reach zero.

  • drop() below zero is a $fatal: an unbalanced raise/drop pair is a TB bug worth failing loudly on.

  • Watchdog and wait_for_done race under fork...join_any — whichever fires first decides, the other is disabled.

Interview angle

This is one of the highest-yield interview builds: "Implement UVM-style objections in plain SystemVerilog." Hit the four marks — counter with raise/drop, drain-time re-check loop, named holders for debug, watchdog alongside — and explain the gap problem the drain time solves. Candidates who only say "count up, count down, wait for zero" miss the re-raise window and that is the detail interviewers probe.

Key takeaways

  • End-of-test evolves: #delay → txn count → objections; objections let every component vote.

  • After count reaches zero, wait a drain time and re-check — late consumers may re-raise.

  • Track holder names so a watchdog dump names the hung component instantly.

  • The watchdog stays even with objections — a stuck objection is just a politer hang.

Common pitfalls

  • Per-test #delay endings — every back-pressure change silently truncates or wastes simulation.

  • Ending the instant count hits zero — the raise gap between producer and consumer ends the test early.

  • A forever-loop component holding one permanent objection — the count never reaches zero.

  • Unbalanced raise/drop pairs left unchecked — the count drifts and end-of-test becomes nondeterministic.