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:
$ tb/run.sh periph_power 1
dbglab: test=periph_power seed=1
FAIL test=periph_power seed=1 cycle=431: STREAM: pstream_valid is unknownThe 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?
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.
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:
Start from the first wrong value, not the failure message: one signal, one cycle.
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.
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.
Step back to the suspect that went wrong first and list its stage one. Repeat.
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.
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.
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:
#!/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:
"""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)$ 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.
$ 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 unknownTwo 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.
$ 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 unknownThe 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.
$ 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 endThe 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:
$ 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 unknownrinc 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?
$ 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_qThe 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:
$ 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.
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.
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.
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.
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.