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
    • The Debug Loop
    • Symptom, Fault and Cause
    • The Cost of a Bug by Stage
    • Guessing vs Searching
    • Meet dbglab
    • A Bug Report Others Can Act On
  • 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
  • Part 10 · Case Studies12
  • Part 11 · Debug as a Team5
  • Part 12 · Interview Prep5
Loading lesson

‹Part 0 · What Debugging Is
  1. Topics
  2. The Art of Hardware Debug
  3. Part 0 · What Debugging Is
  4. The Debug Loop

Part 0 · What Debugging Is · Foundation (F)

The Debug Loop

The nine steps of the debug loop, what each one produces, and what skipping it costs, walked end to end on a real dbglab bug.

On this page

    Why this matters

    Most lost debug days come from a skipped step: changing the design before the failure can be repeated, guessing before looking, or closing a bug that was never proven fixed. The loop names every step, so you always know which one you are on and what finishes it.

    After this lesson you can: name the nine steps of the debug loop and what each one produces, and walk a failing regression from the first report to a check that stops the bug coming back.

    Note

    Level: Foundation (F) · Debug-loop step: The whole loop

    Before this: none

    Nineteen failures on a Monday morning

    The nightly regression ran dbglab's random test with sixty seeds, and nineteen of them failed. The release is on Friday. Your lead asks one question: how long will it take?

    You cannot answer that yet. You can promise a method: nine steps, the same for every bug in this course, each with an output you can show. Where do you start?

    What people usually do, and why it fails

    Here is how this bug often goes. The story is illustrative; the runs in it are real.

    An engineer opens the first failure, seed 10: a RAM word read back wrong. RAM bugs are often write-enable races, so they add a cycle of delay to the RAM's write enable and rerun seed 10. It still fails. They try seed 11 to see whether the problem is general:

    Terminal
    $ tb/run.sh random 11
    dbglab: test=random seed=11
    PASS test=random seed=11 cycles=18509 time=185086 ns

    Seed 11 passes, so the failure looks intermittent. The delay is left in "just in case", the bug is marked cannot reproduce, and the release goes ahead. Three weeks later a customer's driver copies 300 words and gets 44.

    Every step of that story was a guess: a change before anyone looked at the hardware, a pass on another seed read as evidence, and nothing left behind to catch the bug next time. The engineer was not careless. They had no method.

    The debug loop: nine steps, each with an output

    The debug loop. The top row narrows the search; the bottom row closes the bug. Prevent feeds the next report.

    The top row is a search: each step shrinks the space where the bug can hide. The bottom row closes the bug. A step is done when it has produced its output, not when you feel ready to move on.

    Step

    Its output

    Skip it and

    Report

    The failure as expected versus actual, with the command that showed it

    you chase a rumour

    Reproduce

    One command that fails every time

    you cannot tell a fix from luck

    Observe

    What the design actually did, from logs, probes or a waveform

    you debug your idea of the design

    Localise

    The first wrong value: which signal, which cycle, which module

    you read the whole design

    Hypothesise

    A cause that predicts something you have not seen yet

    you try changes at random

    Test

    An experiment whose result could prove the hypothesis wrong

    you believe your first idea

    Fix

    A change to the cause, not to the symptom

    the symptom moves somewhere else

    Prove

    The repro passes, and so does a wider sweep

    the bug comes back next week

    Prevent

    A test or check that would have caught it earlier

    the next bug like it costs just as much

    The loop is not a straight line. A failed test sends you back to Observe, a failed proof back to Fix. That is normal, as long as you know which step you are on.

    One real bug, round the whole loop

    The bug below is in dbglab, the course's running design: a bus with a CPU port, a DMA engine, RAM and a few peripherals. Every command runs in a copy of its history at HEAD.

    Report: say what failed, not what you think broke

    Start with the facts, grouped by signature. Two failures with different messages may be two different bugs, so sort them before you pick one.

    Terminal
    $ for s in $(seq 1 60); do tb/run.sh random $s | grep FAIL; done > sweep.txt
    $ wc -l < sweep.txt
          19
    $ grep -c xxxxxxxx sweep.txt
    9
    $ grep -v xxxxxxxx sweep.txt
    FAIL test=random seed=10 cycle=4895: SCOREBOARD: RAM word 808 (address 00001ca0) read d5202ff9, expected b6c3311c (last written by DMA run #29)
    FAIL test=random seed=13 cycle=3701: SCOREBOARD: RAM word 250 (address 000013e8) read f42b063a, expected c6278de6 (last written by DMA run #22)
    ...
    FAIL test=random seed=59 cycle=3217: SCOREBOARD: RAM word 377 (address 000015e4) read 913f438a, expected 3fec7961 (last written by DMA run #22)

    Nine failures show an unknown value (xxxxxxxx). The other ten read a real but wrong value, and every one of those names a DMA run as the last writer of the word. That is two signatures, so treat them as two bugs until proven otherwise. Take the second group, and its first seed. The report: random, seed 10, HEAD 49c5f04: RAM word 808 should hold b6c3311c, written by DMA run #29; it reads d5202ff9.

    Reproduce: one command that fails every time

    Terminal
    $ tb/run.sh random 10
    dbglab: test=random seed=10
    FAIL test=random seed=10 cycle=4895: SCOREBOARD: RAM word 808 (address 00001ca0) read d5202ff9, expected b6c3311c (last written by DMA run #29)
    $ tb/run.sh random 10 verilator
    dbglab: test=random seed=10
    FAIL test=random seed=10 cycle=4895: SCOREBOARD: RAM word 808 (address 00001ca0) read d5202ff9, expected b6c3311c (last written by DMA run #29)

    Same line, same cycle, in two simulators. The seed fixes the stimulus, so this command is now the bug. It is also the command you will use to prove the fix.

    Observe: look at what the hardware did

    The scoreboard says where the damage was found, not where it was done. It found word 808 wrong at cycle 4895, but DMA run #29 was supposed to write it. So look at run #29. You do not need a waveform viewer for that: a small probe module, compiled next to the design, can print what the DMA was told and what it did. It reads signals by their hierarchical names and changes nothing. The script run-probe.sh used below just compiles it with dbglab's file list and runs the test (the exact command is in the Evidence box).

    dma_probe.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // not-run: needs dbglab's sources and bench; compile it next to them (command in the Evidence box)
    // dma_probe: prints the start and the end of DMA run +RUN=<n>. It only reads the design.
    `timescale 1ns/1ps
    module dma_probe;
      int unsigned want, run = 0, writes = 0;
      logic started = 1'b0;
      initial if (!$value$plusargs("RUN=%d", want)) want = 0;   // 0: every run
      always @(posedge dbglab_tb.clk) begin
        #0.5;                                                     // just after the edge
        if (dbglab_tb.dut.u_dma.m_rvalid && dbglab_tb.dut.u_dma.state.name() == "WR_RSP") writes++;
        if (started && (want == 0 || run == want))
          $display("cycle %0d: DMA run #%0d start  LEN=%0d SRC=%h DST=%h  left_q=%0d",
                   dbglab_tb.cycle, run, dbglab_tb.dut.u_dma.cfg_len, dbglab_tb.dut.u_dma.src_q,
                   dbglab_tb.dut.u_dma.dst_q, dbglab_tb.dut.u_dma.left_q);
        started = 1'b0;
        if (dbglab_tb.dut.u_dma.start && !dbglab_tb.dut.u_dma.busy) begin
          run++; writes = 0; started = 1'b1;
        end
        if (dbglab_tb.dut.u_dma.done && (want == 0 || run == want))
          $display("cycle %0d: DMA run #%0d done   words written=%0d  dst_q=%h",
                   dbglab_tb.cycle, run, writes, dbglab_tb.dut.u_dma.dst_q);
      end
    endmodule
    Terminal
    $ ../probes/run-probe.sh ../probes/dma_probe.sv random 10 +RUN=29
    dbglab: test=random seed=10
    cycle 4443: DMA run #29 start  LEN=308 SRC=000015e8 DST=00001b1c  left_q=52
    cycle 4859: DMA run #29 done   words written=52  dst_q=00001bec
    FAIL test=random seed=10 cycle=4895: SCOREBOARD: RAM word 808 (address 00001ca0) read d5202ff9, expected b6c3311c (last written by DMA run #29)

    Read it line by line. The first line prints at cycle 4443, one cycle after run #29 starts (4442), when left_q holds its load. Software asked for 308 words. The DMA's counter of words still to write, left_q, loaded 52, and the run ended after 52 words at 0x1bec. Word 808, at 0x1ca0, was never written. The bench was right; the DMA stopped early.

    Localise: find the first wrong value

    Walk back from the symptom until the values are right again. The read at cycle 4895 is wrong because the copy was short, the copy was short because left_q was 52, and left_q was wrong the cycle it loaded (4443) from a length that was right. The first wrong value is left_q = 52 where 308 was expected, inside dma_engine. Everything after that is a consequence. Now the code to read is a few lines, not the whole design:

    design.sv
    1
    2
    3
    4
    // snippet: rtl/dma_engine.sv at HEAD, lines 36-37 and 89
      localparam int CW = 8;           // beat counter width
      logic [CW-1:0] left_q;           // words still to write
              left_q  <= CW'(cfg_len);

    Hypothesise: a cause that predicts something new

    52 is 308 minus 256. The counter is 8 bits wide, the length register is 10 bits, and the cast drops the top two bits. Hypothesis: any run longer than 255 words writes only its length minus 256 (or minus 512, and so on). Notice what this predicts that you have not checked yet: every other wrong-value failure must also come from a run longer than 255 words, short by exactly 256.

    Test: run the experiment that could prove you wrong

    Seed 13 failed on DMA run #22. Before running anything, write down the prediction: run #22 is longer than 255 words, and left_q is its length minus 256.

    Terminal
    $ ../probes/run-probe.sh ../probes/dma_probe.sv random 13 +RUN=22
    dbglab: test=random seed=13
    cycle 3227: DMA run #22 start  LEN=290 SRC=000017d0 DST=000010a4  left_q=34
    cycle 3501: DMA run #22 done   words written=34  dst_q=0000112c
    FAIL test=random seed=13 cycle=3701: SCOREBOARD: RAM word 250 (address 000013e8) read f42b063a, expected c6278de6 (last written by DMA run #22)

    290 words asked, 34 loaded, 34 written: 290 minus 256. The prediction could have failed, and it did not. Running the same probe over all sixty seeds makes it stronger: exactly the ten seeds that fail with a wrong value contain a run longer than 255 words, and none of the 41 passing seeds does. Seed 11 passed only because the random test makes a long run once in 400; it never drew one.

    Fix: change the cause

    The cause is the counter's width, so the fix is the width. A 10-bit length needs a 10-bit counter:

    design.sv
    // snippet: the fix, rtl/dma_engine.sv line 36
      localparam int CW = 10;          // beat counter width

    Prove: the same command, then a wider net

    Terminal
    $ tb/run.sh random 10
    dbglab: test=random seed=10
    PASS test=random seed=10 cycles=21581 time=215806 ns
    $ tb/run.sh random 10 verilator
    dbglab: test=random seed=10
    PASS test=random seed=10 cycles=21581 time=215806 ns
    $ for s in $(seq 1 60); do tb/run.sh random $s | grep FAIL; done > sweep.txt
    $ wc -l < sweep.txt
           9
    $ grep -c xxxxxxxx sweep.txt
    9

    The repro passes in both simulators. All ten wrong-value failures are gone; the nine left all show the other signature, the unknown value. That is a second bug for another day, and the reason you sorted the report first.

    Prevent: leave a check behind

    Why did nothing catch this earlier? The directed DMA test copies at most 64 words, and the random test makes a long run once in 400. A test aimed at the counter's edges costs a few lines:

    design.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // snippet: tb/tests.svh, a directed test for the beat counter's boundaries
    // dma_long: copies at the beat counter's boundaries: 255, 256, 257 and 511 words (all that fit
    // at word 512); the random test makes a run this long only once in 400.
    task automatic t_dma_long();
      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 = 257;
          default: len = 511;
        endcase
        run_copy(0, 512, len);
        for (int i = 0; i <= len; i++) ram_check(512 + i);   // the copy and one word after it
      end
    endtask
    Terminal
    $ tb/run.sh dma_long 1          # at HEAD, without the fix
    dbglab: test=dma_long seed=1
    FAIL test=dma_long seed=1 cycle=4920: SCOREBOARD: RAM word 516 (address 00001810) read 228510de, expected 934c82b1 (last written by DMA run #2)
    $ tb/run.sh dma_long 1          # with the fix
    dbglab: test=dma_long seed=1
    PASS test=dma_long seed=1 cycles=14180 time=141796 ns

    It fails on the 256-word copy at cycle 4920 with every seed tried (1, 2, 7 and 33). A second reason it escaped: the commit that narrowed the counter added the CW'() cast, and without the cast Verilator's lint would have warned (%Warning-WIDTHTRUNC: rtl/dma_engine.sv:89:19: Operator ASSIGNDLY expects 8 bits on the Assign RHS, but Assign RHS's VARREF 'cfg_len' generates 10 bits.). A cast tells the tool you meant to truncate. Before you write one, check that you did.

    What the evidence says

    Each step ruled something out. The report cut 19 failures to one group of ten; the repro turned a nightly result into a one-second command; observing run #29 moved the search from the scoreboard to the DMA; localising cut the DMA to three lines. The RTL did not change until a prediction had been tested.

    Traps

    Common pitfall

    A pass on another seed is not evidence. Seed 11 passed because it never drew a long run, not because the bug is intermittent. Different seeds run different stimulus. A failure is "intermittent" only if the same command sometimes passes.

    Common pitfall

    A fix that hides the symptom. Capping the random test's DMA length at 255 would make the regression clean, and the bug would ship. Ask of every fix: does it change the cause I localised, or only the test that saw it?

    Why this matters

    Senior lens. The loop is also how you report progress. "Reproduced, localised to left_q loading 52 for LEN 308, hypothesis confirmed on a second seed, fix in review" tells a lead exactly where the bug stands. "Still looking" does not.

    Time each step on a few bugs. Reproduce and Localise usually take most of it, which is where seeds, probes and bisection pay back.

    Interview

    Asked: "A regression test fails on one seed out of sixty. Walk me through what you do."

    Weak answer: I would open the waveform at the failure time and look for what went wrong, then try a few other seeds to see if it is a real problem.

    Strong answer: First I sort the failures by message, because different signatures may be different bugs. I reproduce one with its seed and commit. The scoreboard tells me where the damage was found, so I walk back to the first wrong value. I form a hypothesis that predicts something I have not seen, like another seed failing for the same reason, and I test that before I edit the RTL. Then I fix the cause, rerun the failing seed and a wide sweep, and add a directed test or assertion so it cannot come back quietly.

    Follow-ups: (1) How do you know the other failing seeds are the same bug? (2) What would make you suspect the testbench rather than the design?

    Check yourself After the fix, a colleague says the nine remaining failures "must be the same bug, we just did not fix it completely". What would you check, and which step are you on?

    You are back at Report: sort before you assume. The nine remaining failures have a different signature (an unknown value, xxxxxxxx, not a wrong real value), and the probe finds no run longer than 255 words before any of them. Treat them as a separate bug with its own repro (seed 16, 17 or 19) and walk the loop again. In this design it is indeed a different bug, the subject of a later case study.

    Check yourself Someone suggests skipping the Test step: "52 is 308 minus 256, it is obviously the counter, just fix it." What does the Test step buy you here?

    It checks that the hypothesis explains all the failures of this signature, not just the one you looked at. Had seed 13 failed with a correct left_q, a second cause would be hiding in the same group, and the counter fix would have buried it under a cleaner regression. The probe run costs a minute.

    Remember

    • Each step ends with something you can show: a failing line, a repro command, a first wrong value, a prediction, a clean seed sweep, a new check.

    • Reproduce and Prove use the same command. Without a repro, a passing run tells you nothing.

    • A hypothesis is useful only if it predicts a result you have not seen yet. Run that experiment before you edit the RTL.

    Rule of thumb: do not touch the design until you can make it fail on demand, and do not close the bug until that same command passes.

    How this was checked

    dbglab at HEAD 49c5f04 (rebuilt with dbglab/build-history.sh), Icarus Verilog 13.0, 2026-10-17. Report: for s in $(seq 1 60); do tb/run.sh random $s | grep FAIL; done gave 19 FAIL lines, 9 of them with an X value. Reproduce: tb/run.sh random 10 failed identically twice, and tb/run.sh random 10 verilator (Verilator 5.048) failed with the same line. tb/run.sh random 11 passed.

    Observe, Localise and Test: the probe shown was compiled next to dbglab with iverilog -g2012 -Wno-timescale -I tb -o build/probe.vvp <rtl/files.f> tb/bus_rules.sv tb/dbglab_tb.sv dma_probe.sv and run with vvp -n build/probe.vvp +TEST=random +SEED=10 +RUN=29 (and seed 13, run 22). The same probe over seeds 1 to 60 found a run longer than 255 words in exactly the ten seeds that fail with a wrong value, and in none of the 41 passing seeds.

    Fix, Prove and Prevent: with localparam int CW = 10; seed 10 passes in both simulators, and seeds 1 to 60 leave 9 failures, all with an X value (a different bug). The directed dma_long test shown, added to a scratch copy of the bench, fails at HEAD (cycle 4920) and passes with the fix for seeds 1, 2, 7 and 33. verilator --lint-only on dma_engine.sv reports WIDTHTRUNC at line 89 only when the CW'() cast is removed.

    Limits: one bug in one small design, in simulation only. The guessing story is illustrative. A clean sweep of 60 seeds is evidence about this bug, not proof that the design has no other long-run bug.

    Keep going

    • Practice: Counter with wrap and saturate modes

    • Practice: Waveform debug: a write transaction timeout

    • Code lab: DMA control FSM

    • Interview: run a mock interview on this topic

    Related topics

    • Part 0 · What Debugging Is

    • Bisection in history: find the commit that broke it

    • Case study: the deadlock under back-pressure

    • RTL: debug hierarchy before touching code

    • RTL: FSM bug stories

    Previous

    1 / 107

    Next: Symptom, Fault and Cause

    Up next

    Up nextSymptom, Fault and Cause

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • The Cost of a Bug by Stage
    • Guessing vs Searching
    • Meet dbglab