Under fault 6, DONE stuck at 1, the bound invariant fired on the first busy cycle and then on every busy cycle after it: 57 UVM_ERROR lines for one stuck flag, interleaved with everything else the bench printed. In an earlier lesson of the industry section, the same kind of checker written with plain $error stopped Verilator at the first failure and printed no UVM summary at all. Neither log is what a triage script, or a person at 2 a.m., wants. How should assertion failures enter the UVM verdict, and how do you keep one root cause from becoming 57 lines?
The UVM report server is where the bench decides pass or fail. It counts by severity and ID, it runs report catchers (waivers), it applies severity overrides, it stops the run at the quit count, and the verdict server prints PASS or FAIL from its counts. A failure that bypasses it, such as a plain $error, is invisible to all of that: it may stop the simulator, or it may scroll past while the summary says UVM_ERROR : 0. So the rule is simple: every check failure, from a scoreboard or from an assertion, reaches the report server. Think of one inbox for a support team. Tickets that arrive by side channels get answered by whoever happens to see them, and are never counted.
// snippet: failure routing in the rule checker
`ifdef DMA_CHK_PLAIN
`define CHK_FAIL(ID, MSG) begin $error("[%s] %s", ID, MSG); dma_chk_pkg::n_fail++; end
`else
`define CHK_FAIL(ID, MSG) `uvm_error(ID, MSG)
`endif
a_done_not_busy: assert property (@(posedge clk) disable iff (!rst_n) !(done && busy))
else `CHK_FAIL("DMA_DONE_BUSY", $sformatf("%m: DONE and BUSY both set"))The default build calls uvm_error. A build that must stay UVM-free (a checker shared with a team that has no UVM, or an emulation flow) defines DMA_CHK_PLAIN: the failure prints with $error and increments a counter, and one small component makes the count fail the test. On a simulator that stops at $error, run it with the tool's option to continue, or the counter never gets read.
// snippet: for checkers that must stay UVM-free, a component turns the count into a UVM error
class chk_fail_counter extends uvm_component;
`uvm_component_utils(chk_fail_counter)
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void check_phase(uvm_phase phase);
if (dma_chk_pkg::n_fail != 0)
`uvm_error("SVA_FAIL", $sformatf("%0d assertion failure(s) reported by plain $error; see the log above", dma_chk_pkg::n_fail))
endfunction
endclassA third route needs no code in the checker at all: the regression script reads the simulator's own assertion summary as well as the UVM summary, and fails the run if either reports failures. It is the cheapest option and a good safety net, but it acts after the run, so waivers, quit count and the verdict line cannot use it.
The ID is the rule, not the instance: DMA_DONE_BUSY, DMA_SHORT_PKT. Triage buckets, waivers and trend charts key off it, so it must not change from run to run.
The location goes in the message. A uvm_error called from a module has no component, so the report's context is the generic reporter. Put %m in the message; in the scratch log it expands to the assertion's own scope, top.dut.u_rules.a_done_not_busy..., which names the instance.
Values go in the message too: the LEN, the count, the state. "input TLAST before word LEN=8" saves a waveform session; "assertion failed" does not.
Two tools help, and they solve different problems. A quit count stops the run after N errors of any kind: set_max_quit_count(5) on the report server in the test, or +UVM_MAX_QUIT_COUNT=5,NO on the command line. It keeps the log short and the farm free, and the first error is still first. A property that fires once per episode fixes the shape of the check itself: $rose(done && busy) reports when the illegal state begins, not on every cycle it lasts. Use the quit count in regression and the property shape when a condition, once broken, stays broken.
+UVM_TESTNAME=sva_quit_test +FAULT=6
FAULT 6 injected
UVM_ERROR sva_tb.sv(36) @ 165000: reporter [DMA_DONE_BUSY] top.dut.u_rules.a_done_not_busy.unnamedblk3: DONE and BUSY both set
UVM_ERROR sva_tb.sv(36) @ 175000: reporter [DMA_DONE_BUSY] top.dut.u_rules.a_done_not_busy.unnamedblk3: DONE and BUSY both set
UVM_ERROR sva_tb.sv(36) @ 185000: reporter [DMA_DONE_BUSY] top.dut.u_rules.a_done_not_busy.unnamedblk3: DONE and BUSY both set
UVM_ERROR sva_tb.sv(36) @ 195000: reporter [DMA_DONE_BUSY] top.dut.u_rules.a_done_not_busy.unnamedblk3: DONE and BUSY both set
UVM_ERROR sva_tb.sv(36) @ 205000: reporter [DMA_DONE_BUSY] top.dut.u_rules.a_done_not_busy.unnamedblk3: DONE and BUSY both set
Quit count reached!
Quit count : 5 of 5
UVM_ERROR : 5
UVM_FATAL : 0
[DMA_DONE_BUSY] 5
TEST VERDICT: FAIL (UVM_ERROR=5, UVM_FATAL=0)The first error is at 165 ns, the first clock on which the DMA is busy in the first job. The run stops 40 ns later instead of at 1,305 ns with 57 errors.
The verdict line still prints, because the verdict server prints from report_summarize, which runs on the way out after a quit.
The per-ID summary names the rule, so a triage script buckets the run as DMA_DONE_BUSY without reading a waveform.
The bench's DMA-142 waiver is scoped to one component: uvm_report_cb::add(env.sb, catcher). Try the same with an assertion ID and there is no component to attach it to, because every module-side uvm_error reports through the root context. A catcher added with uvm_report_cb::add(null, catcher) sees every message in the bench, so a waiver keyed only on DMA_DONE_BUSY silences that rule on every DMA in a subsystem, including the one where it is a real bug. Scope assertion waivers by message pattern instead, which is another reason to put %m in every message: a pattern such as "top.dut0.u_rules*" names one instance. Better still, if a usage rule is expected to fire, switch it off with its knob before it fires, as lesson 2 does, rather than catching it afterwards.
The classic version of this failure is older and simpler: a checker using plain $error on a simulator that continues past it. Every assertion failure prints, the UVM summary says UVM_ERROR : 0, and the regression, which reads only the summary, calls it a pass. Assertion VIP and bind shows both behaviours on real runs.
DMA_DONE_BUSY in 3 runs, each with exactly 5 errors. What do you suspect, and what do you look at first?Exactly 5 suggests the quit count stopped each run, so the real number of failing cycles is unknown, and the runs never reached their own checks. Look at the first error's time and the instance path in its message, then the seed: three runs with the same first-error signature are one bug. The quit count did its job, keeping the logs short and the first error first.
uvm_fatal "so nobody can miss it". What is lost?The run stops at the first failure, so independent failures later in the run are never seen, check_phase and the scoreboard's leftover checks never run, and one flaky protocol check can hide a data bug behind it. Errors with a quit count give the same visibility and keep the rest of the evidence. Fatal is for a bench that cannot continue, such as a missing virtual interface.
Next: the assertions are in place, controlled, measured and reported. The Leadership and Senior Judgment topic moves from the bench to the team that runs it.