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 Regression Test That Pins the Bug

Part 9 · Fix, Prove, Prevent · Working (W)

The Regression Test That Pins the Bug

A regression test that pins a bug fails on the commit that introduced it, passes on the commit before and on the fix, and keeps failing for the right reason as the code changes. A directed test for a real dbglab bug run across its history, against a seeded pin that goes blind.

On this page

    Why this matters

    A fixed bug comes back when someone repeats the change that caused it, and only a test that fails on that change will say so. A failing seed added to the nightly list looks like such a test, but it pins the bug only for as long as the stimulus stays the same.

    After this lesson you can: write a directed test that states a bug's condition, show it fails on the guilty commit and passes on its parent and the fix, run it where it can fail, and name and keep it so it stays useful.

    Note

    Level: Working (W) · Debug-loop step: Prevent

    Before this: Proving the Fix, Determinism and Seeds

    "Covered by seed 17"

    The X bug in dbglab, B4, is fixed: commit 14 had moved the FILL register out of the reset block, and the fix puts it back. The ticket's last line says Regression: added random seed 17 to the nightly list. Seed 17 failed before the fix and passes after it. Does that pin the bug? If someone removes the reset again next year, will the nightly run go red?

    What people usually do, and why it fails

    Adding the failing seed to the regression is the most common way to "pin" a bug, because it costs one line. It pins the seed, not the bug. Seed 17 reaches B4 only because, in this version of the random test, a zero fill happens to come before any write to FILL. Change the random test, and the seed draws something else. Run it in a simulator that cannot see the fault, and it passes on the broken design.

    The other common test is named after the ticket, test_bug_1234, checks the exact symptom from the report and is never run on the commit that caused the bug. Nobody knows whether it can fail. A pin you have never seen fail is a hope.

    The method: a test that states the condition

    Prevent: the pin is the check that stops this bug coming back. It is proven the same way as the fix.
    1. State the bug's condition directly. B4 needs a fill that starts before any write to FILL. A directed test does exactly that, first thing after reset, instead of waiting for random stimulus to stumble into it.

    2. Check the fault, then what the user sees. Read the register (the first wrong value), then do what software relies on (a zero fill) and check the memory. The first check names the cause; the second keeps the test meaningful if the cause moves.

    3. Prove the pin at three commits. Parent: pass. Guilty commit: fail, for this bug's reason. Fix: pass. Better, run it along the history: it should flip exactly once, at the guilty commit.

    4. Run it where it can fail. An X only exists in a four-state simulator; in a two-state one the register starts at 0. Put the pin in a run that can see the fault: Icarus, or Verilator with random start values.

    5. Name it for the rule, keep it cheap, keep it on. fill_after_reset, not bug_b4. A few hundred cycles, in the default test list, with the commit and ticket in a comment.

    Do it: write the pin, then run it through history

    The test

    design.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // not-run: a task for dbglab's bench (tb/tests.svh); it needs the bench's helpers
    // fill_after_reset: FILL resets to 0 (register map), so a fill that starts before any write to
    // FILL must write zeros. Pins b357bd8 (B4: FILL lost its reset). Directed: no seed needed.
    task automatic t_fill_after_reset();
      logic [31:0] st;
      rd_expect(CSR_BASE + CSR_FILL, 32'h0, "FILL after reset");      // the register itself
      csr_wr(CSR_CTRL, 32'h1 << CTRL_FILL);                            // what software relies on
      dma_program(32'h0, RAM_BASE + 4 * 100, 16);
      dma_wait(st);
      dma_close(st, 1'b0, 32'h0);
      model_copy(0, 100, 16, 1'b1);
      for (int i = 0; i < 16; i++) ram_check(100 + i);
    endtask

    It is added to tb/tests.svh with one dispatch line in tb/dbglab_tb.sv. Nothing in it is random; the seed only changes the RAM's preload. It runs in 154 cycles.

    Part 8's ordered test already reproduces B4 every time, but it fails at the symptom (cycle 79, a RAM word). fill_after_reset reads the register first and fails at the fault, at cycle 10, which is what a pin should do.

    Parent, guilty commit, fix

    Terminal
    $ c13/tb/run.sh fill_after_reset 1
    dbglab: test=fill_after_reset seed=1
    PASS test=fill_after_reset seed=1
    $ c14/tb/run.sh fill_after_reset 1
    dbglab: test=fill_after_reset seed=1
    FAIL test=fill_after_reset seed=1 cycle=10: FILL after reset: read 00002024 = xxxxxxxx, expected 00000000
    $ c14fix/tb/run.sh fill_after_reset 1
    dbglab: test=fill_after_reset seed=1
    PASS test=fill_after_reset seed=1

    Commit 13 passes, commit 14 fails at cycle 10 on the register read, before the fill has even started, and the fix passes. Cycle 10 against cycle 7949 for the random seed: the pin fails at the fault, not at the symptom.

    Where it can fail

    Terminal
    $ c14/tb/run.sh fill_after_reset 1 verilator
    dbglab: test=fill_after_reset seed=1
    PASS test=fill_after_reset seed=1
    $ XRAND=1 c14/tb/run.sh fill_after_reset 1 verilator
    dbglab: test=fill_after_reset seed=1
    FAIL test=fill_after_reset seed=1 cycle=10: FILL after reset: read 00002024 = 8f27a164, expected 00000000

    In plain Verilator the test passes on the buggy commit: two-state flip-flops start at 0, which is exactly the value the register map promises, so the missing reset is invisible. With XRAND=1 every unreset flip-flop starts at a random value, as silicon does, and the test fails with 8f27a164. A pin for an X or reset bug belongs in the four-state run or the random-start run, never only in the default two-state one. (X and reset bugs explains why the simulators differ.)

    Along the history

    Three of the four rows flip at commit 14 and back with the fix. Plain Verilator never fails, so it cannot pin this bug.

    Commit 12 introduced fill mode, so the test can run from there. It flips exactly once, at the guilty commit, and stays failing through two later commits that changed other things. A test with that shape is also a perfect git bisect run script, as bisection in history shows.

    The seeded pin, and how it goes blind

    On this history seed 17 looks just as good: the bottom row has the same shape. The difference shows when the stimulus changes. Part 8 showed that writing FILL first turns the random seeds green. Here is what that does to each pin:

    Terminal
    $ diff c14/tb/tests.svh c14init/tb/tests.svh
    245a246
    >   csr_wr(CSR_FILL, 32'h0);                                       // start from a known FILL
    $ c14init/tb/run.sh random 17
    dbglab: test=random seed=17
    PASS test=random seed=17
    $ c14init/tb/run.sh fill_after_reset 1
    dbglab: test=fill_after_reset seed=1
    FAIL test=fill_after_reset seed=1 cycle=10: FILL after reset: read 00002024 = xxxxxxxx, expected 00000000

    This is still commit 14, with the bug in it. Seed 17 now passes, and so does every other seed: across seeds 1 to 60 the X failures go from 9 to 0. The random test can no longer reach the condition at all, and nothing tells you. The directed test fails exactly as before, because it creates the condition itself.

    Not every change kills a seed. Adding an ID read to two of the random test's hundred operation codes kept all nine X seeds failing, but moved seed 17's failure from cycle 7949 to 8117. You cannot tell in advance which changes a seed survives, which is the point: determinism and seeds makes a seed replayable, not stable across versions of the test.

    Keep it in the regression

    Terminal
    $ diff c13/tb/regress.sh c14/tb/regress.sh
    8c8
    < TESTS="smoke dma_copy irq_w1c periph_stream dma_burst_backpressure periph_power random"
    ---
    > TESTS="smoke dma_copy irq_w1c periph_stream dma_burst_backpressure periph_power random fill_after_reset"
    $ c14/tb/regress.sh icarus 1 2 3 | grep -E "fill_after|failing"
    fill_after_reset         seed 1      FAIL
    fill_after_reset         seed 2      FAIL
    fill_after_reset         seed 3      FAIL
    9 failing run(s)
    $ c14fix/tb/regress.sh icarus 1 2 3 | grep -E "fill_after|failing"
    fill_after_reset         seed 1      PASS
    fill_after_reset         seed 2      PASS
    fill_after_reset         seed 3      PASS
    6 failing run(s)

    One word in the test list. On the buggy commit the regression has three more failures, on the fix it has none more (the other six are two other known bugs, B1 in irq_w1c and B2 in dma_burst_backpressure). In the default Verilator run the same list reports 6 failing runs at commit 14: the pin is silent there, as above. XRAND=1 tb/regress.sh verilator 1 2 3 reports 9.

    What the evidence says

    Property of the pin

    random seed 17

    fill_after_reset

    Fails on the guilty commit, passes on parent and fix

    yes, in Icarus

    yes, in Icarus and random-start Verilator

    Fails at

    cycle 7949 (the symptom)

    cycle 10 (the fault)

    Survives a change to the random test

    no: blind after one added line

    yes: it does not use it

    Says what it checks

    no: a number

    yes: its name and first line

    Cost

    about 18,000 cycles

    154 cycles

    Keep seed 17 too if you like; it costs little and catches other things. But it is a sample, not a pin. The pin is the test that makes the condition happen on purpose.

    Naming and keeping

    • Name the rule, not the ticket. fill_after_reset tells a reader in two years what must hold. bug_b4 or seed17_regress tells them nothing, and gets deleted in the first clean-up.

    • Put the history in the comment: the guilty commit and the ticket, so a failure points straight at the old analysis.

    • Make the failure message a report. FILL after reset: read 00002024 = xxxxxxxx, expected 00000000 names the register, the value and the rule. A pin that prints "test failed" sends the next person back to the start of the loop.

    • Keep the precondition in the test. If the condition can stop being reached (as the random test stopped reaching it), assert that it was reached: here the test creates it on its first line, so it cannot drift.

    Traps

    Common pitfall

    Never having seen the pin fail. Write the test, then run it on the guilty commit (or with the fix reverted). If it passes there, it is not a pin, whatever its name says.

    Common pitfall

    Pinning the symptom's exact text. A test that greps for RAM word 816 fails to fail when the same bug corrupts word 815. Check the rule (FILL resets to 0), not the instance from the report.

    Common pitfall

    A pin that only fails for the wrong reason. At commit 14 several tests fail for other bugs. Make the pin's own message specific, and check it fails with that message, as the bisection lesson does with its skip script.

    Why this matters

    Senior lens. Ask for the pin in fix review, with its three runs: parent, guilty commit, fix. It takes minutes once the hunt is done, and it is the only part of the bug's cost that keeps paying.

    Directed pins and random tests do different jobs. The random test finds bugs nobody thought of; the pin keeps a known one found. A regression that has only the first relearns old bugs; one that has only the second never finds new ones.

    Interview

    Asked: "You fixed a bug that only one random seed found. What do you add to the regression?"

    Weak answer: I add that seed to the nightly regression so the bug cannot come back.

    Strong answer: The seed is a sample, so I write a directed test that creates the bug's condition on purpose, here a fill before any write to the fill register, and checks the fault first and the visible behaviour second. I prove it fails on the commit that introduced the bug, passes on its parent and on the fix, and I run it in a configuration that can see the fault: four-state, or two-state with random start values, because plain two-state passed on the broken design. I name it for the rule, note the commit in a comment, and add it to the default list. I keep the seed too, but I do not count on it.

    Follow-ups: (1) How would you know if the pin stopped reaching its condition? (2) The bug only shows on silicon. What does the pin look like then?

    Check yourself A colleague's new test for B4 passes at commit 13, fails at commit 14 and passes with the fix, but only in Icarus. The nightly regression runs Verilator. Is the bug pinned?

    No. In the nightly configuration the test passes on the broken design, as the Verilator run above shows, so it would not catch the bug coming back. Either add the test to an Icarus run, or run that part of the regression in Verilator with random start values (XRAND=1 here), where it fails with a random value instead of an X.

    Check yourself Your directed pin fails on the guilty commit, but its first failure there is an unrelated scoreboard error from another open bug. What is wrong, and how do you fix it?

    The pin is not proven to fail for its own reason: with the other bug fixed it might pass on the guilty commit. Make it reach its own check first (do the B4 condition before anything the other bug touches), or prove it on a tree where the other bug is fixed or absent, and check that the failure message is the pin's own.

    Remember

    • A pin is proven at three points: it passes on the parent commit, fails on the guilty commit, and passes on the fix.

    • A seed reaches the bug by luck. A directed test states the condition, so a change to the random test cannot take it away.

    • Run the pin where it can fail. A test that passes on the buggy commit in your nightly simulator pins nothing.

    Rule of thumb: do not close a bug until a test that names its condition fails on the commit that caused it.

    How this was checked

    dbglab rebuilt with dbglab/build-history.sh; scratch trees with git archive for commits 12 bc2bb34, 13 03ebdc1, 14 b357bd8, 15 122e88a, 16 49c5f04, and commits 14 and 16 with FILL's reset restored. Each tree has the fill_after_reset task shown in tb/tests.svh and one dispatch line in tb/dbglab_tb.sv. 2026-10-18.

    tb/run.sh fill_after_reset 1 (Icarus Verilog 13.0): PASS at 12 and 13, FAIL at cycle 10 with FILL ... = xxxxxxxx at 14, 15 and 16, PASS with the fix. Verilator 5.048: PASS everywhere. XRAND=1 ... verilator: FAIL at cycle 10 with 8f27a164 at 14 to 16, PASS elsewhere. tb/run.sh random 17: PASS at 12 and 13, FAIL at cycle 7949 at 14 to 16, PASS with the fix; Verilator PASS at 14.

    Changed random test, at commit 14: with csr_wr(CSR_FILL, 32'h0) at its start, seeds 1 to 60 give 12 failures and none with an X (9 before), random 17 PASS, fill_after_reset still FAIL. With an ID read added for two of the hundred operation codes, the same nine X seeds fail and seed 17 moves from cycle 7949 to 8117. regress.sh with fill_after_reset added: Icarus 9 failing runs at 14 (6 with the fix), Verilator 6, Verilator with XRAND=1 9.

    Limits: one bug and one directed test, in simulation. The test pins this bug's condition (a fill before any FILL write); it does not check other registers' reset values, which the reset-value check in Part 5 does.

    Keep going

    • Practice: Debugging rare seed failures in regression

    • Practice: Random stability: why randc can destabilise regressions

    • Interview: run a mock interview on this topic

    Related topics

    • Part 9 · Fix, Prove, Prevent

    • Next: the assertion that would have caught it

    • X and reset bugs (a reset-value check for every register)

    • Symptom, fault, cause (the same bug, walked back)

    • Escape analysis: why the tests missed it

    Previous

    81 / 107

    Next: The Assertion That Would Have Caught It

    Up next

    Up nextThe Assertion That Would Have Caught It

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • Fix the Cause, Not the Symptom
    • Root-Cause Analysis, Done Honestly
    • Escape Analysis