Skip to content

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.

OperatorOperationExampleKey Note
+Additiona + bWorks on integers, reals, vectors
-Subtractiona - bSigned/unsigned context matters for interpretation
*Multiplicationa * bResult width = sum of both operand widths
/Divisiona / bInteger truncation toward zero. 7/2 = 3, -7/2 = -3
%Modulusa % bSign follows the dividend. -7%3 = -1
**Power2 ** 8Synthesis support is tool-dependent — use with care in RTL
a++Post-incrementcnt++Use value, then increment. Standalone: same as a = a+1
++aPre-increment++cntIncrement first, then use value. Only differs from post in expressions
a--Post-decrementcnt--Use value, then decrement
--aPre-decrement--cntDecrement 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

SystemVerilog — Increment & Decrement Syntax
// ── 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 = 7

What 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).

TypeValid for ++/--Notes
int✅ Yes2-state, 32-bit signed. Preferred for testbench counters
integer✅ Yes4-state, 32-bit. Starts at X if not initialized — use carefully
byte / shortint / longint✅ YesAll 2-state signed integer types
logic [N:0]✅ Yes (variable only)Must be procedurally assigned. 4-state — X propagation applies
real / shortreal✅ YesIncrements by 1.0
wire❌ NoNet type — cannot be procedurally modified. Compiler error
logic driven by assign❌ NoContinuous assignment makes it net-like. Compiler error
Class object handle❌ NoLRM 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++

StepSimulator Actioncntresult
1Read current value of cnt3
2Assign that value to result33
3Increment cnt by 143
Final43

Pre-increment: result = ++cnt

StepSimulator Actioncntresult
1Increment cnt by 14
2Read the incremented value4
3Assign new value to result44
Final44

Four-Operator Quick Reference (starting from cnt = 5)

ExpressionValue returned by expressioncnt after
cnt++5 (old value)6
++cnt6 (new value)6
cnt--5 (old value)4
--cnt4 (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)
51011106
61101117
71110000 ⚠️ wraps!
00000011

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.

Example 1 — Basic Standalone Increment
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
 
endmodule

Expected output:

Simulation Output
After a++  : a = 11
After ++b  : b = 11
After c--  : c = 9

Example 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.

Example 2 — Pre vs Post in Expressions
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
 
endmodule

Expected output:

Simulation Output
Post-increment: result=5, x=6
Pre-increment:  result=6, x=6
Post-decrement: used=5, x_after=4

Example 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.

Example 3 — Scoreboard with Pre/Post ID Management
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
 
endmodule

Expected output:

Simulation 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=2

Example 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.

Example 4 — Queue Indexing Corner Case
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
 
endmodule

Expected output:

Simulation Output
queue[idx++]: val=10, idx=1
queue[++idx]: val=30, idx=2

Waveform & 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 DeclarationInitial ValueAfter ++Why
int cnt0 (2-state auto-init)12-state type. Always safe in testbench
integer cntX (4-state, uninitialized)XX + 1 = X. Must initialize explicitly
logic [3:0] cnt4'bxxxx (uninitialized)4'bxxxxSame — 4-state arithmetic with X gives X
logic [3:0] cnt = 04'b0000 (initialized)4'b0001Explicit initialization — works correctly

Signed vs Unsigned Behavior

TypeValue Before ++Value After ++Notes
logic [3:0] (unsigned 15)4'b1111 = 154'b0000 = 0Unsigned wrap — expected
int (signed -1)-10Signed arithmetic — correct
byte (signed 127)127-128Signed overflow — wraps to min value
byte unsigned (255)2550Unsigned wrap

Synthesis Implications

ContextSynthesis BehaviorRecommendation
always_ff (sequential RTL)Synthesizes as D-FF with adder feedback — registered counterAcceptable. Most tools handle it correctly
always_comb (combinational RTL)Synthesizes as combinational incrementer, no registerWatch for combinational feedback if no enable guard
Testbench initial blockNot synthesized — simulation onlyUse freely in testbench code
RTL in generalSupported by most modern toolsMany 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.

Real Verification Usage Patterns
// ── 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");
endtask

Common 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.

Bug 1 — Buggy Code
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}
Bug 1 — Fixed Code
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}  ← CORRECT

Bug 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).

Bug 2 — Buggy Code (4-state uninitialized counter)
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
 
endmodule
Bug 2 — Fixed Code (2-state int)
module 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
 
endmodule

Bug 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.

Bug 3 — Undefined Evaluation Order (Never Do This)
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 another
Bug 3 — Fixed: Explicit Sequencing
int 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.

Bug 4 — Buggy: Unsigned Decrement to Infinity
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
end
Bug 4 — Fixed: Use Signed int
int 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
end

Interview 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

PatternRecommendation
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 int for all testbench counters. Incrementing an uninitialized integer will 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.