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

  • Part 0 · What Debugging Is6
  • Part 1 · Reproduce8
  • Part 2 · Observe9
  • Part 3 · Localise10
  • Part 4 · Hypothesise and Test6
  • Part 5 · Bug Classes16
  • Part 6 · Debug by Stage9
  • Part 7 · Hangs and Deadlocks7
  • Part 8 · Intermittent Failures7
  • Part 9 · Fix, Prove, Prevent7
    • Fix the Cause, Not the Symptom
    • Proving the Fix
    • The Regression Test That Pins the Bug
    • The Assertion That Would Have Caught It
    • Root-Cause Analysis, Done Honestly
    • Escape Analysis
    • The Bug Write-Up That Teaches the Team
  • Part 10 · Case Studies12
  • Part 11 · Debug as a Team5
  • Part 12 · Interview Prep5
Loading lesson

‹Part 9 · Fix, Prove, Prevent
  1. Topics
  2. The Art of Hardware Debug
  3. Part 9 · Fix, Prove, Prevent
  4. The Assertion That Would Have Caught It

Part 9 · Fix, Prove, Prevent · Senior (S)

The Assertion That Would Have Caught It

Write the assertion that would have caught a real dbglab deadlock at its cause: from the rule the bug broke, bound where the rule lives, proven to fire on the buggy commit, to stay silent on the fix, and not to pass vacuously. With a measured table of the assertion forms Icarus, Verilator and Yosys really check.

