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.
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.
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
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
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.
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.
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
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.
// ---- 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
);
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.
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.
- 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.
- 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.
-
Check that the checkers can fail. If the scoreboard has a bug, or an
assertion is inside a
disable iffthat is always true, the test cannot fail. Fault injection or mutation testing answers this. - 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.
-
Check the
virtualkeywords. 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 |=> b ≡ a |-> ##1 b. |
| UVM phases | Nine of them; build top-down, connect bottom-up, only run takes time. |