dbglab's random test fails on seed 17 in Icarus: a RAM word reads back as xxxxxxxx at cycle 7949. A colleague runs the same seed in Verilator and it passes. "Icarus is being fussy about X again," they say.
Is this a tool quirk to waive, or a bug the chip will have? And where did the X come from, 7,949 cycles into the run?
X values are everywhere early in a run, so people learn to ignore them. They read X as "don't care", or as a simulator artefact that synthesis will clean up. When one simulator shows an X and another does not, they trust the one that passes.
Both habits get it backwards. An X is the simulator saying I do not know this bit. The chip will have a value there, and nobody controls which. Verilator passes seed 17 only because its flip-flops start at zero, which happens to be the value the test expects. Silicon makes no such promise.
In four-state simulation (Icarus, and every event-driven commercial simulator) a bit can be 0, 1, X or Z. X means the value is unknown: it could be 0 or 1 in hardware. Z means nothing drives the wire. A two-state simulator such as Verilator has no X at all; it picks 0, or a random value if you ask it to.
Source | How it looks | Typical cause |
|---|---|---|
No reset | A register is X from time 0 until something writes it | A flop left out of the reset "because software writes it first" |
Multiple drivers | A wire is X while two drivers disagree | Two |
Out-of-range index | A read returns X |
|
Uninitialised memory | A RAM read returns X until the word is written | A buffer read before it is filled; a missing preload |
Power-down | Outputs of an off domain are X | A missing isolation clamp (a later bug class) |
Once created, an X spreads through logic, but not faithfully. X optimism: some constructs turn an unknown into a known value. An if whose condition is X takes the else branch, so the output looks clean although the chip could have taken either branch. X pessimism: other constructs turn a known result into X. a & ~a is 0 in hardware for any a, but X in simulation when a is X; gate-level netlists are full of such cases. Optimism hides bugs; pessimism produces false alarms. The RTL course's X-propagation discipline compares if, case and the ternary operator under X in detail.
Never waive an X you cannot explain. Find where it was created and prove it harmless, usually by showing a write always comes before the first read.
Trust the four-state run for X questions. When Icarus shows X and Verilator passes, rerun Verilator with random start values (XRAND=1 in dbglab). If it then fails, the bug is real.
Check at the point of use, not at the end. Put $isunknown on values the moment they matter: write data on a bus, enables, selects, addresses. The scoreboard may read the damage much later.
Distrust a clean output fed by an X input. If an if condition can be X, the output proves nothing. Check the condition itself.
On the debug loop this is Observe: an X check is a probe that turns "something read wrong at 7949" into "this value was unknown when it was used at 488".
The small design below has one register without reset, the enable en_q. Two counters, both with a proper reset, read it in two ways: one with if (en_q), one with a ternary.
Read it slot by slot (slot n is just after clock edge n): watch cnt_if and cnt_tern while en_q is X.
Slots 0 to 3: en_q is X. cnt_if stays 0, because if (X) takes the else branch and holds. cnt_tern goes X at slot 1: its two choices (1 and 0) disagree in the last bit.
Slot 3: software raises load, and en_q becomes 1 at slot 4.
Slots 5 to 9: cnt_if counts 1 to 5 and looks perfect. cnt_tern stays X: once a counter is unknown, adding one keeps it unknown. That is correct, because the chip's count really depends on what en_q powered up as.
// xmask: one register without reset, read two ways. The if/else hides its X (X optimism);
// the ternary shows it. Both counters have a reset; only the enable en_q does not.
`timescale 1ns/1ps
module tb;
logic clk = 1'b0, rst_n = 1'b0, load = 1'b0;
logic en_q; // bad: no reset, X until the first load
logic [3:0] cnt_if, cnt_tern;
int cyc = 0, x_en = 0, x_if = 0, x_tern = 0;
always #5 clk = ~clk;
always_ff @(posedge clk) if (load) en_q <= 1'b1;
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) cnt_if <= '0;
else if (en_q) cnt_if <= cnt_if + 1'b1; // en_q = X: the condition is false, cnt_if holds
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) cnt_tern <= '0;
else cnt_tern <= en_q ? cnt_tern + 1'b1 : cnt_tern; // en_q = X: bits that differ go X
always @(negedge clk) if (rst_n) begin
$display("cycle %0d en_q=%b cnt_if=%h cnt_tern=%h", cyc, en_q, cnt_if, cnt_tern);
if ($isunknown(en_q)) x_en++;
if ($isunknown(cnt_if)) x_if++;
if ($isunknown(cnt_tern)) x_tern++;
cyc++; // cycle n is printed after clock edge n
end
initial begin
$dumpfile("wave.vcd");
$dumpvars(0, tb);
@(posedge clk) #1 rst_n = 1'b1; // released just after edge 0
repeat (3) @(posedge clk);
#1 load = 1'b1; // software enables the counter at the 4th edge
@(posedge clk) #1 load = 1'b0;
repeat (5) @(posedge clk);
#1;
// The if/else never shows an X, so a check on cnt_if can never see the missing reset.
// Whenever en_q was unknown, the ternary showed it.
if (x_if != 0 || (x_en != 0 && x_tern == 0)) $fatal(1, "unexpected X behaviour");
$display("PASS: en_q unknown for %0d sampled cycles; X seen on cnt_if in %0d, on cnt_tern in %0d",
x_en, x_if, x_tern);
$finish;
end
endmodule$ iverilog -g2012 -o xmask.vvp xmask.sv && vvp -n xmask.vvp
VCD info: dumpfile wave.vcd opened for output.
cycle 0 en_q=x cnt_if=0 cnt_tern=0
cycle 1 en_q=x cnt_if=0 cnt_tern=X
cycle 2 en_q=x cnt_if=0 cnt_tern=x
...
cycle 4 en_q=1 cnt_if=0 cnt_tern=x
cycle 5 en_q=1 cnt_if=1 cnt_tern=x
...
cycle 8 en_q=1 cnt_if=4 cnt_tern=x
PASS: en_q unknown for 4 sampled cycles; X seen on cnt_if in 0, on cnt_tern in 8
...
$ verilator --binary --timing --assert -Wno-fatal --top-module tb -Mdir vl xmask.sv > /dev/null && vl/Vtb
-Info: xmask.sv:31: $dumpvar ignored, as Verilated without --trace
cycle 0 en_q=0 cnt_if=0 cnt_tern=0
...
cycle 8 en_q=1 cnt_if=4 cnt_tern=4
PASS: en_q unknown for 0 sampled cycles; X seen on cnt_if in 0, on cnt_tern in 0
...
$ verilator --binary --timing -Wno-fatal --x-assign unique --x-initial unique --top-module tb -Mdir vlx xmask.sv > /dev/null
$ vlx/Vtb +verilator+rand+reset+2 +verilator+seed+8
-Info: xmask.sv:31: $dumpvar ignored, as Verilated without --trace
cycle 0 en_q=1 cnt_if=0 cnt_tern=0
cycle 1 en_q=1 cnt_if=1 cnt_tern=1
...
cycle 8 en_q=1 cnt_if=8 cnt_tern=8Icarus shows the X on the ternary counter and hides it on the if/else counter (uppercase X: some bits unknown; lowercase: all). Verilator, two-state, shows no X anywhere: en_q starts at 0 and both counters read 4 at cycle 8. With random start values, seed 8 powers en_q up as 1, and the counter reads 8. That last run is one possible chip, and it disagrees with the clean-looking cnt_if of the other two. The bug is the missing reset on en_q. In the Icarus run only en_q and cnt_tern point at it; in the default Verilator run nothing does.
Back to seed 17. The previous lesson diffed it against commit 13 and found the FILL register X from reset. Now catch it from the design side, with two X checks compiled next to dbglab. One checks the value where the DMA uses it (a fill run starting with an unknown fill value); the other checks every write on the bus.
// not-run: needs dbglab's sources and bench; compile it next to them (command in the Evidence box)
// x_check: two X checks compiled next to dbglab. Each prints its first hit; neither stops the run.
// XCHK-USE the DMA starts a fill run while the fill value it is about to load is unknown
// XCHK-BUS a master's write is accepted with unknown write data
`timescale 1ns/1ps
module x_check;
bit use_seen = 0, bus_seen = 0;
always @(negedge dbglab_tb.clk) begin // mid-cycle: values are settled
if (!use_seen && dbglab_tb.dut.u_dma.start && !dbglab_tb.dut.u_dma.busy
&& dbglab_tb.dut.u_dma.cfg_ctrl[dbglab_pkg::CTRL_FILL] && $isunknown(dbglab_tb.dut.u_dma.cfg_fill)) begin
$display("XCHK-USE cycle=%0d: fill run starts with cfg_fill=%h (DST=%h LEN=%0d)", dbglab_tb.cycle,
dbglab_tb.dut.u_dma.cfg_fill, dbglab_tb.dut.u_dma.cfg_dst, dbglab_tb.dut.u_dma.cfg_len);
use_seen = 1;
end
for (int m = 0; m < 2; m++)
if (!bus_seen && dbglab_tb.dut.m_valid[m] && dbglab_tb.dut.m_ready[m] && dbglab_tb.dut.m_write[m]
&& $isunknown(dbglab_tb.dut.m_wdata[m])) begin
$display("XCHK-BUS cycle=%0d: master %0d writes %h to %h", dbglab_tb.cycle, m,
dbglab_tb.dut.m_wdata[m], dbglab_tb.dut.m_addr[m]);
bus_seen = 1;
end
end
endmodule$ sh ../probes/run-probe.sh ../probes/x_check.sv random 17
dbglab: test=random seed=17
XCHK-USE cycle=483: fill run starts with cfg_fill=xxxxxxxx (DST=00001ca8 LEN=9)
XCHK-BUS cycle=488: master 1 writes xxxxxxxx to 00001ca8
FAIL test=random seed=17 cycle=7949: SCOREBOARD: RAM word 816 (address 00001cc0) read xxxxxxxx, expected 00000000 (last written by DMA run #4)Read the three lines after the banner in order. At cycle 483 a zero-fill run of 9 words starts while the fill value is unknown: that is where the X is used. At cycle 488 the first unknown word goes out on the bus to 0x1ca8: that is where it is written. At cycle 7949 the CPU reads word 816, one of the nine, and the scoreboard complains: that is where it is seen, 7,461 cycles after it was written. dbglab's own bus checker tests address and write enable for X, but not write data, which is why nothing fired at 488.
Why Verilator passes and what random power-up values show is measured in symptom, fault and cause; here the question is where an X check catches it. Run over seeds 1 to 60, the bus check fires in exactly the nine seeds that fail with an X, always earlier:
Seed | XCHK-BUS fires | Test fails | Cycles earlier |
|---|---|---|---|
16 | 298 | 686 | 388 |
17 | 488 | 7949 | 7461 |
19 | 288 | 415 | 127 |
23 | 2229 | 2385 (STREAM) | 156 |
29 | 418 | 1820 | 1402 |
31 | 336 | 5693 | 5357 |
41 | 6769 | 7656 | 887 |
43 | 1025 | 2793 | 1768 |
51 | 959 | 1155 | 196 |
The X is real, not a tool quirk: a random power-up value reproduces the failure in the two-state simulator. It was created at reset in one register, used at cycle 483 and written at 488. The checks moved the failure to the start of the chain and name the register (cfg_fill, from fill_q). Next comes Localise: who removed FILL's reset?
!$isunknown(cnt_if) and it never fires. They conclude the reset problem is fixed. What is wrong?cnt_if cannot show the X: if (en_q) with an unknown en_q takes the else branch and holds a clean value. The assertion is on the wrong signal. Check the condition, !$isunknown(en_q) whenever the counter is out of reset, or read the counter with a construct that propagates X. The fix is still a reset on en_q.
At HEAD its first failure is the X, and the bus check fires at cycle 959, so FILL's missing reset is what shows first. At commit 13 it fails later, at cycle 11722, with a real but wrong value (15d021ca where DMA run #75 should have left zero): a different signature, so a different bug until proven otherwise. One seed can carry two bugs; fix the first one it shows, then rerun and see what remains. Sort by signature, not by seed.
dbglab at HEAD 49c5f04 (rebuilt with dbglab/build-history.sh), 2026-10-17. tb/run.sh random 17 (Icarus Verilog 13.0) fails at cycle 7949 with xxxxxxxx; tb/run.sh random 17 verilator (Verilator 5.048) passes; XRAND=1 tb/run.sh random 17 verilator fails at cycle 7949 with 5d88e2dd.
x_check.sv compiled next to dbglab (iverilog -g2012 -Wno-timescale -I tb -o build/probe.vvp <rtl/files.f> tb/bus_rules.sv tb/dbglab_tb.sv x_check.sv, vvp -n build/probe.vvp +TEST=random +SEED=17): XCHK-USE at cycle 483, XCHK-BUS at 488. Silent at commit 13 (03ebdc1), which passes seed 17 (seed 51 fails there at cycle 11722 with a wrong, known value). Over seeds 1 to 60 at HEAD, XCHK-BUS fires in exactly the nine seeds that fail with an X and in none of the others.
xmask.sv: iverilog -g2012 and vvp -n; verilator --binary --timing --assert -Wno-fatal (two-state, starts at 0); and verilator --binary --timing -Wno-fatal --x-assign unique --x-initial unique run with +verilator+rand+reset+2 +verilator+seed+8. The waveform comes from COURSE=dbg node scripts/tools/gen-waves.mjs observe:x-in-a-waveform, which matched its expectations; a second run changed nothing.
Limits: simulation only, one unreset register. Verilator's random start values stand in for silicon power-up; they show one possible chip, not all of them. Gate-level X pessimism is described, not run here.