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
  • Part 10 · Case Studies12
    • The Off-by-One That Fails at Wrap
    • The Reset Value That Changed
    • The X That Hid for Thousands of Cycles
    • The Race Two Simulators Disagree On
    • The Bus Value That Was Never Sent
    • The Response to the Wrong Master
    • The Deadlock Under Back-Pressure
    • The Credit Leak That Hangs After an Hour
    • The 2x Throughput Regression
    • The Missing Isolation
    • The Test-Mode Leak
    • The Testbench That Was Wrong
  • Part 11 · Debug as a Team5
  • Part 12 · Interview Prep5
Loading lesson

‹Part 10 · Case Studies
  1. Topics
  2. The Art of Hardware Debug
  3. Part 10 · Case Studies
  4. The Reset Value That Changed

Part 10 · Case Studies · Working (W)

The Reset Value That Changed

A driver team reports an interrupt nobody enabled. A real dbglab hunt where git bisect does the work: a suspect picked by its name that is innocent, a bisect script so strict it blames the wrong commit, then four runs to a refactor that changed one reset value.

On this page

    Why this matters

    When a test that passed on the last release fails today, reading code for the cause is a guess, and a guess by commit name or a sloppy bisect script points at the wrong change. A careful bisect finds the guilty commit in log2(N) runs.

    After this lesson you can: decide when to bisect before reading code, write a bisect script whose signature is neither too loose nor too strict, and confirm the guilty diff with a prediction at the two commits around it.

    Note

    Level: Working (W) · Debug-loop step: The whole loop (won at Localise: git bisect with the right signature)

    Before this: The Debug Loop, Bisection in History

    The report

    The ticket comes from the driver team, not from verification. Paraphrased:

    Note

    Spurious DMA interrupt after reset. Our init code does not write CTRL, so per the register map no interrupt is enabled. The first DMA copy after reset still raises the interrupt line when it finishes. Seen on the latest RTL drop. The v0.1 drop did not do this. We have masked it in the driver for now.

    You own dbglab. Before anything else, run what you have:

    Terminal
    $ tb/regress.sh icarus 1 2 3 | grep -E "FAIL|failing"
    irq_w1c                  seed 1      FAIL
    irq_w1c                  seed 2      FAIL
    irq_w1c                  seed 3      FAIL
    dma_burst_backpressure   seed 1      FAIL
    dma_burst_backpressure   seed 2      FAIL
    dma_burst_backpressure   seed 3      FAIL
    periph_power             seed 1      FAIL
    periph_power             seed 2      FAIL
    periph_power             seed 3      FAIL
    9 failing run(s)

    Three tests fail. Only one has "irq" in its name, and its first step is the driver's exact scenario: start a copy right after reset, with CTRL at its reset value, and check that no interrupt fires (meet dbglab lists the tests). The other two failures are other bugs with other signatures; leave them for now.

    Stop here if you want to try it yourself. You know it worked at v0.1 and fails at HEAD. How many runs should it take to find the change, and what exactly should each run test?

    Check yourself Want a hint before you read the hunt?

    Eleven commits separate v0.1 from HEAD, so a bisection needs about four runs. The hard part is not bisect, it is the script: it must answer "is this the bug?" correctly at every commit, including commits where the design runs at a different speed.

    The hunt

    This hunt is won at Localise (bisect over history) and confirmed at Test (a prediction at the two commits around the guilty one).

    Reproduce: the report becomes one command

    Terminal
    $ tb/run.sh irq_w1c 1
    dbglab: test=irq_w1c seed=1
    FAIL test=irq_w1c seed=1 cycle=84: IRQ: irq=1 while CTRL=00 enables no interrupt
    $ tb/run.sh irq_w1c 1 verilator
    dbglab: test=irq_w1c seed=1
    FAIL test=irq_w1c seed=1 cycle=84: IRQ: irq=1 while CTRL=00 enables no interrupt

    The bench says what the driver said: irq is 1 while the model's CTRL is 00. It fails the same way in both simulators, at the same cycle, so it is not an X or a race. The driver's report and the test now share one signature, which you can quote back on the ticket (a bug report others can act on).

    Why bisect first

    You could start reading: the interrupt logic in csr_block.sv, the W1C status bits, the DMA's done pulse. Each is a plausible place, and each takes a while to rule out. But you know something more useful than any of them: it passed at v0.1. That makes it a question about history, and history has a cheap, guaranteed answer. Check both ends first:

    Terminal
    $ git checkout -q v0.1 && tb/run.sh irq_w1c 1
    dbglab: test=irq_w1c seed=1
    PASS test=irq_w1c seed=1
    $ git checkout -q main && tb/run.sh irq_w1c 1
    dbglab: test=irq_w1c seed=1
    FAIL test=irq_w1c seed=1 cycle=84: IRQ: irq=1 while CTRL=00 enables no interrupt

    Good end good, bad end bad, and the test takes under a second. Eleven commits in between means four runs. Two real dead ends sit on the way, and both are common.

    Dead end 1: a suspect picked by its name

    Before bisecting, someone scans the log for commits that touched the CSR block:

    Terminal
    $ git log --oneline v0.1..HEAD -- rtl/csr_block.sv rtl/dbglab_pkg.sv
    b357bd8 csr: no reset on the DMA address and data registers
    bc2bb34 dma: fill mode
    026f8bf csr: collect register reset values in dbglab_pkg
    a60b6f7 periph: power gate model with output isolation
    246ff00 dma: stop on an error response and report it

    The top line jumps out: a reset bug, and a commit that removes resets. It is tempting to start reading that diff. One run tests the guess, because if b357bd8 introduced the bug, its parent passes:

    Terminal
    $ git checkout -q 03ebdc1 && tb/run.sh irq_w1c 1        # the parent of b357bd8
    dbglab: test=irq_w1c seed=1
    FAIL test=irq_w1c seed=1 cycle=84: IRQ: irq=1 while CTRL=00 enables no interrupt

    The parent already fails, so b357bd8 is innocent of this bug. (It is guilty of another one: the X that hid.) A commit's subject tells you what its author meant to change, not what it did change. Choosing a suspect by name is a guess, and a guess here cost one run plus the time spent being sure.

    Dead end 2: a signature that is too strict

    git bisect run needs a script that exits 0 for good, 1 for bad and 125 to skip. A natural first version calls a commit bad only if HEAD's exact failure line appears in the log:

    terminal
    1
    2
    3
    4
    5
    #!/bin/sh
    # strict.sh: bad only if the exact line from HEAD's log appears.
    tb/run.sh irq_w1c 1 > /dev/null
    grep -qF 'FAIL test=irq_w1c seed=1 cycle=84: IRQ: irq=1 while CTRL=00 enables no interrupt' build/icarus-irq_w1c-1.log && exit 1
    exit 0
    Terminal
    $ git bisect start HEAD v0.1
    $ git bisect run ../bisect/irq-bug.sh
    ...
    026f8bfe1f4ed59ec2d8f33c866509ac9d197dc1 is the first bad commit
    ...
        csr: collect register reset values in dbglab_pkg
    ...
        reset branch. No functional change intended.
    ...
    bisect found first bad commit

    Four runs, about a second, and a commit that changes nine lines; bisection in history shows the full log. Compare the two bisects side by side:

    The same range bisected twice. With the message as the signature, bisect tests 10, 7, 9 and 8 and finds commit 9. With the exact HEAD line, it calls commit 10 good, searches 11 to 16 and blames commit 11.
    • Top row: each verdict halves the range correctly, and the culprit is the first bad commit after a good one: 8 good, 9 bad.

    • Bottom row: one wrong verdict, at the first step, decides the whole search. Bisect never checks its own answers, so a single lie is never corrected.

    Read the guilty diff

    Terminal
    $ git show 026f8bf
    ...
    -      scratch_q <= '0; ctrl_q <= '0; done_q <= 1'b0;
    -      src_q <= '0; dst_q <= '0; len_q <= '0;
    +      scratch_q <= SCRATCH_RST; ctrl_q <= CTRL_RST; done_q <= 1'b0;
    +      src_q <= SRC_RST; dst_q <= DST_RST; len_q <= LEN_RST;
    ...
    +  // Reset values.
    +  localparam logic [31:0] SCRATCH_RST = 32'h0000_0000;
    +  localparam logic [7:0]  CTRL_RST    = 8'h01;
    +  localparam logic [31:0] SRC_RST     = 32'h0000_0000;
    +  localparam logic [31:0] DST_RST     = 32'h0000_0000;
    +  localparam logic [9:0]  LEN_RST     = 10'd0;
    ...

    Five literals moved into a package. Four kept their value. ctrl_q used to reset to '0 and now resets to 8'h01, which is bit 0 of CTRL: done_ie, the done-interrupt enable. The commit message says "No functional change intended", and the intent was honest. The change was not.

    Hypothesise and test: a prediction at both commits

    Hypothesis: since commit 9, CTRL comes out of reset with done_ie set, so the first DMA completion raises irq even though software never enabled it. Predictions, each of which could fail: at 026f8bf, ctrl_q is 01 at reset release and irq rises in the same cycle as done_q, with no CPU write to CTRL; at its parent 2e63466, ctrl_q is 00 and irq rises only after the CPU writes CTRL. A probe prints the life of the interrupt, one line per event:

    irq_story.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)
    // irq_story: the life of the interrupt, one line per event: reset release, every CPU write to
    // CTRL, every DMA start and done, every change of done_q and irq.
    `timescale 1ns/1ps
    module irq_story;
      logic irq_q = 1'b0, done_q = 1'b0, rst_q = 1'b0;
      always @(posedge dbglab_tb.clk) begin
        if (dbglab_tb.rst_n && !rst_q)
          $display("cycle %4d  reset released      ctrl_q=%02h", dbglab_tb.cycle, dbglab_tb.dut.u_csr.ctrl_q);
        if (dbglab_tb.dut.m_valid[0] && dbglab_tb.dut.m_ready[0] && dbglab_tb.dut.m_write[0]
            && dbglab_tb.dut.m_addr[0] == 32'h0000_2008)
          $display("cycle %4d  CPU writes CTRL   data=%02h", dbglab_tb.cycle, dbglab_tb.dut.m_wdata[0][7:0]);
        if (dbglab_tb.dut.dma_start) $display("cycle %4d  DMA start", dbglab_tb.cycle);
        if (dbglab_tb.dut.dma_done)  $display("cycle %4d  DMA done", dbglab_tb.cycle);
        if (dbglab_tb.dut.u_csr.done_q !== done_q)
          $display("cycle %4d  done_q -> %b        ctrl_q=%02h", dbglab_tb.cycle, dbglab_tb.dut.u_csr.done_q, dbglab_tb.dut.u_csr.ctrl_q);
        if (dbglab_tb.irq !== irq_q)
          $display("cycle %4d  irq    -> %b        ctrl_q=%02h", dbglab_tb.cycle, dbglab_tb.irq, dbglab_tb.dut.u_csr.ctrl_q);
        rst_q  = dbglab_tb.rst_n;
        done_q = dbglab_tb.dut.u_csr.done_q;
        irq_q  = dbglab_tb.irq;
      end
    endmodule
    Terminal
    $ git checkout -q 026f8bf && ../probes/run-probe.sh ../probes/irq_story.sv irq_w1c 1
    dbglab: test=irq_w1c seed=1
    cycle    0  done_q -> x        ctrl_q=xx
    cycle    0  irq    -> x        ctrl_q=xx
    cycle    1  done_q -> 0        ctrl_q=01
    cycle    1  irq    -> 0        ctrl_q=01
    cycle    4  reset released      ctrl_q=01
    cycle   18  DMA start
    cycle  115  DMA done
    cycle  116  done_q -> 1        ctrl_q=01
    cycle  116  irq    -> 1        ctrl_q=01
    FAIL test=irq_w1c seed=1 cycle=116: IRQ: irq=1 while CTRL=00 enables no interrupt
    $ git checkout -q 2e63466 && ../probes/run-probe.sh ../probes/irq_story.sv irq_w1c 1 | head -14
    dbglab: test=irq_w1c seed=1
    cycle    0  done_q -> x        ctrl_q=xx
    cycle    0  irq    -> x        ctrl_q=xx
    cycle    1  done_q -> 0        ctrl_q=00
    cycle    1  irq    -> 0        ctrl_q=00
    cycle    4  reset released      ctrl_q=00
    cycle   18  DMA start
    cycle  115  DMA done
    cycle  116  done_q -> 1        ctrl_q=00
    cycle  119  CPU writes CTRL   data=01
    cycle  120  irq    -> 1        ctrl_q=01
    cycle  130  done_q -> 0        ctrl_q=01
    cycle  130  irq    -> 0        ctrl_q=01
    cycle  145  DMA start
    • Cycle 1, both commits: reset acts. The only difference between the two runs is here: ctrl_q=01 against ctrl_q=00. The first wrong value exists from reset, 115 cycles before anything fails.

    • Cycle 116 at 026f8bf: done_q and irq rise together, and no line says the CPU wrote CTRL. The enable was there from reset.

    • Cycles 116 to 120 at 2e63466: done_q rises alone. irq follows only after the test writes CTRL=01 at cycle 119, which is step 2 of the test working as designed.

    Both predictions hold, at both commits. Bisect named the commit, the diff named the line, and the probe shows the line's effect and nothing else. (Cycles 0 and 1 show X before reset acts; that is normal for a four-state simulator.)

    The fix and the lesson

    Fix: put the register map's value back

    design.sv
    // snippet: the fix, rtl/dbglab_pkg.sv line 39
      localparam logic [7:0]  CTRL_RST    = 8'h00;

    Prove: the repro, more seeds, both simulators, nothing new

    Terminal
    $ tb/run.sh irq_w1c 1 && tb/run.sh irq_w1c 1 verilator
    dbglab: test=irq_w1c seed=1
    PASS test=irq_w1c seed=1 cycles=316 time=3156 ns
    dbglab: test=irq_w1c seed=1
    PASS test=irq_w1c seed=1 cycles=316 time=3156 ns
    $ for s in $(seq 1 20); do tb/run.sh irq_w1c $s | tail -1; done | cut -c1-4 | sort | uniq -c
      20 PASS
    $ tb/regress.sh icarus 1 2 3 | grep -E "FAIL|failing"
    dma_burst_backpressure   seed 1      FAIL
    dma_burst_backpressure   seed 2      FAIL
    dma_burst_backpressure   seed 3      FAIL
    periph_power             seed 1      FAIL
    periph_power             seed 2      FAIL
    periph_power             seed 3      FAIL
    6 failing run(s)
    $ tb/regress.sh verilator 1 2 3 | tail -1
    6 failing run(s)

    The repro passes in both simulators, twenty seeds pass, and the regression's remaining failures are the two other bugs it had before, with their own signatures (the deadlock under back-pressure is one). Tell the driver team they can remove their mask. A workaround left in software outlives the bug it covered.

    Prevent: three habits, each cheap

    • Test reset values directly. Only irq_w1c noticed, and only by luck: it is the one test that starts a DMA before writing CTRL. A test that reads every register after reset and compares it with the register map catches this before any DMA runs. The bench's model keeps its own reset values instead of importing the package's on purpose; had it imported CTRL_RST, model and RTL would agree and nothing would fail (when the bug is in the test).

    • Check "no functional change" claims. A refactor can be proved equal to its parent. Equivalence failures runs that check on this very commit and gets the counterexample.

    • Keep the bisect scripts. irq-bug.sh is now a test of this bug's signature. Keep it next to the bench, outside the tree being bisected, so the next regression from a known-good release is one command.

    Which step won this hunt? Localise, through history. Reading code would have found the line eventually, but only by guessing where to read. Four runs of a correct script found it without guessing. The two dead ends were both failures of judgement about bisect, and both were cheap to expose: one run at a parent, and one look at the first verdict.

    Common pitfall

    Fixing it in the driver. The driver team's mask (write CTRL to 0 at init) makes their symptom go away and leaves a part whose reset state disagrees with its register map. Every other user of the block, including the boot ROM and the next project, inherits the bug. Fix the hardware and remove the workaround.

    Why this matters

    Senior lens. Bisect was this fast because the history was bisectable: every commit built, every commit ran the tests, and the refactor sat in its own commit instead of inside a feature. A nine-line guilty diff is a gift from whoever kept commits small. Make it a team rule, and protect it in review: a commit that mixes a refactor with a behaviour change makes every future bisect end on a diff too big to read.

    When you hand a bisect result to someone, send the script and the git bisect log with it. The log shows each verdict, so a reader can spot a wrong one, like commit 10's, in a minute.

    Interview

    Asked: "A test passed at the last release and fails today. There are 200 commits in between. What do you do?"

    Weak answer: I would look at the commits that touched the failing block, read the ones that look related, and try reverting the most likely one.

    Strong answer: First I check both ends with the same command: it passes at the release and fails at HEAD. Then I bisect, which takes about eight runs for 200 commits. The script is the important part. It exits 1 on this failure's message, 0 on a pass and 125 on anything else. The message is matched without cycle numbers or addresses that can move between commits, because a strict match can call a bad commit good and send the search into the wrong half. I read the guilty diff for the line that explains the symptom, then confirm with a prediction at that commit and its parent before I call it the cause. I keep the bisect log so others can check each verdict.

    Follow-ups: (1) Bisect lands on a merge commit. What now? (2) Half the commits in the range do not build. How does bisect cope?

    Check yourself A colleague's bisect script for this bug checks only the exit status of tb/run.sh irq_w1c 1: any failure is bad. Would it have found commit 9 here? When would it go wrong?

    Here, yes: no other bug changes the result of irq_w1c in this range, so every failure is this bug. It goes wrong when another bug makes the same test fail differently somewhere in the range. Then bisect blames whichever bug appears first. The fix is the 125 answer for other failures, which is the opposite edge from the strict script: too loose instead of too narrow.

    Check yourself Bisect names commit 9. A teammate says it cannot be the cause because the commit message says no functional change was intended. How do you settle it with one probe?

    Run the probe at commit 9 and at its parent and compare the reset value of CTRL: 01 against 00, with irq rising at commit 9 without any CTRL write. A commit message records what the author meant; the run records what the commit does. Then read the diff for the one constant that changed value.

    Remember

    • A regression from a known-good release is a history question first. Check both ends, then bisect: eleven commits take four runs.

    • A signature is the message, not the cycle. Strip numbers that move between commits, or bisect calls a bad commit good.

    • A commit message that says "no functional change" is a claim, not evidence. Test it against its parent.

    Rule of thumb: if it used to pass, ask history first, and make the bisect script test this bug's message, nothing more and nothing less.

    How this was checked

    dbglab history built with dbglab/build-history.sh (HEAD 49c5f04), 2026-10-18, Icarus Verilog 13.0, Verilator 5.048, git 2.54.0. tb/regress.sh icarus 1 2 3: 9 failing runs (irq_w1c, dma_burst_backpressure, periph_power, seeds 1 to 3). tb/run.sh irq_w1c 1 and the same with verilator: FAIL at cycle 84. At v0.1: PASS. At 03ebdc1 (commit 13): FAIL at cycle 84. At 27acd30 (commit 10): FAIL at cycle 116.

    git bisect start HEAD v0.1; git bisect run ../bisect/strict.sh (exact HEAD line with cycle=84): first bad commit c4a71b5, wrong. git bisect run ../bisect/irq-bug.sh (message with any cycle; 125 for other failures): first bad commit 026f8bf in 4 runs, about a second in all.

    irq_story.sv compiled next to dbglab in Icarus (iverilog -g2012 -Wno-timescale -I tb -o build/probe.vvp <rtl/files.f> tb/bus_rules.sv tb/dbglab_tb.sv irq_story.sv; vvp -n build/probe.vvp +TEST=irq_w1c +SEED=1) at 026f8bf and 2e63466: ctrl_q is 01 and 00 at reset release; irq rises at cycle 116 with no CTRL write, against cycle 120 after the CPU writes CTRL.

    Fix on a copy of HEAD (CTRL_RST = 8'h00): irq_w1c passes in both simulators and for seeds 1 to 20; tb/regress.sh icarus 1 2 3 and tb/regress.sh verilator 1 2 3 each report 6 failing runs (dma_burst_backpressure and periph_power, other bugs). Limits: simulation only; nothing here checks the other registers' reset values.

    Keep going

    • Practice: Reset value testing of registers

    • Practice: Modelling W1C status registers

    • Practice: Reset strategies in RTL

    • Code lab: RAL reset values

    • Code lab: Interrupt aggregator

    • Interview: run a mock interview on this topic

    Related topics

    • Part 10 · Case Studies

    • Bisection in history

    • Configuration and register-map bugs

    • X and reset bugs

    • Case study: the off-by-one that fails at wrap

    • Case study: the X that hid

    Previous

    87 / 107

    Next: The X That Hid for Thousands of Cycles

    Up next

    Up nextThe X That Hid for Thousands of Cycles

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • The Race Two Simulators Disagree On
    • The Bus Value That Was Never Sent
    • The Response to the Wrong Master