SystemVerilog · Module 4
Relational & Equality Operators
== vs === with X/Z values, the scoreboard false-pass bug, signed vs unsigned.
Module 4 · Page 4.2
The Operator That Causes Silent Failures
If you could only learn one thing from this entire operators chapter, it would be this: do not use != in your scoreboard's mismatch check. Use !== instead. The difference is one character. The consequence of getting it wrong is a false pass — a test that reports "PASS" while the DUT is actually outputting X on every cycle.
Here is exactly why. The != operator is a 4-state operator. When either operand contains X or Z, it returns X — not 1 and not 0. In a SystemVerilog if statement, X is treated as false. So if (expected != got) where got is X evaluates as false — the mismatch branch never executes — and your test reports clean.
The !== operator (case inequality) is 2-state. It performs a bit-exact comparison including X and Z bits, and always returns 0 or 1 — never X. If got is X and expected is a clean value, expected !== got returns 1 — the mismatch branch fires.
Two Families: Relational and Equality
SystemVerilog comparison operators split into two groups. Relational operators establish order — is A less than B, greater than or equal to B? They produce a result that depends on the numerical relationship. Equality operators check identity — are A and B the same? The equality group has two sub-families: logical equality (4-state, can return X) and case equality (2-state, always returns 0 or 1).
| Operator | Name | Returns X? | Synthesizable | Use in |
|---|---|---|---|---|
< | Less than | Yes — if operand has X/Z | ✅ Yes | RTL, testbench ordered comparisons |
> | Greater than | Yes — if operand has X/Z | ✅ Yes | RTL, testbench ordered comparisons |
<= | Less than or equal | Yes — if operand has X/Z | ✅ Yes | RTL, testbench ordered comparisons |
>= | Greater than or equal | Yes — if operand has X/Z | ✅ Yes | RTL, testbench ordered comparisons |
== | Logical equality | Yes — returns X if either operand has X/Z | ✅ Yes | RTL; testbench only with clean values |
!= | Logical inequality | Yes — returns X if either operand has X/Z | ✅ Yes | RTL; avoid in scoreboard mismatch checks |
=== | Case equality | Never — always returns 0 or 1 | ❌ No | Testbench X/Z detection, assertions |
!== | Case inequality | Never — always returns 0 or 1 | ❌ No | Scoreboard mismatch checks — use this |
==? | Wildcard equality | Never — always returns 0 or 1 | ✅ Yes (constant pattern) | Masked comparison — X/Z in the right operand only are don't-cares |
!=? | Wildcard inequality | Never — always returns 0 or 1 | ✅ Yes (constant pattern) | Logical inverse of ==?, same asymmetry |
How == Handles X and Z — The Core Concept
The == operator models real hardware behavior. In hardware, comparing a known value against an unknown signal genuinely cannot produce a definitive answer — the result is unknown. So X on either input propagates to the output as X.
The === operator is a simulator construct with no hardware equivalent. It asks: "are these two bit patterns identical, including any X or Z bits?" It treats X as a specific distinguishable bit value rather than "unknown." This is useful for testbench checking but has no meaning in synthesis.
Syntax & Truth Tables
// ── Relational operators ─────────────────────────────────────────
result = (a < b); // less than
result = (a > b); // greater than
result = (a <= b); // less than or equal
result = (a >= b); // greater than or equal
// ── Logical equality (4-state — can return X) ────────────────────
result = (a == b); // logical equal — returns X if a or b has X/Z
result = (a != b); // logical not-equal — returns X if a or b has X/Z
// ── Case equality (2-state — always returns 0 or 1) ─────────────
result = (a === b); // case equal — exact bit match, X matches X, Z matches Z
result = (a !== b); // case not-equal — any bit difference, including X vs 0
// ── Common patterns ──────────────────────────────────────────────
if (dut_out !== expected) // scoreboard check — catches X in dut_out
$error("Mismatch!");
if (signal === 1'bx) // explicit X detection
$warning("X on signal");
if ($isunknown(bus)) // system function — any bit X or Z
$error("X/Z on bus");Truth Table — Logical Equality (==) per Bit
| a | b | a == b | a != b |
|---|---|---|---|
| 0 | 0 | 1 | 0 |
| 0 | 1 | 0 | 1 |
| 1 | 0 | 0 | 1 |
| 1 | 1 | 1 | 0 |
| 0 | X | X | X |
| 1 | X | X | X |
| X | X | X | X |
| 0 | Z | X | X |
| 1 | Z | X | X |
Truth Table — Case Equality (===) per Bit
| a | b | a === b | a !== b |
|---|---|---|---|
| 0 | 0 | 1 | 0 |
| 0 | 1 | 0 | 1 |
| 1 | 1 | 1 | 0 |
| X | X | 1 ← X matches X | 0 |
| Z | Z | 1 ← Z matches Z | 0 |
| 0 | X | 0 | 1 |
| 1 | X | 0 | 1 |
| X | Z | 0 | 1 |
| 0 | Z | 0 | 1 |
Step-by-Step Visual Evaluation
The Scoreboard Scenario — What the Simulator Actually Does
Walk through exactly what happens when a DUT output is X and your scoreboard uses != vs !==:
| Scenario | expected | got (DUT) | expected != got | if fires? | expected !== got | if fires? |
|---|---|---|---|---|---|---|
| Clean match | 8'hA5 | 8'hA5 | 0 | No | 0 | No |
| Clean mismatch | 8'hA5 | 8'h00 | 1 | Yes ✅ | 1 | Yes ✅ |
| DUT output is X | 8'hA5 | 8'hxx | X | No ❌ FALSE PASS | 1 | Yes ✅ CORRECT |
| DUT output is Z | 8'hA5 | 8'hzz | X | No ❌ FALSE PASS | 1 | Yes ✅ CORRECT |
| Partial X (some bits) | 8'hA5 | 8'hxA | X | No ❌ FALSE PASS | 1 | Yes ✅ CORRECT |
Signed vs Unsigned Comparison — The Width and Sign Rules
When comparing two values, the simulator first determines the expression's sign context and bit width. The rules (IEEE 1800-2017 §11.6.1):
- If either operand is unsigned, the entire comparison is unsigned
- Both operands are zero-extended (unsigned) or sign-extended (signed) to the width of the wider operand
logic [N:0]is unsigned by defaultint,byte,shortintare signed by defaultlogic signed [N:0]explicitly marks a vector as signed
| Comparison | Types | Effective operation | Result |
|---|---|---|---|
8'hF0 > 8'h0F | Both unsigned 8-bit | 240 > 15 | 1 (TRUE) |
8'shF0 > 8'sh0F | Both signed 8-bit | −16 > 15 | 0 (FALSE) |
logic[7:0] vs int | Unsigned vs signed — result is unsigned | int is treated as unsigned | Negative int becomes huge unsigned |
8'hFF >= 8'hFF | Both unsigned | 255 >= 255 | 1 (TRUE) |
Code Examples — From Basics to Production
Example 1 — Beginner: All Operators with Clean Values
module tb_comparison_basic;
int a = 10, b = 20, c = 10;
initial begin
// ── Relational operators ──────────────────────────────────────
$display("a < b : %0b", a < b); // 1
$display("a > b : %0b", a > b); // 0
$display("a <= c : %0b", a <= c); // 1 (equal counts)
$display("a >= b : %0b", a >= b); // 0
// ── Logical equality — clean values, == and === agree ─────────
$display("a == c : %0b", a == c); // 1
$display("a != b : %0b", a != b); // 1
// ── Case equality — same result here, but NEVER returns X ─────
$display("a === c : %0b", a === c); // 1
$display("a !== b : %0b", a !== b); // 1
$finish;
end
endmoduleExpected output:
a < b : 1
a > b : 0
a <= c : 1
a >= b : 0
a == c : 1
a != b : 1
a === c : 1
a !== b : 1Example 2 — Intermediate: == vs === with X Values
This is the most important example in this entire section. Run it yourself to see the exact X behavior — particularly how == returns x and === returns a deterministic 0 or 1.
module tb_equality_x;
logic [7:0] dut_out;
logic [7:0] expected = 8'hA5;
initial begin
// ── Case 1: clean, matching ───────────────────────────────────
dut_out = 8'hA5;
$display("[CLEAN MATCH] == %b !== %b",
dut_out == expected, dut_out !== expected);
// == 1, !== 0 — both agree: it's a match
// ── Case 2: clean, NOT matching ──────────────────────────────
dut_out = 8'h00;
$display("[CLEAN MISMATCH] == %b !== %b",
dut_out == expected, dut_out !== expected);
// == 0, !== 1 — both agree: it's a mismatch
// ── Case 3: DUT output is all X ──────────────────────────────
dut_out = 8'hxx;
$display("[DUT IS X] == %b !== %b",
dut_out == expected, dut_out !== expected);
// == x, !== 1 ← CRITICAL: == returns x (false in if)
// !== returns 1 (mismatch detected)
// ── Case 4: DUT output is Z (tristate/undriven) ──────────────
dut_out = 8'hzz;
$display("[DUT IS Z] == %b !== %b",
dut_out == expected, dut_out !== expected);
// == x, !== 1 — same behavior as X
// ── Case 5: both same X pattern ──────────────────────────────
dut_out = 8'hxx;
expected = 8'hxx;
$display("[BOTH X] == %b === %b",
dut_out == expected, dut_out === expected);
// == x, === 1 — case equality: X matches X exactly
$finish;
end
endmoduleExpected output:
[CLEAN MATCH] == 1 !== 0
[CLEAN MISMATCH] == 0 !== 1
[DUT IS X] == x !== 1
[DUT IS Z] == x !== 1
[BOTH X] == x === 1Example 3 — Verification-Oriented: Production Scoreboard
This is the safe, production pattern. It uses !== for mismatch detection and $isunknown() to distinguish X/Z failures from value mismatches — two different root causes that require different debug actions.
class DataScoreboard;
int pass_cnt = 0;
int fail_cnt = 0;
int xz_cnt = 0;
// Use !== so that X/Z in dut_out is always caught
function void check(
input logic [31:0] expected,
input logic [31:0] dut_out,
input string tag = ""
);
if (expected !== dut_out) begin
if ($isunknown(dut_out)) begin
// X or Z on DUT output — different root cause than a value error
xz_cnt++;
$error("[SB] X/Z DETECTED %s | exp=0x%08h got=%b",
tag, expected, dut_out);
end else begin
fail_cnt++;
$error("[SB] MISMATCH %s | exp=0x%08h got=0x%08h",
tag, expected, dut_out);
end
end else begin
pass_cnt++;
end
endfunction
function void report();
$display("[SB] Results: PASS=%0d FAIL=%0d X/Z=%0d",
pass_cnt, fail_cnt, xz_cnt);
if (fail_cnt + xz_cnt == 0)
$display("[SB] ALL CHECKS PASSED");
endfunction
endclass
module tb_scoreboard;
DataScoreboard sb;
initial begin
sb = new();
sb.check(32'hA5A5_A5A5, 32'hA5A5_A5A5, "txn_0"); // PASS
sb.check(32'hA5A5_A5A5, 32'h0000_0000, "txn_1"); // FAIL
sb.check(32'hA5A5_A5A5, 32'hxxxx_xxxx, "txn_2"); // X detected
sb.report();
$finish;
end
endmoduleExpected output:
[SB] MISMATCH txn_1 | exp=0xa5a5a5a5 got=0x00000000
[SB] X/Z DETECTED txn_2 | exp=0xa5a5a5a5 got=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
[SB] Results: PASS=1 FAIL=1 X/Z=1Example 4 — Tricky Corner Case: Signed vs Unsigned Comparison
module tb_signed_comparison;
logic [7:0] u_val = 8'hF0; // unsigned: 240
logic signed [7:0] s_val = 8'hF0; // signed: -16 (same bit pattern!)
initial begin
// Same bits — different numeric meaning
$display("u_val = %0d (0x%0h)", u_val, u_val); // 240
$display("s_val = %0d (0x%0h)", s_val, s_val); // -16
// Unsigned comparison: 240 > 127 → TRUE
if (u_val > 8'h7F)
$display("u_val > 0x7F: TRUE");
// Signed comparison: -16 > 127 → FALSE
if (s_val > 8'sh7F)
$display("s_val > 0x7F: TRUE");
else
$display("s_val > 0x7F: FALSE (signed: -16 is not > 127)");
// Mixed comparison — unsigned wins, signed treated as unsigned
// u_val (unsigned) vs integer (signed) → comparison is unsigned
int threshold = -1;
if (u_val < threshold)
// threshold (-1) as unsigned 32-bit = 4294967295
// 240 < 4294967295 → TRUE — unexpected!
$display("u_val < -1: TRUE ← mixed sign trap!");
$finish;
end
endmoduleExpected output:
u_val = 240 (0xf0)
s_val = -16 (0xf0)
u_val > 0x7F: TRUE
s_val > 0x7F: FALSE (signed: -16 is not > 127)
u_val < -1: TRUE ← mixed sign trap!Waveform & Simulation Thinking
X Propagation in Comparisons
Logical equality (==, !=) follows the same X-propagation rules as all 4-state arithmetic. A single X bit in either operand is enough to produce X on the output. In waveforms, you will see the comparison signal go to X rather than 0 or 1.
Case equality (===, !==) performs a bitwise exact match including X and Z. The result is always a clean 0 or 1. In waveforms, the signal never goes to X — making it far more useful for driving if conditions in testbenches.
Relational Operators and X — The Guard That Stops Guarding
The equality operators get all the attention, and the relational operators have exactly the same hazard with none of the awareness. <, <=, > and >= return X when either operand contains an X or Z bit — they do not return false, and they do not compare the known bits and give up on the rest.
logic [7:0] count = 8'b1010_xxxx; // upper nibble known, lower unknown
logic r;
r = (count > 8'd0); // 1'bx - NOT 1, even though the top nibble
// alone guarantees count > 0
r = (count < 8'd255); // 1'bx
r = (count >= 8'd0); // 1'bx - not even this is 1The third line is worth pausing on. count >= 0 is mathematically true for every possible resolution of those X bits, and it still returns X. The operator does not reason about the range of values the unknown bits could take; a 4-state operand yields a 4-state result.
Where this bites is in guards, because an X condition takes the else branch:
// This looks like a safety check. It is not, once addr can carry X.
if (addr < LIMIT) begin
do_access();
end else begin
report_out_of_range(); // <-- an X addr silently lands HERE
end
// And this is worse, because the "safe" branch is the empty one:
if (fill_level > THRESHOLD)
assert_backpressure(); // <-- an X fill_level never asserts itIn the first case the design reports a range error for an address it never evaluated. In the second, back-pressure is never asserted for a FIFO whose level is unknown, which is precisely when you would want it.
The rule that follows: a comparison used as a guard must be preceded by a knownness check, or written so that X fails safe.
// Explicit, and says what it means.
if ($isunknown(addr)) report_undriven_address();
else if (addr < LIMIT) do_access();
else report_out_of_range();
// Or assert the precondition, so an X addr is a test failure rather
// than a silently mis-routed branch.
a_addr_known: assert property (@(posedge clk) disable iff (!rst_n)
req |-> !$isunknown(addr))
else $error("address is X while req is asserted");Note that there is no case-relational operator. === and !== give you an X-safe equality, and ==? gives you an X-safe masked equality, but nothing in the language gives you an X-safe <. Ordering an unknown value is not a meaningful operation, so the language declines to define one — and the burden of checking sits with the code that uses the comparison.
The $isunknown() System Function
For production scoreboard code, $isunknown(expr) is the most readable way to check whether any bit of a signal is X or Z. It returns 1 if any bit is X or Z, 0 otherwise. It is equivalent to (expr === 'x || expr === 'z) on a multi-bit level but handles partial X patterns (e.g., 8'hxA) correctly.
| Expression | Returns | Use when |
|---|---|---|
$isunknown(sig) | 1 if any bit is X or Z | Checking any unknown bit — most common X-check |
sig === 'x | 1 only if ALL bits are X | Checking if fully undriven — stricter |
sig === 'z | 1 only if ALL bits are Z | Checking tristate/bus release |
sig !== expected | 1 if any bit differs (including X/Z vs 0/1) | Scoreboard mismatch — catches everything |
Synthesis Implications
| Operator | Synthesizable | What synthesis tools do |
|---|---|---|
==, != | ✅ Yes | Maps to comparator gates. X/Z behavior does not exist in silicon |
<, >, <=, >= | ✅ Yes | Maps to subtractor/comparator logic |
===, !== | ❌ No | Synthesis tools either error or silently drop these. RTL with === will not build correctly |
Where You'll Use These in Real Projects
// ── 1. Scoreboard mismatch check — always use !== ─────────────────
function void compare(logic [31:0] exp, logic [31:0] got);
if (exp !== got) // !== catches X/Z, != does not
$error("MISMATCH exp=%0h got=%0h", exp, got);
endfunction
// ── 2. X-check assertion — fires if DUT output goes X after reset ─
assert property (@(posedge clk) disable iff (!rst_n)
!$isunknown(dut_data_out))
else $error("X detected on data_out at time %0t", $time);
// ── 3. Reset check — all registers should be 0 after reset ───────
task automatic check_reset();
@(negedge rst_n);
@(posedge rst_n);
if (dut_status_reg !== 32'h0)
$error("Reset check FAIL: status_reg = 0x%08h", dut_status_reg);
else
$display("Reset check PASS");
endtask
// ── 4. Bus arbitration — check for tristate release ───────────────
task automatic wait_bus_release();
int timeout = 100;
while (bus_data !== 32'hzzzz_zzzz && timeout > 0) begin
@(posedge clk);
timeout--;
end
if (timeout == 0)
$error("Timeout: bus not released (still driving)");
endtask
// ── 5. Coverage guard — skip sample if signal has unknowns ────────
function void sample(logic [7:0] op_code);
if (!$isunknown(op_code)) // don't sample X into coverage bins
cov_op.sample();
endfunctionCommon Bugs & How to Debug Them
Bug 1 — The False-Pass Scoreboard: != Instead of !==
This is the most consequential bug in this chapter. The test appears to pass while the DUT is actively broken. No error is reported. The regression log is clean. The bug ships.
// BUGGY scoreboard — copied from C++ habit
function void check_buggy(logic [31:0] exp, logic [31:0] got);
if (exp != got) // != returns X when got is X
$error("Mismatch!"); // X in if-condition → FALSE → never fires
// Result: DUT can output X on every transaction and this never reports.
endfunction
// Demonstration
logic [31:0] expected = 32'hA5A5_A5A5;
logic [31:0] dut_out = 32'hxxxx_xxxx; // DUT is completely broken
check_buggy(expected, dut_out);
// Output: (nothing) — $error never fires — test passes — BUG SHIPS// CORRECT scoreboard — !== always returns 0 or 1
function void check_correct(logic [31:0] exp, logic [31:0] got);
if (exp !== got) // !== returns 1 when got is X → fires
$error("Mismatch! exp=0x%08h got=%b", exp, got);
endfunction
check_correct(expected, dut_out);
// Output: ERROR: Mismatch! exp=0xa5a5a5a5 got=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
// Bug is caught. Test fails correctly.Bug 2 — === in RTL: Non-Synthesizable Check
// BUGGY RTL — === is a simulation-only operator
always_ff @(posedge clk) begin
if (data_in === 8'hFF) // === left here from debug session
state <= DONE;
end
// Synthesis may warn, replace with ==, or behave unexpectedly
// Simulation passes. Silicon may not match simulation behavior.// CORRECT RTL — == is synthesizable
always_ff @(posedge clk) begin
if (data_in == 8'hFF) // == is synthesizable, maps to comparator
state <= DONE;
endBug 3 — Signed vs Unsigned Comparison: Silent Wrong Behavior
// BUGGY — mixed sign comparison
logic [7:0] addr_offset; // unsigned — DUT address output
int limit = -1; // programmer uses -1 as sentinel: "no limit"
// Bug: addr_offset is unsigned, so comparison becomes unsigned
// limit (-1 as int) = 0xFFFFFFFF unsigned = 4294967295
// addr_offset (any 8-bit value) < 4294967295 → ALWAYS TRUE
if (addr_offset < limit)
$display("offset within limit"); // Always fires — guard is useless// FIX 1 — Use int for offset so both are signed
int addr_offset; // signed — comparison context is now signed
int limit = -1;
if (limit < 0 || addr_offset < limit) // explicit sentinel guard
$display("offset within limit");
// FIX 2 — Explicit cast to force signed context
if ($signed(addr_offset) < limit)
$display("offset within limit");Proving It — The Canonical Comparison Matrix
Every trap on this page reduces to one question: what does each comparison operator do when an operand is not fully known? This runs all of them on the same operands and self-checks.
// comparison_semantics_proof.sv
//
// Self-checking proof of ==, !=, ===, !==, ==?, and the relational
// operators under X/Z, plus the signed/unsigned comparison trap.
module comparison_semantics_proof;
int errors = 0;
task automatic chk(string name, logic got, logic exp);
if (got !== exp) begin
errors++;
$display("FAIL %-52s got=%b exp=%b", name, got, exp);
end else
$display("pass %-52s = %b", name, got);
endtask
initial begin
// ================================================================
// 1. THE CANONICAL MATRIX - one value, three equality operators
// a carries an X and a Z in its low nibble.
// ================================================================
begin
logic [7:0] a = 8'b1010_01xz;
// ...against the IDENTICAL literal
chk("a == 8'b1010_01xz -> X (propagates)", a == 8'b1010_01xz, 1'bx);
chk("a === 8'b1010_01xz -> 1 (literal match)",a === 8'b1010_01xz, 1'b1);
chk("a ==? 8'b1010_01xz -> 1 (right wildcards)",a ==? 8'b1010_01xz, 1'b1);
// ...against a FULLY KNOWN value
chk("a == 8'b1010_0100 -> X", a == 8'b1010_0100, 1'bx);
chk("a === 8'b1010_0100 -> 0", a === 8'b1010_0100, 1'b0);
chk("a ==? 8'b1010_0100 -> 0 (NOT masked)", a ==? 8'b1010_0100, 1'b0);
// ...against a pattern that masks the low nibble
chk("a ==? 8'b1010_xxxx -> 1 (masked)", a ==? 8'b1010_xxxx, 1'b1);
// The inverses are exact complements of the case forms:
chk("a !== 8'b1010_0100 -> 1", a !== 8'b1010_0100, 1'b1);
chk("a != 8'b1010_0100 -> X (propagates)", a != 8'b1010_0100, 1'bx);
end
// ================================================================
// 2. RELATIONAL OPERATORS RETURN X - they do NOT return false
// ================================================================
begin
logic [7:0] count = 8'b1010_xxxx;
chk("count > 8'd0 -> X (not 1)", count > 8'd0, 1'bx);
chk("count < 8'd255 -> X", count < 8'd255, 1'bx);
chk("count >= 8'd0 -> X (not 1!)", count >= 8'd0, 1'bx);
// ^ True for EVERY resolution of those X bits, and still X.
// There is no case-relational operator to fix this.
// A fully known operand behaves normally:
count = 8'd10;
chk("known: 10 > 0 -> 1", count > 8'd0, 1'b1);
end
// ================================================================
// 3. WHY THE SCOREBOARD PASSES - X takes the else branch
// ================================================================
begin
logic [7:0] got = 8'hxx, expected = 8'hA5;
bit fired_ne, fired_case;
fired_ne = 1'b0; fired_case = 1'b0;
if (got != expected) fired_ne = 1'b1; // X -> else -> never fires
if (got !== expected) fired_case = 1'b1; // 1 -> fires correctly
chk("if (got != expected) fires? NO (silent pass)", fired_ne, 1'b0);
chk("if (got !== expected) fires? YES", fired_case, 1'b1);
end
// ================================================================
// 4. SIGNED VS UNSIGNED - the comparison INVERTS
// ================================================================
begin
logic [7:0] data = 8'd10;
int lim = -1;
chk("data > lim (mixed -> lim reads as huge)", data > lim, 1'b0);
chk("$signed(data) > lim (both signed)", $signed(data) > lim, 1'b1);
// The same trap with two same-width operands:
chk("8'shF0 > 8'sh0F (-16 > 15)", 8'shF0 > 8'sh0F, 1'b0);
chk("8'hF0 > 8'h0F (240 > 15)", 8'hF0 > 8'h0F, 1'b1);
// ^ Identical bits. Opposite answers. Declared signedness decides.
end
if (errors == 0) $display("\ncomparison_semantics_proof: ALL CHECKS PASSED");
else $display("\ncomparison_semantics_proof: %0d FAILURES", errors);
$finish;
end
endmoduleTwo checks are worth returning to. In section 2, count >= 0 returns X even though it is true for every possible value the unknown bits could take — the operator does not reason about ranges. In section 4, 8'shF0 > 8'sh0F and 8'hF0 > 8'h0F compare identical bit patterns and give opposite answers, decided entirely by a declaration.
A FIFO back-pressure guard stopped working whenever the level was unknown
RELATIONAL-X-DISABLED-A-GUARD// ❌ BUG: a threshold comparison used as a safety guard. The X case was
// never considered, and X routes to the branch that does nothing.
always_comb begin
almost_full = 1'b0;
if (fill_level > ALMOST_FULL_THRESHOLD) // ❌ X here -> else -> 1'b0
almost_full = 1'b1;
end
// fill_level is computed from a write pointer and a read pointer. During
// the first cycles after reset release, and on any lane whose pointer has
// not yet been written, fill_level carries X in its upper bits:
//
// fill_level = 6'b10xxxx
// fill_level > 6'd48 -> 1'bx (NOT 1, and NOT 0)
// if (1'bx) -> else branch
// almost_full -> 1'b0 <-- back-pressure never asserted
// ✅ FIX: decide explicitly what an unknown level should mean. For a
// safety guard the answer is almost always "assert the guard".
always_comb begin
if ($isunknown(fill_level))
almost_full = 1'b1; // ✅ fail safe: unknown -> back-pressure
else
almost_full = (fill_level > ALMOST_FULL_THRESHOLD);
end
// ✅ AND assert that it should not happen, so the X is fixed rather
// than merely tolerated.
a_fill_level_known: assert property (@(posedge clk) disable iff (!rst_n)
!$isunknown(fill_level))
else $error("fill_level is X - pointer not initialised");A multi-lane FIFO overflowed in a directed stress test, but only on lanes 4 through 7 and only in the first few hundred cycles after reset. The almost_full output was low throughout, and a waveform showed the fill level climbing well past its threshold with no back-pressure asserted.
cycle 214 lane 5
fill_level = 6'b10xxxx (upper bits known, lower unknown)
ALMOST_FULL_THRESHOLD = 6'd48
fill_level > THRESHOLD = 1'bx
almost_full = 1'b0 <-- guard did not fire
writes accepted : continuingThe comparison logic was reviewed twice and found correct. It was correct — for every case anyone considered.
Relational operators return X when an operand is not fully known, and an if treats X as false.
fill_level > ALMOST_FULL_THRESHOLD with fill_level = 6'b10xxxx evaluates to 1'bx. It does not evaluate the known upper bits and conclude the level exceeds 48; it does not return false either. It returns the third value, and the if resolves it silently in the direction that skips the guard.
The most instructive detail is that the comparison is mathematically determined here. 6'b10xxxx is somewhere between 32 and 47 — every resolution of those X bits is less than 48, so the answer is unambiguously false. But relational operators do not reason about the range of values an unknown could take. A 4-state operand yields a 4-state result, full stop. In this case the operator's X was pessimistic where the truth was knowable, and the if converted that pessimism into the wrong branch.
Three things kept it hidden.
The guard fails in the silent direction. Back-pressure not asserted produces no error, no assertion, and no log line — it produces an overflow several hundred cycles later, in a different module.
Lanes 0 to 3 worked. Their pointers were initialised by the first descriptor fetch; lanes 4 to 7 were not exercised until later, so their fill levels carried X for longer. The bug looked like a lane-specific problem rather than a timing one.
The comparison was correct. Every review of the threshold logic confirmed the arithmetic, because the arithmetic was never wrong. The defect was in what happened to a value the arithmetic could not produce.
The general shape, and it recurs throughout this page: the operator behaved exactly as specified, and the if threw away the information it produced.
Decide explicitly what an unknown input should mean, rather than accepting the language's default resolution.
For a guard, unknown should almost always mean "assert the guard" — back-pressure, deny the access, hold the reset. For a comparison result being reported, unknown should propagate so a checker can see it. The wrong answer in both cases is to let if choose.
// The general pattern for any comparison used as a guard:
if ($isunknown(operand)) <fail-safe behaviour>;
else if (operand > THRESHOLD) <the real condition>;
else <the normal path>;
// Paired with an assertion, so the X is diagnosed and not just absorbed:
a_operand_known: assert property (@(posedge clk) disable iff (!rst_n)
!$isunknown(operand))
else $error("%m: comparison operand is X");Three habits, in increasing order of reach:
- Never use a bare relational comparison as a safety guard on a value that can carry X. Pointers, fill levels, credit counters and configuration thresholds all can, most often in the window just after reset release.
- Assert
!$isunknown()on every value that feeds a guard. It is one line, it turns a silent mis-branch into a named failure, and it points at the pointer rather than at the FIFO three modules downstream. - When reviewing any condition, ask what it does on X. There are exactly three answers — takes the branch, takes the else, or was screened explicitly — and only the third is a decision. This is the same question that decides
!=versus!==in a scoreboard,casexversuscasezin a decoder, and?:versusif/elsein a mux. One habit covers all four.
Interview Questions
Best Practices & Coding Guidelines
Scoreboard: Always !==
Replace every != in a scoreboard comparison with !==. No exceptions. One character change eliminates an entire class of false-pass bugs.
RTL: Never ===
Keep === and !== entirely out of synthesizable RTL files. If synthesis gives a warning about them, treat it as an error.
X Detection: $isunknown()
Prefer $isunknown(sig) over sig === 'x for X checking. It catches partial X (individual bits being X) which === 'x does not.
Match Types in Comparisons
When comparing logic[N:0] signals against integer variables, ensure both are the same signedness. Mixed comparisons silently produce unsigned behavior.
Operator Selection Reference
| Context | Use | Avoid | Reason |
|---|---|---|---|
| RTL equality check | ==, != | ===, !== | Only ==/!= are synthesizable |
| Scoreboard mismatch | !== | != | != produces false pass when DUT has X |
| Detecting X on a signal | $isunknown() | == 'x | == 'x itself returns X — never true |
| Detecting tristate (Z) | sig === 32'hzzzz_zzzz | sig == 32'hzzzz_zzzz | == returns X when comparing against Z |
| Assertion guard (disable iff) | !$isunknown(sig) | Nothing — always add this | Prevents false assertion failures during X states in sim |
| Loop/counter comparison | i < N with int | Mixed signed/unsigned | Unsigned loop variables with signed limits produce wrong results |
Summary
Relational operators are straightforward once you know the signed/unsigned context rules. The complexity in this section lives entirely in the equality operators — specifically the behavioral gap between == and === when X or Z values are present.
The three rules to internalize:
- In your scoreboard, use
!==, not!=.!=returns X when the DUT output has X, which evaluates as false in anifand silently masks failures. - In RTL, never use
===or!==. They are simulation-only. Using them in synthesizable code creates simulation-synthesis mismatches — among the hardest bugs to trace. - Mixed signed/unsigned comparisons are silent bugs. If either operand is unsigned, the whole comparison is unsigned. A negative signed value becomes a large unsigned value, and your guard conditions stop working.
These are not academic rules. They are patterns that appear in real production testbench code, produce real false passes, and cause real silicon respins. Getting them right is part of what separates a professional verification engineer from someone who just writes code that looks correct.
Related Pages & References
The rest of the comparison family. Wildcard Equality owns ==? and !=? in full, including the asymmetry that makes operand order matter. case, casex & casez covers the statement-level don't-care rules, which are symmetric where ==? is not. inside compares its set members with an implied ==? and inherits that asymmetry.
Why the comparisons behave as they do. Signedness decides whether a comparison is inverted — see Arithmetic Operators and Integer Types for where an expression's signedness comes from, and Bitwise Operators for the same one-unsigned-operand rule. For the X and Z values that drive every trap on this page, 2-state vs 4-state Types; for why an X condition takes the else branch, Logical Operators. For where these operators sit — equality binds tighter than the bitwise operators, so a & b == c is a & (b == c) — see Operator Precedence.
References.
- IEEE 1800 (SystemVerilog) — the relational, equality, case-equality and wildcard-equality operators are defined in the operators-and-expressions clause, along with their behaviour on X and Z: the logical equality and relational operators produce an unknown result when either operand contains X or Z; the case operators compare all four states literally and always produce 0 or 1; and the wildcard operators treat X and Z in the right operand as wildcards. The rule that an expression is signed only if every operand is signed — which is what inverts a mixed comparison — is in the same clause.
$isunknownis in the system-tasks-and-functions clause. - IEEE 1364 (Verilog) — the same operators and X/Z rules, inherited unchanged;
==?and!=?are SystemVerilog additions.
Requirement versus practice. The operator results, including every X case, are language requirements. The recommendations — !== for scoreboard mismatch checks, $isunknown() screening in front of any guard, and declaring operands with the signedness their data has — are engineering practice. They exist because the language behaves correctly and unforgivingly here: nothing warns when a comparison returns X and an if quietly resolves it, so the discipline has to live in how the comparison is written.
Part of SystemVerilog Fundamentals·Operators & Expressions·Lesson 20 of 53
View program