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 = sum of both operand widths |
/ | 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 |
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
endInterview Questions
Beginner Level
Q1: What is the difference between a++ and ++a when used as standalone statements? There is no difference. When used as standalone statements (not embedded in expressions), both a++ and ++a increment a by 1 and produce identical results. The pre vs post distinction only matters when the return value of the expression is consumed by something else — an assignment, an array index, a function argument. Q2: What happens when you increment a 4-bit unsigned counter holding value 4'hF? It wraps to 4'h0. This is modular arithmetic — 0xF + 1 = 0x10, but only the lower 4 bits are retained, giving 0x0. This wrap-around is predictable and is frequently used intentionally for ring buffers and cyclic counters, but it must be accounted for in any comparison that checks for a terminal count. Q3: Can you increment a wire type in SystemVerilog? No. The ++ and -- operators require a variable — something assigned procedurally. Nets (wire, and logic driven by assign) cannot be modified this way. The compiler will produce an error.
Intermediate Level
Q4: Your scoreboard uses integer pkt_cnt and always reports 0 received packets even though the monitor is calling the receive function. What is the most likely cause?integer is a 4-state type. If declared without initialization, it starts at X. Incrementing X gives X. $display with %0d may display x which could visually read as nothing or zero. The fix: switch to int pkt_cnt = 0 (2-state, auto-initializes to 0) or explicitly initialize the integer. Q5: What does this code print? int a = 10; int b = a++ + ++a; This is technically implementation-defined behavior, because a is modified twice in a single expression. The LRM does not specify the evaluation order of these side effects. The correct interview answer is: "This is undefined behavior in the LRM — never write this code." Different simulators may produce different results, making it a portability bug.
Experienced Engineer Level
Q6: In an always_ff block, is cnt++ equivalent to cnt <= cnt + 1? Yes, functionally. Inside always_ff, all assignments are non-blocking, so cnt++ schedules the updated value as an NBA update at end-of-time-step — identical to cnt <= cnt + 1. Both synthesize to a D flip-flop with an adder in the feedback path. The difference is stylistic: many teams reserve ++/-- for testbench code and use the explicit form in RTL for readability. Q7: Two parallel processes in a fork/join_any both execute shared_cnt++. Is there a race condition? Yes. In SystemVerilog, parallel processes within a fork block are not guaranteed to be atomic relative to each other. If both processes read shared_cnt, increment it, and write it back in the same simulation time step, one update may be lost (classic read-modify-write race). The correct fix is to protect the increment with a semaphore, or to consolidate all increments into a single process that collects events from the parallel threads.
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.