A team tapes out a block whose RTL regression ran 40,000 seeds without a failure. On the first silicon, the block works on most boards and hangs on a few, and on the same board it hangs on some power-ups and not others. The root cause is one state flop that comes out of reset as X on the netlist, because its reset came from a different reset branch that released one cycle late. RTL simulation turned that X into a clean 0 on every one of the 40,000 runs. How can a simulator hide an unknown, and how do you make it stop?
In a four-state simulator a bit is 0, 1, Z (undriven) or X (unknown). X appears wherever real hardware has a value but simulation cannot say which: a flop before reset, an undriven input, two drivers fighting, an out-of-range array read, a timing violation, a powered-down domain. X is the simulator's honest way of saying "this could be 0 or 1 on silicon".
The trouble is that RTL constructs do not treat X honestly. An if (c) statement with c = X takes the else branch, because the condition is not true. A case statement with an X selector matches no item and runs the default. Either way the simulator picks a branch and the X disappears, producing a clean value that silicon may not produce. That is X-optimism: RTL simulation is more optimistic than hardware. Gates do the opposite. An AND-OR mux with X on its select outputs X even when both data inputs are equal, because each gate only sees its own inputs. That is X-pessimism: gate simulation reports unknowns that silicon would never show.
A useful analogy is a weather forecaster who does not know whether it will rain. The optimistic forecaster says "sunny" (the else branch). The pessimistic one says "unknown" even when every cloud is above the next town. The honest one says "unknown" only if the two outcomes would actually differ. The RTL ?: operator happens to be the honest one: with an X condition it merges both results bit by bit and gives X only where they differ.
This four-state model writes the same 2:1 mux as RTL if, RTL case, the RTL conditional operator and AND-OR gates, and puts three no-X checks around them: on the RTL output, on the gate output, and on the select itself.
// not-run: needs a four-state simulator (run with Icarus)
// The same 2:1 mux four ways, driven with an unknown select.
module xprop_demo;
logic sel;
logic a, b;
logic y_if, y_case, y_tern;
wire y_gate, sel_n, t0, t1;
always_comb begin // RTL: if/else
if (sel) y_if = a;
else y_if = b;
end
always_comb begin // RTL: case
case (sel)
1'b1: y_case = a;
default: y_case = b;
endcase
end
assign y_tern = sel ? a : b; // RTL: conditional operator
not g0 (sel_n, sel); // gate level: AND-OR
and g1 (t1, sel, a);
and g2 (t0, sel_n, b);
or g3 (y_gate, t1, t0);
task automatic show(input logic s, input logic va, input logic vb);
sel = s; a = va; b = vb; #1;
$display("sel=%b a=%b b=%b | if=%b case=%b ?:=%b gates=%b", sel, a, b, y_if, y_case, y_tern, y_gate);
// no-X checks: on the RTL output, on the gate output, and on the select itself
assert (!$isunknown(y_if)) else $error("y_if is unknown");
assert (!$isunknown(y_gate)) else $error("y_gate is unknown");
assert (!$isunknown(sel)) else $error("sel is unknown");
endtask
initial begin
show(1'b0, 1'b1, 1'b0);
show(1'b1, 1'b1, 1'b0);
show(1'bx, 1'b1, 1'b0); // select unknown, inputs differ
show(1'bx, 1'b1, 1'b1); // select unknown, inputs equal
end
endmodule$ iverilog -g2012 -o x.vvp xprop.sv && vvp -n x.vvp (Icarus 13.0)
sel=0 a=1 b=0 | if=0 case=0 ?:=0 gates=0
sel=1 a=1 b=0 | if=1 case=1 ?:=1 gates=1
sel=x a=1 b=0 | if=0 case=0 ?:=x gates=x
ERROR: xprop.sv:32: y_gate is unknown
Time: 3 Scope: xprop_demo.show
ERROR: xprop.sv:33: sel is unknown
Time: 3 Scope: xprop_demo.show
sel=x a=1 b=1 | if=1 case=1 ?:=1 gates=x
ERROR: xprop.sv:32: y_gate is unknown
Time: 4 Scope: xprop_demo.show
ERROR: xprop.sv:33: sel is unknown
Time: 4 Scope: xprop_demo.showRead the third line carefully. With an unknown select and different inputs, if and case both output a confident 0: optimism. ?: and the gates output X, which is correct, because silicon could produce either value. On the fourth line the inputs are equal, so silicon would produce 1 whatever the select is; ?: says 1, and the gates still say X: pessimism. Now look at the checks. The assertion on y_if never fired, even though the select was unknown twice. A no-X check on an RTL output cannot see an X that an if already swallowed. The check that caught the problem in RTL is the one on the control signal sel. That is the practical rule: put no-X assertions on state, selects, enables and valid signals, not only on data outputs.
Most X in a design should die at reset, so reset is where X verification starts. The common scheme is asynchronous assertion, synchronous deassertion: reset reaches every flop at once, whether or not the clock runs, but its release is retimed through a two-flop reset synchroniser in each clock domain, so every flop in that domain leaves reset on the same clock edge. Releasing reset asynchronously risks a recovery/removal violation: some flops see the release before an edge and some after, and the state machine starts in a state that is neither.
What a reset test plan covers, beyond "reset at time 0 works":
Reset values of every register, front-door. The course bench does this with uvm_reg_hw_reset_seq in dma_ral_reset_test, which logs reset values checked (registers whose fields are all volatile are skipped) with 0 errors.
Reset in the middle of traffic (warm reset): drivers park, monitors drop partial transfers, the scoreboard flushes. The course's dma_reset_test asserts reset in the middle of job 1 and then runs a full job; the pattern is in Reset on the Fly.
Reset ordering between domains and blocks, and reset released while a clock is stopped or not yet stable.
Reset domain crossings (RDC): a flop reset by one reset feeding a flop on another reset can go metastable when the first reset asserts while the second domain runs. RDC is checked structurally by tools and covered in the Reset Domain Crossing section of the CDC/RDC course, with the simulation side in Reset-aware Simulation.
Write the checks so they are quiet during reset and qualified by valid signals, then bind them to the DUT's ports (ports survive into the netlist, so the same file runs in GLS):
// snippet: no-X properties on dma_dut ports, bound in RTL and GLS alike
module dma_xcheck (input logic clk, rst_n, psel, penable, pready, pslverr,
input logic [31:0] prdata, input logic m_tvalid, m_tlast,
input logic [31:0] m_tdata, input logic s_tready, irq);
// control outputs: known on every clock once reset is released
a_ctrl_known: assert property (@(posedge clk) disable iff (!rst_n)
!$isunknown(pready) && !$isunknown(s_tready) && !$isunknown(m_tvalid) && !$isunknown(irq));
// data outputs: known only when their qualifier says they are valid
a_prdata_known: assert property (@(posedge clk) disable iff (!rst_n)
(psel && penable && pready) |-> !$isunknown({prdata, pslverr}));
a_tdata_known: assert property (@(posedge clk) disable iff (!rst_n)
m_tvalid |-> !$isunknown({m_tdata, m_tlast}));
endmodule
bind dma_dut dma_xcheck u_xcheck (.*);Two details matter. disable iff (!rst_n) stops the properties during reset; if reset itself is X at time 0, the property is not disabled, so make the bench drive reset to a known value from time 0. And these properties still only see X that reaches a port. To find the X an if swallowed inside the RTL, you need either assertions on internal state (RTL only) or a simulator mode that stops the swallowing.
The major commercial simulators each have an X-propagation mode, enabled with a compile option and usually scoped to chosen modules. In that mode, an if or case with an unknown condition no longer picks a branch. The simulator evaluates every branch the X could select and merges the results: bits on which all branches agree keep their value and the rest become X, which is the ?: behaviour from the demo. Most tools also offer a more pessimistic setting that sets every variable assigned in the statement to X, closer to gate behaviour, and rules for edges from or to X on clocks. The option names differ by vendor and release, so look them up in your tool's documentation rather than in a forum post.
How projects use it: turn X-prop mode on for a subset of the RTL regression (reset, boot, low-power and register tests) once the RTL is stable, because it is slower and every new X needs triage. Each X it reveals is either a real initialisation bug, a testbench that drives X where it should not, or a harmless X that needs a waiver with a reason. Formal tools also offer X-propagation apps that try to prove no X reaches a chosen set of signals after reset, without needing a test to hit the case.
A team added assert property (!$isunknown(state_q)) to every FSM after an X escape. GLS later found another X in the same block. The new assertion had never fired in RTL, and not because the design was clean: the X originated in a configuration flop without reset, passed through an if (cfg_q) into the next-state logic, and the if turned it into the else branch before it ever reached state_q. The assertion watched the result of the optimism instead of its cause. The fix was two-sided: assertions on the configuration flops (the select side, as in the demo), and the reset and boot tests rerun with X-propagation mode on for that block, which made state_q go X in RTL on the first run.
assert (!$isunknown(y)) on every output of a block, and it has never fired. A GLS run shows X on y. List two reasons the RTL assertion stayed quiet.First, an if or case inside the RTL may have converted an X condition into a clean branch before it reached y (optimism), so y really was known in RTL. Second, the X may exist only in the netlist: a flop the RTL initialised with an initial block or that synthesis restructured, or gate pessimism on a reconvergent path where silicon would be fine. Checking the select or state signals in RTL and running X-prop mode separate the first case from the second.
valid bit travels next to it. Is that safe?Usually yes, if every consumer qualifies the data with valid using logic that blocks it when valid is 0 (for example valid & data or a mux selected by a known valid). The X in the data then never reaches state or outputs. It becomes unsafe when something reads the data without valid, such as a parity or CRC computed over every cycle, or a debug register that software can read at any time.
Start with a targeted subset: reset, boot, register and low-power tests, on the blocks you care about, because that is where initialisation X lives. Triage each new X signature once (real bug, testbench drives X, or harmless with a written waiver), fix the testbench sources first, and widen the scope only when the subset is clean. Six hundred failures are probably a handful of root causes repeated.
Next: reset is not the only time a domain's flops become unknown. Power-Aware Simulation with UPF turns whole domains off on purpose, and makes the bench prove the X stays inside.