Volume 09 Advanced 5 sub-modules ~65 min read

SystemVerilog for Verification (OOP, CRV & SVA)

Everything so far has been about describing hardware. This volume is about interrogating it - and the language changes character completely. Classes, inheritance, threads, mailboxes and randomisation are software constructs, none of them synthesizable, all of them existing for one purpose: to find the bug before the mask set does.

The economics that justify all of this On a large chip, verification is commonly 60-70% of the total engineering effort, and the team is usually bigger than the design team. That ratio is not waste - a mask set costs millions and a respin costs months, so anything that finds a bug before tape-out pays for itself many times over.

9.1 Verification concurrency & process control

A testbench has to do several things at once: drive stimulus, monitor responses, check a scoreboard, watch for timeouts. fork spawns concurrent processes, and the join variant decides how the parent waits for them.

Timelines comparing fork join, fork join_any and fork join_none process control fork join wait for ALL A B C parent resumes after the LONGEST join_any wait for FIRST A B still running C still running parent resumes - use "disable fork" to kill the losers join_none do not wait all three keep running parent resumes IMMEDIATELY - nothing is waited on
Figure 9.1 - Same three child processes, three different parent behaviours. join_any and join_none leave threads running, which is why disable fork and wait fork exist.

// ---- The timeout idiom: whichever finishes first wins -----------------
task automatic wait_for_done_or_timeout();
  fork
    begin : normal
      @(posedge dut_done);
      $display("[%0t] transfer completed", $time);
    end
    begin : watchdog
      #10us;
      $error("[%0t] TIMEOUT waiting for dut_done", $time);
    end
  join_any

  disable fork;   // kill the loser - without this, the watchdog keeps
                  // running and fires on a LATER, unrelated transfer
endtask


// ---- The classic loop-variable bug ------------------------------------
task automatic spawn_wrong();
  for (int i = 0; i < 4; i++) begin
    fork
      drive_channel(i);   // BUG: all 4 threads see i AFTER the loop ends
    join_none
  end
  wait fork;
endtask

task automatic spawn_right();
  for (int i = 0; i < 4; i++) begin
    // `automatic` gives each iteration its OWN copy, captured now.
    automatic int idx = i;
    fork
      drive_channel(idx);
    join_none
  end
  wait fork;             // block until every spawned thread finishes
endtask
Why the loop bug is so common join_none schedules the thread but does not start it until the parent blocks. By then the loop has finished and i holds its final value, so all four threads drive the same channel. The automatic declaration inside the loop body creates a fresh variable per iteration and copies the current value into it. This is asked in interviews constantly, and it is a real bug in real testbenches.

9.2 Object-oriented programming for verification

A transaction is a bundle of data with behaviour attached. Classes give you that, plus inheritance - which is what lets a project define a base stimulus item once and let every test extend it without touching the original.


