A 4-state type (logic, reg, wire, integer) stores each bit as 0, 1, X, or Z. X means unknown and Z means undriven, so simulation can show an uninitialized flop or a floating bus directly.
A 2-state type (bit, byte, shortint, int, longint) stores only 0 or 1. Each 4-state bit costs two host bits (a value plane plus an X/Z plane) while a 2-state bit costs one, so large testbench structures roughly halve memory with 2-state types.
Initialization differs in a way that matters for debug. A 4-state variable starts at X, which marks a missing assignment plainly, while a 2-state variable starts at 0, which looks like a legal value.
Anything that touches the DUT stays 4-state, and variables used only inside the testbench can go 2-state. RTL needs X so that reset bugs and bus conflicts stay visible, while a testbench counter or an address the testbench chose itself should never hold X.
The driving direction is safe: every 2-state value is already a valid 4-state value. Only the sampling direction needs a guard, which is the next section.
// snippet: the placement rule in each world
module dut_side (input logic [7:0] d); // RTL pins: 4-state keeps reset bugs visible
endmodule
module tb_side;
bit [31:0] addr; // testbench-only values: 2-state is cheaper
int unsigned delay;
endmoduleAssigning 4-state to 2-state silently maps X and Z to 0. That is the single most dangerous implicit conversion in the language: testbench code copies an X-laden bus into a bit [31:0] variable, and the check compares clean-looking garbage.
Sample DUT pins into 4-state variables first and check them with $isunknown before copying to 2-state ones. If a mismatch shows a suspicious 0, ask whether it was born as X upstream.
// snippet: guard partial X (for example 4'b1x00) before the 2-state copy
if ($isunknown(raw_addr)) $display("X/Z on addr: %h", raw_addr);
tb_addr = raw_addr; // the 2-state copy maps X to 0, so the guard comes firstThe bottom row, bad, is a case-equality detector watching the bus. It rises in the exact slot where X first appears.
Slot 1: d is 1, y follows with 1, and bad is 0.
Slot 2: d falls to 0, y follows, and bad stays 0.
Slot 3: d is driven to X, y goes X, and bad rises to 1.
Slot 4: d is held at X, so y and bad repeat the previous slot.
Slot 5: d returns to 1, y follows, and bad falls to 0.
a is X. What are a & 0, a & 1 and a + 1?a & 0 is 0: a 0 forces AND to 0 whatever the other bit is. a & 1 is X: the answer depends on the unknown. a + 1 is X in every bit: arithmetic turns any X operand into an all-X result.
Gates apply controlling-value rules: X & 0 is 0 and X | 1 is 1, but X & 1 and X | 0 stay X. Arithmetic is harsher: if any operand bit is X or Z, the whole result is X, every bit of it.
Simulation can be pessimistic (reporting X where silicon would settle) or optimistic (reporting a known value where silicon could differ). The dangerous direction is optimistic: an X condition in if silently takes the else branch, and casex matches aggressively despite X.
Comparisons have X rules of their own: == can return X, === compares the four states literally, and ==? treats X or Z on its right as wildcards. Equality & Wildcards covers all three. The habit for checkers is == plus an explicit $isunknown guard, because === everywhere accepts X-equals-X as a match and hides the bug being hunted.
Debug X by walking the waveform back to the first X and repeating upstream through its fan-in. The origin is nearly always an unreset flop, an unconnected port, a bus conflict, an out-of-range index, or a don't-care assignment escaping into live logic.
Full-vector X and Z injection at the boundary between the DUT and testbench code: a case-equality detector, exact-value checks, and a masked compare.
module xmon (
input logic [3:0] bus,
output logic bad
);
// Full-vector detector: flags an all-X or all-Z bus at the boundary.
// Partial X (for example 4'b1x00) needs the $isunknown snippet above.
always_comb bad = (bus === 4'hx) || (bus === 4'hz);
endmodule
PASS: detector flagged full X and Z, exact checks held, mask compare agreed// snippet: wildcard equality says the same thing where the tool honors masks
if (got ==? 4'b1z00) $display("bit 2 ignored in compare");int variable sail through checks while the same bug on a logic signal waves a flag?The int starts at 0, a legal-looking value, while the logic starts at X. The X is a feature: it marks the missing assignment in the waveform.
got holds 4'b1x00, exp holds 4'b1000, and the test uses if (got == exp). Which branch runs, and why is the reason uncomfortable?The else branch runs, but only because == returned X and if treats X as false. The test prints the right word for the wrong reason; the lesson pairs a case-equality detector with the $isunknown guard idiom instead.
Run with Icarus 13 (-g2012) on 2026-10-18; the bench printed the PASS line shown, with no warnings. Verilator 5.048 is a 2-state simulator: it turns X and Z into 0 and 1, so it cannot run this lesson's checks.
The bench injects all-X and all-Z values; the partial-X guard ($isunknown) is shown as a fragment, not run on its own.