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.

diagram
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.

systemverilog
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.

systemverilog
// 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
end

Why 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.