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. Fix the Cause, Not the Symptom

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

Fix the Cause, Not the Symptom

Two fixes for one real dbglab bug: both make the failing seed pass and a 60-seed sweep clean, and one of them only moves the bug. How to tell a fix of the cause from a fix sized to the symptom, and the directed run that separates them.

On this page

    Why this matters

    A fix that makes the failing test pass can still leave the cause in place, sized to the case you saw. The regression goes green, the ticket closes, and the same bug returns later as a rarer and more expensive one.

    After this lesson you can: name the cause as the decision that made a bug possible, place each candidate fix on the chain from cause to symptom, and find and run the input where two fixes disagree before choosing one.

    Note

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

    Before this: Symptom, Fault, Cause, Logic Bugs

    Two patches, one afternoon

    dbglab's random test fails on seed 10, and the hunt in the debug loop has already localised it: the DMA's beat counter left_q is 8 bits wide, a run of 308 words loads 52, and the copy stops early. Two engineers send a patch the same afternoon.

    Patch A sets CW = 9: "308 needs nine bits, and the random test never asks for more than 320." Patch B sets CW = $bits(cfg_len). Both make seed 10 pass. Both give the same clean 60-seed sweep. The reviewer has to pick one. Which, and how would you show it rather than argue it?

    What people usually do, and why it fails

    The usual test of a fix is the test that failed, then the regression. If both are green, the fix is merged. That works when the fix and the cause are the same thing. It fails when a fix covers the cases the tests make and no more, because the regression is exactly those cases. A fix sized to the failure will always look perfect to the tests that found the failure.

    The other habit is to argue taste: "nine bits is enough", "deriving the width is cleaner". Arguments do not settle this. A run does, if you choose the input where the two patches disagree.

    The method: fix the decision, then test where the fixes differ

    This lesson sits on Fix and reaches into Prove: choosing the fix is itself an experiment.
    1. Name the cause as a decision, not a line. Here it is not "line 36 says 8". It is commit 03ebdc1 declaring the counter's width on its own, as a number, separate from the 10-bit length it must hold. Two widths that must agree were written in two places.

    2. Place every candidate fix on the chain from cause to fault to symptom (symptom, fault, cause). A change after the fault, in the checker, the test or the driver, is a symptom fix. A change at the fault that covers only the values you saw is an instance fix: right place, wrong size.

    3. Ask what range the cause covers. The register map says LEN is 0 to 1023. The failing test covered at most 320. A fix must hold for the first range, not the second.

    4. Find the input where the fixes disagree, and run it on both before choosing. If no such input exists, they are the same fix; if one does, the choice is made by a result.

    5. Prefer the fix that makes the bug impossible to write again. A width derived from its source cannot drift. Then remove anything that hides the next mistake, here the cast.

    The picture: what each fix covers, what each test tries

    Patch A moves the wrap from 256 to 512, which no regression test reaches. Only a test aimed at the specification's limits sees the difference.

    Read it top down. Every length the random test makes lies inside both lower bars, so it cannot tell patch A from patch B. Only the ticks at 512 and 1023 fall in the gap.

    Do it: both patches, then the run that splits them

    Both pass what failed

    Terminal
    $ grep -n "localparam int CW" cw9/rtl/dma_engine.sv cwfix/rtl/dma_engine.sv
    cw9/rtl/dma_engine.sv:36:  localparam int CW = 9;           // beat counter width
    cwfix/rtl/dma_engine.sv:36:  localparam int CW = $bits(cfg_len); // beat counter width: as wide as the length it counts
    $ cw9/tb/run.sh random 10
    dbglab: test=random seed=10
    PASS test=random seed=10 cycles=21581 time=215806 ns
    $ cwfix/tb/run.sh random 10
    dbglab: test=random seed=10
    PASS test=random seed=10 cycles=21581 time=215806 ns

    Same cycle count, to the cycle. Seed 10 cannot tell them apart, and neither can Verilator, which gives the same two lines.

    Both pass the sweep

    Terminal
    $ for v in head cw9 cwfix; do (cd $v; for s in $(seq 1 60); do tb/run.sh random $s | grep FAIL; done > ../sweep60-$v.txt); echo "$v: $(wc -l < sweep60-$v.txt) fail, $(grep -c xxxxxxxx sweep60-$v.txt) with X"; done
    head:       19 fail, 9 with X
    cw9:        9 fail, 9 with X
    cwfix:        9 fail, 9 with X
    $ diff sweep60-cw9.txt sweep60-cwfix.txt && echo same
    same

    Ten wrong-value failures at HEAD, none with either patch. The nine left show an X, which is a different bug (B4), and they are line for line the same with both patches. If the review stops here, the patches are equal and the shorter one wins.

    The test where they disagree

    The disagreement is at lengths from 512 up. The register map, not the random test, says those are legal, so write the test from the register map. Name it for the rule it checks, not for the bug that prompted it:

    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
    // not-run: a task for dbglab's bench (tb/tests.svh); it needs the bench's helpers
    // dma_len_limits: LEN is 10 bits, so every length from 0 to 1023 is legal (register map).
    // Copy at the edges of each power of two that fits in RAM, then fill 1023 words.
    task automatic t_dma_len_limits();
      logic [31:0] st;
      int len;
      csr_wr(CSR_CTRL, 32'h0);
      for (int k = 0; k < 4; k++) begin
        case (k)
          0: len = 255;
          1: len = 256;
          2: len = 511;
          default: len = 512;
        endcase
        run_copy(0, 512, len);
        for (int i = 0; i < len; i++) ram_check(512 + i);
      end
      csr_wr(CSR_FILL, 32'hA5A5_0000 ^ seed);                          // the longest legal run: a fill
      csr_wr(CSR_CTRL, 32'h1 << CTRL_FILL);
      dma_program(32'h0, RAM_BASE + 4 * 1, 1023);
      dma_wait(st);
      dma_close(st, 1'b0, 32'h0);
      model_copy(0, 1, 1023, 1'b1);
      for (int i = 0; i < 1024; i++) ram_check(i);
    endtask
    Terminal
    $ head/tb/run.sh dma_len_limits 1
    dbglab: test=dma_len_limits seed=1
    FAIL test=dma_len_limits seed=1 cycle=4917: SCOREBOARD: RAM word 516 (address 00001810) read 228510de, expected 934c82b1 (last written by DMA run #2)
    $ cw9/tb/run.sh dma_len_limits 1
    dbglab: test=dma_len_limits seed=1
    FAIL test=dma_len_limits seed=1 cycle=15445: SCOREBOARD: RAM word 516 (address 00001810) read 228510de, expected 934c82b1 (last written by DMA run #4)
    $ cwfix/tb/run.sh dma_len_limits 1
    dbglab: test=dma_len_limits seed=1
    PASS test=dma_len_limits seed=1 cycles=24673 time=246726 ns

    Read the three results together:

    • HEAD fails on run #2, the 256-word copy, at cycle 4917: an 8-bit counter loads 0 for 256.

    • Patch A gets past runs #1 to #3 (255, 256 and 511 words) and fails on run #4, the 512-word copy, at cycle 15445. The same failure, on the same word, one power of two later.

    • Patch B passes all five runs, including the 1023-word fill. Seeds 2 and 3 and Verilator give the same three verdicts.

    A probe on patch A's run #4 shows the mechanism, and why the bench needed a data check rather than a length check:

    Terminal
    $ probes/run-probe.sh cw9 ../probes/dma_probe.sv dma_len_limits 1 | grep "run #4"
    cycle 11329: DMA run #4 start  LEN=512 left_q=0
    cycle 15421: DMA run #4 done   words written=512
    FAIL test=dma_len_limits seed=1 cycle=15445: SCOREBOARD: RAM word 516 (address 00001810) read 228510de, expected 934c82b1 (last written by DMA run #4)

    left_q loads 0, the chunk logic misbehaves, and the run still writes 512 words, of the wrong data. A check that only counted words would have passed patch A.

    Close the door behind the fix

    Patch B keeps the CW'(cfg_len) cast, which is now a no-op. Remove it. Without the cast, a future narrowing is a lint warning instead of a silent wrap:

    Terminal
    $ verilator --lint-only -Wall --top-module dma_engine ../cw9/rtl/dbglab_pkg.sv ../cw9/rtl/sync_fifo.sv dma_cw9.sv 2>&1 | grep WIDTH | grep dma_
    %Warning-WIDTHTRUNC: dma_cw9.sv:89:19: Operator ASSIGNDLY expects 9 bits on the Assign RHS, but Assign RHS's VARREF 'cfg_len' generates 10 bits.
    $ verilator --lint-only -Wall --top-module dma_engine ../cwfix/rtl/dbglab_pkg.sv ../cwfix/rtl/sync_fifo.sv dma_cwfix.sv 2>&1 | grep WIDTH | grep dma_; echo "exit $?"
    exit 1

    The derived width prints no warning (grep finds nothing and exits 1); patch A without its cast would have been flagged at once. The cast was part of the cause: it told the tool the truncation was intended.

    What the evidence says

    Patch A was a real change at the fault, in the right line, and it was still the wrong fix. It moved the wrap from 256 to 512, beyond every length the tests make, so the regression could never fail again. That is worse than no fix: the bug is now rarer, and its ticket is closed. Patch B removed the decision that made the bug possible. The run that separated them took a second, and it was found by asking one question: what range must this hold for?

    Candidate

    Acts on

    Seed 10

    60-seed sweep

    dma_len_limits

    Verdict

    Cap the random test at 255 words

    the stimulus (symptom)

    PASS

    clean (9, all X)

    FAIL at 256, as HEAD

    workaround, not a fix

    CW = 9

    the fault, sized to the failure

    PASS

    clean (9, all X)

    FAIL at 512

    instance fix

    CW = $bits(cfg_len)

    the cause (two widths that must agree)

    PASS

    clean (9, all X)

    PASS

    the fix

    The first row is the most common symptom fix of all, and it was run too: one line in the random test, if (r_len > 255) r_len = 255;, makes seed 10 pass and the sweep clean while the design is untouched. The limits test fails on it exactly as it fails at HEAD, because the test programs LEN itself. The debug loop names this as a trap; here it is measured.

    Traps

    Common pitfall

    "It passes the failing test" is the weakest evidence there is. Every candidate passes it; that is how it became a candidate. Treat it as the entry ticket and go looking for the input where the candidates part.

    Common pitfall

    A workaround filed as a fix. Splitting long copies in software, or capping the random test at 255, changes what the hardware is asked to do, not what it does. After tape-out that can be the right decision, but then it is an erratum with an open cause, documented as one, not a closed bug.

    Common pitfall

    A fix that silences a tool. Commit 03ebdc1 added the cast to silence the Verilator WIDTH warning. When a fix or a refactor exists to make a warning go away, read the warning first: here it was describing the bug.

    Why this matters

    Senior lens. In fix review, ask two questions of every patch: what is the full specified range of the thing you changed? and which test exercises its top end? If the author can only answer with the failing case, the fix is sized to the symptom.

    Look for the duplicated decision behind a bug. Widths, depths, address maps and enumerations that are declared twice will disagree one day. The fix that derives one from the other removes the whole class, which is why it is worth a slightly bigger diff.

    Interview

    Asked: "Your fix makes the failing test pass and the regression is clean. How do you know you fixed the cause and not the symptom?"

    Weak answer: If the failing test passes and nothing else broke, the bug is fixed. I would also rerun a few more seeds to be safe.

    Strong answer: Passing the failing test only shows the fix touches the right path. I name the cause as the decision that allowed the bug, here a counter width declared separately from the length it counts, and I check the fix against the specified input range, not the range the tests happened to make. If there is another candidate fix, I find the input where the two disagree and run it on both. In this case a 512-word copy failed with the nine-bit fix and passed with the derived width. Then I remove anything that hid the bug, like a cast that silenced a lint warning.

    Follow-ups: (1) When is a workaround the right call? (2) How do you find the input where two fixes disagree?

    Check yourself A FIFO overflows when 17 entries are written to a 16-entry FIFO, and a patch makes it 32 deep. The overflow test passes. Is that a cause fix?

    Probably not. Ask what the cause is: why did a producer write 17 entries? If the specification says the producer must respect full, the cause is a producer or flow-control bug, and a deeper FIFO is an instance fix that moves the failure to 33. Write the test where the two disagree: a burst longer than 32 with the consumer stopped. The cause fix (honouring full) passes it; the deeper FIFO fails it.

    Check yourself Patch A and patch B give identical results on every test in the regression and on 200 seeds. What does that tell you about them?

    Only that the regression cannot tell them apart. It is a statement about the tests, not the patches. Diff the patches and ask which inputs reach the lines where they differ; then write a test for those inputs. If no legal input reaches the difference, they really are equivalent, and the clearer one wins.

    Remember

    • Every candidate fix makes the failing seed pass. That is the entry ticket, not the evidence.

    • Size a fix to the specification of the input, never to the values your tests happened to make.

    • Before choosing between two fixes, find the input where they disagree, write a test for it and run both.

    Rule of thumb: a fix is the cause fix only if it holds for every input the specification allows.

    How this was checked

    dbglab at HEAD 49c5f04 (rebuilt with dbglab/build-history.sh) in three scratch copies made with git archive: HEAD, CW = 9, and CW = $bits(cfg_len) at rtl/dma_engine.sv line 36. Icarus Verilog 13.0, 2026-10-18. tb/run.sh random 10: FAIL at cycle 4895 at HEAD, PASS (cycles=21581) with either fix, the same in Verilator 5.048.

    Sweep: for s in $(seq 1 60); do tb/run.sh random $s | grep FAIL; done per copy: HEAD 19 failures (9 with an X), each fix 9 (all X, B4); diff of the two fix sweeps is empty. dma_len_limits (the task shown, added to tb/tests.svh with one dispatch line in tb/dbglab_tb.sv): HEAD FAIL at cycle 4917 (run #2, 256 words), CW = 9 FAIL at cycle 15445 (run #4, 512 words), the specification fix PASS (cycles=24673); seeds 1, 2, 3 and Verilator agree. A fourth copy with if (r_len > 255) r_len = 255; added to the random test: seed 10 PASS, 60 seeds 9 failures (all X), dma_len_limits FAIL at cycle 4917 as at HEAD. The probe line run #4 LEN=512 left_q=0 comes from a DMA probe compiled next to the bench.

    Lint: with the CW'() cast removed, verilator --lint-only -Wall --top-module dma_engine reports %Warning-WIDTHTRUNC at line 89 for CW = 9 and nothing for the specification fix.

    Limits: one bug, in simulation. The directed test covers five lengths, not all 1024; the next lesson closes that gap with a formal check. The review story is illustrative.

    Keep going

    • Practice: Counter with wrap and saturate modes

    • Code lab: DMA control FSM

    • Interview: run a mock interview on this topic

    Related topics

    • Part 9 · Fix, Prove, Prevent

    • Next: proving the fix

    • Symptom, fault, cause (a symptom patch on B4)

    • One change at a time

    • Case study: a fix that looks right and is not

    Previous

    79 / 107

    Next: Proving the Fix

    Up next

    Up nextProving the Fix

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • The Regression Test That Pins the Bug
    • The Assertion That Would Have Caught It
    • Root-Cause Analysis, Done Honestly