dbglab's random test fails on seed 17. The scoreboard read RAM word 816 at cycle 7949 and got xxxxxxxx, an unknown value, where it expected zero. The message even names the last writer: DMA run #4. A colleague has a fix ready: "the RAM should never store an X; clamp it to zero on the way in." The regression goes green. Is the bug fixed?
Most debugging starts where the failure is reported, which is natural: the FAIL line names a module, a cycle and a value. The trap is to stop there. The scoreboard is a reporter. It checks at certain moments, and it reports whatever was wrong by then. The RAM is a carrier: it stored the value it was given. Neither is necessarily where the design went wrong. A change at the reporter or the carrier makes the message go away, and in a four-state simulator that can look exactly like a fix. It is not, and the run below shows how you find out.
Use three words, each for one thing, and do not let them blur:
Word | What it is | In this failure | Found by |
|---|---|---|---|
Symptom | Where and when a check, a log or a user first sees something wrong | Cycle 7949: the scoreboard reads word 816 as X | Report and Reproduce: the FAIL line |
Fault | The first wrong value inside the design: one signal, one cycle, one module | Cycle 5: the FILL register is X where the specification says 0 | Observe and Localise: walk back |
Cause | The code, decision or assumption that produced the fault; the thing the fix changes | Commit 14 moved FILL out of the reset block | Hypothesise and Test, then Fix |
Between the fault and the symptom lies a chain of wrong values, each produced by the one before it. Every link in that chain is a consequence, not a cause. Four rules follow:
Write the symptom down exactly and do not edit it into a theory. "Word 816 read X at 7949", not "the RAM is corrupt".
Walk back, one link at a time, asking who wrote this value, and was their input right? Stop when a block's inputs are right and its output is wrong. That output is the fault.
The cause is in the code or the decision, not in time. Ask why the fault happened: a line, a missing reset, a wrong assumption in a commit message.
Test a fix where symptom and cause would disagree. A fix that only hides the symptom must fail somewhere the real fix passes. Find that place before you believe either.
Almost all of the axis is empty. The design went wrong in the first 500 cycles and said nothing for the next 7,461. A waveform opened at the symptom would show a perfectly healthy RAM read.
$ tb/run.sh random 17
dbglab: test=random seed=17
FAIL test=random seed=17 cycle=7949: SCOREBOARD: RAM word 816 (address 00001cc0) read xxxxxxxx, expected 00000000 (last written by DMA run #4)Read it as a symptom, nothing more: one word, one cycle, one check. The message gives one useful pointer back: the model believes DMA run #4 wrote this word last, so that run is the next link to look at.
A probe module, compiled next to the design as in the debug loop, prints the FILL register at reset release, every DMA run in fill mode, the first write of an unknown value into the RAM, and the read of word 816. It only reads signals; it changes nothing.
// not-run: needs dbglab's sources and bench; compile it next to them (command in the Evidence box)
// x_probe: follows an unknown value from the register that holds it to the RAM. It only reads the design.
`timescale 1ns/1ps
module x_probe;
int unsigned run = 0, xwrites = 0;
bit seen_rst = 1'b0;
always @(posedge dbglab_tb.clk) begin
#0.5; // just after the edge
if (!seen_rst && dbglab_tb.rst_n) begin
seen_rst = 1'b1;
$display("cycle %0d: reset released, FILL register (u_csr.fill_q) = %h", dbglab_tb.cycle, dbglab_tb.dut.u_csr.fill_q);
end
if (dbglab_tb.dut.u_dma.start && !dbglab_tb.dut.u_dma.busy) begin
run++;
if (dbglab_tb.dut.u_dma.cfg_ctrl[4])
$display("cycle %0d: DMA run #%0d start, fill mode, LEN=%0d DST=%h FILL=%h", dbglab_tb.cycle, run,
dbglab_tb.dut.u_dma.cfg_len, dbglab_tb.dut.u_dma.cfg_dst, dbglab_tb.dut.u_dma.cfg_fill);
end
if (dbglab_tb.dut.u_ram.req && dbglab_tb.dut.u_ram.write && $isunknown(dbglab_tb.dut.u_ram.wdata)) begin
xwrites++;
if (xwrites == 1)
$display("cycle %0d: first write of an unknown value: RAM word %0d (address %h) <= %h",
dbglab_tb.cycle, dbglab_tb.dut.u_ram.addr[11:2], dbglab_tb.dut.u_ram.addr, dbglab_tb.dut.u_ram.wdata);
end
if (dbglab_tb.dut.u_ram.req && !dbglab_tb.dut.u_ram.write && dbglab_tb.dut.u_ram.addr[11:2] == 816)
$display("cycle %0d: CPU reads RAM word 816 (unknown writes so far: %0d)", dbglab_tb.cycle, xwrites);
end
endmodule$ ../probes/run-probe.sh ../probes/x_probe.sv random 17
dbglab: test=random seed=17
cycle 5: reset released, FILL register (u_csr.fill_q) = xxxxxxxx
cycle 483: DMA run #4 start, fill mode, LEN=9 DST=00001ca8 FILL=xxxxxxxx
cycle 488: first write of an unknown value: RAM word 810 (address 00001ca8) <= xxxxxxxx
cycle 823: DMA run #6 start, fill mode, LEN=17 DST=00001ea4 FILL=3578a189
...
cycle 7947: CPU reads RAM word 816 (unknown writes so far: 9)
FAIL test=random seed=17 cycle=7949: SCOREBOARD: RAM word 816 (address 00001cc0) read xxxxxxxx, expected 00000000 (last written by DMA run #4)Read it backwards, from the symptom to the fault:
Cycle 7947: the CPU reads word 816, which the bench reports two cycles later. The RAM returns what it holds. The RAM is a carrier, so keep walking.
Cycle 488: DMA run #4 writes X into word 810, the start of a 9-word fill from 0x1ca8. Words 810 to 818 all receive X by cycle 524 (the nine unknown writes the probe counts, word 816 among them). The DMA wrote what it was told to write, so keep walking.
Cycle 483: run #4 starts in fill mode with FILL=xxxxxxxx. Fill mode writes the FILL register's value to every word. A zero fill relies on FILL holding its documented reset value of 0, and the bench's model expects zeros for that reason.
Cycle 5: reset is released and FILL is already X. The register map says FILL resets to 0. This is the first wrong value: the fault. Nothing before it is wrong, because nothing came before it.
Notice run #6 at cycle 823: also a fill, with a real value. Software wrote FILL before that run, so it was fine. Only a fill that comes before any write to FILL exposes the fault, which is why this needs a particular seed.
Why is FILL unknown after reset? Its register is written here:
// snippet: rtl/csr_block.sv at HEAD, lines 55-64
// Datapath registers: always programmed before START, so they need no reset.
always_ff @(posedge clk)
if (wr) begin
case (off)
CSR_SRC: src_q <= wdata;
CSR_DST: dst_q <= wdata;
CSR_LEN: len_q <= wdata[9:0];
CSR_FILL: fill_q <= wdata;
default: ;
endcaseNo reset. The comment states the assumption: these registers are always programmed before START. That is true of SRC, DST and LEN, and false of FILL, whose specification promises 0. The commit that made the change, b357bd8 ("csr: no reset on the DMA address and data registers"), is the cause: a wrong assumption written into line 62. How you would find that commit without reading it is bisection in history.
Two candidate fixes. The symptom patch makes the RAM store 0 whenever it is handed an unknown value. The cause fix gives FILL its reset back. In Icarus both look identical:
// snippet: the symptom patch, rtl/ram_slave.sv line 14
if (write) mem[addr[11:2]] <= $isunknown(wdata) ? 32'h0 : wdata; // symptom patch// snippet: the cause fix, rtl/csr_block.sv: FILL joins the registers with a reset
err_q <= 1'b0; err_addr_q <= '0; fill_q <= FILL_RST; // in the reset branch
CSR_FILL: fill_q <= wdata; // in the write case, which has a reset
// ...and the CSR_FILL line is removed from the block without resetRun random with seeds 1 to 60 for HEAD and each fix. Then run them again with XRAND=1 in Verilator, which starts every flip-flop without a reset at a random value, the way silicon powers up, instead of at X:
Version | Icarus: failing seeds of 60 | Verilator, random power-up: failing seeds of 60 |
|---|---|---|
HEAD | 19 | 19 |
Symptom patch (RAM stores 0 for X) | 12 | 19 |
Cause fix (FILL reset to 0) | 12 | 12 |
In Icarus the two fixes give exactly the same twelve failures, line for line; those twelve are a different bug, the one from the debug-loop lesson. A regression run only in a four-state simulator cannot tell the fixes apart. With random power-up values the patch fixes nothing:
$ cd symfix && XRAND=1 tb/run.sh random 17 verilator
dbglab: test=random seed=17
FAIL test=random seed=17 cycle=7949: SCOREBOARD: RAM word 816 (address 00001cc0) read 5d88e2dd, expected 00000000 (last written by DMA run #4)
$ cd ../causefix && XRAND=1 tb/run.sh random 17 verilator
dbglab: test=random seed=17
PASS test=random seed=17 cycles=18442 time=184416 nsSame cycle, same word, same DMA run, but the value is now 5d88e2dd instead of X. In silicon there is no X: an unreset register powers up at whatever value it lands on. The patch only catches the simulator's picture of the fault. The real chip would fill the buffer with junk, and no check in the RAM can know that the junk is wrong. The cause fix passes, because the register now starts at the value the specification promises.
STREAM: word #41 = xxxxxxxx, expected 00000000 (dbglab seed 23). Which of the three words does this message give you, and what is your next question?Only the symptom: the stream checker saw an unknown word on the output pins. The next question is who put that word into the stream: which bus write sent it to the peripheral, and where did that write's data come from. In this seed the word was copied from a buffer that an earlier zero fill had filled with X, so the chain leads back to the same fault, the FILL register at reset, and the same cause.
That it moves the fix even further from the cause: from the RAM to the checker. The X is the simulator's honest picture of a register with no reset. In silicon that register holds some value, often not 0, and the chip writes it. Ignoring X in the checker hides the only warning you get. Run with random power-up values (Verilator's XRAND=1 here) and the same test fails with a real wrong value.
dbglab at HEAD 49c5f04 (rebuilt with dbglab/build-history.sh), Icarus Verilog 13.0, 2026-10-17. tb/run.sh random 17 fails at cycle 7949 reading RAM word 816 as xxxxxxxx. x_probe.sv, compiled next to dbglab as in the debug-loop lesson (iverilog -g2012 -Wno-timescale -I tb ..., then vvp -n build/probe.vvp +TEST=random +SEED=17), printed the lines shown.
Fixes, on copies of HEAD: the RAM patch ($isunknown(wdata) ? 32'h0 : wdata in ram_slave.sv) and the cause fix (FILL moved back into the reset always_ff with FILL_RST). Each ran random seeds 1 to 60 in Icarus and with XRAND=1 tb/run.sh random <seed> verilator (Verilator 5.048, --x-initial unique): HEAD 19 and 19 failures, patch 12 and 19, cause fix 12 and 12. The 12 left are another bug's signature.
Limits: one bug in one small design, in simulation. Verilator's random start values stand in for silicon power-up; real flip-flops are not uniformly random. The guessing in the scene is illustrative.