SystemVerilog · Module 4
Bitwise Operators
&, |, ^, ~, ~^ — masking, bit manipulation, XOR parity, X propagation.
Module 4 · Page 4.4
The Workhorses of Signal Manipulation
Bitwise operators are the most physically intuitive operators in hardware description. Every operation maps directly to a gate. AND masks bits. OR sets bits. XOR toggles bits. NOT inverts them. These four primitives are the foundation of everything from register access macros to CRC engines to one-hot encoders.
In verification, you use bitwise operators constantly — extracting protocol fields from a packed bus, injecting a specific error bit into a transaction payload, checking whether a particular flag is set in a status register, computing expected parity. The patterns are mechanical once you understand what each gate does at the bit level.
The operator that gets the most interesting treatment is XOR. At face value it toggles bits. But XOR has a mathematical property — it is its own inverse — that makes it central to parity computation, error detection, and even simple encryption. A signal XORed with itself is always zero. A signal XORed with all-ones inverts every bit. These properties appear regularly in RTL datapath design and in the testbenches that verify them.
The Bitwise Operator Family
| Operator | Name | Gate equivalent | Primary use |
|---|---|---|---|
& | Bitwise AND | AND gate per bit pair | Masking — extract or clear specific bits |
| | Bitwise OR | OR gate per bit pair | Setting — force specific bits to 1 |
^ | Bitwise XOR | XOR gate per bit pair | Toggling, parity, difference detection |
~ | Bitwise NOT | Inverter per bit | Invert every bit — generate complement |
~& | Bitwise NAND | NAND gate per bit pair | Uncommon in RTL — used in reduction form |
~| | Bitwise NOR | NOR gate per bit pair | Uncommon in RTL — used in reduction form |
~^ or ^~ | Bitwise XNOR | XNOR gate per bit pair | Equivalence check per bit, comparator design |
The Four Core Bit-Manipulation Patterns
Everything you do with bitwise operators in practice reduces to one of these four patterns. Memorise them. They appear in every register access implementation, every protocol driver, every error-injection routine.
MASK — Extract bits
result = data & mask
Keeps only the bits where mask is 1. All other bits become 0. Used to isolate a field from a packed bus.
SET — Force bits to 1
result = data | mask
Forces all bits where mask is 1 to become 1. Other bits unchanged. Used to enable flags or set control bits.
CLEAR — Force bits to 0
result = data & ~mask
Forces all bits where mask is 1 to become 0. Other bits unchanged. Used to disable flags.
TOGGLE — Flip bits
result = data ^ mask
Flips all bits where mask is 1. Other bits unchanged. Used for error injection, parity insertion.
Syntax & Truth Tables
logic [7:0] a = 8'hA5; // 1010_0101
logic [7:0] b = 8'hF0; // 1111_0000
logic [7:0] result;
result = a & b; // AND: 1010_0000 = 8'hA0
result = a | b; // OR: 1111_0101 = 8'hF5
result = a ^ b; // XOR: 0101_0101 = 8'h55
result = ~a; // NOT: 0101_1010 = 8'h5A
result = a ~^ b; // XNOR: 1010_1010 = 8'hAA (complement of XOR)
// ── Four manipulation patterns ────────────────────────────────────
localparam logic [7:0] MASK = 8'h0F; // 0000_1111
result = a & MASK; // EXTRACT lower nibble: 0000_0101 = 8'h05
result = a | MASK; // SET lower nibble: 1010_1111 = 8'hAF
result = a & ~MASK; // CLEAR lower nibble: 1010_0000 = 8'hA0
result = a ^ MASK; // TOGGLE lower nibble: 1010_1010 = 8'hAAPer-Bit Truth Table — All Operators
| a | b | a & b | a | b | a ^ b | ~a | a ~^ b |
|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 1 | 1 |
| 0 | 1 | 0 | 1 | 1 | 1 | 0 |
| 1 | 0 | 0 | 1 | 1 | 0 | 0 |
| 1 | 1 | 1 | 1 | 0 | 0 | 1 |
| 0 | X | 0 ← 0 absorbs | X | X | 1 | X |
| 1 | X | X | 1 ← 1 absorbs | X | 0 | X |
| X | X | X | X | X | X | X |
| 0 | Z | 0 ← 0 absorbs | X | X | 1 | X |
| 1 | Z | X | 1 ← 1 absorbs | X | 0 | X |
Step-by-Step Visual Evaluation
Bit-Level Walkthrough: a = 8'hA5, mask = 8'h0F
| Operation | Bit 7 | Bit 6 | Bit 5 | Bit 4 | Bit 3 | Bit 2 | Bit 1 | Bit 0 | Result (hex) |
|---|---|---|---|---|---|---|---|---|---|
| a | 1 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 8'hA5 |
| mask | 0 | 0 | 0 | 0 | 1 | 1 | 1 | 1 | 8'h0F |
a & mask (EXTRACT) | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 8'h05 |
| a | mask (SET) | 1 | 0 | 1 | 0 | 1 | 1 | 1 | 1 | 8'hAF |
a & ~mask (CLEAR) | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 8'hA0 |
| a ^ mask (TOGGLE) | 1 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 8'hAA |
| ~a (INVERT) | 0 | 1 | 0 | 1 | 1 | 0 | 1 | 0 | 8'h5A |
XOR Properties Worth Memorizing
| XOR expression | Result | Why this matters |
|---|---|---|
a ^ 0 | a unchanged | XOR with 0 is identity — no effect |
a ^ 1 | ~a (inverted) | XOR with 1 inverts the bit — useful for toggling |
a ^ a | 0 (all zeros) | XOR with itself cancels — used in parity checks and diff detection |
a ^ ~a | all 1s | XOR with complement is always all-ones |
(a ^ b) ^ b | a (original) | XOR is its own inverse — decode by re-applying the key |
a ^ 8'hFF | ~a | XOR with all-ones inverts every bit — same as ~ but via XOR |
XNOR — Bit-Wise Equivalence
XNOR (~^ or ^~) produces 1 where two bits match, and 0 where they differ. It is the complement of XOR. A common use in RTL is building bit-parallel comparators: if all bits of a ~^ b are 1, then a == b at every bit position. In verification, XOR is more commonly used to find which bits differ between expected and actual — a non-zero XOR result tells you exactly which bits are wrong.
| a | b | a ^ b (XOR — differ?) | a ~^ b (XNOR — match?) |
|---|---|---|---|
| 0 | 0 | 0 (same) | 1 (match) |
| 0 | 1 | 1 (differ) | 0 (mismatch) |
| 1 | 0 | 1 (differ) | 0 (mismatch) |
| 1 | 1 | 0 (same) | 1 (match) |
Code Examples — From Basics to Production
Example 1 — Beginner: All Operators on Known Values
module tb_bitwise_basic;
logic [7:0] a = 8'hA5; // 1010_0101
logic [7:0] b = 8'hF0; // 1111_0000
initial begin
$display("a = %08b (0x%0h)", a, a);
$display("b = %08b (0x%0h)", b, b);
$display("────────────────────────────────");
$display("a & b = %08b (0x%0h)", a & b, a & b); // AND
$display("a | b = %08b (0x%0h)", a | b, a | b); // OR
$display("a ^ b = %08b (0x%0h)", a ^ b, a ^ b); // XOR
$display("~a = %08b (0x%0h)", ~a, ~a); // NOT
$display("a ~^ b = %08b (0x%0h)", a ~^ b, a ~^ b); // XNOR
// XOR self-cancels
$display("a ^ a = %08b (should be 0)", a ^ a);
$finish;
end
endmoduleExpected output:
a = 10100101 (0xa5)
b = 11110000 (0xf0)
────────────────────────────────
a & b = 10100000 (0xa0)
a | b = 11110101 (0xf5)
a ^ b = 01010101 (0x55)
~a = 01011010 (0x5a)
a ~^ b = 10101010 (0xaa)
a ^ a = 00000000 (should be 0)Example 2 — Intermediate: Register Bit Manipulation
This is the production pattern for register field access — the kind of code you write in register model methods, driver register writes, and scoreboard register mirror updates.
module tb_reg_manipulation;
// Imagine a control register layout:
// [7:6] MODE [5:4] SPEED [3] ERR_INT_EN [2] TX_EN [1] RX_EN [0] RST
localparam logic [7:0]
MASK_MODE = 8'hC0, // 1100_0000
MASK_SPEED = 8'h30, // 0011_0000
MASK_ERR_INT = 8'h08, // 0000_1000
MASK_TX_EN = 8'h04, // 0000_0100
MASK_RX_EN = 8'h02, // 0000_0010
MASK_RST = 8'h01; // 0000_0001
logic [7:0] ctrl_reg = 8'h00;
initial begin
// ── SET: enable TX and RX ─────────────────────────────────────
ctrl_reg = ctrl_reg | MASK_TX_EN | MASK_RX_EN;
$display("After SET TX+RX: 0x%0h (%08b)", ctrl_reg, ctrl_reg);
// 0x06 = 0000_0110
// ── CLEAR: disable RX only ───────────────────────────────────
ctrl_reg = ctrl_reg & ~MASK_RX_EN;
$display("After CLR RX: 0x%0h (%08b)", ctrl_reg, ctrl_reg);
// 0x04 = 0000_0100
// ── EXTRACT: read the MODE field ─────────────────────────────
logic [1:0] mode_val;
ctrl_reg = 8'hC4; // set mode bits for demo
mode_val = (ctrl_reg & MASK_MODE) >> 6;
$display("MODE field = %0d", mode_val);
// (0xC4 & 0xC0) >> 6 = 0xC0 >> 6 = 3
// ── TOGGLE: flip error interrupt enable ──────────────────────
ctrl_reg = ctrl_reg ^ MASK_ERR_INT;
$display("After TOGGLE ERR_INT: 0x%0h", ctrl_reg);
$finish;
end
endmoduleExample 3 — Verification-Oriented: XOR for Mismatch Diagnosis
XOR between expected and actual output tells you exactly which bits are wrong — every 1 in the XOR result marks a bit that differs. This is more informative than just reporting "mismatch" and is used in production scoreboards to accelerate debug.
class DiagnosticScoreboard;
function void check(
input logic [31:0] expected,
input logic [31:0] actual,
input string tag
);
logic [31:0] diff;
if (expected !== actual) begin
diff = expected ^ actual; // 1 where bits differ, 0 where they match
$error("[SB] MISMATCH %s", tag);
$display(" expected : 0x%08h (%032b)", expected, expected);
$display(" actual : 0x%08h (%032b)", actual, actual);
$display(" XOR diff : 0x%08h (%032b) ← 1=wrong bit", diff, diff);
// Count wrong bits
$display(" %0d bit(s) wrong", $countones(diff));
end
endfunction
endclass
module tb_xor_diag;
DiagnosticScoreboard sb;
initial begin
sb = new();
sb.check(32'hA5A5_A5A5, 32'hA5A5_A5A5, "txn_0"); // PASS
sb.check(32'hA5A5_A5A5, 32'hA5A5_A5A0, "txn_1"); // 3 bits wrong
$finish;
end
endmoduleExample 4 — Corner Case: Error Injection with XOR
A standard error-injection pattern in constrained-random verification: XOR a good payload with an error mask to flip specific bits. Because XOR is its own inverse, you can also use the same operation to "repair" an injected error — useful in error recovery test scenarios.
class ErrorInjector;
// Inject a single-bit error at a random bit position
function logic [31:0] inject_single_bit(
input logic [31:0] data,
input int bit_pos
);
logic [31:0] err_mask = 32'h1 << bit_pos; // single 1-bit at bit_pos
return data ^ err_mask; // flip that bit
endfunction
// Inject burst errors across a range of bits
function logic [31:0] inject_burst(
input logic [31:0] data,
input logic [31:0] err_mask
);
return data ^ err_mask; // flip every bit where mask is 1
endfunction
// Recovery: XOR is its own inverse — re-apply same mask to fix
function logic [31:0] repair(
input logic [31:0] corrupted,
input logic [31:0] err_mask
);
return corrupted ^ err_mask; // XOR same mask again → original data
endfunction
endclass
module tb_error_inject;
ErrorInjector ei;
logic [31:0] original, corrupted, recovered;
initial begin
ei = new();
original = 32'hA5A5_A5A5;
corrupted = ei.inject_single_bit(original, 4);
recovered = ei.repair(corrupted, 32'h10); // same mask: 1<<4
$display("Original : 0x%08h", original);
$display("Corrupted: 0x%08h (bit 4 flipped)", corrupted);
$display("Recovered: 0x%08h (matches original: %0b)",
recovered, recovered === original);
$finish;
end
endmoduleExpected output:
Original : 0xa5a5a5a5
Corrupted: 0xa5a5a5b5 (bit 4 flipped)
Recovered: 0xa5a5a5a5 (matches original: 1)Waveform & Simulation Thinking
X Propagation — AND and OR Special Cases
The X propagation rules for bitwise operators have an important asymmetry. AND can "absorb" X when the other operand is 0. OR can absorb X when the other operand is 1. But this is per-bit — if your mask has some 0 bits and some 1 bits, the 0-bit positions absorb X while the 1-bit positions propagate it.
| Expression | Example | Result | Why |
|---|---|---|---|
8'hxx & 8'h0F | X AND lower-nibble mask | 8'h0x | Upper nibble absorbed to 0, lower nibble propagates X |
8'hxx & 8'h00 | X AND zero mask | 8'h00 | ALL bits absorbed — result is clean 0 |
8'hxx | 8'hFF | X OR all-ones mask | 8'hFF | ALL bits absorbed — result is clean FF |
8'hxx ^ 8'h0F | X XOR any mask | 8'hxx | XOR never absorbs — X propagates through all positions |
~8'hxx | NOT of X | 8'hxx | Inversion of unknown is still unknown |
Width and Sign Extension in Bitwise Operations
When operands have different widths, the shorter one is extended to match the wider before the operation. Which kind of extension is used is decided by the signedness of the whole expression, not by the operand's own declaration — and that is where this gets dangerous.
The rule: an expression is signed only if every operand is signed. One unsigned operand makes the whole expression unsigned, and then even a signed operand is zero-extended.
logic signed [7:0] s = -1; // 8'hFF
logic [7:0] u = 8'h0F; // unsigned
logic signed [15:0] r1, r2;
// Both operands signed -> expression is signed -> s SIGN-extends.
r1 = s & 16'shFFFF; // 16'hFFFF & 16'hFFFF = 16'hFFFF
// One operand unsigned -> the WHOLE expression is unsigned ->
// s is ZERO-extended, despite being declared signed.
r2 = s & 16'hFFFF; // 16'h00FF & 16'hFFFF = 16'h00FFs is the same variable, declared signed, in both lines. It sign-extends in the first and zero-extends in the second, because of what it was combined with. Nothing in the expression involving s changed.
This is the mechanism behind a whole family of "the mask works on positive values and not on negative ones" reports. It matters most when mixing int (signed 32-bit) with logic [N:0] (unsigned) — a single unsigned literal or port in the expression silently switches the extension for everything in it. $signed() on the offending operand restores the signed context; see Shift Operators, where the same rule decides what >>> fills with.
logic [7:0] byte_val = 8'hFF;
logic [15:0] word_val = 16'hFFFF;
// byte_val zero-extended to 16 bits before AND
logic [15:0] result = byte_val & word_val;
// = 16'h00FF & 16'hFFFF = 16'h00FF
// Not 16'hFFFF — only lower 8 bits are preserved
$display("result = 0x%0h", result); // 0xffSynthesis Behavior
| Operator | Synthesized to | Notes |
|---|---|---|
& | AND gate array | One gate per bit pair. Fully synthesizable |
| | OR gate array | One gate per bit pair. Fully synthesizable |
^ | XOR gate array | One gate per bit pair. Used in parity generators, comparators |
~ | Inverter array | One inverter per bit. Fully synthesizable |
~^ | XNOR gate array | One XNOR per bit pair. Common in comparator synthesis |
Where You'll Use These in Real Projects
// ── 1. Extract protocol field from packed bus ─────────────────────
logic [31:0] axi_araddr;
logic [7:0] page_id = (axi_araddr >> 12) & 8'hFF; // bits [19:12]
logic [11:0] page_offset = axi_araddr & 12'hFFF; // bits [11:0]
// ── 2. Check specific flag in status register ─────────────────────
logic [7:0] status;
if (status & 8'h04) // bit 2 set?
$display("TX FIFO overflow detected");
// ── 3. Build expected register value from fields ──────────────────
function logic [31:0] build_ctrl_reg(
input logic [1:0] mode,
input logic tx_en,
input logic rx_en
);
return ((mode & 2'h3) << 6)
| (tx_en << 2)
| (rx_en << 1);
endfunction
// ── 4. Even parity generation using XOR (across all bits) ─────────
// Note: reduction ^ covered in 4.5, but multi-input XOR is bitwise
function logic calc_parity(input logic [7:0] data);
return ^data; // reduction XOR — counts 1s, 1 if odd
endfunction
// ── 5. Error injection — corrupt specific byte in a 32-bit word ──
function logic [31:0] corrupt_byte(
input logic [31:0] data,
input int byte_idx // 0=LSB, 3=MSB
);
logic [31:0] err_mask = 32'hFF << (byte_idx * 8);
return data ^ err_mask;
endfunctionCommon Bugs & How to Debug Them
Bug 1 — Wrong Mask: Extracting the Wrong Field
// Register: [7:5]=FLAGS [4:2]=TYPE [1:0]=STATUS
logic [7:0] reg_val = 8'b101_011_10; // FLAGS=5, TYPE=3, STATUS=2
// BUGGY: wrong mask for TYPE field — mask should cover bits [4:2]
logic [2:0] type_buggy = (reg_val & 8'h0E) >> 1;
// 8'h0E = 0000_1110 — covers bits [3:1], not [4:2]!
// Extracting the WRONG field by one bit
$display("Buggy TYPE = %0d", type_buggy); // 7 — wrong!// CORRECT: mask bits [4:2] = 0001_1100 = 8'h1C, shift right by 2
logic [2:0] type_correct = (reg_val & 8'h1C) >> 2;
$display("Correct TYPE = %0d", type_correct); // 3 — correct
// Better: use part-select directly when possible
logic [2:0] type_clean = reg_val[4:2];
$display("Clean TYPE = %0d", type_clean); // 3 — cleanest approachBug 2 — OR Without Clear: Old Bits Contaminate New Field Value
// BUGGY: attempting to set SPEED field (bits [5:4]) to value 2'b10
// But the old value still has bits set in that field!
logic [7:0] ctrl = 8'h3F; // 0011_1111 — SPEED field = 2'b11
ctrl = ctrl | (2'b10 << 4);
// 0011_1111 | 0010_0000 = 0011_1111 ← old 2'b11 | new 2'b10 = 2'b11
// OR cannot clear bits — old 1s remain. SPEED is still 2'b11, not 2'b10
$display("Buggy ctrl = 0x%0h", ctrl); // 0x3f — unchangedlocalparam logic [7:0] SPEED_MASK = 8'h30; // 0011_0000
logic [7:0] ctrl = 8'h3F;
// CORRECT: clear field first with AND ~mask, then set with OR
ctrl = (ctrl & ~SPEED_MASK) // clear bits [5:4]
| ((2'b10 << 4) & SPEED_MASK); // set new value
$display("Fixed ctrl = 0x%0h", ctrl);
// (0x3F & 0xCF) | 0x20 = 0x0F | 0x20 = 0x2F — SPEED=2'b10 correctlyBug 3 — XOR to Check Equality: Wrong Interpretation
logic [7:0] expected = 8'hA5, actual = 8'hA5;
// BUGGY: XOR gives 0 when equal, but 0 in if-condition is FALSE
// Engineer reads "if (a ^ b)" as "if a equals b" — wrong!
if (expected ^ actual)
$display("Values are equal"); // never prints — 0 is false
else
$display("Values differ"); // always prints when equal — backwards!// XOR for DIFFERENCE DETECTION — 0 means equal, nonzero means differ
if (expected ^ actual)
$display("Values DIFFER: diff=0x%0h", expected ^ actual);
else
$display("Values EQUAL"); // else branch = XOR was 0 = equal
// For direct equality check, use == or ===
if (expected === actual)
$display("Values EQUAL");Proving It — Masking, Extension, and X Absorption
The three things that decide a bitwise result are the mask, the extension, and the absorbing element. All three are checkable.
// bitwise_semantics_proof.sv
//
// Self-checking proof of: the read-modify-write field update, the
// signedness rule that decides zero- vs sign-extension, and the
// per-bit X absorption that makes masking X-tolerant but XOR not.
module bitwise_semantics_proof;
int errors = 0;
task automatic chk(string name, logic [31:0] got, logic [31:0] exp);
if (got !== exp) begin
errors++;
$display("FAIL %-48s got=%08h exp=%08h", name, got, exp);
end else
$display("pass %-48s = %08h", name, got);
endtask
initial begin
logic [7:0] reg8, mask;
// ================================================================
// 1. THE THREE SINGLE-BIT OPERATIONS
// ================================================================
reg8 = 8'b1010_0101;
chk("SET bit 1", reg8 | (8'h01 << 1), 8'b1010_0111);
chk("CLEAR bit 0", reg8 & ~(8'h01 << 0), 8'b1010_0100);
chk("TOGGLE bit 7", reg8 ^ (8'h01 << 7), 8'b0010_0101);
// The '!' trap: logical NOT reduces the mask to ONE BIT.
mask = 8'h01;
chk("reg & ~mask (correct, clears bit 0)", reg8 & ~mask, 8'b1010_0100);
chk("reg & !mask (WRONG - !mask is 1'b0)", reg8 & !mask, 8'h00);
// !8'h01 is 1'b0, so the whole register is destroyed. No warning.
// ================================================================
// 2. FIELD UPDATE - the clear step is not optional
// ================================================================
reg8 = 8'b0011_0000; // bits [5:4] currently 2'b11
// WRONG: OR alone can only ever set bits.
chk("OR without clear leaves 2'b11", (reg8 | (2'b10 << 4)) >> 4 & 2'b11, 2'b11);
// RIGHT: clear the field, then insert.
chk("clear-then-OR gives 2'b10",
((reg8 & ~(2'b11 << 4)) | (2'b10 << 4)) >> 4 & 2'b11, 2'b10);
// ================================================================
// 3. EXTENSION IS DECIDED BY THE WHOLE EXPRESSION
// An expression is signed only if EVERY operand is signed.
// ================================================================
begin
logic signed [7:0] s = -1; // 8'hFF
logic signed [15:0] r_signed, r_mixed;
r_signed = s & 16'shFFFF; // both signed -> SIGN-extend
r_mixed = s & 16'hFFFF; // one unsigned -> ZERO-extend
chk("s & 16'shFFFF (all signed -> sign-extend)", r_signed, 16'hFFFF);
chk("s & 16'hFFFF (mixed -> ZERO-extend)", r_mixed, 16'h00FF);
// Same `s`, declared signed, extended two different ways.
// $signed on the offending operand restores the signed context:
chk("s & $signed(16'hFFFF) restores it",
s & $signed(16'hFFFF), 16'hFFFF);
// And note it is invisible on non-negative data - the reason a
// positive-only test suite cannot find this:
begin
logic signed [7:0] p = 8'sd5;
chk("positive operand: both forms agree",
(p & 16'shFFFF), (p & 16'hFFFF));
end
end
// ================================================================
// 4. X ABSORPTION - per bit position
// ================================================================
chk("8'h0F & 8'hxx - AND absorbs 0 in the top nibble",
8'h0F & 8'hxx, 8'h0x);
chk("8'hF0 | 8'hxx - OR absorbs 1 in the top nibble",
8'hF0 | 8'hxx, 8'hFx);
chk("8'h0F ^ 8'hxx - XOR absorbs NOTHING",
8'h0F ^ 8'hxx, 8'hxx);
// Masking is X-tolerant. XOR is not, and no mask makes it so.
if (errors == 0) $display("\nbitwise_semantics_proof: ALL CHECKS PASSED");
else $display("\nbitwise_semantics_proof: %0d FAILURES", errors);
$finish;
end
endmoduleSection 3 is the one to run. s is declared signed and is the same variable on both lines. It sign-extends once and zero-extends once, decided entirely by what it was ANDed with.
A logical NOT in a mask expression cleared an entire configuration register
LOGICAL-NOT-COLLAPSED-A-MASK// ❌ BUG: one character. `!` where `~` was meant. Both compile.
task automatic clear_field(input logic [31:0] field_mask);
cfg_reg = cfg_reg & !field_mask; // ❌ !mask is ONE BIT
endtask
// What this computes for field_mask = 32'h0000_00F0:
// !32'h0000_00F0 -> 1'b0 (nonzero reduces to true, inverted)
// cfg_reg & 1'b0 -> 32'h0000_0000
//
// Every bit of cfg_reg is cleared, not just the field. And when the mask
// happens to be ZERO:
// !32'h0000_0000 -> 1'b1
// cfg_reg & 1'b1 -> cfg_reg[0] zero-extended -> almost everything
// cleared too, but not quite - which is why the failure looked random.
// ✅ FIX: bitwise NOT preserves the width and inverts each bit.
task automatic clear_field(input logic [31:0] field_mask);
cfg_reg = cfg_reg & ~field_mask; // ✅ 32'hFFFF_FF0F - clears only [7:4]
endtaskA device driver's field-update routine, exercised through a register-model testbench, produced a configuration register that was mostly zero after any field write. The failure was reported as intermittent, because it depended on which field was written and on what the register happened to hold:
write field [7:4] = 4'hA (mask 32'h0000_00F0)
before: cfg_reg = 32'hDEAD_BEEF
after: cfg_reg = 32'h0000_00A0 <-- everything else gone
write field [7:4] = 4'h0 (mask 32'h0000_0000, a no-op write)
before: cfg_reg = 32'hDEAD_BEEF
after: cfg_reg = 32'h0000_0001 <-- different kind of wrongThe register model's own read-back check passed, because the model applied the same task.
! is logical NOT. It reduces its operand to a single truth value and returns one bit. ~ is bitwise NOT, which inverts each bit and preserves the width.
For a nonzero mask, !field_mask is 1'b0, and ANDing a 32-bit register with 1'b0 — after the one-bit value is zero-extended to 32 bits — clears everything. For a zero mask, !field_mask is 1'b1, which zero-extends to 32'h0000_0001, so the AND keeps bit 0 and clears the other thirty-one. That is why the two failures above look like different bugs: they are the same bug on the two sides of the reduction.
Three properties made this expensive rather than trivial.
Both operators are legal here. cfg_reg & !field_mask is a well-formed expression producing a 32-bit result. There is no width mismatch to warn about, because a one-bit value zero-extends silently into a 32-bit context. No compiler, and no default lint rule, has grounds to object.
The zero-mask case gave a different signature. Because a zero mask leaves bit 0 alive, the corrupted values differed depending on the field being written, and the first three bug reports were filed as separate issues against three different fields.
The register model shared the routine. The testbench's expected value was computed by calling the same task, so the model and the design agreed and the scoreboard was silent. This is the same failure shape as a reference model sharing a type definition with its DUT: the comparison cannot see an error that is upstream of both sides.
The general rule: ~ computes a value, ! answers a question. If the result of the expression is going to be ANDed, ORed, assigned to a vector, or used as a mask, it must be ~. If it is going into an if or an assertion, it should be !. The width of the operand is the tell — a ! applied to anything wider than one bit deserves a second look.
Use ~, then make the collapse detectable rather than relying on the character being read correctly.
// 1. The fix.
cfg_reg = cfg_reg & ~field_mask;
// 2. An elaboration-time width check. A one-bit expression cannot satisfy
// this, so the logical form fails to elaborate rather than failing in
// the lab.
generate
if ($bits(~field_mask) != 32)
$error("field mask expression is not 32 bits - a logical operator has "
, "collapsed it");
endgenerate
// 3. The invariant that catches any mask bug, not just this one: a field
// update must leave every bit OUTSIDE the mask unchanged.
property p_only_masked_bits_change;
@(posedge clk) disable iff (!rst_n)
field_write |=> ((cfg_reg & ~$past(field_mask)) ==
($past(cfg_reg) & ~$past(field_mask)));
endproperty
a_only_masked_bits_change: assert property (p_only_masked_bits_change)
else $error("field update modified bits outside the mask");
// 4. And the modelling rule that would have made the scoreboard useful:
// the reference model must compute the expected value INDEPENDENTLY,
// not by calling the design's own update routine.Three habits, in increasing order of what they buy:
- Choose by what the result feeds.
~for values,!for questions. A!on a multi-bit operand is nearly always wrong. - Assert the invariant, not the implementation. "Only bits inside the mask changed" is true of every correct field update regardless of how it is written, and it catches
!for~, a missing clear step, an off-by-one shift, and a mask computed from the wrong parameter — all with one property. - Never let the reference model call the design's routine. A model that shares code with what it checks can only verify self-consistency. Whatever else it costs, the expected value has to be computed a second, independent way.
Interview Questions
Best Practices & Coding Guidelines
Named constants for masks
Always define bit masks as named localparam or parameter constants. reg & MASK_TX_EN is readable; reg & 8'h04 requires a comment to explain.
Part-select over masking
For fixed-boundary field extraction, sig[hi:lo] is always clearer than (sig & mask) >> shift. Reserve masking for dynamically computed field positions.
Clear before set
When writing a multi-bit field, always AND with the inverse mask first to clear old bits, then OR the new value. OR alone silently corrupts fields.
XOR = diff, not equality
XOR gives 0 when values are equal and nonzero when they differ. Use it for mismatch diagnosis and error injection — not as a substitute for ==.
| Task | Correct pattern | Common mistake |
|---|---|---|
| Extract a field | sig[hi:lo] or (sig & mask) >> shift | Wrong mask — off-by-one in position |
| Set specific bits | reg | mask | Using ^ (toggles, not sets) |
| Clear specific bits | reg & ~mask | Using reg & mask (keeps, not clears) |
| Update a field | Clear first: (reg & ~mask) | new_val | Just OR: reg | new_val — old bits remain |
| Find differing bits | a ^ b (nonzero = differ) | if (a ^ b) confused with "if equal" |
| Count wrong bits | $countones(a ^ b) | Not using XOR — missing diagnostic info |
Summary
Bitwise operators map one-to-one to hardware gates and are synthesizable without exception. The four manipulation patterns — mask, set, clear, toggle — cover the vast majority of bit-level work in both RTL and verification.
Three things worth carrying forward:
- AND has an absorbing element (0). OR has an absorbing element (1). XOR has neither. This determines how X propagates:
0 & X = 0,1 | X = 1, but any X in an XOR input always propagates to the output. Plan your masking operations accordingly. - To update a multi-bit field: clear first, then set. OR cannot clear bits. Using OR alone to write a new field value leaves old bits set wherever the old value had 1s and the new value has 0s.
- XOR measures bit difference, not equality.
a ^ b = 0means equal. A nonzero result tells you which exact bits differ. Use$countones(a ^ b)for Hamming distance. This is a diagnostic tool, not an equality operator.
Related Pages & References
The two families these are confused with. Logical Operators covers &&, ||, ! — the reducing forms that answer questions where these compute values. Substituting one for the other is the most common one-character bug in RTL, and Debug Lab 1 above is the ! for ~ version of it. Reduction Operators covers the unary &v, |v, ^v, which use the same symbols with one operand and collapse a vector to a bit. Because the unary forms bind tightest, &a & b is a reduction of a ANDed with b.
What decides the result besides the operator. Signedness governs whether a narrower operand zero- or sign-extends, and one unsigned operand makes the whole expression unsigned — see Integer Types and the same rule applied to fills in Shift Operators. Where these sit relative to equality — a & b == c groups as a & (b == c) — is in Operator Precedence. For building masked fields into wider words, Concatenation, Replication & Conditional. For the X values that make the absorbing-element rule matter, 2-state vs 4-state Types.
References.
- IEEE 1800 (SystemVerilog) — the binary bitwise operators and their per-bit truth tables, including the X and Z rows, are in the operators-and-expressions clause. Two rules on this page trace to it: an expression is signed only if every operand is signed, so a single unsigned operand causes even a signed operand to zero-extend; and the extension happens before the operation, at the width of the whole expression.
$signed()and$unsigned()are the explicit overrides. - IEEE 1364 (Verilog) — the same operators and tables, inherited unchanged.
Requirement versus practice. The truth tables, the extension rules, and the X absorption are language requirements. The convention of reserving &/|/~ for values and &&/||/! for conditions is style — both compile — but it is the habit that survives a signal being widened later, which is the change that turns a harmless equivalence into a real bug. The read-modify-write idiom (clear the field, then OR in the value) is likewise practice; the language will happily let you skip the clear.
Part of SystemVerilog Fundamentals·Operators & Expressions·Lesson 22 of 53
View program