Skip to content
DenseChip
Learn
PracticeCode labInterview

DenseChip

Navigate

Menu

LearnPracticeCode labInterview

Tools

All toolsSV editorCode repoResume
DenseChip

Practice, courses, and chip-design tools in one focused workspace.

LearnPracticeCode labInterviewToolsLearning paths

© 2026 DenseChip

AboutContactPrivacyTerms
Skip to lesson

Course contents

  • Start Here4
  • UVM Foundations14
  • UVM Phases & Simulation Lifecycle26
  • Factory & Configuration15
  • Transaction-Level Modeling (TLM)13
  • UVM Sequences & Stimulus21
  • UVM Agents & Protocol VIP21
  • Environment & Test Classes11
  • Checking & Coverage in UVM21
  • UVM Register Abstraction Layer (RAL)22
  • Advanced UVM in Practice13
  • Debug & Regression10
  • Coding Practice47
  • Verification in Industry Practice10
    • Simulation for Sign-off
    • Safety, Acceleration and Models
    • Beyond Simulation
      • Formal Alongside Simulation
      • Assertion VIP and bind: Checkers That Travel with the RTL
      • ML and Automation in Verification
  • Senior Prep: Architecture, Leadership and Interviews32
Loading lesson

‹Beyond Simulation
  1. Topics
  2. UVM Verification
  3. Verification in Industry Practice
  4. Beyond Simulation
  5. Assertion VIP and bind: Checkers That Travel with the RTL

Part 12 · Industry Practice · Intermediate

Assertion VIP and bind: Checkers That Travel with the RTL

An APB protocol checker as a bound SVA module: failures reported through UVM, per-test control, a broken driver caught at 75 ns, a four-state X check, and reuse in formal.

On this page

    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.

    The idea: rules that live with the interface, not with one bench

    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 mechanism, already in the bench

    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:

    dma_bd_if.sv
    1
    2
    3
    4
    5
    // 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
    design.sv
    // 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.

    Build it: an APB protocol checker

    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).

    apb_protocol_checker.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    // 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:

    design.sv
    1
    2
    3
    4
    // 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));

    Reporting failures through UVM

    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:

    Diagram
    [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 $stop

    With +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.

    Controlling the checker from a test

    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:

    design.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    // 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
    endclass

    Watch it run (scratch runs, Verilator 5.048)

    With 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:

    Diagram
    +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]     1
    • Three 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:

    Diagram
    +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 :    1

    That 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.

    The X rule needs a four-state simulator

    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:

    xchk_demo.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // 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
    Diagram
    $ 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.

    Packaging assertion VIP so it travels

    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 bind per target, kept out of the RTL file list

    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 assume

    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:

    design.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 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.

    What breaks: a checker switched off to get green

    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.

    Interview

    "How do you attach protocol assertions to a design you are not allowed to edit?"

    A weak answer: "Put the assertions in the interface," or "ask the designer to add them to the RTL."

    A strong answer: "I write the protocol as a checker module with the bus signals as inputs, one property per spec rule plus covers, and attach it with bind to the module type, or to specific instances by path. Ports resolve in the target scope, so internal signals are reachable. Failures go through uvm_error behind a macro, so they count in the UVM verdict and the triage, while formal tools still read the file. Tests control it through a config bit the checker reads, because a test class in a package cannot name hierarchy for $assertoff. The same file runs at block level, at chip level where there is no agent, and in formal, where the input-side rules become assumptions."

    Follow-ups: Why might an SVA failure not fail a UVM test? ($error bypasses the report server; route through uvm_error or have the regression check the simulator's assertion summary). Interface assertions or bound module? (the interface is fine when the bench owns the interface; bind works where only the RTL exists).

    Check yourself A colleague binds the APB checker with 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.

    Check yourself The checker uses plain $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.

    Related topics

    • Where Assertions Live

    • Assertion Control from the Test

    • Backdoor Access

    • bind: Attaching Verification to RTL (SystemVerilog course)

    • Formal Alongside Simulation

    Previous

    246 / 280

    Next: ML and Automation in Verification

    Up next

    Up nextML and Automation in Verification

    Sign in to save progress

    Practice this lessonReport an error
    PCLKPSEL (correct)PENABLE (correct)PREADY (correct)PSEL (no-wait)PENABLE (no-wait)PREADY (no-wait)
    One APB write to the DMA, which inserts one wait state. The correct driver holds PSEL and PENABLE until PREADY. The no-wait driver drops both after one access cycle; PREADY rises a cycle later, when nothing is selected, so the write never lands. a_hold_until_ready fails on the edge after the first access cycle.