SystemVerilog · Module 4
Arithmetic Operators
Pre/post increment & decrement, X-propagation, signed vs unsigned.
Module 4 · Page 4.1
Why Operators Matter More Than You Think
Every verification engineer has typed i = i + 1 a thousand times. SystemVerilog gave us i++ and ++i as shorthand, and most people moved on without a second thought. That works fine until the day you embed one of them inside an expression — a queue push, an array index, a $display argument — and your scoreboard starts reporting mismatches for no obvious reason.
The pre vs post distinction is trivial when used standalone. It becomes a real source of bugs the moment the return value is consumed. Engineers coming from C sometimes assume SystemVerilog handles this identically. It mostly does, but there are critical differences — especially around 4-state X propagation and the LRM's restrictions on modifying the same variable twice in a single expression.
Arithmetic operators as a whole — +, -, *, /, %, ** — are the workhorses of every counter, address generator, and data checker you write. The fundamentals are predictable, but signed vs unsigned behavior in division and modulus catches people regularly. This section covers the complete picture — starting from the full family, then deep diving into ++ and -- where the real complexity lives.
The Arithmetic Operator Family
SystemVerilog provides ten arithmetic operators. Eight of them you probably know. Two of them — increment and decrement — have subtleties that deserve careful attention.
| Operator | Operation | Example | Key Note |
|---|---|---|---|
+ | Addition | a + b | Works on integers, reals, vectors |
- | Subtraction | a - b | Signed/unsigned context matters for interpretation |
* | Multiplication | a * b | Result width = max of the operand widths and the context — not their sum. Products truncate unless you widen first |
/ | Division | a / b | Integer truncation toward zero. 7/2 = 3, -7/2 = -3 |
% | Modulus | a % b | Sign follows the dividend. -7%3 = -1 |
** | Power | 2 ** 8 | Synthesis support is tool-dependent — use with care in RTL |
a++ | Post-increment | cnt++ | Use value, then increment. Standalone: same as a = a+1 |
++a | Pre-increment | ++cnt | Increment first, then use value. Only differs from post in expressions |
a-- | Post-decrement | cnt-- | Use value, then decrement |
--a | Pre-decrement | --cnt | Decrement first, then use value |
Building Intuition for ++ and --
Think of the position of ++ as answering one question: "When does the increment happen relative to the expression being evaluated?" If the ++ is after the variable (a++), the current value is used first, then the increment happens. If it's before (++a), the increment happens first and the new value is what gets used.
Here's the golden rule: when used as a standalone statement, pre and post are identical. The difference only appears when the expression's return value is consumed by something else — an assignment, a function argument, an array index, a comparison.
Syntax & Valid Operand Types
// ── Standalone statements — pre/post produce identical results ──
a++; // post-increment: a = a + 1
++a; // pre-increment: a = a + 1 (same effect when standalone)
a--; // post-decrement: a = a - 1
--a; // pre-decrement: a = a - 1 (same effect when standalone)
// ── Inside expressions — pre/post behavior diverges ─────────────
b = a++; // b = current a, THEN a increments → b gets OLD value
b = ++a; // a increments FIRST, THEN b = a → b gets NEW value
// ── Practical examples ───────────────────────────────────────────
int x = 5;
int y = x++; // y = 5, x = 6
int z = ++x; // x = 7, z = 7What Can You Increment?
The ++ and -- operators require a variable as their operand — something that is procedurally assigned. Nets and class object handles are explicitly prohibited by the SystemVerilog LRM (IEEE 1800-2017, §11.4.2).
| Type | Valid for ++/-- | Notes |
|---|---|---|
int | ✅ Yes | 2-state, 32-bit signed. Preferred for testbench counters |
integer | ✅ Yes | 4-state, 32-bit. Starts at X if not initialized — use carefully |
byte / shortint / longint | ✅ Yes | All 2-state signed integer types |
logic [N:0] | ✅ Yes (variable only) | Must be procedurally assigned. 4-state — X propagation applies |
real / shortreal | ✅ Yes | Increments by 1.0 |
wire | ❌ No | Net type — cannot be procedurally modified. Compiler error |
logic driven by assign | ❌ No | Continuous assignment makes it net-like. Compiler error |
| Class object handle | ❌ No | LRM explicitly prohibits this. Use a separate integer member |
Step-by-Step Visual Evaluation
The tables below trace exactly what happens inside the simulator for each operator form. All examples start with cnt = 3.
Post-increment: result = cnt++
| Step | Simulator Action | cnt | result |
|---|---|---|---|
| 1 | Read current value of cnt | 3 | — |
| 2 | Assign that value to result | 3 | 3 |
| 3 | Increment cnt by 1 | 4 | 3 |
| Final | — | 4 | 3 |
Pre-increment: result = ++cnt
| Step | Simulator Action | cnt | result |
|---|---|---|---|
| 1 | Increment cnt by 1 | 4 | — |
| 2 | Read the incremented value | 4 | — |
| 3 | Assign new value to result | 4 | 4 |
| Final | — | 4 | 4 |
Four-Operator Quick Reference (starting from cnt = 5)
| Expression | Value returned by expression | cnt after |
|---|---|---|
cnt++ | 5 (old value) | 6 |
++cnt | 6 (new value) | 6 |
cnt-- | 5 (old value) | 4 |
--cnt | 4 (new value) | 4 |
Bit-Level View — Overflow and Wrap-Around
For a 3-bit unsigned variable (logic [2:0] cnt), incrementing past the maximum wraps back to zero — this is modular arithmetic and is expected behavior. Size your counter to hold its maximum value plus one.
| cnt (decimal) | cnt (binary) | After cnt++ (binary) | After cnt++ (decimal) |
|---|---|---|---|
| 5 | 101 | 110 | 6 |
| 6 | 110 | 111 | 7 |
| 7 | 111 | 000 | 0 ⚠️ wraps! |
| 0 | 000 | 001 | 1 |
Code Examples — From Basics to Production
Example 1 — Beginner: Standalone Usage
Start here. This shows that standalone a++ and ++a are completely interchangeable. The pre/post distinction simply does not matter when the return value is not consumed.
module tb_increment_basic;
int a, b, c;
initial begin
a = 10;
b = 10;
c = 10;
// Standalone — pre and post produce identical results here
a++; // a = a + 1
++b; // b = b + 1 (same effect as b++)
c--; // c = c - 1
$display("After a++ : a = %0d", a); // → 11
$display("After ++b : b = %0d", b); // → 11
$display("After c-- : c = %0d", c); // → 9
$finish;
end
endmoduleExpected output:
After a++ : a = 11
After ++b : b = 11
After c-- : c = 9Example 2 — Intermediate: Pre vs Post in Expressions
This is where the distinction matters. The return value of the expression is captured into another variable — and that's when pre and post produce different results.
module tb_pre_post_compare;
int x;
int post_result, pre_result;
initial begin
// ── Test 1: post-increment ────────────────────────────────────
x = 5;
post_result = x++;
$display("Post-increment: result=%0d, x=%0d", post_result, x);
// result=5 (old value), x=6
// ── Test 2: pre-increment ─────────────────────────────────────
x = 5;
pre_result = ++x;
$display("Pre-increment: result=%0d, x=%0d", pre_result, x);
// result=6 (new value), x=6
// ── Test 3: post-decrement ────────────────────────────────────
x = 5;
$display("Post-decrement: used=%0d, x_after=%0d", x--, x);
// Prints 5, then x becomes 4
$finish;
end
endmoduleExpected output:
Post-increment: result=5, x=6
Pre-increment: result=6, x=6
Post-decrement: used=5, x_after=4Example 3 — Verification-Oriented: Scoreboard with ID Management
This is a production-style pattern. The pre/post choice in get_next_id() is a deliberate design decision — pre-increment ensures the sentinel initial value is never returned to callers. This kind of detail separates reliable scoreboards from buggy ones.
class Scoreboard;
int pkt_sent;
int pkt_received;
int pkt_id_next;
function new();
pkt_sent = 0;
pkt_received = 0;
pkt_id_next = 1000; // sentinel — never returned to callers
endfunction
function void log_sent(int data);
// Post-increment: Packet #0 sent first, THEN counter advances
$display("[SB] Packet #%0d sent data=0x%08h", pkt_sent++, data);
endfunction
function void log_received(int data);
++pkt_received; // standalone pre-increment — pre/post irrelevant
$display("[SB] Received %0d packets total", pkt_received);
endfunction
// Pre-increment: first call returns 1001, not 1000
// Using post here would leak the sentinel value 1000 on first call
function int get_next_id();
return ++pkt_id_next;
endfunction
function void report();
$display("[SB] sent=%0d received=%0d", pkt_sent, pkt_received);
endfunction
endclass
module tb_scoreboard;
Scoreboard sb;
initial begin
sb = new();
sb.log_sent(32'hA5A5_A5A5);
sb.log_sent(32'hCAFE_BABE);
sb.log_received(32'hA5A5_A5A5);
sb.log_received(32'hCAFE_BABE);
$display("[SB] Next ID: %0d", sb.get_next_id()); // 1001
$display("[SB] Next ID: %0d", sb.get_next_id()); // 1002
sb.report();
$finish;
end
endmoduleExpected output:
[SB] Packet #0 sent data=0xa5a5a5a5
[SB] Packet #1 sent data=0xcafebabe
[SB] Received 1 packets total
[SB] Received 2 packets total
[SB] Next ID: 1001
[SB] Next ID: 1002
[SB] sent=2 received=2Example 4 — Tricky Corner Case: Queue Indexing
This example shows how the pre/post distinction creates an off-by-one in array and queue indexing — the kind of bug that silently skips entries in DMA descriptor rings or packet queues.
module tb_corner_case;
int queue[$] = '{10, 20, 30, 40, 50};
int idx = 0;
int val;
initial begin
// Post-increment: reads queue[0], THEN idx becomes 1
val = queue[idx++];
$display("queue[idx++]: val=%0d, idx=%0d", val, idx);
// val=10, idx=1
// Pre-increment: idx becomes 2 FIRST, reads queue[2]
// queue[1] (value 20) is SKIPPED entirely!
val = queue[++idx];
$display("queue[++idx]: val=%0d, idx=%0d", val, idx);
// val=30, idx=2
$finish;
end
endmoduleExpected output:
queue[idx++]: val=10, idx=1
queue[++idx]: val=30, idx=2Waveform & Simulation Thinking
How the Simulator Evaluates These
In procedural code, ++ and -- are evaluated within the current time step — there are no delta-cycle complications for standalone increment statements. When used inside always_ff, the increment is part of a non-blocking assignment (NBA) and the update is scheduled for end-of-time-step — exactly the same as writing cnt <= cnt + 1.
When used inside always_comb, the increment is a blocking assignment within the combinational process. The sensitivity list is automatically complete (that is the guarantee of always_comb), but you need to ensure the logic doesn't create unintended combinational feedback.
X/Z Propagation — The 4-State Problem
This is one of the most important simulation considerations for testbench engineers. If your counter is a 4-state type (integer, logic, reg) and it starts uninitialized, incrementing it does not clear the X — it propagates it.
| Variable Declaration | Initial Value | After ++ | Why |
|---|---|---|---|
int cnt | 0 (2-state auto-init) | 1 | 2-state type. Always safe in testbench |
integer cnt | X (4-state, uninitialized) | X | X + 1 = X. Must initialize explicitly |
logic [3:0] cnt | 4'bxxxx (uninitialized) | 4'bxxxx | Same — 4-state arithmetic with X gives X |
logic [3:0] cnt = 0 | 4'b0000 (initialized) | 4'b0001 | Explicit initialization — works correctly |
Result Width — Arithmetic Does Not Grow to Fit
The single most consequential rule on this page, and the one most often remembered wrongly: an arithmetic result does not widen to hold the mathematically correct answer. Its width is max(width of left operand, width of right operand, width of the context), and anything above that is discarded.
That applies to multiplication too. An 8-bit times an 8-bit does not give you sixteen bits:
logic [7:0] a = 8'd200, b = 8'd3;
logic [15:0] wide;
logic [7:0] narrow;
narrow = a * b; // 600 mod 256 = 8'd88 <-- silently truncated
wide = a * b; // 16'd600 <-- context widened it
wide = 16'(a) * 16'(b); // 16'd600 <-- explicit, same resultnarrow is wrong and nothing warns. wide is right — but only because the 16-bit assignment target made the whole expression 16 bits, extending both operands before the multiply. The context saved it, not the operator.
Two habits follow. When a product must not truncate, widen an operand explicitly (16'(a) * b) rather than relying on the assignment target, because the target can change in a later refactor and the multiply will quietly start losing bits. And be aware that this is one of the places where SystemVerilog differs from the intuition a software background supplies — there is no promotion to a wider type here.
The same rule governs addition. 8'hFF + 8'h01 is 8'h00 in an 8-bit context and 16'h0100 in a 16-bit one, which is why a carry-out has to be created deliberately:
logic [8:0] sum_with_carry = 9'(a) + 9'(b); // bit 8 is the carrySignedness — One Unsigned Operand Makes the Whole Expression Unsigned
An expression is signed only if every operand is signed. A single unsigned operand — a port, a literal, a concatenation — makes the whole thing unsigned, and then even a signed operand is zero-extended rather than sign-extended.
logic signed [7:0] s = -1; // 8'hFF
logic [7:0] u = 8'd1;
logic signed [15:0] r;
r = s + 16'sd0; // all signed -> s sign-extends -> 16'hFFFF = -1
r = s + u; // u is unsigned -> s ZERO-extends -> 16'h0100 = 256The second line is the trap in its most compressed form: adding 1 to −1 gives 256. It is the same rule that decides bitwise extension — see Bitwise Operators — and note the contrast with shifts, where the result's signedness comes from the left operand alone, so an unsigned shift amount is harmless.
The comparison version is worse because it inverts a decision rather than corrupting a value:
logic signed [7:0] temp = -5;
if (temp < 8'd10) ... // FALSE. temp is compared as 251.
if (temp < 8'sd10) ... // TRUE. Both operands signed.$signed() on the offending operand fixes an expression; declaring the operands with the signedness their data actually has fixes everything downstream of them.
Unsized Literals Are 32-Bit and Signed
An unadorned integer literal is at least 32 bits wide and signed. That is usually harmless and occasionally decisive.
| Literal | Width | Signed? | Note |
|---|---|---|---|
1 | 32 (at least) | signed | The default; makes a mixed expression signed only if everything else is too |
-1 | 32 (at least) | signed | Value 32'hFFFF_FFFF |
'd1 | width of context | unsigned | Sized by its context, not by 32 |
32'd1 | 32 | unsigned | Explicit width, explicitly unsigned |
32'sd1 | 32 | signed | Explicit width, explicitly signed |
'1 | width of context | unsigned | All bits set — width follows the context |
The one to watch is '1, which fills the context with ones and is not the same as 1:
logic [63:0] mask;
mask = 1; // 64'h0000_0000_0000_0001
mask = '1; // 64'hFFFF_FFFF_FFFF_FFFFWhere unsized literals cause real trouble is in wide arithmetic, because the literal's 32-bit signed nature can pull the whole expression's signedness with it. In a 64-bit accumulator expression built from unsigned ports, a bare -1 makes nothing signed on its own — but a bare literal alongside $signed() operands can make an expression signed that you expected to be unsigned, and the extension flips. When the width matters, size the literal.
Division Truncates Toward Zero — Arithmetic Shift Does Not
This is worth its own section because the two are used interchangeably to divide by powers of two, and they disagree on exactly the inputs a directed test is least likely to contain.
-7 / 2 = -3 integer division TRUNCATES toward zero
-7 >>> 1 = -4 arithmetic shift FLOORS toward negative infinity
-7 % 2 = -1 the remainder's sign follows the DIVIDEND-3 and -4 are both defensible answers to "minus seven divided by two"; they are different conventions, and SystemVerilog uses both. They agree for non-negative operands and for negative operands that divide exactly (-8 / 4 and -8 >>> 2 are both -2), which is why a suite built from round numbers cannot tell them apart.
The consequence is a reference model that disagrees with its DUT only on negative, non-exact values. If the DUT shifts, the model must shift. If the model must divide, it needs an explicit floor: subtract one from the quotient when the operands have opposite signs and the remainder is nonzero. See Shift Operators, which owns the shift side of this.
The % rule is the one most often guessed: the sign of the result follows the dividend, so -7 % 2 is -1 and 7 % -2 is +1. It is not the mathematical modulo, which would give +1 for the first.
Signed vs Unsigned Behavior
| Type | Value Before ++ | Value After ++ | Notes |
|---|---|---|---|
logic [3:0] (unsigned 15) | 4'b1111 = 15 | 4'b0000 = 0 | Unsigned wrap — expected |
int (signed -1) | -1 | 0 | Signed arithmetic — correct |
byte (signed 127) | 127 | -128 | Signed overflow — wraps to min value |
byte unsigned (255) | 255 | 0 | Unsigned wrap |
Synthesis Implications
| Context | Synthesis Behavior | Recommendation |
|---|---|---|
always_ff (sequential RTL) | Synthesizes as D-FF with adder feedback — registered counter | Acceptable. Most tools handle it correctly |
always_comb (combinational RTL) | Synthesizes as combinational incrementer, no register | Watch for combinational feedback if no enable guard |
Testbench initial block | Not synthesized — simulation only | Use freely in testbench code |
| RTL in general | Supported by most modern tools | Many teams prefer cnt <= cnt + 1 for RTL clarity |
Where You'll Actually Use These in Real Projects
Increment and decrement operators appear throughout every layer of a verification environment. Here are the common patterns with context on why each one uses the specific form it does.
// ── 1. Driver burst — sequential packet IDs ───────────────────────
task automatic send_burst(int count);
for (int i = 0; i < count; i++) begin
pkt.id = base_id++; // post: assign current, then advance base
pkt.data = $urandom();
drive_packet(pkt);
end
endtask
// ── 2. Monitor — counting received transactions ───────────────────
function void write(my_txn txn);
rx_count++; // standalone — pre/post irrelevant
if (txn.err) err_count++;
endfunction
// ── 3. Coverage model — tracking events ──────────────────────────
function void sample(op_type_e op);
cov_samples++;
case (op)
OP_READ: read_count++;
OP_WRITE: write_count++;
endcase
endfunction
// ── 4. Static sequence counter — unique ID per transaction ────────
class BaseTransaction;
static int global_seq = 0;
int seq_num;
function new();
seq_num = ++global_seq; // pre: first object gets ID 1, not 0
endfunction
endclass
// ── 5. Timeout watchdog — decrement until zero ────────────────────
task automatic wait_for_done(int timeout_cycles);
int remaining = timeout_cycles;
while (remaining > 0) begin
if (done_signal) break;
@(posedge clk);
remaining--; // standalone — pre/post identical
end
if (remaining == 0) $error("Timeout waiting for done");
endtaskCommon Bugs & How to Debug Them
Bug 1 — Post-Increment in Push: Wrong Data Enters Queue
This is the most common increment bug in verification code. The intent is to push incrementing values starting from a certain base. Post-increment silently causes the original base value to be pushed first.
int payload = 50;
int q[$];
// INTENT: push values 51, 52, 53 — post-increment after initial bump
// ACTUAL: pushes 50, 51, 52 — post uses current BEFORE incrementing
repeat (3) q.push_back(payload++);
$display("Queue: %p", q);
// Prints: '{50, 51, 52} ← WRONG — intended '{51, 52, 53}int payload = 50;
int q[$];
// FIX: pre-increment — increments first, THEN pushes the new value
repeat (3) q.push_back(++payload);
$display("Queue: %p", q);
// Prints: '{51, 52, 53} ← CORRECTBug 2 — X Propagation: The Counter That Never Counts
A 4-state variable that starts at X will never count correctly, no matter how many times you increment it. This is a silent failure — the simulation runs but every comparison involving that counter evaluates to X (which is treated as false in conditionals).
module tb_x_bug;
integer pkt_cnt; // 4-state type — starts at X, NOT 0
// No initialization anywhere in the code
initial begin
repeat (5) begin
pkt_cnt++;
$display("Count = %0d", pkt_cnt); // prints: x x x x x
end
if (pkt_cnt == 5)
$display("PASS");
else
$display("FAIL: pkt_cnt=%0d", pkt_cnt); // FAIL: pkt_cnt=x
end
endmodulemodule tb_x_fix;
int pkt_cnt = 0; // 2-state — auto-initializes to 0, no X possible
initial begin
repeat (5) begin
pkt_cnt++;
$display("Count = %0d", pkt_cnt); // prints: 1 2 3 4 5
end
if (pkt_cnt == 5)
$display("PASS"); // PASS — as expected
else
$display("FAIL");
end
endmoduleBug 3 — Multiple Increments in One Expression: Undefined Order
This is the most dangerous increment-related bug because it is simulator-dependent. Code that passes on one simulator may produce different results on another. The SystemVerilog LRM does not define the order of evaluation for side effects when the same variable is modified more than once in a single expression.
int arr[5] = '{10, 20, 30, 40, 50};
int i = 2;
int result;
// DANGEROUS — i is both read and modified in the same expression
// The LRM does not define which arr[i] is evaluated first
result = arr[i] + arr[i++];
// VCS: may give 30 + 30 = 60 (i=2 for both, then increments)
// Xcelium: may give 30 + 40 = 70 (different evaluation order)
// This is a PORTABILITY BUG — your regression may pass on one tool, fail on anotherint arr[5] = '{10, 20, 30, 40, 50};
int i = 2;
int result;
// SAFE — explicit sequencing, deterministic on all simulators
result = arr[i] + arr[i + 1]; // Always 30 + 40 = 70
i++; // Advance index separately
// Or if you really need the post-index side effect:
int tmp = arr[i]; // Capture current value
i++;
result = tmp + arr[i];Bug 4 — Unsigned Decrement: Infinite Loop via Underflow
Decrementing an unsigned variable past zero wraps it to the maximum positive value. Comparing an unsigned variable against zero with >= 0 is always true — so a while loop guarded by this condition never terminates.
logic [3:0] retry_count = 4'd0;
// BUGGY — unsigned >= 0 is ALWAYS TRUE
// When retry_count hits 0, decrement wraps it to 4'hF = 15
// The loop runs forever: 0 → 15 → 14 → 13 → ... → 0 → 15 → ...
while (retry_count >= 0) begin
retry_count--;
// This never exits
endint retry_count = 3; // Signed type — comparison to 0 is meaningful
// CORRECT — signed integer can go negative, loop terminates
while (retry_count > 0) begin
retry_count--; // 3 → 2 → 1 → 0 → exits
// Correctly terminates after 3 iterations
endProving It — Width, Signedness, and the Two Ways to Divide
Three rules decide almost every arithmetic surprise: results do not widen, one unsigned operand makes everything unsigned, and division and arithmetic shift round differently. All three are checkable in one file.
// arithmetic_semantics_proof.sv
//
// Self-checking proof of: result width (products truncate), signedness
// contamination by a single unsigned operand, unsized-literal behaviour,
// and division-versus-shift rounding on negative operands.
module arithmetic_semantics_proof;
int errors = 0;
task automatic chk(string name, logic [63:0] got, logic [63:0] exp);
if (got !== exp) begin
errors++;
$display("FAIL %-50s got=%0d (%0h) exp=%0d (%0h)", name, got, got, exp, exp);
end else
$display("pass %-50s = %0d", name, got);
endtask
initial begin
// ================================================================
// 1. RESULTS DO NOT GROW - the product truncates
// ================================================================
begin
logic [7:0] a = 8'd200, b = 8'd3;
logic [7:0] narrow;
logic [15:0] wide;
narrow = a * b;
wide = a * b;
chk("8-bit context: 200*3 truncates to 88", {56'd0, narrow}, 64'd88);
chk("16-bit context: the CONTEXT widened it", {48'd0, wide}, 64'd600);
chk("explicit widening is equivalent",
{48'd0, 16'(16'(a) * 16'(b))}, 64'd600);
// Addition is the same rule. A carry-out must be created on purpose:
begin
logic [7:0] x = 8'hFF, y = 8'h01;
logic [8:0] sum9 = 9'(x) + 9'(y);
chk("8'hFF + 8'h01 in 8-bit context wraps", {56'd0, 8'(x + y)}, 64'h00);
chk("...and in a 9-bit context carries", {55'd0, sum9}, 64'h100);
end
end
// ================================================================
// 2. ONE UNSIGNED OPERAND MAKES THE EXPRESSION UNSIGNED
// ================================================================
begin
logic signed [7:0] s = -1; // 8'hFF
logic [7:0] u = 8'd1;
logic signed [15:0] r_all_signed, r_mixed;
r_all_signed = s + 16'sd0; // every operand signed
r_mixed = s + u; // one unsigned operand
chk("s + 16'sd0 (all signed -> sign-extend)", {48'd0, 16'(r_all_signed)}, 64'hFFFF);
chk("s + u (mixed -> ZERO-extend)", {48'd0, 16'(r_mixed)}, 64'h0100);
// ^ -1 + 1 = 256. Nothing warns.
chk("$signed(u) restores the signed context",
{48'd0, 16'(s + $signed(u))}, 64'h0000);
// The comparison form flips a DECISION rather than a value:
begin
logic signed [7:0] temp = -5;
chk("temp < 8'd10 (mixed -> temp reads as 251)", {63'd0, temp < 8'd10}, 64'd0);
chk("temp < 8'sd10 (both signed -> correct)", {63'd0, temp < 8'sd10}, 64'd1);
end
end
// ================================================================
// 3. UNSIZED LITERALS - '1 IS NOT 1
// ================================================================
begin
logic [63:0] m;
m = 1; chk("m = 1 (one)", m, 64'h0000_0000_0000_0001);
m = '1; chk("m = '1 (all ones)", m, 64'hFFFF_FFFF_FFFF_FFFF);
m = '0; chk("m = '0 (all zeros)", m, 64'h0);
end
// ================================================================
// 4. DIVISION TRUNCATES; ARITHMETIC SHIFT FLOORS
// ================================================================
begin
logic signed [7:0] n = -7;
chk("-7 / 2 -> -3 (truncate toward zero)", {56'hFF, 8'(n / 2)}, {56'hFF, 8'shFD});
chk("-7 >>> 1 -> -4 (floor)", {56'hFF, 8'(n >>> 1)}, {56'hFF, 8'shFC});
chk("-7 % 2 -> -1 (sign follows dividend)",{56'hFF, 8'(n % 2)}, {56'hFF, 8'shFF});
chk("7 % -2 -> +1 (sign follows dividend)",{56'd0, 8'(8'sd7 % -8'sd2)}, {56'd0, 8'sd1});
// They AGREE when the division is exact - which is why round-number
// directed tests cannot tell them apart:
n = -8;
chk("-8 / 4 == -8 >>> 2", {56'hFF, 8'(n / 4)}, {56'hFF, 8'(n >>> 2)});
end
if (errors == 0) $display("\narithmetic_semantics_proof: ALL CHECKS PASSED");
else $display("\narithmetic_semantics_proof: %0d FAILURES", errors);
$finish;
end
endmoduleSection 2's second check is the one to remember: -1 + 1 evaluates to 256 when one operand is unsigned. Section 4's last check is the one that explains why the division bug survives regression — the two conventions agree on exactly the inputs a hand-written test tends to use.
A reference model divided where the DUT shifted, and mismatched only on negative odd values
TRUNCATE-VERSUS-FLOOR// The DUT scales a signed sample down by a power of two using an
// arithmetic right shift - the normal, cheap way to do it in hardware.
// RTL: assign scaled = sample >>> shift_amt;
// ❌ BUG: the reference model expressed the same operation as division,
// because "divide by 2^n" is what the specification said.
class gain_model;
function automatic bit signed [15:0] scale(bit signed [15:0] sample,
int shift_amt);
return sample / (1 << shift_amt); // ❌ truncates toward zero
endfunction
endclass
// For sample = -7, shift_amt = 1:
// DUT: -7 >>> 1 = -4 (floors toward negative infinity)
// model: -7 / 2 = -3 (truncates toward zero)
// -> mismatch of exactly 1 LSB, on negative odd samples only
// ✅ FIX: model the operation as what the hardware actually does.
function automatic bit signed [15:0] scale(bit signed [15:0] sample,
int shift_amt);
return sample >>> shift_amt; // ✅ same rounding as the DUT
endfunction
// ✅ If the model MUST use division (e.g. a shared C reference), apply
// the floor explicitly:
function automatic int floor_div(int num, int den);
int q = num / den;
// Truncation and flooring differ only when the signs differ and the
// division is inexact.
if (((num % den) != 0) && ((num < 0) != (den < 0))) q--;
return q;
endfunctionA signed audio gain block's regression reported a low, stubborn mismatch rate that nobody could characterise:
regression summary: 4,912,003 comparisons, 612,344 mismatches (12.5%)
sample of failures:
sample=-7 shift=1 dut=-4 model=-3 diff=-1
sample=-13 shift=2 dut=-4 model=-3 diff=-1
sample=-1 shift=3 dut=-1 model= 0 diff=-1
passes:
sample=-8 shift=2 dut=-2 model=-2 OK
sample= 7 shift=1 dut= 3 model= 3 OKEvery mismatch was exactly one LSB, always in the same direction, and always on a negative sample. The directed test suite — written from the specification's example table, all round numbers — passed completely. Only the randomized regression saw it, and for four months it was carried as a known "rounding difference" with a filter in the scoreboard.
The DUT and the model were computing two different, both-correct answers to "divide by two".
Arithmetic right shift floors — it rounds toward negative infinity. -7 >>> 1 is -4, because ⌊−3.5⌋ = −4.
Integer division truncates toward zero. -7 / 2 is -3, because the fractional part is simply discarded.
Neither is a bug in isolation. The bug is that the specification said "divide by 2ⁿ", the RTL implemented it as a shift because that is what costs nothing in hardware, and the model implemented it as a division because that is what the sentence said. Nobody noticed that the two English-equivalent operations disagree.
Three properties made this a four-month problem rather than a four-minute one.
The error is always exactly one LSB. That is small enough to look like a rounding convention question rather than a functional mismatch, which is precisely how it was classified.
It is invisible on non-negative data and on exact divisions. -8 / 4 and -8 >>> 2 both give -2. The specification's example table used powers of two and positive samples, so every directed test agreed.
The 12.5% mismatch rate looked like noise. Under uniform signed randomization, roughly half the samples are negative and half of those are odd at the relevant bit — which lands near an eighth, a rate low enough to be triaged as flaky rather than systematic.
The general lesson: "divide by a power of two" is ambiguous in a specification, and the ambiguity is invisible until a negative operand appears. A reference model must implement the operation the hardware performs, not the sentence the specification uses to describe it.
Make the model shift, because the DUT shifts. Where a shared reference cannot be changed, wrap the division in an explicit floor.
// The property that would have caught this on the first negative sample.
// It states the DUT's actual rounding contract rather than the spec's prose.
a_scale_floors: assert property (@(posedge clk) disable iff (!rst_n)
valid_out |-> (scaled == (sample >>> shift_amt)))
else $error("scaling does not match arithmetic-shift rounding");
// The coverage that would have shown the directed suite could not find it.
covergroup cg_scale;
cp_sign_parity : cross
coverpoint sample[15] { bins neg = {1}; bins pos = {0}; },
coverpoint |sample[2:0] { bins exact = {0}; bins inexact = {1}; };
// The (neg, inexact) bin is the ONLY one where the two conventions
// differ. Zero hits there means the mismatch was unreachable.
endgroupThree habits, and the last generalises well beyond arithmetic:
- Model the operation, not the sentence. If the RTL shifts, the model shifts. A specification's "divide by 2ⁿ" does not select a rounding convention, and the two available conventions differ.
- Cross sign against exactness whenever rounding is involved. One coverpoint on the sign is not enough — the conventions agree on exact divisions, so only the cross of negative-and-inexact reaches the difference.
- Never filter a small, consistent mismatch. A one-LSB error that always points the same way is a systematic difference, not noise. Randomness produces errors in both directions; a bug produces them in one. That asymmetry was visible in the very first failure report and was the signal that the difference was a convention, not a rounding tolerance.
Interview Questions
Best Practices & Coding Guidelines
The Non-Negotiables
Use int for Testbench Counters
int is 2-state and auto-initializes to 0. It eliminates X-propagation bugs entirely. Reserve integer and logic for where 4-state behavior is specifically needed.
Keep ++ Standalone When Possible
Only embed ++ in an expression when the return value is intentionally needed. A standalone i++; on its own line is always clearer and avoids accidental pre/post confusion.
Never Modify Same Variable Twice
Never use ++ or -- on a variable that also appears elsewhere in the same expression. This is undefined behavior per the LRM and a portability bug.
Use Signed int for Decrements
Timeout counters, retry counters, and anything decremented toward zero should be int (signed). Unsigned counters decrement below zero and wrap to large positive values.
Pattern Reference
| Pattern | Recommendation |
|---|---|
for (int i = 0; i < N; i++) | ✅ Standard, universally readable. Use this everywhere |
q.push_back(x++) | ⚠️ Legal, but add a comment confirming you want the old value in the queue |
q.push_back(++x) | ⚠️ Legal, but add a comment confirming you want the new value |
result = arr[i] + arr[i++] | ❌ Never. Split into two statements |
while (--timeout > 0) | ⚠️ Subtle but legal. Add a comment explaining intent |
return ++id_counter | ✅ Clear intent: ID starts at initial_value + 1 |
integer cnt (no initialization) | ❌ Never in testbench. Use int cnt = 0 |
Summary
Arithmetic operators are the foundation of every counter, accumulator, and index in your testbench. The standard operators (+, -, *, /, %) behave predictably once you internalize that integer division truncates toward zero and that the modulus sign follows the dividend.
Increment and decrement look trivial but carry three real engineering considerations:
- Pre vs post matters only in expressions. Standalone, they are identical. Embedded in a function argument, queue push, or array index — pick the wrong form and your scoreboard silently receives the wrong value.
- 4-state variables propagate X. Use
intfor all testbench counters. Incrementing an uninitializedintegerwill never produce a valid count — it will produce X forever. - Never increment the same variable twice in one expression. The LRM does not define evaluation order for those side effects. It is a portability bug that will produce different results on VCS vs Xcelium.
The engineering discipline that separates reliable testbench code from fragile code is understanding exactly what the simulator evaluates and when. Arithmetic operators are where that discipline starts. Every more complex operator in the chapters that follow builds on this same foundation of thinking about return values, evaluation order, and 4-state propagation.
Related Pages & References
The operators that share these rules. Shift Operators owns the other half of the rounding story — >>> floors where / truncates — and follows a different signedness rule: a shift's result signedness comes from the left operand alone, so an unsigned shift amount is harmless where an unsigned addend is not. Bitwise Operators shares this page's signedness rule exactly. Concatenation, Replication & Conditional matters here because a concatenation is always unsigned, so using one as an arithmetic operand silently makes the whole expression unsigned.
Where widths and types come from. Integer Types for declared signedness and the int versus integer choice behind the X-counter trap; 2-state vs 4-state Types for why an uninitialised integer counter reads as zero. Operator Precedence for grouping — shift binds below arithmetic, so a + 2 << 3 is (a + 2) << 3. For increments inside sequential blocks, Blocking vs Non-blocking; for count-down loop variables, Loops.
References.
- IEEE 1800 (SystemVerilog) — the arithmetic operators, expression bit lengths, and signedness rules are in the operators-and-expressions clause. Three rules on this page trace to it, one of them a correction: the result width of
+,-,*,/,%and the bitwise operators ismax(L(left), L(right))— not the sum, so a product truncates unless the context or an explicit cast widens it; an expression is signed only if every operand is signed; and integer division truncates toward zero while the sign of%follows the dividend. Literal width and signedness, including the'0/'1fill literals, are in the lexical-conventions clause. - IEEE 1364 (Verilog) — the same sizing and signedness rules, inherited unchanged.
Requirement versus practice. All of the above is a language requirement. The recommendations — widen operands explicitly rather than relying on the assignment target, use int for counters, keep increments out of larger expressions, and use nonblocking assignments throughout an always_ff — are engineering practice. They exist because the language rules are correct but unforgiving: nothing warns when a product truncates or when a signed operand zero-extends, so the guard has to be in how the code is written.
Part of SystemVerilog Fundamentals·Operators & Expressions·Lesson 19 of 53
View program