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. Cone-of-Influence Tracing

Part 3 · Localise · Senior (S)

Cone-of-Influence Tracing

Turn one wrong signal into a short suspect list: list the registers one stage behind it with Yosys, clear the ones the waveform says were right, and step back; a real walk on dbglab from the stream pins to a missing isolation clamp.

On this page

    Why this matters

    A wrong signal can be driven by thousands of flops, and reading the RTL behind it by eye invites you to chase the block you suspect rather than the one that drives it. Tracing the cone stage by stage, pruned by recorded values, turns it into a handful of suspects per step and ends on the line that made the value.

    After this lesson you can: list the fan-in cone of a wrong signal one register stage at a time with Yosys, prune each stage with values from the failing run, and recognise when the cause is a register, the logic between, or a missing input.

    Note

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

    Before this: The First Wrong Cycle, X in a Waveform

    An unknown on the stream pins

    dbglab's periph_power test powers the stream peripheral's sink down with words waiting in its FIFO, powers it up again, and expects every word to arrive. At HEAD it fails in Icarus:

    Terminal
    $ tb/run.sh periph_power 1
    dbglab: test=periph_power seed=1
    FAIL test=periph_power seed=1 cycle=431: STREAM: pstream_valid is unknown

    The first wrong cycle is known: the stream's valid pin goes X at cycle 427, and the bench samples it at 431. What drives that pin, and which of those drivers went wrong first?

    What people usually do, and why it fails

    Most engineers open the module that owns the pin, read its code, decide it looks fine, and move on to whatever looks next most likely: the async FIFO, because crossings are scary, or the power controller, because the test is about power. That is a search ordered by fear, not by structure, and it spends hours on blocks that do not drive the pin at all.

    The opposite mistake is to ask a tool for everything that can influence the pin. In dbglab that is almost the whole design, and a suspect list that long is not a list.

    The method: one stage at a time, pruned by values

    On the debug loop this is Localise. The cone of influence of a signal is every signal that can change it: the logic driving it, the registers feeding that logic, the logic driving those registers, and so on back to the inputs. Walk it like this:

    1. Start from the first wrong value, not the failure message: one signal, one cycle.

    2. List stage one: the registers that reach the signal through logic only, with no register between. A tool does this exactly; your eye skips the driver you did not expect.

    3. Get each suspect's value from the failing run at the cycle that matters. A suspect that was right is cleared, and its whole cone goes with it.

    4. Step back to the suspect that went wrong first and list its stage one. Repeat.

    5. Stop when every register behind a wrong value was right. The cause is then the logic between them, or a missing term in it: an input the logic should depend on and does not.

    The values come from the waveform or dump. Here the wrong value is an X, so "wrong" is easy to test with a script; for a wrong known value you need the expected value of each suspect, which is where a reference helps.

    The picture

    The walk from the stream pin back to the cause. Each column is one register stage; the accent box is the suspect that went unknown first, grey boxes were cleared by their values.

    Read it right to left. Each step clears most of the column and follows one box. A blind cone grows to 37 register cells in three stages; the pruned walk never looks at more than five.

    Do it: from the pin to the cause

    The tools

    Yosys reads dbglab's RTL and flattens it, so a selection can cross module boundaries. Two select operators do the work: %cie* grows a selection backwards through logic cells only, stopping at registers; %ci1 steps back across one cell, which is how the walk goes through a register to its inputs. cone.sh wraps them and prints the registers one stage behind each signal it is given:

    terminal
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    #!/bin/sh
    # cone.sh: the registers one stage behind each named signal (through logic only), from Yosys.
    # usage: cone.sh <rtl-dir> <top> <signal> [<signal> ...]   (flattened names, e.g. u_per.rinc)
    # A signal driven by a register is followed through that register to its inputs.
    dir=$1 top=$2; shift 2
    ys=$(mktemp -t cone).ys; out=$(mktemp -t cone)
    {
      printf 'read_verilog -sv '; sed "s|^|$dir/|" "$dir/files.f" | tr '\n' ' '; echo
      echo "hierarchy -top $top"; echo "proc"; echo "flatten"; echo "opt_clean"
      echo 'select -set regs t:$dff t:$adff %u'
      echo 'select -set regq @regs %co1 w:* %i'
      for s in "$@"; do
        echo "tee -q -a $out log == $s"
        echo "tee -q -a $out select -list w:$s %ci1 @regs %i %ci1 w:$s %u %cie* @regq %i"
      done
    } > "$ys"
    yosys -q -s "$ys" > "$out.log" 2>&1 || { cat "$out.log"; exit 1; }
    sed "s|^$top/||" "$out"
    rm -f "$ys" "$out" "$out.log"

    Yosys 0.69's parser rejects a package import in a module header and an early return, so it reads a copy of rtl/ with those two spots rewritten (see the Evidence box). The values come from first_x.py, which reads the failing run's VCD and prints, for each suspect, the first cycle it went unknown, its last known value, and whether it recovered. With no arguments it runs a self-test:

    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
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    """first_x.py: when did each suspect first go unknown?
    
    usage: python3 first_x.py run.vcd --after T --clock NAME SIGNAL [SIGNAL ...]
           python3 first_x.py            (no files: run the built-in self-test)
    
    For each signal (full hierarchical name, or a unique suffix of one) prints the first cycle
    at or after time T at which any bit is x or z (cycle = the value of the counter NAME), the
    last known value before it, and when it was fully known again. Sorted by first cycle.
    Standard library only.
    """
    import sys
    
    
    def parse_vcd(lines):
        """Return {name: [(time, value), ...]} for every variable, with full dotted names."""
        scope, names_of, changes = [], {}, {}
        t, header = 0, True
        for raw in lines:
            tok = raw.split()
            if not tok:
                continue
            if header:
                if tok[0] == "$scope":
                    scope.append(tok[2])
                elif tok[0] == "$upscope":
                    scope.pop()
                elif tok[0] == "$var":
                    name = ".".join(scope + [tok[4]])
                    names_of.setdefault(tok[3], []).append(name)
                    changes[name] = []
                elif tok[0] == "$enddefinitions":
                    header = False
                continue
            head = tok[0]
            if head[0] == "#":
                t = int(head[1:])
            elif head[0] in "bB":
                for name in names_of.get(tok[1], []):
                    changes[name].append((t, head[1:].lower()))
            elif head[0] in "01xzXZ":
                for name in names_of.get(head[1:], []):
                    changes[name].append((t, head[0].lower()))
        return changes
    
    
    def resolve(changes, want):
        """Full name for `want`: exact, else the only name ending in '.' + want."""
        if want in changes:
            return want
        hits = [n for n in changes if n.endswith("." + want)]
        if len(hits) != 1:
            raise SystemExit(f"{want}: {len(hits)} matches")
        return hits[0]
    
    
    def value_at(chg, t):
        v = None
        for ct, cv in chg:
            if ct > t:
                break
            v = cv
        return v
    
    
    def unknown(v):
        return v is not None and ("x" in v or "z" in v)
    
    
    def first_unknown(chg, after):
        """(first time at or after `after` with an x/z bit, last known value before it,
        first time after that the value is fully known again, or None)."""
        start, before = None, None
        for t, v in chg:
            if t > after and start is None and unknown(value_at(chg, after)):
                start = after
            if start is not None and t > start and not unknown(v):
                return start, before, t
            if start is None and t >= after and unknown(v):
                start = t
            elif start is None and not unknown(v):
                before = v
        if start is None and unknown(value_at(chg, after)):
            start = after
        return start, before, None
    
    
    def show(v):
        """A known value in hex, anything else as it is."""
        if v is None:
            return "-"
        return format(int(v, 2), "x") if set(v) <= {"0", "1"} else v
    
    
    def cycle_at(clk, t):
        c = value_at(clk, t)
        return int(c, 2) if c and set(c) <= {"0", "1"} else c
    
    
    def report(changes, after, clock, signals):
        clk = changes[resolve(changes, clock)]
        rows = []
        for s in signals:
            t, before, end = first_unknown(changes[resolve(changes, s)], after)
            if t is None:
                rows.append((float("inf"), f"{s:24s} never unknown"))
                continue
            until = f"known again at cycle {cycle_at(clk, end)}" if end is not None else "unknown to the end"
            rows.append((t, f"{s:24s} unknown from cycle {cycle_at(clk, t):>5} (was {show(before)}), {until}"))
        return [line for _, line in sorted(rows)]
    
    
    SELFTEST = """$timescale 1ps $end
    $scope module tb $end
    $var reg 8 ! cyc $end
    $var reg 1 " a $end
    $var reg 2 # b $end
    $upscope $end
    $enddefinitions $end
    #0
    b0 !
    0"
    b00 #
    #1000
    b1 !
    1"
    #2000
    b10 !
    x"
    b0x #
    #3000
    b11 !
    b01 #
    """
    
    if __name__ == "__main__":
        if len(sys.argv) == 1:
            ch = parse_vcd(SELFTEST.splitlines())
            out = report(ch, 500, "cyc", ["a", "b"])
            ok = (out[0].startswith("a ") and "cycle     2 (was 1)" in out[0] and "to the end" in out[0]
                  and "cycle     2 (was 0)" in out[1] and "known again at cycle 3" in out[1])
            print("PASS: self-test found a and b unknown at cycle 2" if ok else "FAIL: self-test")
            sys.exit(0 if ok else 1)
        args = sys.argv[1:]
        after, clock = 0, "cycle"
        if "--after" in args:
            i = args.index("--after"); after = int(args[i + 1]); del args[i:i + 2]
        if "--clock" in args:
            i = args.index("--clock"); clock = args[i + 1]; del args[i:i + 2]
        with open(args[0]) as f:
            ch = parse_vcd(f)
        for line in report(ch, after, clock, args[1:]):
            print(line)

    Why not ask for the whole cone?

    Terminal
    $ cat tools/blind.ys
    ...
    select -set s1 w:pstream_valid %cie* @regq %i
    select -set s2 @s1 %ci1 @regs %i %ci1 @s1 %u %cie* @regq %i
    ...
    $ yosys -q -s tools/blind.ys; cat blind.txt
    Warning: Replacing memory \mem with list of registers. See ys/sync_fifo.sv:67
    Warning: Replacing memory \mem with list of registers. See ys/ram_slave.sv:14
    registers within 1 to 6 stages, all registers, the whole cone
    2 objects.
    11 objects.
    37 objects.
    47 objects.
    53 objects.
    1083 objects.
    1086 objects.
    1085 objects.

    The warnings say why the RAM shows up as 1,024 separate registers. Without pruning, the cone of one output pin holds 2 register cells after one stage, 37 after three and 1,083 of the design's 1,086 after six. Structure alone cannot localise; structure plus values can.

    Stage one

    Terminal
    $ tools/cone.sh ys dbglab_top pstream_valid
    == pstream_valid
    u_per.u_pd.u_sink.out_valid
    u_per.pctrl_q
    $ python3 tools/first_x.py b5.vcd --after 100000 --clock dbglab_tb.cycle dut.pstream_valid u_sink.out_valid u_per.pctrl_q
    dut.pstream_valid        unknown from cycle   427 (was 0), known again at cycle 431
    u_sink.out_valid         unknown from cycle   427 (was 0), known again at cycle 431
    u_per.pctrl_q            never unknown

    Two suspects. pctrl_q, the peripheral's control register (power, isolation and sink enable), is never unknown, so it and the whole bus behind it are cleared in one line. The sink's out_valid register went X on the same cycle as the pin. Follow it.

    Stage two

    Terminal
    $ tools/cone.sh ys dbglab_top u_per.u_pd.u_sink.out_valid
    == u_per.u_pd.u_sink.out_valid
    u_per.u_pd.u_sink.phase
    u_per.u_pd.u_sink.out_valid
    u_per.u_en_sync.stage2
    u_per.u_fifo.rempty
    u_per.pctrl_q
    $ python3 tools/first_x.py b5.vcd --after 100000 --clock dbglab_tb.cycle u_sink.phase u_en_sync.stage2 u_fifo.rempty
    u_fifo.rempty            unknown from cycle    87 (was 0), unknown to the end
    u_en_sync.stage2         never unknown
    u_sink.phase             never unknown

    The FIFO's empty flag went X at cycle 87, 340 cycles before the pin. The sink's own phase register and the enable synchroniser were always known, and pctrl_q is already cleared. Notice the jump: the symptom is at 427, but the walk is now at 87. That is the point of walking values rather than code.

    Stage three, and the stage that runs out

    Terminal
    $ tools/cone.sh ys dbglab_top u_per.u_fifo.rempty u_per.u_fifo.rbin
    == u_per.u_fifo.rempty
    u_per.u_pd.u_sink.phase
    u_per.u_en_sync.stage2
    u_per.u_fifo.rempty
    u_per.u_fifo.rbin
    u_per.u_fifo.u_w2r.q
    == u_per.u_fifo.rbin
    u_per.u_pd.u_sink.phase
    u_per.u_en_sync.stage2
    u_per.u_fifo.rempty
    u_per.u_fifo.rbin
    $ python3 tools/first_x.py b5.vcd --after 100000 --clock dbglab_tb.cycle u_fifo.rbin u_fifo.u_w2r.q
    u_fifo.rbin              unknown from cycle    87 (was 4), unknown to the end
    u_fifo.u_w2r.q           unknown from cycle    94 (was c), unknown to the end

    The read pointer rbin went X on the same cycle as rempty; the synchronised write pointer only at 94, so it is a consequence, not a cause. Now look at rbin's own stage one: phase, the enable, rempty and rbin itself. At cycle 86 all four were known, and at 87 rbin was X. No register delivered that X. It came through the logic between them, rbin_next = rbin + (rinc & ~rempty), and the only term there that is not a register is rinc, the sink's read strobe:

    Terminal
    $ python3 tools/first_x.py b5.vcd --after 100000 --clock dbglab_tb.cycle u_per.rinc u_pd.rinc_s u_pd.iso_en u_pd.pwr_on
    u_per.rinc               unknown from cycle    73 (was 0), known again at cycle 394
    u_pd.rinc_s              unknown from cycle   424 (was 0), known again at cycle 427
    u_pd.iso_en              never unknown
    u_pd.pwr_on              never unknown

    rinc is X from cycle 73, the cycle the test powered the sink down, until 394, when it powered it up. It did no harm while the FIFO was empty, because rinc & ~rempty is 0 whatever rinc is. The first word written during power-off made rempty fall, and on that edge the X walked into the pointer. A powered-off block drives garbage; that is what isolation is for. So ask the structural question: which of the power domain's outputs have the isolation enable in their cone?

    Terminal
    $ cat tools/boundary.ys
    ...
    tee -a boundary.txt select -list w:u_per.u_pd.rinc %cie* w:u_per.u_pd.iso_en %a %i
    ...
    $ yosys -q -s tools/boundary.ys; cat boundary.txt
    ...
    == u_per.u_pd.out_valid
    dbglab_top/u_per.pctrl_q
    == u_per.u_pd.out_data
    dbglab_top/u_per.pctrl_q
    == u_per.u_pd.rinc
    $ yosys -q -s tools/boundary-fix.ys; cat boundary-fix.txt
    ...
    == u_per.u_pd.out_data
    dbglab_top/u_per.pctrl_q
    == u_per.u_pd.rinc
    dbglab_top/u_per.pctrl_q

    The isolation enable is bit 1 of pctrl_q (the %a turns the port into the register that drives it). It is in the logic cone of out_valid and out_data, and missing from rinc's. With the one-line clamp in a scratch copy, it appears there too. That missing input is the cause, at rtl/periph_pd.sv line 34:

    Terminal
    $ sed -n 33,36p rtl/periph_pd.sv
      // Isolation: every output of the domain is clamped to 0 while iso_en is high.
      assign rinc      = rinc_p;          // internal: only feeds our own async FIFO
      assign out_valid = iso_en ? 1'b0  : valid_p;
      assign out_data  = iso_en ? 32'h0 : data_p;

    The comment says rinc stays inside the peripheral. The cone says otherwise: it feeds the FIFO's read side, which is not in the switched domain.

    What the evidence says

    Four steps, each with a list of at most five registers and one value per register, took the search from a pin at cycle 427 to a wire at cycle 73 and a missing term in its logic. No step needed an opinion about which block was likely. In a scratch copy the clamp assign rinc = iso_en ? 1'b0 : rinc_p; makes periph_power seed 1 pass in Icarus and Verilator (469 cycles). That is a test of the hypothesis, not yet a proof of the fix.

    • Pruning did the work: clearing pctrl_q at stage one removed every bus register behind it.

    • The last step was not a register. A walk that only follows registers stops at rbin and hunts for a bad flop; the answer was a missing input to the logic in front of it.

    Traps

    Common pitfall

    The cone is the cone of the code the tool read. Yosys defines SYNTHESIS, so it sees rinc_p = rinc_s and no supply model; first_x.py sees the simulation, where the powered-off sink drives X. That split is right here (the chip has no X, only garbage), but check that the tool and the simulator read the same ifdef branches, files and parameters.

    Common pitfall

    Clearing a suspect at the wrong cycle. A register that is right at the symptom's cycle may have been wrong when it mattered. Compare at the cycle the next stage sampled it, or use the first-wrong time as here. rinc_s was unknown from 424 to 427 and looked guilty; it was a late echo of rempty, not the start.

    Why this matters

    Senior lens. Cone tracing scales because the suspect list per step stays small even when the design is large: the cost is the number of stages times a few lookups, not the size of the cone. Teams that debug big SoCs script exactly this, a driver query in the netlist plus a value lookup in the dump, and the scripts pay for themselves on the first hard bug.

    Two habits make it cheap: dump enough to answer value questions near the failure (dump strategy), and keep a view of the design that a netlist tool can read and query.

    Interview

    Asked: "A status bit at the top of a block is wrong in simulation. The block is fifty thousand lines. How do you find where it comes from?"

    Weak answer: I would search the code for where the bit is assigned and read backwards through the modules until I found something that looked wrong.

    Strong answer: I start from the first cycle the bit is wrong and list its stage-one fan-in: the registers that reach it through logic only, from a netlist query, not from reading. For each I check its value in the failing run at that cycle. Registers that were right drop out with their whole cone; I step back to the one that went wrong first and repeat. If every register behind a wrong value was right, the bug is in the logic between them, often a missing term such as an enable or a clamp. Each step is a handful of lookups, so the size of the block barely matters.

    Follow-ups: (1) What if the wrong value is a valid number, not an X? (2) How do you handle a combinational loop in the cone?

    Check yourself At stage two, two suspects went wrong on the same cycle, and both are in each other's cone. Which do you follow?

    Neither yet. Two registers that go wrong together and feed each other usually share a cause one step further back, as rbin and rempty did: both are computed from rbin_next, which takes rinc. List the union of their stage-one inputs and check those values at the cycle before; if all were right, look at the logic that computes both, including wires that are not registers.

    Check yourself Your cone walk ends at a register whose inputs were all correct, yet its value is wrong. What are the candidates?

    The logic between the inputs and the register: a wrong operator, width or condition, or a missing term (as with the unclamped rinc). Then the register itself: a wrong reset value, a missing reset, or a clock or enable you did not list (check that the query followed clock-enable and reset pins). Last, the tool view: a different ifdef branch or parameter in simulation than in the netlist you queried.

    Remember

    • Take the cone one stage at a time. The whole transitive cone of an output is usually most of the design; one stage is a handful of registers.

    • Clear a suspect only with a value from the failing run at the right cycle. A cleared register removes its whole cone from the search.

    • If every register behind a wrong value was right, the cause is the logic between them, or an input the logic should have and does not.

    Rule of thumb: never read the RTL behind a wrong signal before you have its stage-one suspect list and a value for each suspect.

    How this was checked

    dbglab at HEAD 49c5f04 (rebuilt with dbglab/build-history.sh), 2026-10-17. tb/run.sh periph_power 1 (Icarus Verilog 13.0) fails at cycle 431 with STREAM: pstream_valid is unknown; DUMP=1 wrote the VCD read by first_x.py, with --after 100000 --clock dbglab_tb.cycle and the signal lists shown.

    Yosys 0.69+post (git sha1 143eb14f), on a copy of rtl/ with two parser workarounds only: import dbglab_pkg::*; moved from the module header to file scope (csr_block, dma_engine, periph_slave) and sync_fifo's bump_ptr written as one conditional assignment instead of an early return. Script: read_verilog -sv, hierarchy -top dbglab_top, proc, flatten, opt_clean, then the select expressions shown (cone.sh, blind.ys, boundary.ys). Blind growth: 2, 11, 37, 47, 53, 1083 register cells within 1 to 6 stages; 1,086 in the design; 1,085 in w:pstream_valid %ci*.

    Fix check: with assign rinc = iso_en ? 1'b0 : rinc_p; in a scratch copy, tb/run.sh periph_power 1 passes in Icarus and in Verilator 5.048 (cycles=469), and pctrl_q joins the cone of rbin and of rinc.

    Limits: Yosys reads the RTL with SYNTHESIS defined, so its netlist has no supply model; the cone is the chip's, the X values are the simulation's. One bug, an X bug, where "wrong" is easy to test; a wrong known value needs a reference value per suspect.

    Keep going

    • Practice: Isolation cells and boundary wrapper: clamp logic and X containment

    • Practice: Isolation cell modelling

    • Code lab: Isolation cell model

    • Code lab: Isolation enable

    • Interview: run a mock interview on this topic

    Related topics

    • Part 3 · Localise

    • The first wrong cycle: where the walk starts

    • Bisection in space: cutting the hierarchy before the cone

    • Differential debugging: where expected values come from

    • Power-intent bugs: the missing clamp as a class

    Previous

    30 / 107

    Next: Differential Debugging

    Up next

    Up nextDifferential Debugging

    Sign in to save progress

    Practice this lessonReport an error

    See also

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