Part 1 · Language Foundations · Intermediate

Signed & Unsigned Arithmetic

Sign extension rules, mixed signed/unsigned expression poisoning, $signed/$unsigned casts, and the classic signed-vs-unsigned compare bug.

How the simulator decides signedness

Every expression in SystemVerilog has a signedness computed before evaluation . The rule from the LRM is brutal in its simplicity: an arithmetic or comparison context is signed only if every operand is signed . A single unsigned operand — a plain logic [7:0] net, an unsized literal like '1, a sized unsigned literal — converts the entire context to unsigned. This is called expression poisoning : the unsigned operand poisons every signed operand around it, and negative values are silently reinterpreted as large positive numbers.

Sign extension follows from signedness. When a byte or logic signed [7:0] value widens to fit a larger context, the simulator replicates the MSB (the sign bit) into the new upper bits. An unsigned value zero-extends instead. The extension decision is made per-operand after the context signedness is resolved — so a signed operand inside a poisoned (unsigned) expression zero-extends , which is exactly where the wrong answers come from.

diagram
SIGNEDNESS RESOLUTION (per expression context)

  operand A          operand B          context result
  ─────────          ─────────          ──────────────
  signed      +      signed            SIGNED   (sign-extend both)
  signed      +      unsigned          UNSIGNED (zero-extend BOTH!)
  unsigned    +      unsigned          UNSIGNED

  Example: byte s = -1 (8'hFF), logic [15:0] u = 16'h0010

  s < u     context UNSIGNED
            s zero-extends: 16'h00FF = 255
            255 < 16    FALSE   (you expected -1 < 16  TRUE)

The classic compare bug

The single most common signedness bug — and a favorite interview question — is comparing a signed counter against an unsigned bound. The intent reads correctly; the simulation does not. The fix is to make every operand signed, either by declaring it so or by casting with $signed().

systemverilog
module signed_compare_bug;
  logic signed [7:0] temperature = -8'sd5;  // signed: -5
  logic        [7:0] threshold   = 8'd10;   // unsigned: 10

  initial begin
    // BUG: mixed signed/unsigned → unsigned context.
    // -5 reinterpreted as 8'hFB = 251, and 251 < 10 is FALSE.
    if (temperature < threshold)
      $display("below threshold");      // never prints!
    else
      $display("BUG: 251 < 10 is false");

    // FIX 1: cast the unsigned operand into the signed world
    if (temperature < $signed({1'b0, threshold}))
      $display("FIX: -5 < 10 correctly true");

    // FIX 2: declare both operands signed at the source
    // logic signed [7:0] threshold = 8'sd10;
  end
endmodule

Note the {1'b0, threshold} in the fix: casting an unsigned 8-bit value directly with $signed(threshold) would reinterpret 8'hFB as -5, which is wrong if the value is a genuine magnitude above 127. Prepending a zero bit guarantees the cast preserves the unsigned value. $signed and $unsigned never change bits — they only change how the bits are interpreted for extension and comparison.


Where poisoning hides in real code

Poisoning rarely appears as a clean two-operand compare. It hides inside larger expressions: a literal without an s suffix, a concatenation (concatenation results are always unsigned regardless of operands), a part-select (also always unsigned), or a function returning an unsigned type. Any one of these inside an otherwise-signed expression flips the whole context.

systemverilog
logic signed [15:0] a, b;

// Literal without 's' suffix is unsigned → poisons the context
logic ok1 = (a > -16'sd4);    // signed compare: correct
logic bad = (a > -4);          // -4 is a signed integer literal: OK
logic bad2 = (a > 16'hFFFC);   // unsigned literal → unsigned compare!

// Part-selects and concatenations are ALWAYS unsigned
logic signed [7:0] s8;
logic r1 = (s8[7:0] < 0);      // part-select is unsigned → never true
logic r2 = ({s8} < 0);         // concat is unsigned → never true
logic r3 = (s8 < 0);           // plain signed variable → works

// Arithmetic right shift requires a signed operand (see shifts lesson)
logic signed [15:0] q = a >>> 2;   // sign-replicating shift
logic [15:0]        u = a >> 2;    // logical shift, zero fill

Interview angle

  • "Why does my signed comparison fail?" — explain poisoning: one unsigned operand makes the context unsigned.

  • "What does $signed actually do?" — reinterprets bits for extension/compare; it never modifies them.

  • "Is a part-select signed if the vector is signed?" — no, part-selects and concatenations are always unsigned.

Key takeaways

  • Expression signedness is resolved before evaluation: all operands signed, or the context is unsigned.

  • A poisoned context zero-extends signed operands — negative values become large positives.

  • $signed/$unsigned change interpretation, not bits; widen unsigned values with a leading 0 before $signed.

  • Part-selects, concatenations, and unsized based literals are always unsigned.

Common pitfalls

  • Comparing signed counter < unsigned bound — the comparison silently goes unsigned and fails for negatives.

  • Assuming 16'hFFFC means -4 in a signed context — sized hex literals are unsigned without the s suffix.

  • Calling $signed(u8) on a value that may exceed 127 — reinterprets the MSB as a sign bit.

  • Trusting a part-select of a signed vector to stay signed — it never does.