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
    • RTL Simulation
    • Formal Counterexamples as Traces
    • Gate-Level Simulation
    • Equivalence Failures
    • Emulation and FPGA Prototyping
    • The First Failing Test on Silicon
    • Silicon vs Simulation Correlation
    • Marginal and Environmental Failures
    • Moving the Failure to the Cheapest Stage
  • Part 7 · Hangs and Deadlocks7
  • Part 8 · Intermittent Failures7
  • Part 9 · Fix, Prove, Prevent7
  • Part 10 · Case Studies12
  • Part 11 · Debug as a Team5
  • Part 12 · Interview Prep5
Loading lesson

‹Part 6 · Debug by Stage
  1. Topics
  2. The Art of Hardware Debug
  3. Part 6 · Debug by Stage
  4. Formal Counterexamples as Traces

Part 6 · Debug by Stage · Working (W)

Formal Counterexamples as Traces

A formal counterexample is the shortest failing run there is. Read it like a waveform, strip the input noise out of it, make the property fail closer to the cause, replay it in simulation, and prove the fix: real yosys-smtbmc runs on dbglab's beat-counter bug.

On this page

    Why this matters

    dbglab's beat-counter bug needs a DMA run longer than 255 words, which the random test draws once in 400 runs; seed 10 fails 4,895 cycles in. A bounded check finds it in three steps from reset, but only for engineers who read the counterexample as a trace, not a verdict.

    After this lesson you can: read a formal counterexample step by step like a waveform, shrink it to the inputs that matter, move the failure closer to the cause with a stronger property, and replay it in simulation as a directed test.

    Note

    Level: Working (W) · Debug-loop step: Observe, Localise

    Before this: RTL: Your First Formal Run, The First Wrong Cycle

    Three steps instead of 4,895 cycles

    In The Debug Loop the beat-counter bug, B3, took a 60-seed sweep, a probe and a hypothesis to pin down: a DMA run longer than 255 words stops early because a 10-bit length is cast into an 8-bit counter. The random test reached that corner once in 400 runs.

    Now a colleague cuts the counter out of the DMA engine, writes one property, and runs a bounded model check. It fails in under a second and hands back a file called cex.vcd. What is in it, and how do you read it?

    What people usually do, and why it fails

    Two habits waste a counterexample. One is to read only the verdict, Assert failed, and search the RTL as if formal had only said "something is wrong". The other is to distrust the trace because it looks strange, with inputs toggling in reset and a length of 513, and to waive it as "an unrealistic case".

    Both miss what a counterexample is: a legal input sequence, under your assumptions, that breaks the property in the fewest possible cycles: the best failing test you will ever get. Where it looks odd, either the oddness does not matter or an assumption is missing. Both take a minute to check. (Setting up formal belongs to the RTL course's first formal run; this lesson is about debugging with what it gives you.)

    The method: a counterexample is a failing test

    • Read it from reset, one step per clock. Each step is one cycle. Find the step where the assertion fails, then walk back to the first step where the design did something you did not intend: the first wrong cycle, as in any waveform.

    • Separate signal from noise. In steps where an input cannot matter (in reset, while busy), the solver picks any value. Pin those inputs with assumptions and run again. If the check still fails at the same depth, they were noise.

    • Move the property towards the cause. An end-of-run property fails when the damage shows. An invariant on the state that went wrong fails the cycle it goes wrong.

    • Replay it in simulation. The trace is a stimulus. Drive it into the RTL, confirm the same failure, then widen it into a sweep or a directed test.

    • Prove the fix with the same check, and know what a bounded pass covers.

    On the debug loop this is Observe and Localise: formal does the reproducing, and the trace is what you observe.

    The design and the property

    beat_ctr is the counter from dbglab's dma_engine at commit 13, with the same widths: a 10-bit len, an 8-bit left_q, and the CW'() cast between them. The chunking, the FIFO and the bus are left out; beat stands for "the bus accepted one write". The formal block counts the beats of each run in seen and asserts that when done pulses, seen equals the length asked for. The same listing ends with the simulation replay used later, which the course checker runs.

    beat_ctr.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
    `timescale 1ns/1ps
    // beat_ctr: the beat counter of dbglab's DMA engine (commit 13, bug B3), cut out with its
    // widths unchanged. start loads the 10-bit length into the counter; each accepted write
    // (beat) counts it down; done pulses after the last beat.
    // CW = 8 is the bug: the length is cast into 8 bits. CW = 10 is the fix.
    module beat_ctr #(parameter int CW = 8) (
      input  logic       clk, rst_n,
      input  logic       start,          // ignored while busy
      input  logic [9:0] len,
      input  logic       beat,           // the bus accepted one write
      output logic       busy, done
    );
      logic [CW-1:0] left_q;             // words still to write
      always_ff @(posedge clk)
        if (!rst_n) begin
          busy <= 1'b0; done <= 1'b0; left_q <= '0;
        end else begin
          done <= 1'b0;
          if (!busy && start) begin
            left_q <= CW'(len);
            if (len == 10'd0) done <= 1'b1;
            else              busy <= 1'b1;
          end else if (busy && beat) begin
            left_q <= left_q - 1'b1;
            if (left_q == CW'(1)) begin busy <= 1'b0; done <= 1'b1; end
          end
        end
    
    `ifdef FORMAL
      // The property, in words: when a run ends, it wrote exactly the words it was asked for.
      logic       past_valid = 1'b0;
      logic [9:0] len_f;                 // the length of the current run
      logic [10:0] seen;                 // beats accepted in the current run
      always_ff @(posedge clk) begin
        past_valid <= 1'b1;
        if (!rst_n) begin len_f <= '0; seen <= '0; end
        else if (!busy && start) begin len_f <= len; seen <= '0; end
        else if (busy && beat) seen <= seen + 1'b1;
      end
      always @(*) if (!past_valid) assume (!rst_n);          // start in reset
      always @(*) if (past_valid && rst_n && done) a_len: assert (seen == {1'b0, len_f});
    `ifdef HELPER
      // While a run is busy, words written plus words left is the length asked for.
      always @(*) if (past_valid && rst_n && busy) a_left: assert (left_q != 0 && seen + 11'(left_q) == {1'b0, len_f});
    `endif
    `ifdef CLEAN
      // Trace clean-up: no back-pressure, and one run only.
      logic started = 1'b0;
      always_ff @(posedge clk) if (rst_n && start) started <= 1'b1;
      always @(*) if (past_valid) assume (beat == busy);
      always @(*) if (started) assume (!start);
    `endif
    `endif
    endmodule
    

    seen and len_f are formal-only bookkeeping, so the property says nothing about how the counter works inside. The one assumption is that the run starts in reset; start, len and beat are free in every step. HELPER and CLEAN are switched on with -D for two experiments below. Yosys reads the module part of the listing, saved as beat_ctr.sv.

    Do it: the bounded check fails in three steps

    terminal
    $ yosys -q -p "read_verilog -formal -sv beat_ctr.sv; prep -top beat_ctr; async2sync; write_smt2 -wires bug.smt2"
    $ yosys-smtbmc -s z3 -t 20 --dump-vcd cex.vcd bug.smt2
    Terminal
    ##   0:00:00  Solver: z3
    ##   0:00:00  Checking assertions in step 0..
    ##   0:00:00  Checking assertions in step 1..
    ##   0:00:00  Checking assertions in step 2..
    ##   0:00:00  Checking assertions in step 3..
    ##   0:00:00  BMC failed!
    ##   0:00:00  Assert failed in beat_ctr: a_len
    ##   0:00:00  Writing trace to VCD file: cex.vcd
    ##   0:00:00  Status: FAILED

    The check was allowed 20 steps and needed 3. Because it checks step 0, then step 1, and so on, the first failure it reports is at the smallest depth any failure exists: no input sequence breaks a_len in fewer cycles. That is the first half of minimising, done for free.

    Read the trace as a table

    cex.vcd has every signal at every step. With no waveform viewer installed here, a few lines of Python print it as a table. Each row is one step; registers show their value in that step, and the inputs in a row act at the clock edge that ends it.

    example.py
    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
    # not-run: reads cex.vcd, which yosys-smtbmc writes (commands above)
    # cextable.py: print a yosys-smtbmc counterexample VCD as a table, one row per solver step.
    # usage: python3 cextable.py cex.vcd sig1 sig2 ...   (multi-bit values in decimal)
    import sys
    
    path, names = sys.argv[1], sys.argv[2:]
    ids, rows, cur = {}, [], None
    with open(path) as f:
        for line in f:
            tok = line.split()
            if not tok:
                continue
            if tok[0] == "$var" and tok[4] in names:
                ids[tok[3]] = tok[4]                     # VCD id code -> signal name
            elif tok[0].startswith("#"):
                if cur is not None and int(tok[0][1:]) % 10 == 0:
                    rows.append(dict(cur))               # a step ends every 10 ns (the last edge adds none)
            elif tok[0].startswith("b") and len(tok) == 2 and tok[1] in ids:
                cur = cur or {}
                v = tok[0][1:]
                cur[ids[tok[1]]] = str(int(v, 2)) if set(v) <= {"0", "1"} else v
            elif tok[0][0] in "01xz" and len(tok[0]) > 1 and tok[0][1:] in ids:
                cur = cur or {}
                cur[ids[tok[0][1:]]] = tok[0][0]
    print("step  " + "  ".join(f"{n:>7}" for n in names))
    for i, r in enumerate(rows):
        print(f"{i:>4}  " + "  ".join(f"{r.get(n, '-'):>7}" for n in names))
    Terminal
    $ python3 -I cextable.py cex.vcd rst_n start len beat busy left_q seen len_f done
    step    rst_n    start      len     beat     busy   left_q     seen    len_f     done
       0        0        1      513        1        0        0        0      513        0
       1        1        1      513        1        0        0        0        0        0
       2        1        0        0        1        1        1        0      513        0
       3        1        1      513        1        0        0        1      513        1
    • Step 0: reset, as assumed. start and beat are high, but nothing reacts in reset.

    • Step 1: start with len = 513; the counter loads at the edge that ends the step.

    • Step 2: busy is 1 and left_q is 1, not 513: the first wrong value. 513 is 0x201 and the 8-bit cast kept 0x01. A beat arrives.

    • Step 3: that was the "last" beat, so done pulses with seen = 1 and len_f = 513. a_len fails, one step after the cause.

    Any length whose low eight bits are 1 gives this shortest trace; 513 is simply the one the solver found. The len of 0 in step 2 is a don't-care: the counter is busy and ignores it.

    The same trace as a waveform

    Replayed in simulation, with a correct three-word run after it for contrast, it looks like any failing waveform:

    Slot n is just after clock edge n; slots 0 to 2 are the trace's steps 1 to 3. Read left_q against len.

    • Slots 0 and 1: start with len = 201 (hex, 513), then busy with left_q = 1.

    • Slot 2: done after one beat, where 513 words were asked for.

    • Slots 3 to 7: len = 3 counts 3, 2, 1 and finishes correctly. Short runs hide the bug.

    Minimise it: pin the noise, then fail closer to the cause

    Pin the noise. The raw trace has start high in reset and in step 3, and beat high when nothing is busy. The CLEAN assumptions forbid that: beats only while busy, one run only. If the depth or the failing assertion changed, one of those inputs was part of the bug.

    Terminal
    $ yosys -q -p "read_verilog -formal -sv -DCLEAN beat_ctr.sv; prep -top beat_ctr; async2sync; write_smt2 -wires clean.smt2"
    $ yosys-smtbmc -s z3 -t 20 --dump-vcd clean.vcd clean.smt2 | tail -3
    ##   0:00:00  Assert failed in beat_ctr: a_len
    ##   0:00:00  Writing trace to VCD file: clean.vcd
    ##   0:00:00  Status: FAILED
    $ python3 -I cextable.py clean.vcd rst_n start len beat busy left_q seen len_f done
    step    rst_n    start      len     beat     busy   left_q     seen    len_f     done
       0        0        0      513        0        0        0        0      513        0
       1        1        1      513        0        0        0        0        0        0
       2        1        0        0        1        1        1        0      513        0
       3        1        0      513        0        0        0        1      513        1

    Same depth, same assertion, and only two input events: one start with len = 513 and one beat. Assumptions used like this are an experiment, not sign-off setup, because each one hides behaviour (assumption discipline).

    Fail closer to the cause. a_len fires when the damage shows. The HELPER invariant watches the state: while busy, the counter is never 0, and words written plus words left equals the length.

    Terminal
    $ yosys-smtbmc -s z3 -t 20 --dump-vcd bugh.vcd bugh.smt2 | tail -3      # built with -DHELPER
    ##   0:00:00  Assert failed in beat_ctr: a_left
    ##   0:00:00  Writing trace to VCD file: bugh.vcd
    ##   0:00:00  Status: FAILED
    $ python3 -I cextable.py bugh.vcd rst_n start len beat busy left_q seen len_f done
    step    rst_n    start      len     beat     busy   left_q     seen    len_f     done
       0        0        1      512        0        0        0        0        0        0
       1        1        1      512        0        0        0        0        0        0
       2        1        1      512        0        1        0        0      512        0

    The trace now stops on the load itself: 512 loaded as 0, no beat needed. The stronger property names the cause, the counter's value, not the symptom, the run's length: the same move as an assertion at the point of use instead of the scoreboard.

    Replay it in simulation, then generalise

    The trace says "513 fails", not which other lengths fail. That is cheap to ask in simulation: replay_tb drives the trace's inputs into both counters, then runs every length from 1 to 1023:

    Terminal
    $ iverilog -g2012 -o lf.vvp lesson_formal.sv && vvp -n lf.vvp
    replay step 3: bug done=1 busy=0   fix done=0 busy=1 left_q=512
    len  257: bug wrote 1 words, then done
    len  258: bug wrote 2 words, then done
    len  259: bug wrote 3 words, then done
    len  512: bug wrote 256 words, then done
    len 1023: bug wrote 255 words, then done
    bug: 767 of 1023 lengths wrong (first 257, last 1023); fix: 0 wrong
    PASS: the counterexample replays in simulation; every length above 256 fails with CW = 8, none with CW = 10

    The first line confirms the replay matches the trace: the buggy counter is done at step 3, the fixed one still has 512 words to go. The sweep generalises it: in this model every length above 256 writes the wrong number of words, its length modulo 256 (512 wraps through 0 and writes 256), as the probe found in dbglab's real runs. Verilator 5.048 prints the same lines. The replay is now a directed test in the regression.

    Warning

    What this property cannot see. a_len checks the word count only, and the cut-out model has no data path, so it passes dbglab's LEN 256 failure, which writes the right count with wrong data (reducing the design). A property proves only what it states: to catch that case, the formal harness needs the data path and a check on each written word.

    Fix it, and run the same check

    Terminal
    $ yosys -q -p "read_verilog -formal -sv beat_ctr.sv; chparam -set CW 10 beat_ctr; prep -top beat_ctr; async2sync; write_smt2 -wires fix.smt2"
    $ yosys-smtbmc -s z3 -t 20 fix.smt2
    ##   0:00:00  Solver: z3
    ##   0:00:00  Checking assertions in step 0..
    ...
    ##   0:00:03  Checking assertions in step 19..
    ##   0:00:05  Status: PASSED
    $ yosys-smtbmc -s z3 -i -t 20 fix.smt2 | tail -3
    ##   0:00:00  Temporal induction failed!
    ##   0:00:00  Assert failed in beat_ctr: a_len
    ##   0:00:00  Status: FAILED
    $ yosys-smtbmc -s z3 -i -t 20 fixh.smt2 | tail -3                      # CW = 10 with -DHELPER
    ##   0:00:00  Trying induction in step 19..
    ##   0:00:00  Temporal induction successful.
    ##   0:00:00  Status: PASSED

    With a 10-bit counter, no run of up to 20 cycles breaks a_len, but a 1023-word run lasts a thousand cycles. Induction closes the gap only with the helper invariant; without it the solver starts from unreachable states, as your first formal run shows on a FIFO. The bounded check with the helper also passed, so both properties hold for every cycle, under the reset assumption.

    What the evidence says

    Formal reproduced in three steps a bug that random simulation reached once in 400 runs, because the solver searches for the failing input instead of waiting for it. Two input events matter; the rest is noise. The first wrong value is left_q the cycle after start, and the stronger property moved the failure onto that cycle. The replay showed the bug's full extent, which the trace alone did not.

    Traps

    Common pitfall

    "That input is unrealistic." If a counterexample needs an input your system can never produce, the fix is an assumption that states the rule, reviewed like RTL, and a cover that shows the remaining behaviour is still reachable. Here 513 is a legal length; calling it unrealistic would have shipped B3.

    Common pitfall

    Reading a bounded pass as a proof. "PASSED to depth 20" covers runs of 20 cycles. A variant of this bug that showed only at the end of a 300-word run would pass it. Ask how deep the bug could be before you trust the depth, and edit the property only when you can say why the design is right.

    Why this matters

    Senior lens. Keep counterexamples as artefacts: check the replay bench into the regression next to the property, with the trace's commit and command in its header. After the fix it keeps checking the case in every simulator run, and when someone changes the assumptions it shows whether the old bug can still happen.

    Interview

    Asked: "Formal hands you a counterexample. Walk me through what you do with it."

    Weak answer: I look at the assertion that failed and search the RTL for the bug. If the trace uses inputs that cannot happen, I add assumptions until it passes.

    Strong answer: I read the trace from reset, one step per clock, and find the first step where the design does something unintended, not just where the assertion fires. I pin the inputs I think are noise with temporary assumptions and check it still fails at the same depth. If the property watches a symptom, I add an invariant on the state so it fails at the cause. Then I replay the inputs in simulation, which gives me a directed test, and sweep around it to see how wide the bug is. After the fix I rerun the same check, clear about whether a pass is bounded or an induction proof.

    Follow-ups: (1) Why is a BMC trace the shortest failing run? (2) When is an assumption that removes a counterexample the right fix?

    Check yourself A bounded check fails at step 14 and the trace shows beat toggling in almost every step. A colleague says the bug needs back-pressure. How do you find out?

    Add a temporary assumption that removes back-pressure (for example, beat whenever busy) and run the same check. If it still fails, back-pressure was noise the solver filled in, and the shorter, cleaner trace shows the real cause. If it now passes, back-pressure is part of the bug, and the trace's beat pattern is worth reading closely. Either way, remove the assumption afterwards.

    Check yourself Why did the helper invariant make the counterexample one step shorter?

    a_len can only fail when done pulses, which needs at least one beat after the load. a_left checks the counter's state in every busy cycle, so it fails the first cycle the state is wrong: the cycle after start, when left_q holds the truncated length. A property on the state fails at the cause; a property on the result fails at the symptom.

    Remember

    • A bounded check stops at the first failing step, so its trace is already the shortest. Read it from reset, one step per clock.

    • Inputs in steps where they cannot matter are filled in at random by the solver. Pin them with assumptions and check the trace still fails.

    • A property that watches the cause fails earlier than one that watches the symptom. Replay the trace in simulation to make a test.

    Rule of thumb: treat a counterexample as a failing test: read it, shrink it, replay it, then fix and run the same check again.

    How this was checked

    beat_ctr.sv (the module shown, dbglab's DMA beat counter at commit 13 with its widths), 2026-10-18, Yosys 0.69+post, yosys-smtbmc with z3 5.1.0: yosys -q -p "read_verilog -formal -sv beat_ctr.sv; prep -top beat_ctr; async2sync; write_smt2 -wires bug.smt2" then yosys-smtbmc -s z3 -t 20 --dump-vcd cex.vcd bug.smt2: BMC failed at step 3, Assert failed in beat_ctr: a_len. With -DCLEAN: still step 3, beat and start now quiet. With -DHELPER: fails at step 2 on a_left. Tables printed by python3 -I cextable.py <vcd> rst_n start len beat busy left_q seen len_f done.

    Fix: the same flow after chparam -set CW 10 beat_ctr: yosys-smtbmc -s z3 -t 20 PASSED (5 s); -i -t 20 (induction) FAILED on a_len; with -DHELPER, bounded PASSED and induction Temporal induction successful, PASSED.

    Replay: the listing's replay_tb with iverilog -g2012 -o lf.vvp lesson_formal.sv && vvp -n lf.vvp (Icarus 13.0) and verilator --binary --timing --assert -Wno-fatal --top-module replay_tb (Verilator 5.048): identical output, 767 of 1023 lengths wrong (257 to 1023), 0 with CW = 10, PASS. The waveform is cex_replay.sv run by gen-waves (Icarus).

    Limits: the counter is cut out of dma_engine without its chunking, FIFO and bus, so it is a model of the bug, not dbglab itself (Yosys 0.69 also cannot parse dbglab as is). a_len checks the word count only, so it cannot see dbglab's LEN 256 failure, which writes the right count with wrong data. A pass to depth 20 is bounded; the induction pass is a proof for this small module and its assumptions only.

    Keep going

    • Practice: Formal-assisted coverage: witness traces

    • Practice: Minimal counterexample: automated shrinking

    • Practice: k-induction with strengthening invariants

    • Code lab: Abstract counterexample

    • Code lab: Formal property bind

    • Interview: run a mock interview on this topic

    Related topics

    • Part 6 · Debug by Stage

    • RTL: Your first formal run

    • SystemVerilog: assertion debug

    • The debug loop: B3 found by simulation

    Previous

    57 / 107

    Next: Gate-Level Simulation

    Up next

    Up nextGate-Level Simulation

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • Equivalence Failures
    • Emulation and FPGA Prototyping
    • The First Failing Test on Silicon
    startlen2013beatbusyleft_q0103210done
    The counterexample replayed in simulation: a 513-word run loads 1 into left_q and ends after one beat; a 3-word run then counts 3, 2, 1 and ends correctly