Module 7: Advanced RTL Design Techniques ยท 7.2 Arithmetic Circuits in RTL

Signed Arithmetic Pitfalls

Section 7.1 was about reuse. Section 7.2 is about arithmetic being correct at the edges, and the first edge is signedness.

A quick question: what does a > b give when a is 4'b1000 and b is 4'b0111? The honest answer is that it depends on something you cannot see in the bit patterns. If both are unsigned, 1000 is 8 and 0111 is 7, so the comparison is true. If both are signed two’s complement, 1000 is -8 and 0111 is 7, so it is false. Same bits, same operator, opposite answer. That is the whole problem with signed arithmetic in Verilog: the meaning of an expression depends on a declaration you may not be looking at when you read it.

What signed actually changes

Declaring a wire or reg signed does not change how many bits it has or how it is stored. It changes how operators interpret those bits:

wire signed [7:0] a;   // same 8 bits as an unsigned wire, interpretation differs
wire        [7:0] b;   // plain unsigned

a and b are physically identical. The difference appears only when an operator looks at them: >, <, >=, <= and >>> all check the operand’s signedness. Bitwise &, |, ^ and << mostly do not care, since they work on the bit pattern regardless of declaration.

Pitfall 1: mixing signed and unsigned silently goes unsigned

This is the rule that catches everyone at least once. If any operand in an expression is unsigned, the whole expression evaluates as unsigned, even if every other operand was declared signed.

wire signed [7:0] a = -8'sd5;   // -5
wire        [7:0] b = 8'd3;     // 3, unsigned
wire result = (a > b);          // evaluated UNSIGNED because b is unsigned

You would expect -5 > 3 to be false. Because b is unsigned, the comparison is done in unsigned terms: the bit pattern of -5, 11111011, is read as 251, and 251 > 3 is true. The answer flips, with no warning and a simulation that runs to completion looking normal.

The fix is to make signedness consistent across every operand: declare b as signed too, or cast it with $signed(b) at the point of use.

wire result = (a > $signed(b));   // now evaluated as signed: -5 > 3 = false

Pitfall 2: » versus »> on signed values

Both shift operators appeared in Module 4: >> is a logical right shift that fills with 0, >>> is an arithmetic shift that fills with the sign bit. The trap is that >>> only behaves arithmetically if its operand is declared signed. On an unsigned value it behaves exactly like >>.

wire [7:0] x = 8'b1000_0000;    // -128 if you meant it as signed, 128 if unsigned
wire [7:0] y1 = x >>> 1;         // x is UNSIGNED: this is just a logical shift, 0100_0000
wire signed [7:0] xs = x;
wire signed [7:0] y2 = xs >>> 1; // xs is signed: sign bit replicated, 1100_0000, which is -64

Same bit pattern, same operator, different results, decided by a declaration a few lines away that is easy to lose track of once a design grows.

Pitfall 3: concatenation ignores signedness

Concatenation {...} always treats its operands as unsigned, no matter how they were declared. It is one of the few places where the signed declaration is ignored outright, and it is exactly where people expect it to apply.

wire signed [3:0] small = -4'sd3;         // 4'b1101
wire signed [7:0] wrong = {4'b0, small};  // WRONG: zero-extends to 0000_1101 = +13, not -3
wire signed [7:0] right = {{4{small[3]}}, small}; // sign-extend by hand: 1111_1101 = -3

There is a shortcut for this situation. Plain assignment of a narrower signed value to a wider signed target does sign-extend, because assignment, unlike concatenation, respects the declaration:

wire signed [7:0] right2 = small;   // correctly sign-extends to 1111_1101 = -3

The rule: assignment between signed operands sign-extends; concatenation never does. If you need to widen a signed value inside a {...} expression, replicate the sign bit explicitly.

The practical rule

Given how many of these traps there are, the safest default is to keep every operand of a signed expression declared signed all the way through: ports, internal wires and literals (8'sd5, not 8'd5, when you mean a signed five). Never let an unsigned operand into a comparison or shift you intend to be signed. When you must mix on purpose, cast with $signed(...) or $unsigned(...) right at the point of use so the intent is visible in the line doing the work.

The pattern I see in design reviews is an engineer declaring operands signed, getting correct results for the vectors in their testbench (usually small positive numbers, where signed and unsigned interpretations agree), and shipping. The bug appears the first time a negative value flows through a concatenation or gets compared against an unsigned operand, sometimes months later, on an input nobody tested because “it worked in simulation”.

Practice Exercise

Given wire signed [7:0] a = -8'sd20; and wire [7:0] b = 8'd15; (note b is unsigned), predict the result of a < b as written, then fix the expression so it evaluates the intended comparison.

Solution

As written, b is unsigned, so the whole comparison is unsigned. The bit pattern of -20, 11101100, is read as 236, and 236 < 15 is false, even though -20 < 15 is true.

The fix:

wire result = (a < $signed(b));   // now correctly evaluates -20 < 15 = true

Casting b to signed at the comparison makes both operands signed, so the comparison happens in the domain the values were meant to be in.

signed changes interpretation, not storage, and the interpretation silently reverts to unsigned the moment any operand in the expression is unsigned. Comparisons flip, arithmetic shifts stop being arithmetic, and concatenation zero-extends where you wanted a sign extension. None of it produces an error. In the next article we look at saturating arithmetic, where getting overflow behaviour right, instead of letting it wrap, is the whole point of the circuit.

โ† A Width-Configurable ALU Saturating Arithmetic โ†’