1. SVA Guide - Immediate Assertions

The nightly regression produced 4,212 failures. All from one assertion. A monitor checked a decoded one-hot select inside a combinational always block, and every time the address changed, the decoder's outputs settled one delta apart — old bit dropping, new bit rising — passing through a two-hot state for exactly one evaluation. No clock edge ever saw it; no flip-flop ever captured it; the design was correct. But the simple immediate assertion fired on every transient, four thousand times a night, burying the one real failure of the run somewhere around line 3,800 of the log. The fix was three characters: assert became assert #0.

Immediate assertions are the workhorse checks of testbenches and RTL — instant, procedural, no clocks, no sequences. They are also widely used at 60% understanding: the deferred variants (#0 and final) that solve the glitch problem above are bolted onto most engineers' knowledge as folklore. This post rebuilds the topic on the scheduler itself — where each variant actually evaluates and reports — then climbs to the expert material: assertion control, UVM messaging integration, and design-by-contract discipline.

Note Originally published in 2016; rewritten in 2026. The original's glitch demonstration used back-to-back blocking assignments in one process — which doesn't actually create the delta-separated updates the demo needs. The example below produces a real, provable one-delta glitch. The assert final variant, previously a table row with no explanation, now has its own section.
~15 min read · Beginner body, Advanced tail · Part 1 of 2 — continue with Part 2: Concurrent Assertions.

Why Not Just if (!cond) $error(...)?

The question the 2016 version never answered, and the right place to start — because an immediate assertion looks exactly like an if-statement with attitude:

Key An assertion is an if-statement registered with the tool. That registration buys four things plain procedural code can't have: (1) central control — $assertoff/$asserton can silence and restore whole hierarchies (reset windows, error-injection tests) without touching the code; (2) database integration — assertions appear in coverage and triage tooling as named objects with pass/fail counts; (3) uniform severity — the $fatal/$error/$warning/$info ladder, remappable per tool or methodology; (4) a name — the label becomes a stable failure signature that log-mining scripts and triage dashboards can track across a thousand regressions. An if + $display has none of these. That's the whole argument.

Syntax and the Three Variants

// Simple immediate — evaluates the instant it executes
check_valid: assert (valid) else $error("valid low at %0t", $time);

// Deferred, observed flavor — reports at the END of the time step
check_onehot: assert #0 ($onehot(sel)) else $error("sel not one-hot");

// Deferred, final flavor — reports even later (Postponed region)
check_last: assert final (count == expected) else $error("count mismatch");

All three take the same optional pass action / else fail action. The differences are entirely about when they evaluate and report — which is a scheduler question, so here is the scheduler:

The Map That Explains Everything: SystemVerilog Scheduling Regions

flowchart TD
    ACT["ACTIVE
blocking assigns, simple asserts EVALUATE + REPORT here"] --> INA["INACTIVE
#0 delays"] INA --> NBA["NBA
nonblocking updates land"] NBA --> OBS["OBSERVED
concurrent assertions evaluate
deferred #0 asserts REPORT here"] OBS --> REA["REACTIVE
program blocks, testbench reaction"] REA --> LOOP{"more activity
this time step?"} LOOP -->|yes| ACT LOOP -->|no| POST["POSTPONED
assert final REPORTS, $monitor, $strobe"] style ACT fill:#fee2e2,stroke:#ef4444 style OBS fill:#d1fae5,stroke:#059669 style POST fill:#dbeafe,stroke:#3b82f6

Three facts fall straight out of this diagram:

  • A simple assert evaluates mid-turmoil, in the Active region, while combinational logic is still settling delta by delta. It sees every intermediate state — including two-hot selects that exist for one delta. That's the cold open.
  • A deferred #0 assert evaluates when encountered but holds its report until the Observed region. Crucially, if its enclosing process re-executes later in the same time step (the combinational block re-triggering as inputs settle), the pending report is flushed and replaced by the new attempt's result. Only the final execution's verdict survives to be reported. That flush-and-replace mechanism — not magic glitch immunity — is why deferred assertions ignore transients.
  • An assert final defers all the way to Postponed — after even reactive testbench code has finished. Nothing can change after it reports; it is the last word on the time step.

The Glitch Demo, Done Honestly

A demonstration you can trust needs a provable glitch. Here is the canonical one — a signal that is logically constant 1 but must glitch for exactly one delta:

module glitch_demo;
  logic a, a_n, y;

  assign a_n = ~a;         // updates one delta AFTER a changes
  assign y   = a | a_n;    // logically ALWAYS 1... except for one delta

  // When a falls 1->0: y sees (a=0, a_n still 0) for one delta -> y=0 glitch,
  // then a_n updates to 1 and y returns to 1. Guaranteed, every falling edge.

  always @(y) begin
    simple_chk:   assert    (y) else $error("simple:   y low at %0t", $time);
    deferred_chk: assert #0 (y) else $error("deferred: y low at %0t", $time);
  end

  initial begin
    a = 1; #1;
    a = 0;   // <- glitch here: simple_chk FIRES, deferred_chk stays silent
    #1 $finish;
  end
endmodule

The simple assertion fires on every falling edge of a — correctly reporting a state that genuinely existed and genuinely doesn't matter. The deferred assertion's first attempt (y=0) gets flushed when the block re-executes after a_n settles, and the surviving attempt sees y=1. One is measuring deltas; the other is measuring answers.

Tip The decision rule: checking inside clocked processes or after explicit sequencing — simple assert is fine and its immediacy is a feature (the failing statement is right there). Checking anything driven by combinational logic from a sensitivity-triggered block — deferred #0, always. If you're about to write assert inside always_comb, your fingers should type #0 on reflex.

assert final: the End-of-Story Check

The 2016 table row, finally explained. assert final reports in the Postponed region — after NBA updates, after observed-region checks, after your program-block testbench code has reacted. Two places it earns its keep:

// 1. Checks that must see the ABSOLUTE settled state of a time step,
//    even after reactive testbench code has run:
always @(posedge commit) begin
  bal_chk: assert final (credits_in - credits_out == credits_held)
    else $error("credit accounting broken at %0t", $time);
end

// 2. End-of-simulation invariants — final blocks accept assert final
//    (and time-consuming checks are illegal there anyway):
final begin
  drain_chk: assert final (scoreboard_q.size() == 0)
    else $error("%0d transactions never checked", scoreboard_q.size());
  outstanding_chk: assert final (outstanding == 0)
    else $error("%0d requests still in flight at end of test", outstanding);
end

That second pattern — invariants in final blocks — is the cheapest end-of-test safety net in SystemVerilog: three lines that catch "the test ended but the scoreboard wasn't empty," which otherwise reports as a clean pass with unchecked traffic.

Action Blocks and Severity

assert (b != 0) else $fatal(1, "divide by zero");   // 1 = $finish diagnostic level
assert (valid)  else $error("no valid");            // fails the test, sim continues
assert (ready)  else $warning("slow ready");        // noted, not a failure
assert (parity_ok);                                  // no else: tool emits a default failure message
  • $fatal's first argument — never explained in the original — is the $finish diagnostic verbosity (0/1/2: nothing / time+location / plus memory stats). It is not an error code.
  • The pass action runs on success. assert (x) $display("ok"); prints on every passing evaluation — a classic accidental log-flooder. Use pass actions for coverage-ish counting, not celebration.
  • No else is still a check: failure produces the tool's default message and counts against the run. Bare asserts are fine; unlabeled asserts are the problem (see mistakes).
Warning In a UVM environment, $error bypasses the UVM report server: no component context, no severity remapping, and — the operational killer — the report catcher can't demote it during error-injection tests. House style for class-based code: assert (cond) else `uvm_error("CHK", "..."). Keep $error for modules and interfaces, where UVM macros can't reach.

Common Mistakes

  • Simple asserts on combinational nets. The 4,212-failure cold open. always_comb + assert without #0 is a false-alarm generator by construction.
  • Checking an NBA-updated value in the same clocked block. count <= count + 1; assert (count == expected); reads the old count — NBA updates haven't landed in the Active region. Check count + 1, or defer the check.
  • Unlabeled assertions. The label is the failure's identity in logs, waivers, and triage dashboards. check_axi_wstrb_aligned: survives a year of regressions; "Assertion failed at line 341" survives one refactor.
  • Continuing after a failed precondition in functions. An assert's fail action doesn't alter control flow — the function barrels on with garbage unless you return explicitly in the else.
  • Side effects in pass actions. Passing evaluations can vastly outnumber interesting events; a $display there is a gigabyte of log in a long soak run.
  • $fatal in shipped VIP code. You don't own the simulation you're linked into; the integrator does. Make lethal-vs-recoverable a config knob, or use UVM severity so the environment can remap it.

Interview Corner

Q: Simple vs deferred immediate assertions — what's the actual mechanism separating them?

A: Both evaluate when executed. A simple assert also reports immediately, in the Active region, so it sees combinational settling states. A deferred assert queues its report until the Observed region (#0) or Postponed region (final) — and if the enclosing process re-executes within the same time step, the pending report is flushed and replaced. Only the final execution's result is ever reported, which is precisely why transient glitches vanish.

Q: When is a simple immediate assertion the right choice over #0?

A: When the surrounding code guarantees settled inputs: inside clocked blocks (posedge-triggered, sampling stable flops), after explicit synchronization in testbench tasks, or in functions validating their arguments. There, immediacy is a feature — failure points at the exact statement — and deferral would only distance the report from the cause.

Q: What does assert (x) $display("ok"); with no else clause do on failure?

A: Still fails — the tool emits its default failure message and the run's error accounting increments. The surprise direction is the pass action: it executes on every success, which is usually thousands of times more often than intended. No-else is legal and common; pass-action side effects are the actual trap.

Q: Why does UVM methodology prefer assert ... else `uvm_error over $error?

A: $error reports outside the UVM report server: no component scope, no verbosity control, no severity override, and invisible to uvm_report_catcher — so an error-injection test that expects failures can't demote them, and the regression reports false failures. Routing the fail action through UVM macros keeps assertion failures inside the same reporting machinery as everything else in the environment.

Beyond the Basics: Advanced → Expert

Level 1 — The assertion control system

Registration with the tool (the Key callout's point 1) has a full API. $assertoff(0, tb.dut_wrap) silences every assertion under a scope; $asserton restores; $assertkill additionally aborts attempts already in flight. The bread-and-butter pattern is reset squelching — assertions on un-reset logic see Xs and scream, so: $assertoff at reset assertion, $asserton a few cycles after release, from one place in the base test rather than disable iff bolted onto five hundred checks. 1800-2012's $assertcontrol generalizes this with numeric control types (off/on/kill/lock/unlock and more) selectable by assertion directive type — the escape hatch when you need "silence only cover directives" or "lock these checks against later control calls."

Level 2 — unique/priority: the assertions you're already writing

The expert's view of unique case is that it is a deferred immediate assertion in disguise: it checks one-hot match conditions (no overlap, no fallthrough) and reports violations with deferred-style semantics — evaluated as the case executes, reported at Observed, flushed on re-execution, controllable via the same $assertcontrol machinery. Two consequences: unique/priority violations in combinational always blocks are glitch-immune for the same reason assert #0 is; and your assertion-control calls (reset squelching, $assertoff scopes) affect them too — which surprises teams who thought those keywords were "just synthesis pragmas." They're checks, with everything that implies.

Level 3 — Design by contract in SystemVerilog

Immediate assertions are the natural vehicle for the discipline software calls design-by-contract: every non-trivial task/function opens with precondition asserts (arguments legal, object state sane) and closes side-effectful operations with postcondition asserts. The payoff profile is specific: contract failures point at the caller's bug at the moment of the call — versus the traditional alternative of corrupting state now and failing mysteriously later. Discipline notes that keep it viable at scale: preconditions are #0-free (procedural context is already sequenced); failures name the violated expectation and the received values; and contracts guard interfaces between components, not every internal statement — a function that asserts its own arithmetic on every line has become its own testbench. Pair with randomize(null) (from the constraint-gotcha post) to validate whole-object invariants in one call.

Level 4 — Assertion failures as regression infrastructure

At regression scale, the label stops being documentation and becomes a failure signature: triage systems bucket runs by which named assertions fired, auto-annotate known issues against label + hierarchy path, and track failure-rate trends per label across builds. Engineering for that consumer changes how you write checks: stable, hierarchical label naming (chk_<protocol>_<rule>); one condition per assertion (compound conditions make one label cover three distinct bugs — unbucketable); message text carrying the values, label carrying the identity. The same registration feeds assertion coverage: tools report per-assertion attempt/pass/fail counts, and an assertion with zero lifetime attempts is dead instrumentation — the immediate-assertion cousin of Part 2's vacuity problem, findable in the same coverage review.

Level 5 — X-semantics and the honesty of your checks

The deepest layer: what does assert (expr) do when expr evaluates to X? The rule — X is not true, so the assertion fails — makes immediate assertions quietly X-pessimistic in the safe direction, unlike the RTL around them (where if (x_valued) silently takes the else branch and X-optimism hides bugs). Consequences worth engineering: an assertion firing "spuriously" during reset is usually reporting genuine X-contamination — investigate before you $assertoff it away; comparisons like assert (a == b) fail on X but assert (a !== b)-style case-equality checks can hide X-vs-X matches — choose ==/!= in assertions unless you explicitly mean to bless Xs; and in X-propagation verification flows, immediate assertions on control signals (assert (!$isunknown(psel)) at decision points) are the cheapest X-detection net you can deploy — each one a tripwire at exactly the place X-optimism in RTL would have swallowed the corruption.

Key Takeaways

  • An immediate assertion is an if-statement registered with the tool — control, coverage, severity, and a trackable name are what the registration buys.
  • The three variants differ only in scheduling: simple reports in Active (sees glitches), #0 in Observed, final in Postponed — with flush-on-re-execution as the mechanism behind glitch immunity.
  • Combinational context → assert #0, reflexively. Clocked/sequenced context → simple is fine and immediate is better.
  • assert final in final blocks is the three-line end-of-test safety net; empty scoreboards don't check themselves.
  • In UVM code, route fail actions through `uvm_error, label everything with signature-stable names, and treat X-failures as information, not noise.
Author
Mayur Kubavat
DV engineer working on SoC verification. Writes here about UVM, PCIe, SystemVerilog, and the everyday craft of getting designs to tape-out.

Comments (0)

Leave a Comment