Skip to content

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.

OperatorOperationExampleKey Note
+Additiona + bWorks on integers, reals, vectors
-Subtractiona - bSigned/unsigned context matters for interpretation
*Multiplicationa * bResult width = max of the operand widths and the context — not their sum. Products truncate unless you widen first
/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
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── 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
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
[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
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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

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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 result

narrow 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
logic [8:0] sum_with_carry = 9'(a) + 9'(b);   // bit 8 is the carry

Signedness — 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 = 256

The 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

LiteralWidthSigned?Note
132 (at least)signedThe default; makes a mixed expression signed only if everything else is too
-132 (at least)signedValue 32'hFFFF_FFFF
'd1width of contextunsignedSized by its context, not by 32
32'd132unsignedExplicit width, explicitly unsigned
32'sd132signedExplicit width, explicitly signed
'1width of contextunsignedAll 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
logic [63:0] mask;
mask = 1;         // 64'h0000_0000_0000_0001
mask = '1;        // 64'hFFFF_FFFF_FFFF_FFFF

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  -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

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
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── 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
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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
endmodule

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

An arithmetic expression takes its width from the widest of the two operands and the context, then extends both operands to that width; the extension is signed only if every operand is signed, and one unsigned operand forces zero-extension for all of them. The operator then evaluates and any excess bits are discarded.Operand widthsleft and rightContext widththe assignment targetEvery operand signed?one unsigned decides allWidth = max of thethreeno growth beyond itExtend both operandssign- or zero-extendOperator evaluatesat the chosen widthExcess bits discardedsilent truncationSigned valuezero-extended-1 + 1 becomes 25612
Figure 1 - how an arithmetic expression is sized and typed before it is evaluated. Both operands and the surrounding context contribute a width, and the largest wins; every operand is then extended to that width. The extension is a sign-extension only when every operand is signed; one unsigned operand makes the whole expression unsigned, so even a signed operand extends with zeros. Only then does the operator run, and anything above the chosen width is discarded.
5

A reference model divided where the DUT shifted, and mismatched only on negative odd values

TRUNCATE-VERSUS-FLOOR
Buggy Code & Fix
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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;
endfunction
Symptom

A signed audio gain block's regression reported a low, stubborn mismatch rate that nobody could characterise:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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   OK

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

Root Cause

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.

Fix

Make the model shift, because the DUT shifts. Where a shared reference cannot be changed, wrap the division in an explicit floor.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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.
endgroup

Three 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

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.

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 is max(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 / '1 fill 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

Continue learning