class Transaction;
  rand bit [31:0] addr;
  rand bit [31:0] data;
  rand bit        write;

  // Constructor. Always `function new`, always returns nothing.
  function new(bit [31:0] addr = '0);
    this.addr = addr;
  endfunction

  // VIRTUAL - so a derived class can override it and still be called
  // correctly through a base handle. See the trap below.
  virtual function void display(string prefix = "");
    $display("%s addr=%h data=%h write=%b", prefix, addr, data, write);
  endfunction

  // Deep copy. Assigning handles copies the POINTER, not the object.
  virtual function Transaction clone();
    Transaction t = new();
    t.addr  = this.addr;
    t.data  = this.data;
    t.write = this.write;
    return t;
  endfunction
endclass


class BurstTransaction extends Transaction;
  rand bit [3:0] len;

  function new(bit [31:0] addr = '0);
    super.new(addr);            // base constructor MUST be called first
  endfunction

  virtual function void display(string prefix = "");
    $display("%s BURST len=%0d addr=%h", prefix, len, addr);
  endfunction
endclass
Handles are pointers Transaction t2 = t1; does not copy the transaction - both handles now point at the same object, and modifying one changes "both". Pushing the same handle into a mailbox twice and then randomising it gives you two references to one randomised object. Every UVM sequence item has a copy() and clone() method for exactly this reason.

The virtual trap

This is the single most asked SystemVerilog interview question, and the answer is precise: without virtual, the method is chosen by the type of the handle. With virtual, it is chosen by the type of the object.

Method dispatch resolved from the handle type without virtual, and from the object type with virtual Base b; Derived d = new(); b = d; b.show(); WITHOUT virtual handle b type: Base object type: Derived Base::show() prints "Base" The override is silently ignored. No error. No warning. WITH virtual handle b type: Base object type: Derived Derived::show() prints "Derived" Polymorphism works. This is why UVM marks nearly everything virtual. Same handle. Same object. One keyword decides which code runs.
Figure 9.2 - The failure mode is what makes this dangerous: nothing errors. Your derived driver is instantiated, connected, and never actually used, and the testbench happily reports a pass.

9.3 Inter-process communication

Once you have concurrent threads, they need to hand work to each other safely. SystemVerilog provides three primitives, and picking the right one is mostly about what you are synchronising.

Primitive Use it to Typical role
mailbox Pass objects between threads, with buffering Generator → driver, monitor → scoreboard
semaphore Guard a shared resource so only N users touch it Two drivers sharing one bus
event Signal that something happened, no data attached "Reset is done", "test may finish"

// ---- MAILBOX: a typed, blocking FIFO of objects -----------------------
// Parameterise it. A bare `mailbox` accepts ANY type and turns a type
// error into a runtime surprise.
mailbox #(Transaction) mbx = new();      // unbounded
mailbox #(Transaction) bnd = new(16);    // bounded: put() blocks when full

task generator();
  repeat (100) begin
    Transaction t = new();
    if (!t.randomize()) $fatal(1, "randomize failed");
    mbx.put(t);                 // blocks if the mailbox is full
  end
endtask

task driver();
  Transaction t;
  forever begin
    mbx.get(t);                 // blocks until something arrives
    drive_on_bus(t);
  end
endtask
// Non-blocking variants: try_put / try_get return 0 instead of blocking.
// peek() reads without removing.


// ---- SEMAPHORE: keys for a shared resource ---------------------------
semaphore bus_lock = new(1);    // 1 key == a mutex

task send(Transaction t);
  bus_lock.get(1);              // blocks until a key is free
  drive_on_bus(t);              // critical section - exclusive access
  bus_lock.put(1);              // ALWAYS return the key
endtask


// ---- EVENT: a pure notification --------------------------------------
event reset_done;

initial begin
  apply_reset();
  -> reset_done;                // trigger
end

initial begin
  wait (reset_done.triggered);  // safe: does not miss a same-timestep trigger
  // @(reset_done);             // UNSAFE: misses a trigger that already fired
  start_test();
end
@(event) vs wait(event.triggered) @ is edge-sensitive: if the trigger fires before you reach the @ - even in the same simulation timestep - you wait forever. wait(e.triggered) stays true for the remainder of the timestep, so it cannot miss. This is the same class of race as Volume 1.2's event queue ordering, and wait(...triggered) is the safe default.

9.4 Constrained random verification

Directed tests find the bugs you thought of. Constrained random finds the ones you did not - you describe the legal stimulus space and let the solver explore corners no human would write by hand.


class Packet;
  rand  bit [10:0] length;      // 0 .. 2047
  randc bit [3:0]  stream_id;   // visits all 16 values before repeating
  rand  bit [1:0]  kind;
  rand  bit        inject_err;

  // Weighted distribution. ":=" gives the weight to EACH value in the
  // range; ":/" spreads ONE weight across the whole range.
  constraint c_length {
    length dist { [64:127]   := 50,      // small packets, most common
                  [128:511]  := 30,
                  [512:1518] := 20 };
  }

  constraint c_err  { inject_err dist { 0 := 95, 1 := 5 }; }

  constraint c_kind { kind inside {[0:2]}; }   // 3 is reserved

  // Hint to the solver about ORDER. See below - this changes the
  // probability distribution, not the set of legal solutions.
  constraint c_order { solve kind before length; }
endclass


// Randomization can FAIL (over-constrained). Always check the return.
task run_test();
  Packet p = new();
  repeat (1000) begin
    if (!p.randomize()) $fatal(1, "packet randomization failed");
    send(p);
  end

  // Inline constraints for one specific call, ANDed with the class ones.
  if (!p.randomize() with { length > 1000; inject_err == 1; })
    $fatal(1, "constrained randomization failed");
endtask

What solve … before actually does

It is not an optimisation hint and it does not change which values are legal. It changes how likely each legal value is - and the effect is far larger than most people expect.

Probability of a being one with and without a solve before constraint, showing 0.4 percent versus 50 percent rand bit a; rand bit [7:0] b; constraint c { a -> b == 0; } Legal (a,b) pairs: a=0 with any b → 256 · a=1 with b=0 → 1 · total 257 no solve uniform over pairs a = 0 → 256/257 = 99.6% a=1 0.4% Your "a = 1" scenario fires roughly 4 times in 1000 packets. solve a before b a chosen first a = 0 → 50% a = 1 → 50% Same legal solutions. Completely different coverage in practice. solve…before changes PROBABILITY, never legality. It cannot make an illegal value appear.
Figure 9.3 - The solver picks uniformly from the solution space, not from each variable. Because a = 1 admits only one partner value, it is crowded out 256-to-1. This is why a constraint that reads like a coin flip can leave a scenario essentially untested.

9.5 Functional coverage, assertions & UVM

Random stimulus is only useful if you can answer two questions: what did we actually hit? and did the design misbehave while we were there? Coverage answers the first; assertions answer the second.


covergroup cg_packet @(posedge clk);
  option.per_instance = 1;

  cp_kind: coverpoint pkt.kind {
    bins read    = {2'b00};
    bins write   = {2'b01};
    bins burst   = {2'b10};
    illegal_bins reserved = {2'b11};     // hitting this is an error
  }

  cp_len: coverpoint pkt.length {
    bins small  = {[64:127]};
    bins medium = {[128:511]};
    bins large  = {[512:1518]};
  }

  // Cross coverage: did we see EVERY kind at EVERY size? 3 x 3 = 9 bins.
  // This is where the real holes usually are.
  x_kind_len: cross cp_kind, cp_len;
endgroup

cg_packet cg = new();     // covergroups must be explicitly instantiated
Code coverage vs functional coverage - the interview answer Code coverage is collected automatically and tells you which lines, branches and states were executed. Functional coverage is written by hand from the verification plan and tells you which scenarios occurred.

You can absolutely have 100% code coverage and 50% functional coverage - and ship a bug. Executing every line of a FIFO proves nothing about whether you ever hit full-and-write-simultaneously. Code coverage tells you what you failed to execute; only functional coverage tells you what you failed to try.

SystemVerilog Assertions

An assertion states a property that must always hold, and the simulator checks it on every clock - including in tests written years later by people who never read your module.

Waveform showing overlapping implication checking the same cycle and non-overlapping implication checking the next cycle cycle 1cycle 2cycle 3 clk a antecedent true in cycle 2 a |-> b b checked HERE same cycle - overlapping a |=> b b checked HERE next cycle - non-overlapping a |=> b is exactly equivalent to a |-> ##1 b THE TWO IMPLICATION OPERATORS
Figure 9.4 - The only difference is when the consequent is evaluated. Getting this backwards produces an assertion that passes for the wrong reason, which is worse than no assertion at all.

// ---- Immediate assertion: procedural, evaluated like an if ------------
always_comb begin
  assert (!(wr_en && full))
    else $error("write attempted while FIFO full");
end

// ---- Concurrent assertion: temporal, evaluated every clock ------------
property p_req_ack;
  @(posedge clk) disable iff (!rst_n)      // ignore checks during reset
  req |-> ##[1:5] ack;                     // ack within 1 to 5 cycles
endproperty

a_req_ack: assert property (p_req_ack)
  else $error("ack did not arrive within 5 cycles of req");

// The Volume 4.5 one-hot check, now enforced by the simulator on EVERY
// cycle of EVERY test rather than by a comment nobody reads.
a_onehot: assert property (
  @(posedge clk) disable iff (!rst_n) $onehot(state)
) else $error("FSM state is not one-hot: %b", state);

// Useful sampling functions inside properties:
//   $rose(x) $fell(x) $stable(x) $past(x, n)
a_stable_cfg: assert property (
  @(posedge clk) disable iff (!rst_n) busy |-> $stable(cfg_mode)
) else $error("config changed mid-transaction");

// `cover` reports how often something HAPPENED - functional coverage
// for temporal sequences, not a pass/fail check.
c_back_to_back: cover property (
  @(posedge clk) req ##1 req
);
Where assertions live in the event queue Concurrent assertion expressions are sampled in the Observed region and their action blocks run in the Reactive region - the two regions from Volume 1.2 that looked like trivia at the time. That placement is what guarantees an assertion sees settled values and can never race the design logic it is checking.

UVM in one diagram

The Universal Verification Methodology is a class library built on everything above: transactions are classes, components communicate through mailbox-like ports, and nearly every method is virtual so tests can override behaviour without editing the environment.

The UVM component hierarchy alongside the nine UVM phases in execution order COMPONENT TREE uvm_test uvm_env uvm_agent uvm_scoreboard uvm_sequencer uvm_driver uvm_monitor The driver and monitor both reach the DUT through a clocking block (Volume 8.4). THE NINE PHASES 1 build top-down 2 connect bottom-up 3 end_of_elaboration 4 start_of_simulation 5 run the ONLY timed phase 6 extract 7 check 8 report 9 final Every phase but "run" completes in zero time. build_phase constructs children, so it must run top-down; connect_phase needs children to exist first, so it runs bottom-up.
Figure 9.5 - The phase ordering is not arbitrary: build_phase creates the children, so a parent must run before them; connect_phase wires ports that must already exist, so children run first. Only run_phase consumes simulation time.
Interview grilling - "Your random test passes every night for a month. Are you done verifying?"

The answer they are listening for is "no, and here is how I would know". A passing test proves nothing on its own - it may be passing because it never reaches the interesting states.

  1. Check functional coverage, not the pass rate. A green regression with 60% functional coverage means 40% of the verification plan is untested. The pass is meaningless in that region.
  2. Look for crowded-out constraints. Exactly the Figure 9.3 problem - a scenario that reads like a coin flip firing 0.4% of the time. Cross coverage exposes these fastest.
  3. Check that the checkers can fail. If the scoreboard has a bug, or an assertion is inside a disable iff that is always true, the test cannot fail. Fault injection or mutation testing answers this.
  4. Vary the seed. One seed for a month is one test run a month. Coverage should still be climbing; if it has flattened, add constraints or directed tests to reach the remaining holes.
  5. Check the virtual keywords. A missing one means your derived driver is never actually called and you have been testing the base behaviour all along - silently, with no error.

The framing that lands: "A passing test tells me nothing until I know what it covered. I trust the coverage report and the assertion count, not the green tick."

Volume 09 recap

Concept The one thing to remember
join variants All / first / none. After join_any, use disable fork.
Loop + join_none Declare automatic inside the loop or every thread sees the final value.
Handles Assignment copies the pointer, not the object. Use clone().
virtual Without it, the handle type picks the method. With it, the object does.
Mailbox Always parameterise it - mailbox #(T), never bare.
Events wait(e.triggered) is safe; @e can miss a same-timestep trigger.
randomize() Returns 0 on failure. Always check it.
solve … before Changes probability, never legality. 0.4% becomes 50%.
Coverage 100% code coverage with 50% functional coverage ships bugs. Cross bins find holes.
|-> vs |=> Same cycle vs next cycle. a |=> ba |-> ##1 b.
UVM phases Nine of them; build top-down, connect bottom-up, only run takes time.