On this page

    Why this matters

    A watchdog or scoreboard reports a bug long after it happened, and only in the test that happens to reach it. An assertion on the broken rule reports it at the cause, in every test, but only if it was written from the rule, can fail, and is really checked by your simulator.

    After this lesson you can: write an assertion from the rule a bug broke, bind it where the rule lives, show it fires at the root-cause cycle and is silent on the fix, expose vacuous passes with a cover, and know which SVA forms each tool checks.

    Note

    Level: Senior (S) · Debug-loop step: Prevent

    Before this: Case study: The Deadlock Under Back-Pressure, Assertions as Always-On Probes

    Two thousand cycles late

    dbglab's deadlock, B2, is fixed and pinned (the case study tells the hunt). The escape review asks the uncomfortable question. Commit 11 added the bus lock; the only thing that caught the deadlock it made was a watchdog, 2,000 cycles after the fact, in the one test written for back-pressure. What assertion, written with commit 11, would have reported it at the cause, and would it actually run in the simulators this team uses?

    What people usually do, and why it fails

    The quick answer is an assertion written from the fix: the DMA must not hold `m_lock` in WAIT. It describes one line of one module, so it means nothing after the safe fix (where the fabric ignores lock entirely) and fires on designs that are fine. The other quick answer is a timeout, the CPU gets `ready` within N cycles, which is the watchdog again with a smaller N: it still reports the symptom, and it fires on any slow but correct design.

    The method: from the rule, at its home, proven three ways

    Prevent: the assertion is the check that would have caught this bug earlier, and will catch the next one like it.
    1. Find the rule the bug broke, as the specification states it. bus_fabric.sv documents lock in one comment: a locked owner keeps the bus for its next beat. So when the fabric keeps a grant, the owner must use it soon. The DMA holding the bus while it waits for the peripheral broke that promise.

    2. Put the bound from the design, not from the failure. A DMA chunk spends at most three cycles between beats (POP, LOAD, WAIT), so the next request must come within 4 cycles of the kept response. A bound taken from the spec fires at the cause; a bound taken from the symptom fires at the symptom.

    3. Put it where the rule lives. The fabric owns the grant, so the assertion binds into bus_fabric and speaks in its signals: rsp_fire, accept, owner, m_valid.

    4. Prove it three ways. It fires on the buggy commit at the cause cycle. It is silent on every fix and on the whole regression. A cover counts its antecedent, so you know when it is passing because nothing happened.

    5. Check that the tool checks it. Run each form once where it must fail. The table at the end of this lesson is that run for the three tools installed here.

    The picture: four reports of one deadlock

    The assertion fires at cycle 168, four cycles after the fabric kept the grant and one after the DMA started waiting; the watchdog fires at 2165.

    The case study's idle-grant check states a broader rule, so it needs a looser bound and fires at 181. Keep both: the narrow rule fires closer to the cause, the broad one catches causes nobody thought of.

    Do it: write it, run it, prove it

    The assertion

    lock_checks.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 dbglab's sources and bench; compile it with them in Verilator (command in the Evidence box)
    // lock_checks: the rule behind bus_fabric's lock, as concurrent SVA, bound into the fabric.
    // The fabric's comment says what lock is for: "a locked owner keeps the bus for its next beat".
    // So whenever the fabric keeps the grant at a response, that owner must request again soon:
    // a DMA chunk needs at most 3 cycles (POP, LOAD, WAIT) between beats. Verilator only.
    `timescale 1ns/1ps
    module lock_checks (
      input logic       clk, rst_n,
      input logic [1:0] m_valid,
      input logic       owner, rsp_fire, accept
    );
      int unsigned kept = 0;
      a_lock_next_beat: assert property (@(posedge clk) disable iff (!rst_n)
                          rsp_fire && !accept |-> ##[1:4] m_valid[owner])
        else $error("LOCK_NEXT_BEAT cycle=%0d: grant kept for master %0d, no request within 4 cycles",
                    dbglab_tb.cycle, owner);
      c_kept: cover property (@(posedge clk) disable iff (!rst_n) rsp_fire && !accept) kept++;
      final $display("LOCK_CHECK: grant kept at %0d responses", kept);
    endmodule
    
    bind bus_fabric lock_checks u_lock (.*);

    rsp_fire && !accept is the fabric keeping the grant at a response: that is the antecedent, stated in the fabric's own terms, not the DMA's. .* in the bind connects the checker's ports to the fabric's signals of the same names. The cover counts every time the antecedent happens; its action needs --coverage-user in Verilator, as the table below shows.

    It fires at the cause

    Terminal
    $ ../probes/run-vl-check.sh . ../probes/lock_checks.sv dma_burst_backpressure 1 +verilator+error+limit+100
    dbglab: test=dma_burst_backpressure seed=1
    [1685000] %Error: lock_checks.sv:14: Assertion failed in dbglab_tb.dut.u_fab.u_lock.a_lock_next_beat: LOCK_NEXT_BEAT cycle=168: grant kept for master 1, no request within 4 cycles
    FAIL test=dma_burst_backpressure seed=1 cycle=2165: WATCHDOG: no bus or stream progress for 2000 cycles; cpu_valid=1 cpu_ready=0 cpu_addr=00003008
    ...

    Read it as a bug report. Where: dbglab_tb.dut.u_fab.u_lock, the fabric. When: cycle 168. What: the grant was kept for master 1, the DMA, and it made no request within the 4 cycles the rule allows. The error limit lets the run continue so you can see the watchdog arrive 1,997 cycles later with nothing moves as its only information. At commit 11, the commit that added lock, the result is the same for seeds 1, 2 and 3.

    It is silent on the fix, and on everything else

    Terminal
    $ cd ../safefix && ../probes/run-vl-check.sh . ../probes/lock_checks.sv dma_burst_backpressure 1
    dbglab: test=dma_burst_backpressure seed=1
    PASS test=dma_burst_backpressure seed=1 cycles=772 time=7716 ns
    LOCK_CHECK: grant kept at 0 responses
    $ cd ../pacedfix && ../probes/run-vl-check.sh . ../probes/lock_checks.sv dma_copy 1
    dbglab: test=dma_copy seed=1
    PASS test=dma_copy seed=1 cycles=1182 time=11816 ns
    LOCK_CHECK: grant kept at 157 responses

    Then the whole regression, every test with seeds 1 to 3, on five versions of the design:

    Version

    Fires

    Grants kept in dma_copy

    What it means

    Commit 10, before lock

    never

    0

    vacuous: the feature does not exist yet

    Commit 11 and HEAD (the bug)

    only in dma_burst_backpressure, cycle 168

    157

    fires at the cause, silent on correct use

    HEAD + safe fix (accept = rsp_fire)

    never

    0

    vacuous: the fabric never keeps a grant now

    HEAD + paced fix (no lock for paced chunks)

    never

    157

    silent and exercised: a real pass

    The random test, seeds 1 to 60 at commit 11, passed every seed with the assertion silent, across 150,122 kept grants. That matters: an assertion that fires on correct traffic gets switched off within a week.

    A pass that proves nothing

    Look at the safe-fix row again. The assertion passes everywhere, and its cover says the antecedent never happened, not once. That pass carries no information: the fix stopped the fabric keeping grants, so there is nothing left to check. It is still right to keep the assertion, because the next person to add a lock will make the antecedent real again. But in a report, a vacuous pass must be counted separately from a real one, and the cover is how you count it. The assertion debug lessons cover vacuity in depth.

    The same rule in Icarus

    Icarus 13 does not compile concurrent assertions or bind (see the table), so for the four-state runs the rule is written as clocked code with hierarchical names:

    lock_checks_proc.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 dbglab's sources and bench; compile it next to them in Icarus (command in the Evidence box)
    // lock_checks_proc: a_lock_next_beat as clocked code for Icarus 13 (no concurrent SVA, no bind).
    `timescale 1ns/1ps
    module lock_checks_proc;
      int wait_n = 0;                                   // cycles since the grant was kept; 0 = not armed
      logic who;
      always @(posedge dbglab_tb.clk) begin
        if (!dbglab_tb.rst_n) wait_n <= 0;
        else if (wait_n > 0) begin
          if (dbglab_tb.dut.u_fab.m_valid[who]) wait_n <= 0;
          else if (wait_n == 4) begin
            $error("LOCK_NEXT_BEAT cycle=%0d: grant kept for master %0d, no request within 4 cycles",
                   dbglab_tb.cycle, who);
            wait_n <= 0;
          end else wait_n <= wait_n + 1;
        end
        if (dbglab_tb.rst_n && dbglab_tb.dut.u_fab.rsp_fire && !dbglab_tb.dut.u_fab.accept) begin
          wait_n <= 1; who <= dbglab_tb.dut.u_fab.owner;
        end
      end
    endmodule
    Terminal
    $ ../probes/run-probe.sh . ../probes/lock_checks_proc.sv dma_burst_backpressure 1
    dbglab: test=dma_burst_backpressure seed=1
    ERROR: ../probes/lock_checks_proc.sv:11: LOCK_NEXT_BEAT cycle=168: grant kept for master 1, no request within 4 cycles
    FAIL test=dma_burst_backpressure seed=1 cycle=2165: WATCHDOG: no bus or stream progress for 2000 cycles; cpu_valid=1 cpu_ready=0 cpu_addr=00003008

    Same cycle, same message. Two versions of one rule are a maintenance cost; checking that both fire at the same cycle on the buggy commit keeps them in step.

    What the tools here really check

    Each form below was written into a one-module test that must fail at a known cycle (or, for the cover, hit at a known cycle), so a tool that compiles a form and checks nothing is caught too. For example, the range test:

    design.sv
    // snippet: f_range.sv, one of the 14 support tests; a rises at c = 3, b only at c = 9, so this must fail at c = 6
      a_p: assert property (@(posedge clk) a |-> ##[1:3] b) else $error("FIRED range c=%0d", c);

    Form

    Icarus 13.0

    Verilator 5.048

    Yosys 0.69 + smtbmc

    Immediate assert (e) else $error in a clocked block

    checked (fired at c = 5)

    checked

    checked only without the else action: assert (e);

    Deferred assert final (e)

    "sorry: Deferred assertions are not supported"

    checked

    checked (without else)

    Concurrent: |->, |=>, $past, $rose, $stable, disable iff, ##2, ##[1:3], [*3], a local variable

    syntax error, none compile

    all checked, each at the expected cycle

    syntax error at @; only an unclocked assert property (e)

    s_eventually

    syntax error

    "Unsupported: s_eventually"

    not available

    cover property with an action

    syntax error

    action runs only with --coverage-user

    immediate cover (e), reached with yosys-smtbmc -c

    bind

    syntax error

    checked

    parsed, then the checker is removed as unused: PASSED, nothing checked

    $isunknown on an undriven signal

    fires (four-state)

    never fires (two-state)

    not applicable

    So: in Icarus write checks as clocked procedural code (its suggested -gsupported-assertions gave the same syntax error, and crashed on the deferred test); in Verilator any X check is dead code; and Yosys reads the immediate form with $past, as the proof in Proving the Fix used. The silent bind is the dangerous row: a formal run that "passes" because the property was never there.

    What the evidence says

    The assertion turned a hang reported 2,000 cycles late as nothing moves into a rule violation four cycles after the wrong decision, naming the fabric, the master and the rule, on commit 11's own regression. It stayed silent on 150,122 correct kept grants, and its cover told apart two passes that look identical in a log: the paced fix, where it checked real traffic, and the safe fix, where it checked nothing.

    Traps

    Common pitfall

    An assertion written from the fix. "No lock in WAIT" is true of one implementation. After the safe fix the fabric ignores lock, so that assertion would fire on a correct design. Write the rule in the terms of the block that owns it, here the fabric's grant.

    Common pitfall

    Never seeing it fail. Run the new assertion on the buggy commit first. If it does not fire there, at the cause, it does not catch this bug, whatever it says.

    Common pitfall

    Counting vacuous passes as passes. "0 assertion failures" is the same line whether the antecedent happened 150,000 times or never. Pair each assertion with a cover on its antecedent and report both.

    Why this matters

    Senior lens. The assertion belongs in the commit that adds the rule. Commit 11 introduced a new promise, a locked owner uses the bus for its next beat, and shipped no check for it. In review, ask of every new handshake, lock, credit or mode: where is the assertion for the rule this adds, and where is its cover? It is a two-line question and it would have stopped this bug at check-in.

    Write down which simulator runs which checks (concurrent SVA in Verilator, X checks in the four-state run, formal in the immediate form Yosys reads) and rerun the support tests at every tool upgrade: "it compiled" is not "it checked".

    Interview

    Asked: "We found a deadlock with a watchdog, 2,000 cycles after it started. What assertion would have caught it earlier?"

    Weak answer: An assertion that every request gets ready within some number of cycles, say 100, so we catch the hang sooner.

    Strong answer: That is still a watchdog. I would find the rule that was broken: here, the fabric kept the grant for a locked owner, and lock only promises the owner's next beat. So: whenever the fabric keeps the grant at a response, the owner must request again within the few cycles its chunk needs. I would bind it into the fabric, show it fires on the buggy commit at the cycle the owner stopped using the bus, show it is silent on the fix and the whole regression, and add a cover on the antecedent so a vacuous pass is visible. And I would check the simulator really runs it: Icarus does not compile concurrent SVA, and Yosys ignores bind.

    Follow-ups: (1) How do you choose the bound of 4 cycles? (2) Your assertion never fires in a year of regressions. Good news or bad?

    Check yourself A teammate adds assert property (@(posedge clk) req |-> ##[1:8] gnt); to a bench that runs only in Icarus. The nightly run reports no assertion failures. What do you check?

    Whether it compiled at all. Icarus 13 gives a syntax error for this form; if the build used a flag or an ifdef that drops assertions, the property is not there and "no failures" means nothing. Run it once on a version that must violate it. For Icarus, rewrite it as clocked procedural code, or run that check in Verilator.

    Check yourself Your new assertion is silent on the fixed design across 1,000 seeds. What else must you show before you call it a good check?

    That it fires on the buggy version, at or near the cause cycle, so it can fail; and that its antecedent actually happened on the fixed design, with a cover or a counter, so the silence is not vacuous. If the antecedent never happens, either the stimulus does not reach the rule or the fix removed the behaviour, and the report should say which.

    Remember

    • Write the assertion from the specification of the rule, never from the fix or the buggy line: then it means the same before and after.

    • Prove it three ways: it fires on the buggy commit at the cause, it is silent on the fix across the regression, and a cover shows it was not vacuous.

    • Check what your tools check. Icarus 13 compiles no concurrent SVA; Yosys 0.69 ignores bind; Verilator is two-state, so X checks never fire there.

    Rule of thumb: an assertion you have not seen fail, and seen not vacuous, protects nothing.

    How this was checked

    dbglab trees from git archive of commit 10 27acd30, commit 11 c4a71b5 (B2) and HEAD 49c5f04, plus HEAD with assign accept = rsp_fire; (the safe fix) and HEAD with && !paced_q on m_lock (the paced fix). lock_checks.sv (shown) compiled in Verilator 5.048 with --binary --timing --assert --coverage-user -DDBGLAB_SVA and the dbglab sources (run-vl-check.sh), 2026-10-18.

    dma_burst_backpressure seeds 1 to 3 at commit 11 and HEAD: LOCK_NEXT_BEAT cycle=168, then the watchdog at cycle 2165. With either fix: PASS, silent. Over smoke, dma_copy, irq_w1c, periph_stream, dma_burst_backpressure and periph_power, seeds 1 to 3: no other firing anywhere; dma_copy reports 157 kept grants at commit 11, HEAD and the paced fix, 0 at commit 10 and with the safe fix. random seeds 1 to 60 at commit 11: 60 PASS, no firing, 150,122 kept grants.

    Icarus Verilog 13.0: lock_checks_proc.sv (shown) compiled next to the bench reports LOCK_NEXT_BEAT cycle=168. Support table: 14 one-module tests, each written to fail at a known cycle, run with iverilog -g2012 + vvp, verilator --binary --timing --assert, and yosys read_verilog -formal -sv + yosys-smtbmc -t 16; every cell in the table is a run.

    Limits: one rule for one bug. The bound of 4 cycles comes from this DMA's chunk sequence; another master could need a different bound. Yosys was tested on small modules only, not on dbglab.

    Keep going

    • Practice: Strong vs weak, vacuity and non-vacuous success

    • Practice: -> vs |=> in SVA

    • Practice: Interface assertions for deadlock detection

    • Practice: bind assertions for protocol checking

    • Code lab: Bind assertion

    • Code lab: SVA cover wrapper

    • Interview: run a mock interview on this topic

    Related topics

    • Part 9 · Fix, Prove, Prevent

    • Next: root-cause analysis, done honestly

    • Assertions as always-on probes

    • Watchdogs and hang detectors

    • SystemVerilog: assertion debug

    Previous

    82 / 107

    Next: Root-Cause Analysis, Done Honestly

    Up next

    Up nextRoot-Cause Analysis, Done Honestly

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • Fix the Cause, Not the Symptom
    • Proving the Fix
    • Escape Analysis