Capstone: the complete layered testbench

Assemble every layer into an environment and a test, decide pass or fail on more than an error count, and see how the same structure maps onto UVM.

Lesson
7.7
Time
40 min
Runs on
Verilator
Check
5 questions

A testbench refactor left the scoreboard reading from a mailbox that nothing wrote to. For a week every test printed PASS: 0 errors, because a scoreboard that checks nothing finds nothing wrong. The verdict counted errors and asked nothing about the work done, so a disconnected checker looked exactly like a perfect design.

Stimulus runs, nothing is checked, the verdict is PASS
01234operations driven212edges checkederrorsverdictPASSmonitor disconnected

One column per nightly run. A verdict that looks only at errors cannot tell these runs from good ones.

The whole testbench

Every layer is now built, and each one reuses a module of this course. The classes come from Module 4, the queue model from Module 2 and the random stimulus from Module 5. The processes and mailboxes are Module 3's, and the interface, clocking blocks and assertions are Module 6's.

LayerComponentJobReusesStatus
Testfifo_test, fifo_configchooses depth, seed and length, runs the environment, gives the verdictModule 4 classesthis lesson
Generatorfifo_generatorcreates fifo_op objects: directed, then seeded randomModules 4 and 5built in 7.3
Mailboxesmailbox #(fifo_op), mailbox #(fifo_obs)carry operations and observations between processesModule 3built in 7.3
Driverfifo_driverdrives one operation per clock through drv_cbModule 6built in 7.3
Monitorfifo_monitorsamples the pins through mon_cb into a new fifo_obsModule 6built in 7.4
Scoreboardfifo_scoreboardpredicts each edge with the queue exp_q and comparesModules 2 and 4built in 7.4
Assertionsfifo_propschecks timing rules on the pins at every edgeModule 6built in 7.4
Coveragefifo_coverage, fifo_scenariosmeasures which situations the stimulus reachedModule 5 stimulusbuilt in 7.5
DUTfifothe design under testlesson 7.1 rulesbuilt in 7.2

The layered testbench after lesson 7.7.

  1. Configure

    fifo_test fills a fifo_config with the depth, the seed and the number of random operations, and prints them.

  2. Build and connect

    fifo_env creates the two mailboxes and every component, and hands the driver and monitor the virtual interface.

  3. Run

    The environment forks the generator, driver, monitor and the scoreboard loop; the assertions run on the pins on their own.

  4. Drain

    It waits until every operation is driven, then two more edges, so the last answer is checked.

  5. Decide

    report() prints the counts and the verdict, which depends on more than errors.

Environment and test

fifo_env is the one place where components meet. It owns the mailboxes, so no component creates a channel for another, and each component sees only the handles it is given. Swapping the monitor or the scoreboard means changing one line in the environment.

Each channel also carries data in one direction only. The generator only puts operations and the driver only gets them; the monitor only puts observations and the scoreboard only gets them. That makes a hang easy to place, because the side that stopped moving is the one to inspect.

fifo_test holds nothing but choices. A second test, say a long run at depth 3 with another seed, is a few lines that build a different fifo_config and reuse the same environment. That is the payoff of keeping settings out of the components.

The top module stays small: the clock, the interface, the DUT, the assertions, one test object and a watchdog. Everything that verifies lives in classes, which is what lets the same environment check a second FIFO instance or a later version of the design.

Pass means more than zero errors

The verdict in report() requires four things. The scoreboard found no errors, and the model is empty after the drain. Both mailboxes are empty, so nothing was left unprocessed. Finally, every scenario in the test plan happened, from a write refused at full to two laps of the ring.

Errors only

pass = sb.errors == 0;

Passes a run that checked nothing, or one whose stimulus never reached the hard cases.

Errors, leftovers and coverage

pass = sb.errors == 0
    && sb.exp_q.size() == 0
    && req.num() == 0 && obs_mb.num() == 0
    && sc.missing() == "";

Fails if anything was left unchecked or any planned scenario never happened.

A disconnected monitor, as in the opening story, now fails: with no observations, no scenario is ever counted, so sc.missing() lists every one of them. A good verdict makes the testbench prove that it did its work.

Before you trust the testbench, run it with several seeds and at more than one depth, and keep the broken designs from lesson 7.5 in the regression. Together they show that the stimulus reaches the corners, that the checks can fail, and that the verdict notices a run that did not do its work.

From here to UVM

UVM, the Universal Verification Methodology, is a class library that standardises exactly this structure, so testbenches from different teams fit together. Almost every piece you wrote has a UVM counterpart.

This testbenchUVM
fifo_opuvm_sequence_item
fifo_generatora uvm_sequence running on a uvm_sequencer
fifo_driveruvm_driver
fifo_monitor and mailbox #(fifo_obs)uvm_monitor with an analysis port
fifo_scoreboarduvm_scoreboard
fifo_coveragea uvm_subscriber holding covergroups
fifo_env, fifo_testuvm_env, uvm_test
fifo_config handed to each componentuvm_config_db
the end-of-test waitobjections, raised and dropped by sequences and the test

The same layers, with UVM's names.

UVM supplies the plumbing: building, connecting, phasing and reporting. The judgement stays yours: the model, the timing of each check, the scenarios that must happen and the verdict. The UVM track builds testbenches of this shape, several of them around a FIFO, and the problem bank's FIFO problems use this same design and rules.

One difference you will meet first: UVM splits the generator into a sequence, which decides the operations, and a sequencer, which hands them to the driver when it asks. The driver requests each item instead of reading a mailbox, but the rhythm of one operation per clock is the same.

Try it

