Part 1 · Language Foundations · Intermediate
X/Z Propagation & Pessimism
How X spreads through operators, pessimism vs optimism, === vs ==, X in if/case, and tracing X to its source in debug.
How X moves through operators
Once a bit is X, most operators propagate the uncertainty — but not blindly. Logic gates apply their controlling-value rules: X & 0 is 0 (a 0 input forces AND low regardless), X | 1 is 1, but X & 1 and X | 0 stay X. Arithmetic is harsher: one X bit anywhere makes the entire result X (8'h0X + 1 is all-X), because a carry chain can spread one unknown everywhere. Equality == returns X (not 0, not 1) if either operand contains X or Z — and an X condition in if is treated as false.
Legend: [TYPE]
X PROPAGATION RULES [TYPE]
gates: controlling value wins arithmetic: total loss
─────────────────────────── ──────────────────────
X & 0 = 0 X | 1 = 1 4'b00X0 + 1 = 4'bXXXX
X & 1 = X X | 0 = X X * anything = all X
~X = X X ^ a = X
relational/equality:
mux is the pessimism battleground (a == b) with any X/Z → X
─────────────────────────────── if (X) → takes ELSE branch
sel=X: out = sel ? a : b
LRM: if a==b bitwise, out=that bit (optimism!)
if a!=b → X
real gate: out could be a, b, or glitch — sim may be
MORE optimistic than silicon (dangerous direction)This is where pessimism vs optimism comes in. Simulation is X-pessimistic when it reports X although real hardware would settle to a known value (e.g. two reconvergent paths of the same X that would cancel in silicon). It is X-optimistic when it reports a known value although silicon could differ — the dangerous direction. The classic optimism cases are if (x_cond) silently taking the else branch and casex/plain case matching despite X. Gate-level sim and formal X-prop tools exist largely to catch what RTL-level optimism hid.
=== vs == and writing X-aware checks
== is the logical equality: any X/Z in either operand yields X, which an if then treats as false. === is case equality: it compares all four states literally, so 4'b10XZ === 4'b10XZ is 1 and the result is always 0 or 1, never X. Testbench checks should normally use == plus an explicit $isunknown guard — using === everywhere accidentally accepts X==X as a match, hiding the very bug you are hunting. There is also ==? (wildcard equality), where X/Z in the right-hand operand act as don't-care positions — useful for masked compares.
logic [3:0] got, exp;
// Scoreboard check: X-aware, X is a FAILURE not a match
if ($isunknown(got))
$error("DUT output contains X/Z: %b", got);
else if (got !== exp) // !== safe here: got proven known
$error("mismatch got=%h exp=%h", got, exp);
// Why plain == fails silently:
got = 4'b1x00; exp = 4'b1100;
if (got == exp) $display("match"); // == is X → else branch
else $display("mismatch"); // prints, but for wrong reason
if (got === exp) $display("exact match"); // 0: differs in bit 2
// Wildcard compare: ignore don't-care bits of the EXPECTED value
exp = 4'b1?00 ? 4'b1z00 : exp; // conceptually
if (got ==? 4'b1z00) $display("bit2 ignored in compare");Tracing X to its source
X debug is a backwards walk: the X you see on an output is usually many cycles and modules downstream of its birth. The standard procedure — and a favorite interview scenario ("your scoreboard sees X at time 10us, what do you do?") — is: find the first time the signal goes X in the waveform, trace its fan-in cone at that time, and repeat upstream until you hit the origin. Origins are nearly always one of: an unreset flop, an unconnected/misconnected port (floats Z, then reads as X through logic), a bus conflict, an out-of-range index read, or a don't-care assignment (casez default X) escaping into live logic.
// X-source canaries you can build into the bench
always @(posedge clk) begin
if (!$isunknown(rst_n) && rst_n) begin // only after clean reset
if ($isunknown(dut_if.valid))
$error("[%0t] valid is X — check reset of producer FSM", $time);
if (dut_if.valid && $isunknown(dut_if.data))
$error("[%0t] data X while valid high", $time);
end
endWhy 2-state simulation hides these bugs
A 2-state (or X-suppressed) compile turns every would-be X into 0 — an unreset flop reads as 0, which often IS the reset value, so the missing reset is invisible.
The bug then escapes to gate-level netlist sim or silicon, where the flop genuinely powers up random.
Standard practice: keep RTL regressions 4-state, and treat any X after reset deassertion as an error.
Some flows deliberately randomize uninitialized state (2-state with random init) to compensate — know which mode your project runs.
Key takeaways
Gates propagate X unless a controlling value decides; arithmetic and comparisons go fully X.
if/case are X-optimistic — X conditions silently take the else/default path, masking bugs.
Use == with an $isunknown guard in checkers; === everywhere makes X==X a false pass.
Debug X by walking the waveform back to the first X and its fan-in; origin is usually reset, a port, or a conflict.
Common pitfalls
Using === in scoreboards 'to be safe' — X on both sides compares equal and the check passes.
Trusting a 2-state regression as proof of reset correctness — X-hiding converts unknowns to plausible 0s.
Forgetting if(X) takes the else branch — an X-valid signal makes the monitor silently drop transactions.
casex in RTL — X in the selector matches aggressively, an optimism trap; prefer case/unique case with explicit handling.