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. Bisection in Time

Part 3 · Localise · Working (W)

Bisection in Time

Find the first cycle a run's state goes bad by halving the time window: one look per run, a question whose answer changes once, both ends checked first. A real dbglab failure narrowed from 2,375 cycles to one in 12 looks.

On this page

    Why this matters

    When a run is long and every look at its state is expensive (a full dump, a checkpoint, a scan dump that stops a chip), you cannot afford to look everywhere. Halving the time window finds the first bad cycle in about log2 of the run's length in looks.

    After this lesson you can: choose a state question whose answer changes once, check it at both ends, bisect the cycle at which to ask it, and hand the last few cycles to a walk back.

    Note

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

    Before this: The First Wrong Cycle, Guessing vs Searching

    A bad word on the stream, and 2,385 cycles before it

    dbglab's random test fails with seed 23 on the peripheral's stream:

    Terminal
    $ tb/run.sh random 23
    dbglab: test=random seed=23
    FAIL test=random seed=23 cycle=2385: STREAM: word #41 = xxxxxxxx, expected 00000000

    Unlike a scoreboard line, this one names no DMA run and no address. Word #41 could have come from the CPU or from a DMA copy, and its value could have gone bad anywhere between reset and cycle 2385. Suppose each look at the design's state costs you something real: an hour of simulation, a dump you cannot store, a chip you have to stop. When did the state first go bad?

    What people usually do, and why it fails

    The usual move is to dump everything from time zero and scroll back from the failure. On a small design that works. On a long run it is the problem: the dump is too big to write or to load, so people dump a window just before the failure, and the cause is earlier than the window (the dump strategy lesson shows exactly that). The other habit is to guess a time from the symptom ("the stream started at cycle 2000, look there") and dump around the guess. A wrong guess costs a full run and tells you only that the cause is not there.

    Time is an ordered axis, and a run that goes wrong usually stays wrong. That is all bisection needs. The guessing vs searching lesson halved the number of operations a test makes, which changes the stimulus. Here the run stays exactly as it was and you halve when you look.

    The method: one look per run, halve the window

    1. Choose a question about the state at one cycle, answerable with yes or no: does any RAM word hold an unknown value?, does the RAM disagree with the model?, is any FIFO pointer unknown? It should come from the symptom: here an unknown word reached the stream, so ask where unknown words live.

    2. Make sure the answer flips once. Clean up to some cycle, bad from then on, until the failure. Ask what could make it bad early (reset, uninitialised memory) or clean again (an overwrite).

    3. Check both ends. Look at a cycle you believe is clean and at the failing cycle. If the ends do not disagree, stop: the question or the window is wrong.

    4. Look in the middle. Bad: the first bad cycle is at or before it. Clean: it is after it. Keep the half that holds the change.

    5. Never disturb the run before the look. Read state from the side (hierarchical names, a backdoor, a scan chain), not by sending traffic through the design.

    6. Stop when the window is small enough to read. Then dump or log just that window and walk back the last hops.

    A window of N cycles takes about

    ⌈log2​N⌉

    looks: 2,375 cycles take 12, a million take 20. Each look costs a run up to its cycle, so the total is roughly a dozen runs, never one full dump.

    The picture: twelve looks

    The real bisection of seed 23: twelve looks between a clean cycle 10 and the failing cycle 2385, each halving the suspect cycles until one is left: 2230.

    The bars are on a log scale, so every look shortens them by the same step, whether its answer was clean or bad. Three looks said bad; nine said clean. Both kinds carry exactly one bit.

    Do it: bisecting seed 23

    The look

    A look is a small module compiled next to dbglab. At cycle +AT it counts the RAM words that hold an unknown value, prints one line, and stops the simulation, the way a scan dump stops a chip: one look per run. It reads the RAM's array directly, so the run up to the look is the run that failed.

    look_at.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)
    // look_at: one look at the design's state at cycle +AT=<T>, then stop, the way a scan dump
    // stops a chip. The question: does any RAM word hold an unknown value at cycle T?
    // Prints one LOOK line: BAD with the count and the first such word, or CLEAN. Reads only.
    `timescale 1ns/1ps
    module look_at;
      int unsigned at, n, first;
      initial if (!$value$plusargs("AT=%d", at)) at = 0;
      always @(posedge dbglab_tb.clk) begin
        #0.5;                                                  // just after the edge
        if (at != 0 && dbglab_tb.cycle == at) begin
          n = 0;
          for (int i = 0; i < 1024; i++)
            if ($isunknown(dbglab_tb.dut.u_ram.mem[i])) begin
              if (n == 0) first = i;
              n++;
            end
          if (n != 0) $display("LOOK cycle=%0d BAD: %0d RAM words unknown, first word %0d", at, n, first);
          else        $display("LOOK cycle=%0d CLEAN", at);
          $finish;
        end
      end
    endmodule

    Check the ends

    Terminal
    $ vvp -n build/look.vvp +TEST=random +SEED=23 +AT=1
    dbglab: test=random seed=23
    LOOK cycle=1 BAD: 1024 RAM words unknown, first word 0
    ...
    $ vvp -n build/look.vvp +TEST=random +SEED=23 +AT=10
    dbglab: test=random seed=23
    LOOK cycle=10 CLEAN
    ...

    Cycle 1 is already bad: every RAM word is unknown until the bench preloads the RAM, a few cycles after reset. So the question flips twice, bad, then clean at cycle 10, then bad again somewhere later. Cycle 1 cannot be the clean end. Cycle 10 can.

    Let a script halve

    example.py
    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
    # not-run: drives the look_at probe through vvp; run it from the dbglab folder (command in the Evidence box)
    """bisect_time.py: the first cycle at which a one-look check sees bad state.
    usage: python3 bisect_time.py <test> <seed> <good cycle> <bad cycle>   (run from the dbglab folder,
    after compiling the look_at probe into build/look.vvp)
    Each probe is one run that stops at its look, so it costs as many cycles as the look is late."""
    import subprocess, sys
    
    test, seed, lo, hi = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
    
    def look(t):
        out = subprocess.run(["vvp", "-n", "build/look.vvp", f"+TEST={test}", f"+SEED={seed}", f"+AT={t}"],
                             capture_output=True, text=True).stdout
        line = next((l for l in out.splitlines() if l.startswith("LOOK")), f"LOOK cycle={t} (no look: the run ended first)")
        return "BAD" in line or "no look" in line, line
    
    runs, cost = 0, 0
    for t in (lo, hi):                                  # check both ends before trusting the search
        bad, line = look(t); runs += 1; cost += t
        print(f"end   {line}")
        if bad != (t == hi):
            sys.exit(f"the ends do not bracket a change: cycle {t} is {'bad' if bad else 'clean'}")
    while hi - lo > 1:
        mid = (lo + hi) // 2
        bad, line = look(mid); runs += 1; cost += mid
        if bad: hi = mid
        else:   lo = mid
        print(f"probe {line:62s} -> cycles {lo + 1}..{hi}")
    print(f"first bad cycle: {hi}  ({runs} runs, {cost} cycles simulated in all)")
    Terminal
    $ python3 ../probes/bisect_time.py random 23 10 2385
    end   LOOK cycle=10 CLEAN
    end   LOOK cycle=2385 BAD: 15 RAM words unknown, first word 48
    probe LOOK cycle=1197 CLEAN                                          -> cycles 1198..2385
    probe LOOK cycle=1791 CLEAN                                          -> cycles 1792..2385
    probe LOOK cycle=2088 CLEAN                                          -> cycles 2089..2385
    probe LOOK cycle=2236 BAD: 2 RAM words unknown, first word 48        -> cycles 2089..2236
    probe LOOK cycle=2162 CLEAN                                          -> cycles 2163..2236
    probe LOOK cycle=2199 CLEAN                                          -> cycles 2200..2236
    probe LOOK cycle=2217 CLEAN                                          -> cycles 2218..2236
    probe LOOK cycle=2226 CLEAN                                          -> cycles 2227..2236
    probe LOOK cycle=2231 BAD: 1 RAM words unknown, first word 48        -> cycles 2227..2231
    probe LOOK cycle=2228 CLEAN                                          -> cycles 2229..2231
    probe LOOK cycle=2229 CLEAN                                          -> cycles 2230..2231
    probe LOOK cycle=2230 BAD: 1 RAM words unknown, first word 48        -> cycles 2230..2230
    first bad cycle: 2230  (14 runs, 27429 cycles simulated in all)

    Read it line by line. At the failing cycle 15 words are unknown, the first at word 48. The first three looks are clean, which moves the window to the last 300 cycles. Look 4 at cycle 2236 sees two bad words; looks 5 to 8 close in from below; look 9 sees one. At look 12 the answer is a single cycle: at cycle 2230 word 48 becomes unknown, 155 cycles before the stream check fails. Fourteen runs simulated 27,429 cycles in all, about eleven times the failing run, and nothing was dumped.

    Read the last window

    Now the window is small enough to log in full. A probe that prints every bus request between two cycles, and the setup of any DMA run that starts near them:

    Terminal
    $ ../probes/run-probe.sh ../probes/window_log.sv random 23 +FROM=2220 +TO=2232
    dbglab: test=random seed=23
    cycle 2220: cpu WR 00002018 0000000f
    cycle 2223: cpu WR 0000201c 00000001
    cycle 2224: DMA run #16 starts: ctrl=11 len=15 src=00001c8c dst=000010c0 fill=xxxxxxxx
    cycle 2226: cpu RD 0000200c
    cycle 2229: dma WR 000010c0 xxxxxxxx
    FAIL test=random seed=23 cycle=2385: STREAM: word #41 = xxxxxxxx, expected 00000000

    The CPU writes LEN = 15 (0x2018) and START (0x201c). DMA run #16 starts at cycle 2224 in fill mode (CTRL 0x11: fill, plus the done interrupt) with a fill value that is already unknown, and its first write, accepted at cycle 2229, puts xxxxxxxx into 0x10c0, which is word 48. The RAM shows it from cycle 2230. The same probe over cycles 2290 to 2385 shows the rest of the chain: DMA run #16 fills words 48 to 62, run #17 starts at cycle 2355 and copies two words from 0x10f4 (word 61) into the peripheral, and the stream check sees one of them at 2385.

    What the evidence says

    The search moved from "somewhere in 2,385 cycles" to "the first write of a zero fill at cycle 2229" in twelve looks and one short log. It also handed the walk back its next hop: the DMA's fill value was unknown when the run started, so the next question is about the CSR block's FILL register, not about RAM, the peripheral or the stream. That hunt is in X in a waveform, on another seed of the same bug.

    Notice what the answer is: the first cycle this question could see. A question about the stream would have bisected to 2385, which you already knew. A question about the DMA's fill input at each start would have landed at 2224, earlier still. Choose the sharpest question the symptom suggests, and treat the answer as the place to start walking back, not the root cause.

    In dbglab every look is cheap, so you could also check every cycle in one run, and that is the better tool when you can: a per-cycle count of unknown RAM words changes at cycles 1, 8 and 2230, the same answer. Bisection in time earns its keep when a look is expensive or destroys the run.

    Traps

    Common pitfall

    A question that flips more than once. Here the RAM was bad before the preload, so cycle 1 answered BAD. Any question can do this: an overwrite can clean a bad word, a FIFO can drain a bad entry. If the question flips back and forth, bisection still returns a change, not the first. The end check is your guard; the script above refuses to start when the ends agree.

    Common pitfall

    A look that changes the run. Reading RAM through the bus at cycle T adds traffic, shifts the arbiter, and the run after T (and sometimes before, if the look waits for the bus) is no longer the failing run. Look from the side: hierarchical reads in simulation, scan or trace in hardware.

    Common pitfall

    Runs that do not repeat. Every look reruns the test from zero, so the run must be cycle-for-cycle deterministic. If it is not, look 7 sees a different run from look 6 and the search quietly lies (determinism and seeds).

    Why this matters

    Senior lens. This is the method of choice wherever a look is destructive or scarce. On silicon, a scan dump stops the clock and reads the flops once: you bisect the cycle at which the clock stops, rerunning the test each time. An emulator's trace buffer holds a short window: you bisect where to put the trigger. A day-long simulation with periodic checkpoints lets each look restart from the nearest checkpoint, so a look costs minutes. The instruments live in the bring-up course; the halving is the same.

    Build cheap state questions into the bench before you need them: an X sweep of the memories, a model compare, a credit count. A bench with five ready-made questions turns every long failure into a dozen unattended runs.

    Interview

    Asked: "A six-hour simulation fails at the very end and you cannot dump the whole run. How do you find when it went wrong?"

    Weak answer: I would dump the last part of the run, maybe the final hour, and look for the problem there, and if it is not there I would dump an earlier hour.

    Strong answer: I pick a yes-or-no question about the state that the symptom suggests, like an X sweep of the memories or a compare against the model, and check that its answer changes once. I confirm a clean cycle and the failing cycle, then bisect the cycle at which I ask, one look per run, reading state from the side so the run is unchanged. That is about thirty looks for a billion cycles, and checkpoints make each look cheap. When the window is a few hundred cycles I dump just that and walk back to the cause.

    Follow-ups: (1) What if your question's answer flips back to clean at some point? (2) How would you do the same on silicon?

    Check yourself You bisect with the question "does the RAM disagree with the model?" instead of "does any RAM word hold an unknown value?". The model updates a DMA run's words only when the bench closes the run. Would you land on cycle 2230? Why or why not?

    No, later. The fill writes the bad words from cycle 2229, but until the bench closes run #16 the model has not been updated and the comparison must skip those words, so the question first says bad after the run is closed. Bisection finds the first cycle your question can see, and this question sees the damage only when the model catches up. It still narrows the search well; you then walk back from there.

    Check yourself A colleague's bisection over a 100,000-cycle run lands on cycle 3. What do you suspect?

    That the question was already bad near reset, like this lesson's RAM before the preload, or that the clean end was never checked. Look at cycle 3 by hand: if the state is merely uninitialised and settles after reset, pick a clean end after that point and bisect again. A first bad cycle right at reset is either a real reset bug or, more often, a question that does not flip once.

    Remember

    • The question must flip once: clean before some cycle, bad after it. Check both ends first; reset and later overwrites can break that.

    • Each look must leave the run unchanged up to its cycle. Read state from the side, never through the bus the design uses.

    • Bisection finds the first cycle your question can see. A sharper question sees earlier; then walk back the last hops.

    Rule of thumb: if you can check every cycle cheaply, do that in one run; bisect in time when each look is costly.

    How this was checked

    dbglab at HEAD 49c5f04, Icarus Verilog 13.0, Python 3.14.3, 2026-10-17. tb/run.sh random 23 fails at cycle 2385 (STREAM word #41 unknown). look_at.sv compiled next to dbglab into build/look.vvp (iverilog -g2012 -Wno-timescale -I tb -o build/look.vvp <rtl/files.f> tb/bus_rules.sv tb/dbglab_tb.sv look_at.sv); vvp -n build/look.vvp +TEST=random +SEED=23 +AT=1 printed BAD (1024 words), +AT=10 CLEAN.

    python3 bisect_time.py random 23 10 2385: 14 runs (2 ends, 12 looks), 27,429 cycles simulated in all, 0.47 s, first bad cycle 2230; with a lower end of 1 it stops at the end check. A per-cycle count of unknown RAM words (one run, xtrace.sv) changes at cycles 1, 8 and 2230, agreeing with the bisection. window_log.sv with +FROM=2220 +TO=2232 printed the window shown, and with +FROM=2290 +TO=2385 the start of run #17 (two words from 0x10f4 to the peripheral, cycle 2355). A full dump of the run (DUMP=1 tb/run.sh random 23) is 963,311 bytes.

    Limits: dbglab's runs take well under a second, so the cost argument is shown in cycles, not hours; the scan-dump and emulator uses are described, not run here. One bug, one seed, one question.

    Keep going

    • Practice: Inline RTL checkers: X/Z detection, liveness and bindable monitors

    • Interview: run a mock interview on this topic

    Related topics

    • Part 3 · Localise

    • Next: bisection in space

    • The first wrong cycle: walking the last hops

    • Dump strategy: windows and triggers

    • Bring-up: debug interfaces, scan and trace

    Previous

    25 / 107

    Next: Bisection in Space

    Up next

    Up nextBisection in Space

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • Bisection in Stimulus
    • Bisection in History
    • Bisection Across Configurations and Seeds