The test prints its configuration first, then the environment reports 215 checked edges for 212 operations, every scenario of the plan at least once, and an empty model and mailboxes. Only then does it print PASS.

Change the test to new(bus, DEPTH, 0, 2024), so only the directed cases run, and predict the verdict and the missing scenarios.

RUNVerilator · simulator service
Edit the code, then run it
Expected: fifo_test: depth=4 seed=2024 random operations=200 215 edges, 86 writes, 84 reads, 0 errors scenarios: laps=20 write_blocked=6 read_blocked=28 both_at_full=4 both_at_empty=9 both_between=30 reset_holding=1 PASS: 212 operations driven, model and mailboxes empty

Check yourself

Predict the outputSix observations are fed to fifo_scenarios for a depth-2 FIFO, each with the number of items stored before the edge. Predict the exact output.
// The FIFO's pins, with the clocking blocks from module 6.
interface fifo_if #(parameter int WIDTH = 8) (input bit clk);
  bit             reset, write_en, read_en;
  bit [WIDTH-1:0] write_data;
  bit             full, empty, read_valid;
  bit [WIDTH-1:0] read_data;
  clocking drv_cb @(posedge clk);
    default input #1step output #2;
    output reset, write_en, read_en, write_data;
    input  full, empty;
  endclocking
  clocking mon_cb @(posedge clk);
    default input #1step;
    input reset, write_en, read_en, write_data, full, empty, read_valid, read_data;
  endclocking
endinterface

// What the monitor saw at one rising edge: every value is from just before the edge.
class fifo_obs;
  bit       reset, write_en, read_en, full, empty, read_valid;
  bit [7:0] write_data, read_data;
endclass

// Samples the pins through mon_cb and publishes one fifo_obs per edge.
class fifo_monitor;
  virtual fifo_if     vif;
  mailbox #(fifo_obs) obs_mb;
  function new(virtual fifo_if vif, mailbox #(fifo_obs) obs_mb);
    this.vif = vif; this.obs_mb = obs_mb;
  endfunction
  task run();
    forever begin
      fifo_obs o = new();                      // a fresh object for every edge
      @(vif.mon_cb);
      o.reset      = vif.mon_cb.reset;
      o.write_en   = vif.mon_cb.write_en;
      o.read_en    = vif.mon_cb.read_en;
      o.write_data = vif.mon_cb.write_data;
      o.full       = vif.mon_cb.full;
      o.empty      = vif.mon_cb.empty;
      o.read_valid = vif.mon_cb.read_valid;
      o.read_data  = vif.mon_cb.read_data;
      obs_mb.put(o);
    end
  endtask
endclass

// Counts the boundary scenarios the test plan requires (works on any simulator).
class fifo_scenarios;
  int depth;
  int write_blocked, read_blocked, both_at_full, both_at_empty, both_between, reset_holding, laps;
  int writes_since_reset;
  function new(int depth);
    this.depth = depth;
  endfunction
  // Call before the scoreboard updates its model, with the occupancy before the edge.
  function void sample(fifo_obs o, int stored);
    if (o.reset) begin
      if (stored > 0) reset_holding++;
      writes_since_reset = 0;
      return;
    end
    if (o.write_en && stored == depth) write_blocked++;
    if (o.read_en && stored == 0) read_blocked++;
    if (o.write_en && o.read_en) begin
      if (stored == depth) both_at_full++;
      else if (stored == 0) both_at_empty++;
      else both_between++;
    end
    if (o.write_en && stored < depth) begin
      writes_since_reset++;
      if (writes_since_reset % depth == 0) laps++;   // the write pointer is back at slot 0
    end
  endfunction
  function string missing();
    string m = "";
    if (write_blocked == 0) m = {m, " write_blocked"};
    if (read_blocked  == 0) m = {m, " read_blocked"};
    if (both_at_full  == 0) m = {m, " both_at_full"};
    if (both_at_empty == 0) m = {m, " both_at_empty"};
    if (both_between  == 0) m = {m, " both_between"};
    if (reset_holding == 0) m = {m, " reset_holding"};
    if (laps < 2)           m = {m, " two_laps"};
    return m;
  endfunction
endclass

module tb;
  fifo_scenarios sc = new(2);
  function automatic fifo_obs obs(bit rst, bit w, bit r);
    fifo_obs o = new();
    o.reset = rst; o.write_en = w; o.read_en = r;
    return o;
  endfunction
  initial begin
    sc.sample(obs(1, 0, 0), 0);   // reset while empty
    sc.sample(obs(0, 1, 0), 0);   // write
    sc.sample(obs(0, 1, 0), 1);   // write; now full
    sc.sample(obs(0, 1, 1), 2);   // read and write at full
    sc.sample(obs(0, 0, 1), 1);   // read
    sc.sample(obs(0, 0, 1), 0);   // read at empty
    $display("missing:%s", sc.missing());
  end
endmodule
Spot the bugEach option is the verdict line in report(). The harness runs only the directed cases, which never reach several planned scenarios, so the environment must return 0. Which option passes this incomplete run?
ConceptWhy does fifo_env create the mailboxes, instead of each component creating the channel it uses?
ConceptIn the UVM version of this testbench, which part still has to be written by you, whatever the library provides?
ConceptA team wants a second test: 2,000 random operations at depth 3 with seed 99. What is the smallest change?

Remember

  • The environment builds every component, owns the channels, runs the layers and gives the verdict.
  • A test is a configuration plus a run; new tests reuse the same environment.
  • Pass only when there are no errors, nothing is left unchecked, and every planned scenario happened.
  • UVM standardises the same layers; the model, the checks and the verdict remain your design.

Practice