An X on an output is the last car of a train. Find when the signal first went X, ask which input was X then, and repeat until the origin.
X BACK-PROPAGATION
output X @ 5,200 ns
│ when did it FIRST go X? ◄── jump-to-previous-edge, not scroll
▼
first X @ 3,100 ns ── which input was X then? ── pipe_q was X
│
▼
pipe_q first X @ 3,000 ns ── input? ── ctrl_reg was X
│
▼
ctrl_reg X SINCE TIME 0 ◄── ORIGIN
│
▼ origin checklist (one of these, nearly always):
· flop never reset (missing from reset list / wrong reset domain)
· RAM read before first write
· undriven input port (TB never drove it / wrong modport)
· multi-driver conflict resolving to X on a wire
· deliberate X injection reached live dataThe origin is nearly always on that short list. The wave below shows the signature: an X present from the very first slot.
Watch cfg_en, data, gated, and out. The X sits at slot 0, which already names the origin.
Slot 0: cfg_en X, data 0, gated 0, out 0. The enable is already X; zero data masks it.
Slot 1: data 1 arrives at this edge, so gated goes X. The flop sampled the old 0, so out is still 0.
Slot 2: the next edge captures the X: out reads X from here on.
Slot 3: unchanged. Nothing downstream can repair an origin this early.
Slot 4: unchanged. The fix belongs at cfg_en, not at out.
List every state element on the failure path and confirm which reset domain owns it.
Check reset duration: a flop on a divided clock may never see an active edge during a short pulse.
Memories are usually unreset by design, so verify read-before-write protection instead.
Beware two-state variables in the bench masking X values the RTL produces.
At 3,100 ns, asking which input was X then. Debug the first X, never the one you saw.
The X listing below is not run in this course's checks. The course runner tries Verilator first, and Verilator models two states, so the X never appears and the checks fail. It passes under Icarus (iverilog -g2012), which models four states. The runnable bench after it shows the same origin-trace idea with validity flags.
// not-run: needs four-state X; Verilator 5.048 (tried first by the runner) ties undriven logic to 0 so $isunknown never fires; passes under Icarus
module tb_x_origin;
timeunit 1ns; timeprecision 1ps;
logic clk = 0;
always #5 clk = ~clk;
logic cfg_reset = 0; // properly reset model
logic cfg_missing; // never driven: stands in for the unreset flop
logic mask = 1;
wire leak;
assign leak = cfg_missing & mask;
int errors = 0;
initial begin
#1; // settle time 0
if (!$isunknown(cfg_missing)) errors++; // origin is X since time 0
if (leak !== 1'bx) errors++; // X fans out through live logic
if (cfg_reset !== 0) errors++;
@(posedge clk); #1;
if (!$isunknown(cfg_missing)) errors++; // still X after a clock
if (errors == 0) $display("PASS: unreset flop reads X since time 0 and leaks through live logic");
else $fatal(1, "FAIL: X-origin check");
$finish;
end
endmodulemodule tb_origin_trace;
timeunit 1ns; timeprecision 1ps;
logic clk = 0;
always #5 clk = ~clk;
// Two-state version of the X hunt: each value carries a validity flag.
logic [7:0] cfg_good = 8'hA5;
bit valid_good = 1; // reset path validates its register
logic [7:0] cfg_missing = 8'h00;
bit valid_missing = 0; // never validated: the unreset flop
bit data_valid = 0; // live data arrives later
wire [7:0] gated;
assign gated = cfg_missing & {8{cfg_good[0]}};
// Two pipeline stages. Each flop's flag is the AND of its input flags.
bit s1_ok = 0, s2_ok = 0; // path fed by the validated register
bit s1_bad = 0, s2_bad = 0; // path fed by the unvalidated origin
always_ff @(posedge clk) begin
s1_ok <= data_valid & valid_good;
s2_ok <= s1_ok;
s1_bad <= data_valid & valid_missing;
s2_bad <= s1_bad;
end
int errors = 0;
initial begin
#1;
if (valid_missing !== 0) errors++; // origin flagged since time 0
@(negedge clk);
data_valid = 1; // live data arrives
@(posedge clk); #1;
if (s1_ok !== 1 || s2_ok !== 0) errors++; // clean flag reaches stage 1
if (s1_bad !== 0) errors++; // tainted flag stays invalid
@(posedge clk); #1;
if (s2_ok !== 1) errors++; // clean flag reaches stage 2
if (s2_bad !== 0) errors++; // invalidity traced to stage 2
if (gated !== 8'h00) errors++; // data path stays deterministic
if (errors == 0) $display("PASS: unvalidated origin flagged since time 0 and traced through two live stages");
else $fatal(1, "FAIL: origin-trace check");
$finish;
end
endmoduleThe flag version runs on every engine and mirrors the method. The clean path turns valid stage by stage, while the path fed by the unvalidated origin never does: the origin is the stage whose validity was never set. Two-state mirrors in a real bench would mask a true X as zero, which is why the audit checklist above matters.
A race announces itself when behavior-neutral changes move the failure: a print, a flag, a tool version. Then debug event ordering, not data.
Classic bench-side cause: driving inputs with blocking assignments from a clocked block. Same-edge sampling order turns tool-dependent. Fix with nonblocking drives, or a clocking block at that interface, which removes the same-edge bench-to-DUT race there.
Clocking blocks sample in Preponed and drive in re-NBA at that interface. Races persist across clock domains, skewed signals, and non-clocked access.
Zero-delay loops through a shared variable: order undefined by the standard, stable per tool, broken on upgrade.
A print that moves the failure is a detector, not a fix. It reorders Active-region evaluation just enough to flip the race.
Pin everything: seed, tool version, flags, arguments.
Diff two identical-input runs to the first divergent line; that names the racing pair.
Force both orderings deliberately and confirm the failure flips on and off.
Fix structurally, remove the ordering hack, and confirm stability.
module race_dut (
input logic clk,
input logic d,
output logic q
);
logic q_r = 0;
always_ff @(posedge clk) q_r <= d;
assign q = q_r;
endmodule
module tb_drive_order;
timeunit 1ns; timeprecision 1ps;
logic clk = 0, d = 0, q;
always #5 clk = ~clk;
race_dut dut (.*);
int errors = 0;
initial begin
@(negedge clk);
d <= 1; // setup-respecting drive: settled before the edge
@(posedge clk); #1;
if (q !== 1) errors++; // captured cleanly
@(posedge clk);
#1 d = 0; // blocking change after the edge: too late to sample
if (q !== 1) errors++; // flop still holds the sampled 1
@(posedge clk); #1;
if (q !== 0) errors++; // the 0 lands on the next edge
if (errors == 0) $display("PASS: setup drive captured at the edge, late drive waited a cycle");
else $fatal(1, "FAIL: drive-ordering check");
$finish;
end
endmoduleThe bench flips the ordering deliberately: a setup-respecting drive captures cleanly, a post-edge change waits a cycle. No scheduler luck is involved.
A racing pair at that timestamp. Diff to the first divergent line, then flip the ordering deliberately to prove it.
A design passed RTL regression, then X-stormed at gate level. Triage said X signature with divergence at the first post-reset cycle: initialization, not logic.
Symptom: gate-level X-storm with a fully green RTL regression.
Back-propagation: output X, datapath enable X, configuration register X since time 0.
Origin check: the register was reset by an initial block, not a reset term.
Fix: a real reset term in the RTL, plus a review item against initial-block resets.
Lesson banked: simulation forgives what gates do not.
Scope note: this rule targets ASIC gate-level correctness, since FPGAs honor power-up initial values. The hunt method itself is identical at both levels.
Initialization. Divergence from cycle one points at reset or init, and initial-block resets do not survive synthesis for ASIC.