A team reuses a proven APB driver on a new block. On the old block every slave answered in one cycle, so the driver was written to end each access after a single access cycle. The DMA inserts one wait state. Nothing in the bench complains: the register writes simply never land, the job never starts, and twenty microseconds later the test dies with PH_TIMEOUT. The log has no other clue. Someone spends an afternoon in waveforms before noticing that PSEL drops one cycle before PREADY rises.
A twelve-line protocol checker would have named the bug at 75 ns, in the first transfer, with the rule that was broken. This lesson builds that checker, attaches it to the DMA without editing the RTL, wires its failures into the UVM verdict, and shows how the same file travels into every other bench and into formal.
A UVM monitor checks transactions after it has assembled them. A protocol rule like "PADDR must not change until PREADY" is a statement about cycles, and it belongs next to the signals. Assertion VIP is a protocol's rules packaged as SVA in a checker module (or interface) with ports for the protocol's signals, plus covers for the interesting cases. SystemVerilog's `bind` statement instantiates that checker inside a design module from the outside, as if the designer had written the instance in the RTL, without touching the RTL file.
Think of a clip-on meter an electrician attaches to a cable. The meter is the same whichever building it is used in, it never cuts the cable, and it reads the live signal at its source. Because the checker is instantiated inside dma_dut, it sees exactly the pins of every DMA instance: in this block bench, in the two-DMA subsystem bench, at chip level where no APB agent exists at all, and on an emulator, all from one file.
The register layer's backdoor uses bind today. dma_ral_tb.sv declares a small read-only interface and binds it into every dma_dut, so the register model can read KEY, LEN and COUNT without bus traffic:
// snippet: from the course DMA bench
// A read-only window into the DMA's internal registers, bound into every
// dma_dut. The register model's backdoor reads through it.
interface dma_bd_if (input logic [31:0] key, input logic [15:0] len, input logic [15:0] count);
endinterface// snippet: the bind line in dma_ral_tb.sv
bind dma_dut dma_bd_if bd (.key(key), .len(len), .count(count));Read it as: inside every instance of module `dma_dut`, instantiate `dma_bd_if` named `bd`, connecting its ports to `dma_dut`'s own signals `key`, `len` and `count`. The port expressions are resolved in the target's scope, which is why internal registers are reachable. The top then hands dut.bd to the register environment through config_db. A bind can also target listed instances only (bind dma_dut : top.dut0 ...) or one instance by path (bind top.dut0 ...), which matters when two copies of a block need different checker settings.
The checker takes the APB signals as inputs and states the protocol as seven properties. Each one is a sentence from the APB specification, which is the test for whether a property is worth writing: if you cannot point at the spec line, it is probably a testbench habit (bench war story 10 is what happens otherwise).
// snippet: the scratch file beyond_tb.sv, bound into the DMA bench (Verilator 5.048, --assert)
module apb_protocol_checker #(parameter int AW = 8, parameter int DW = 32) (
input logic pclk, presetn,
input logic psel, penable, pwrite, pready,
input logic [AW-1:0] paddr,
input logic [DW-1:0] pwdata
);
import uvm_pkg::*;
`ifdef APB_CHK_UVM
`define APB_FAIL(ID, MSG) `uvm_error(ID, MSG)
`else
`define APB_FAIL(ID, MSG) $error("[%s] %s", ID, MSG);
`endif
bit chk_en = 1'b1; // a test can switch the checker off (see below)
initial begin
@(posedge presetn);
if (uvm_config_db#(bit)::get(null, "apb_chk", "enable", chk_en))
`uvm_info("APB_CHK", $sformatf("checker enable set by the test: %0d", chk_en), UVM_LOW)
end
// 1. A transfer opens with a setup cycle: PENABLE is low when PSEL rises.
a_setup_first: assert property (@(posedge pclk) disable iff (!presetn || !chk_en)
$rose(psel) |-> !penable)
else `APB_FAIL("APB_SETUP", "PENABLE high in the first cycle of PSEL")
// 2. PENABLE only with PSEL.
a_enable_needs_sel: assert property (@(posedge pclk) disable iff (!presetn || !chk_en)
penable |-> psel)
else `APB_FAIL("APB_ENABLE", "PENABLE high without PSEL")
// 3. Setup is followed by access.
a_setup_to_access: assert property (@(posedge pclk) disable iff (!presetn || !chk_en)
psel && !penable |=> psel && penable)
else `APB_FAIL("APB_ACCESS", "setup cycle not followed by an access cycle")
// 4. PSEL and PENABLE stay high until PREADY.
a_hold_until_ready: assert property (@(posedge pclk) disable iff (!presetn || !chk_en)
psel && penable && !pready |=> psel && penable)
else `APB_FAIL("APB_HOLD", "PSEL/PENABLE dropped before PREADY")
// 5. Address, direction and write data are stable for the whole transfer.
a_stable: assert property (@(posedge pclk) disable iff (!presetn || !chk_en)
psel && !(penable && pready) |=> $stable(paddr) && $stable(pwrite) && (!pwrite || $stable(pwdata)))
else `APB_FAIL("APB_STABLE", "PADDR/PWRITE/PWDATA changed during a transfer")
// 6. PENABLE drops after the completing cycle.
a_enable_drops: assert property (@(posedge pclk) disable iff (!presetn || !chk_en)
psel && penable && pready |=> !penable)
else `APB_FAIL("APB_END", "PENABLE still high after the transfer completed")
// 7. No X or Z on the control lines (meaningful on a four-state simulator only).
a_no_x: assert property (@(posedge pclk) disable iff (!presetn || !chk_en)
!$isunknown({psel, penable}) && (!psel || !$isunknown({paddr, pwrite})))
else `APB_FAIL("APB_X", "X or Z on an APB control line")
// Witness: at least one transfer with a wait state happened.
c_wait_state: cover property (@(posedge pclk) disable iff (!presetn)
(psel && penable && ~pready) ##1 (psel && penable && pready));
endmodule`disable iff (!presetn || !chk_en)` turns every property off during reset and when the test switches the checker off. Without the reset term, the first cycle after reset would compare against garbage from before it.
Rule 5 checks PWDATA only on writes: the specification leaves it unconstrained during reads, and a checker stricter than the spec rejects legal masters.
Rule 7 uses $isunknown, which can only fire on a four-state simulator. On Verilator (two-state) it is always true; the four-state run is further down.
The cover is a witness that the bench exercised wait states at all. A protocol checker that never saw a wait state proves nothing about them.
`APB_FAIL` picks the failure action at compile time: uvm_error in a UVM bench, plain $error elsewhere. Formal tools do not run action blocks, so the same file stays usable there.
One bind line attaches it to every DMA, with the checker's ports connected to the DUT's port names:
// snippet
bind dma_dut apb_protocol_checker u_apb_chk (
.pclk(clk), .presetn(rst_n), .psel(psel), .penable(penable), .pwrite(pwrite),
.pready(pready), .paddr(paddr), .pwdata(pwdata));A plain SVA $error is a simulator message, not a UVM report. The UVM report server never counts it, so on a simulator that continues past $error the summary can say UVM_ERROR : 0 with assertion failures above it, and a regression that reads the summary calls the run a pass. The scratch run built the checker both ways. With plain $error, Verilator stopped at the first failure (its default), and no UVM summary or verdict line was printed at all:
[75000] %Error: beyond_tb.sv:41: Assertion failed in top.dut.u_apb_chk.a_hold_until_ready: [APB_HOLD] PSEL/PENABLE dropped before PREADY
%Error: beyond_tb.sv:41: Verilog $stopWith +define+APB_CHK_UVM, the failure is a uvm_error with its own ID. It is counted, it can be waived with a report catcher or demoted per ID like any other message, it appears in the summary by ID, and the triage script from Regression Triage by Signature buckets it like any other first error. More options are in Reporting Assertion Failures through uvm_report.
Tests sometimes need a checker off: an error-injection test that breaks the protocol on purpose, or a bring-up phase. The assertion control tasks $assertoff, $asserton, $assertkill and the general $assertcontrol take hierarchical names. A UVM test is a class in a package, and package items cannot contain hierarchical references, so a test cannot simply write $assertoff(0, top.dut.u_apb_chk). Two patterns work:
A config bit read by the checker, as above: the test calls uvm_config_db#(bit)::set(null, "apb_chk", "enable", 0) in build_phase, and the checker reads it after reset and folds it into disable iff. It needs no hierarchy and is narrow by design.
A module-side helper in the top (or in the checker) that waits on a uvm_event or a config value set by the test and then calls $assertoff/$asserton on the hierarchy it can name. Use this when you need the system tasks' scoping, for example to kill attempts already in flight.
Here are the broken driver and the two tests that use it, from the scratch run. The driver is the zero-wait-state assumption from the opening story, written as a factory override:
// snippet: the deliberately broken driver from the scratch run
class apb_driver_no_wait extends apb_driver;
`uvm_component_utils(apb_driver_no_wait)
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
// drive() is not virtual in apb_driver, so run_phase is replaced as well
task run_phase(uvm_phase phase);
vif.psel <= 1'b0;
vif.penable <= 1'b0;
@(posedge vif.rst_n);
forever begin
seq_item_port.get_next_item(req);
drive_no_wait(req);
seq_item_port.item_done();
end
endtask
task drive_no_wait(apb_item it);
@(posedge vif.clk);
vif.paddr <= it.addr;
vif.pwrite <= it.write;
vif.pwdata <= it.data;
vif.psel <= 1'b1;
vif.penable <= 1'b0;
@(posedge vif.clk);
vif.penable <= 1'b1;
@(posedge vif.clk); // bug: assumes PREADY is already high
it.rdata = vif.prdata;
it.slverr = vif.pslverr;
vif.psel <= 1'b0;
vif.penable <= 1'b0;
endtask
endclass
class dma_apb_no_wait_test extends dma_smoke_test;
`uvm_component_utils(dma_apb_no_wait_test)
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
set_type_override_by_type(apb_driver::get_type(), apb_driver_no_wait::get_type());
super.build_phase(phase);
uvm_root::get().set_timeout(20us, 1);
endfunction
endclass
// Same broken driver; this test switches the checker off.
class dma_apb_chk_off_test extends dma_apb_no_wait_test;
`uvm_component_utils(dma_apb_chk_off_test)
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
uvm_config_db#(bit)::set(null, "apb_chk", "enable", 1'b0);
endfunction
endclassWith the correct driver, the checker is silent; dma_smoke_test and dma_rand_test match the course logs exactly (8 words matched, 14 words matched, UVM_ERROR : 0). With the no-wait driver:
+UVM_TESTNAME=dma_apb_no_wait_test
UVM_ERROR beyond_tb.sv(41) @ 75000: reporter [APB_HOLD] PSEL/PENABLE dropped before PREADY
UVM_ERROR beyond_tb.sv(41) @ 105000: reporter [APB_HOLD] PSEL/PENABLE dropped before PREADY
UVM_ERROR beyond_tb.sv(41) @ 135000: reporter [APB_HOLD] PSEL/PENABLE dropped before PREADY
UVM_FATAL uvm_phase_hopper.svh(598) @ 20000000: reporter [PH_TIMEOUT] Explicit timeout of 20000000 hit, indicating a probable testbench issue
UVM_ERROR : 3
UVM_FATAL : 1
[APB_HOLD] 3
[PH_TIMEOUT] 1Three errors, three writes. dma_smoke_test writes KEY, LEN and CTRL; each write breaks the rule once. The fourth step, streaming data, blocks forever because the DMA never went busy, and the timeout ends the run.
75 ns is the first transfer. Reset ends at 35 ns, the setup cycle is driven after the 45 ns edge, the access cycle after 55 ns; at 65 ns the checker samples PSEL and PENABLE high with PREADY low, and at 75 ns PSEL is already low.
The ID names the rule. [APB_HOLD] tells the reader what broke before anyone opens a waveform.
The same broken driver with the checker switched off by the test:
+UVM_TESTNAME=dma_apb_chk_off_test
UVM_INFO beyond_tb.sv(23) @ 35000: reporter [APB_CHK] checker enable set by the test: 0
UVM_FATAL uvm_phase_hopper.svh(598) @ 20000000: reporter [PH_TIMEOUT] Explicit timeout of 20000000 hit, indicating a probable testbench issue
UVM_ERROR : 0
UVM_FATAL : 1That is the opening story's log: a timeout and nothing else. The checker did not change the outcome (the run fails either way), it changed the time to diagnosis, from an afternoon to one line.
Verilator is two-state, so rule 7 can never fire there. The scratch run below (Icarus Verilog 13.0) uses a driver that parks PSEL in reset but forgets PENABLE. Icarus has no concurrent assertions, so the rule is written as a clocked immediate assertion:
// not-run: needs a four-state simulator (run with Icarus)
module xchk_demo;
logic clk = 0, presetn = 0;
always #5 clk = ~clk;
logic psel, penable; // the "driver" below forgets to park PENABLE in reset
initial begin
psel = 1'b0;
repeat (3) @(posedge clk);
presetn <= 1'b1;
@(posedge clk); psel <= 1'b1; penable <= 1'b0;
@(posedge clk); penable <= 1'b1;
@(posedge clk); psel <= 1'b0; penable <= 1'b0;
repeat (2) @(posedge clk);
$finish;
end
// Icarus has no concurrent assertions, so the same rule is a clocked immediate assertion.
always @(posedge clk)
if (presetn)
a_no_x: assert (!$isunknown(psel) && !$isunknown(penable))
else $error("[APB_X] X or Z on an APB control line: psel=%b penable=%b", psel, penable);
endmodule$ iverilog -g2012 -o x.vvp xchk.sv && vvp x.vvp
ERROR: xchk.sv:19: [APB_X] X or Z on an APB control line: psel=0 penable=x
Time: 35 Scope: xchk_demo
xchk.sv:13: $finish called at 75 (1s)While writing it, Icarus 13 mis-evaluated !$isunknown({psel, penable}) inside an immediate assert (it failed with known values), while !$isunknown(psel) && !$isunknown(penable) behaved. Simulator corner cases like this are why assertion VIP is tested on every simulator it ships to. X checks in sign-off simulation are covered in Reset and X-Propagation Verification.
Piece | What it holds | Why |
|---|---|---|
Checker module | Parameterised widths, one property per spec rule, covers, an enable, the failure macro | One file serves block, subsystem, chip, emulation and formal |
Bind file | One | The design team never edits RTL for verification; the bind file is in the verification file list only |
Assume/assert switch | A parameter or macro that turns input-side rules into | The same rules are the formal environment and the simulation check |
UVM glue | The config-bit enable and the failure IDs | Tests control it; failures count in the verdict and the triage |
Rule list | Each property mapped to its spec clause | Reviewers can check coverage of the spec; waivers name a rule, not a line |
An AXI-Stream checker for the DMA's data ports follows the same pattern. The core rules, as properties:
// snippet: AXI-Stream rules, same packaging as the APB checker
// Once TVALID is high, it stays high until the beat is accepted.
a_valid_held: assert property (@(posedge clk) disable iff (!rst_n)
tvalid && !tready |=> tvalid);
// The beat does not change while it waits.
a_data_stable: assert property (@(posedge clk) disable iff (!rst_n)
tvalid && !tready |=> $stable(tdata) && $stable(tlast));
// TVALID is never unknown out of reset (four-state simulators only).
a_valid_known: assert property (@(posedge clk) disable iff (!rst_n)
!$isunknown(tvalid));Bound to the input port, these check the bench's stream driver; bound to the output port, they check the DMA itself. That symmetry is the point of assertion VIP: the rules do not care who drives.
The most common way assertion VIP fails is not a wrong property but a silenced one. A test that injects protocol errors switches the checker off with a wide scope, the switch gets copied into the base test "because it was noisy", and months later the checker is off in every test. Every result looks like dma_apb_chk_off_test above: green until something hangs, and no clue when it does. The defences are dull and effective: switch off one rule ID or one instance, never the whole checker by default; print the enable state in every log (the APB_CHK line above does that); and count checkers that ran but never fired a cover, which flags a checker disabled or bound to the wrong signals.
bind top.dut0 apb_protocol_checker ... and later adds a second DMA instance, top.dut1. What happens, and what would you change?The checker is instantiated only in top.dut0, so dut1 runs unchecked, silently. Bind to the module type (bind dma_dut ...) to cover every instance, or list the instances explicitly (bind dma_dut : top.dut0, top.dut1 ...) if they need different parameters. A cover per instance shows at a glance whether each copy is being checked.
$error, and the regression decides pass or fail from the UVM summary. On a simulator that continues past $error, what does a run with one APB violation report?Probably a pass: the UVM summary counts only UVM reports, so it says UVM_ERROR : 0 while the assertion failure sits higher up in the log. Fix it by routing the action block through uvm_error (behind a macro, so formal tools still read the file), or by making the regression also check the simulator's assertion failure count.
Next: checkers, covers and proofs all produce data. ML and Automation in Verification looks at what can be learned from that data, what is hype, and what you can build yourself.