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
    • The First Wrong Cycle
    • Bisection in Time
    • Bisection in Space
    • Bisection in Stimulus
    • Bisection in History
    • Bisection Across Configurations and Seeds
    • Cone-of-Influence Tracing
    • Differential Debugging
    • When the Bug Is in the Test
    • The Localisation Checklist
  • 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 3 · Localise
  1. Topics
  2. The Art of Hardware Debug
  3. Part 3 · Localise
  4. Differential Debugging

Part 3 · Localise · Senior (S)

Differential Debugging

Localise by comparing the failing run with something that works: a reference model checked on every beat, the previous release, another simulator or another configuration. A self-contained example where only the per-beat compare sees the bug, and dbglab's five bugs, each against the commit before it.

On this page

    Why this matters

    Reading a failing run alone, you must work out what every signal should have been, which is slow and error-prone. A reference that agrees with the design except for the bug gives the expected value for free, and the first place they disagree is the first wrong cycle.

    After this lesson you can: choose a reference (model, previous release, other simulator or configuration), list how it may legitimately differ, compare often enough to see the first disagreement, and read what each disagreement points to.

    Note

    Level: Senior (S) · Debug-loop step: Localise

    Before this: Comparing a Good and a Bad Run, The First Wrong Cycle

    The total is right, and software still sees nonsense

    A saturating accumulator sums signed samples into a 12-bit total that must stick at +2047 and -2048. Its regression is green: after 600 samples the total equals the model's. Then the firmware team, which polls the total while samples stream in, reports that it sometimes jumps from about -2000 to about +2000 in one sample. The bench has a reference model. Why did it not see this, and how do you make it point at the sample that went wrong?

    What people usually do, and why it fails

    The usual first move is to stare at the failing run and reason about what each signal should have been: a reference model in your head, slow, and wrong exactly where your understanding is. The second is to trust the existing check: "the model agrees at the end, so the firmware must be wrong". A check that compares once per test can say whether the end state matches, never when the design left the path.

    The third habit: diff everything against a second simulator or an older release, get hundreds of differences, and give up. A reference that differs in many legitimate ways buries the one that matters.

    The method: a reference, its allowed differences, and the first surprise

    On the debug loop this is Localise: the reference supplies the expected value of every compared signal, so the first disagreement is the first wrong cycle without walking back by hand.

    1. Pick the reference that differs from the failing run in the fewest ways. The same stimulus and seed always; then as little else as possible.

    2. Write down how it is allowed to differ. A model has no pipeline latency; another simulator has no X, or orders races differently; an older release lacks some features. Anything on the list is noise.

    3. Compare as often as the reference allows. Every beat or transaction beats every test; every register beats every output.

    4. Read the first difference not on the list, with the inputs at that moment. That is your localisation.

    5. If design and reference disagree, the spec decides who is right. A reference can be wrong too; see when the bug is in the test.

    Reference

    Allowed to differ in

    A disagreement points at

    Where it is taught

    Reference model

    timing detail, internal structure

    the first beat and inputs where behaviour departs from the spec

    this lesson

    Previous release or commit

    the commits in between

    a change in that range, and the first cycle it matters

    comparing a good and a bad run, bisection in history

    Another simulator

    X versus two-state values, race ordering, tool bugs

    X, initial-state or race bugs (if only one fails)

    bisection across configurations, determinism and seeds

    Another configuration or seed

    parameters, enabled features, stimulus

    the feature or parameter the bug needs

    bisection across configurations

    The picture

    The accumulator's output against its reference model over 600 beats. They split at beat 89, when the design wraps instead of saturating, and agree again from beat 517, when both stick at +2047. The end-of-test compare sees only beat 599.

    For 428 of 600 beats the design was wrong, and the last compare happens in the 83 beats where it was right again. Saturation forgets history: once both totals stick at +2047, the bug is erased from the state the end check reads.

    Do it, part 1: a reference model on every beat

    The bench below drives 600 samples, drifting down for 300 and up for 300. ref_add is the reference: plain integers and a clamp, written from the spec, sharing no code with the RTL. One checker compares once at the end; the other compares after every beat and prints the first mismatch with the state before it. The bench runs the shipped design and the fixed one side by side and checks that each checker behaves as this page says.

    sat_acc_bad.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
    // sat_acc adds a signed 8-bit sample to a 12-bit signed total on every valid beat and must
    // saturate at +2047 and -2048. acc_o is visible every cycle (software polls it).
    // sat_acc_bad is the version that shipped; sat_acc is the fix. The bench runs both.
    `timescale 1ns/1ps
    module sat_acc_bad (
      input  logic               clk, rst_n, valid,
      input  logic signed [7:0]  s,
      output logic signed [11:0] acc_o
    );
      logic signed [12:0] sum;
      assign sum = acc_o + s;                          // one extra bit holds any single overflow
      always_ff @(posedge clk or negedge rst_n)
        if (!rst_n)                 acc_o <= '0;
        else if (valid) begin
          if (sum > 13'sd2047)      acc_o <= 12'sd2047;  // saturate high
          else                      acc_o <= sum[11:0];  // bug: no low limit, -2049 wraps to +2047
        end
    endmodule
    
    module sat_acc (
      input  logic               clk, rst_n, valid,
      input  logic signed [7:0]  s,
      output logic signed [11:0] acc_o
    );
      logic signed [12:0] sum;
      assign sum = acc_o + s;
      always_ff @(posedge clk or negedge rst_n)
        if (!rst_n)                 acc_o <= '0;
        else if (valid) begin
          if (sum > 13'sd2047)      acc_o <= 12'sd2047;
          else if (sum < -13'sd2048) acc_o <= -12'sd2048; // the fix: saturate low too
          else                      acc_o <= sum[11:0];
        end
    endmodule
    
    Terminal
    $ iverilog -g2012 -o sat.vvp sat_acc_tb.sv && vvp -n sat.vvp
    lockstep, sat_acc_bad: first difference at beat 89: acc_o=2034 model=-2048 (before: acc_o=-2023 model=-2023, s=-39)
    end of test, sat_acc_bad: acc_o=2008 model=2008 (equal)
    end of test, sat_acc:     acc_o=2008 model=2008; lockstep differences: none
    PASS: end-of-test compare missed the bug; lockstep compare found it at beat 89
    sat_acc_tb.sv:82: $finish called at 6020000 (1ps)

    Verilator prints the same four lines. Read them in order:

    • Lockstep, beat 89. Before the beat both agree at -2023. The sample is -39, so the true sum is -2062, below the floor. The model clamps to -2048; the design shows +2034, which is -2062 plus 4096. That is a wrap, and it happens exactly when the sum crosses the low limit. One line has localised the bug to one branch of the saturation logic.

    • End of test, buggy design: equal. Both read 2008. The end check would have shipped this design.

    • End of test, fixed design: the same total and no lockstep difference in 600 beats. The fix adds the missing low branch, one line.

    Nobody had to work out, beat by beat, what the total should be; the reference did, for five lines of checker code.

    Do it, part 2: dbglab's five bugs against their references

    dbglab's HEAD has five failing tests, each from a different bug. Before reading any waveform, run each against references that differ in one known way. The simulator and start-value matrix for all five bugs is in bisection across configurations: three bugs fail identically everywhere (logic), B4 splits on initial values (an unreset register), and B5 splits on how unknown values are modelled. The fourth reference, the commit before each bug, costs one run each:

    Terminal
    $ ../tools/before.sh
    ## 2e63466 Fix comments in the fabric and the DMA; README register map
    PASS test=irq_w1c seed=1
    ## 27acd30 periph: deepen the async FIFO to 16 entries
    PASS test=dma_burst_backpressure seed=1
    ## bc2bb34 dma: fill mode
    PASS test=random seed=10
    ## 03ebdc1 dma: one width for the beat counters
    PASS test=random seed=17
    ## b357bd8 csr: no reset on the DMA address and data registers
    PASS test=periph_power seed=1
    • The commit before passes every one. Each failure has a good end one commit away, so comparing that pair of runs will find its first difference with almost no noise: the only allowed difference is one commit.

    What the evidence says

    A reference localises in two ways. Compared often, it gives the first wrong cycle and the inputs there: beat 89, sample -39, a sum just under the floor. Compared across kinds, it says which class of cause to look in before you look at anything: logic, initial state, or unknown values. Both took minutes. The next step is Hypothesise: "the low saturation branch is missing" predicts that every first mismatch follows a sum below -2048, which the lockstep line already shows for one case and a sweep of seeds could test.

    Traps

    Common pitfall

    A reference with too many allowed differences. Diffing the two simulators' dumps of periph_power looks like a shortcut. It is not: vcd_first_diff.py icarus.vcd verilator.vcd prints compared up to time 4310500: 31 differences, first at 0 (cycle 0), and the lines that follow are mostly Icarus's X against Verilator's 0 before reset, such as dbglab_tb.dut.u_csr.ctrl_q (+5 same) good=xx bad=00 until 5000 (cycle 1). The real divergence is in there, but every line before it is on the allowed-differences list. Use the references that differ in one way, and compare at the level where they should agree (the PASS/FAIL line here, not the dump).

    Common pitfall

    A reference that shares the bug. A model written by reading the RTL, or by the same engineer from the same misreading of the spec, agrees with the design exactly where both are wrong. dbglab's bench keeps its own copy of every reset value for this reason; when the bug is in the test shows what happens when it does not.

    Why this matters

    Principal lens. A reference model is a debug instrument, not just a pass/fail oracle, and it should be built as one: compared at every transaction (or every cycle for small blocks), able to print its state at the first mismatch, and written from the spec by someone other than the RTL author. The extra cost is small next to a week spent reconstructing expected values by hand.

    The same applies to history. A project that keeps the last good release built and runnable gets the commit-before reference for free on every failure, and the diff between the two runs has one allowed difference instead of hundreds.

    Interview

    Asked: "Your block's end-of-test check passes, but software sees wrong values while it runs. How would you find where the design first goes wrong?"

    Weak answer: I would add more checks at the end of the test and look at the waveform where software saw the problem.

    Strong answer: An end check can match after the design was wrong for most of the run, because later values overwrite or saturate the bad ones. I would compare a reference with the design on every beat or transaction, at the level software sees. Before reading anything I write the allowed-differences list: latency, legal reordering, fields the spec leaves open. Then I read only the first mismatch that is not on the list, with the inputs at that moment, and that is my first wrong cycle. The reference must come from the spec, not from the RTL's constants or tables, or it will agree with the bug.

    Follow-ups: (1) The design has three cycles of latency the model does not. How do you compare them? (2) How would you check that your reference model is not wrong?

    Check yourself A colleague's model check compares the final memory image after each test, and every test passes. Firmware sees occasional wrong reads during the test. What would you change, and what do you expect to learn?

    Compare on every read (or every write) against the model, and print the first mismatch with the address, the data and the transaction that last wrote it. The final image can match even when intermediate values were wrong, because later writes overwrite them, as saturation did here. The first per-transaction mismatch gives the cycle and the inputs that broke it.

    Check yourself You diff a release and HEAD on the same test and seed and get 400 differences. What do you write down before reading any of them?

    The ways the two runs are allowed to differ: what each commit in between changes on purpose (a new register, a different latency, a renamed signal), and anything else not held equal (tool version, options). Filter or align those away, for example compare transactions instead of cycles if latency changed, and read the first difference left. If the list is long, the reference is too far away: diff against the commit just before the change instead.

    Remember

    • A reference is only as useful as the list of ways it is allowed to differ. Write that list first; everything else in the diff is evidence.

    • Compare on every beat if you can. An end-of-test compare can pass a design that was wrong for most of the run.

    • Which references disagree is itself a clue: tool-independent bugs agree everywhere; X and initial-state bugs split the simulators.

    Rule of thumb: never diff two runs until you can say, in one sentence, every way they are supposed to differ.

    How this was checked

    sat_acc_tb as shown: iverilog -g2012 -o sat.vvp sat_acc_tb.sv && vvp -n sat.vvp (Icarus Verilog 13.0) and verilator --binary --timing --assert -Wno-fatal --top-module sat_acc_tb (Verilator 5.048), identical output, 2026-10-17. The figure's points come from the same bench with one $display per beat added: 428 of 600 beats mismatch (89 to 516), second wrap at beat 276.

    dbglab at HEAD 49c5f04 (rebuilt with dbglab/build-history.sh): before.sh runs irq_w1c 1, dma_burst_backpressure 1, random 10, random 17 and periph_power 1, each at the commit before the one that introduces its bug (2e63466, 27acd30, bc2bb34, 03ebdc1, b357bd8), Icarus, git 2.54.0. Output as shown.

    Cross-simulator dump diff: DUMP=1 tb/run.sh periph_power 1 in each simulator, then python3 vcd_first_diff.py icarus.vcd verilator.vcd --cycle dbglab_tb.cycle --top 25 (the script from the observe lesson): compared up to time 4310500: 31 differences, first at 0 (cycle 0).

    Limits: one reference model, written independently of the design here; a model written from the RTL would share its bugs. The commit-before runs show that each bug has a good end one commit away; they do not say which line of that commit is wrong.

    Keep going

    • Practice: Counter with wrap and saturate modes

    • Code lab: Reference model class

    • Code lab: Saturation logic

    • Interview: run a mock interview on this topic

    Related topics

    • Part 3 · Localise

    • Comparing a good and a bad run: the previous release as the reference

    • Cone-of-influence tracing: B5 from the stream pins to the cause

    • When the bug is in the test

    • UVM: debugging the testbench and its scoreboards

    Previous

    31 / 107

    Next: When the Bug Is in the Test

    Up next

    Up nextWhen the Bug Is in the Test

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • The First Wrong Cycle
    • Bisection in Time
    • Bisection in Space