Print (int)0xFFFFFFFF and you get -1. Every bit set, and the value is negative one. New systems programmers memorize this as a fact; it’s better understood as a decision — arguably the best-aggregated design decision in hardware history — and once you see why, a family of famous bugs (INT_MIN, sign extension, abs() returning negative) stops being trivia and starts being obvious.

The Problem: Adders Don’t Want to Know About Signs

The naive encoding is sign-magnitude: one sign bit, the rest magnitude. It has two zeros (+0 and -0) and, worse, addition needs special cases — adding a positive and a negative means subtracting magnitudes and comparing, a different circuit path.

Two’s complement instead defines the top bit as having negative weight. For 8 bits, the place values are:

text
   bit:      7     6    5    4    3   2   1   0
   weight: -128   64   32   16    8   4   2   1

   11111111 = -128 + 64+32+16+8+4+2+1 = -1
   10000000 = -128                     = INT8_MIN
   01111111 =  127                     = INT8_MAX

That’s the whole system. Everything else is a consequence.

Why Hardware Loves It

The payoff: addition is the same circuit for signed and unsigned. Add the bit patterns, discard the carry out, done. 0xFF + 0x01 = 0x00 reads as 255 + 1 = 0 (unsigned, wrapped) or -1 + 1 = 0 (signed) — both correct, same gates. Subtraction is addition too: negate means invert-all-bits-plus-one (-x = ~x + 1), so a - b is a + ~b + 1, which is just the adder with an inverted input and carry-in set. One ALU path handles signed add, unsigned add, and subtract. Only comparisons and right shifts need to know signedness — which is exactly where the instruction set splits (jl vs jb, sar vs shr).

There’s also exactly one zero, and comparisons against it are a single bit test away from “is negative.”

The Sharp Edges

The asymmetry is baked in: the range is -128..127, not ±127, because zero takes a slot on the positive side. Consequences with real CVE counts attached:

  • -INT_MIN == INT_MIN. Negating -2147483648 should give +2147483648, which doesn’t fit; invert-plus-one wraps back to itself. So abs(INT_MIN) returns a negative number, and INT_MIN / -1 traps (SIGFPE on x86 — the one division that overflows).
  • Sign extension. Widening a signed value must replicate the top bit (0xFF int8 → 0xFFFFFFFF int32, both -1). Mixing signed and unsigned types makes C do this when you didn’t expect it — the classic being char (signed on x86) holding byte 0x80+, compared against an int, suddenly negative. Parsers reading “lengths” from char buffers have shipped this bug for decades.
  • Signed overflow is UB in C/C++. The hardware wraps predictably; the language declares overflow undefined so compilers can assume it never happens (e.g., folding x + 1 > x to true, which deletes overflow checks written in exactly that style). Write checks as x > INT_MAX - y — test before the operation — or use __builtin_add_overflow.
  • Unsigned “negative” indexes. size_t i; ... i - 1 when i is 0 gives 18446744073709551615, not -1, and for (size_t i = n - 1; i >= 0; --i) never terminates. The pattern-matched fix: for (size_t i = n; i-- > 0;).

Since C23 (and effectively forever in practice), two’s complement is the only representation the C standard permits — the ones’-complement machines are museum pieces. The UB rules, however, remain.

Takeaways

  • Two’s complement = top bit has negative weight. -x = ~x + 1 falls out.
  • It won because signed and unsigned share the adder; only compares and right shifts are sign-aware.
  • The range is asymmetric: abs(INT_MIN) is negative, INT_MIN / -1 traps.
  • Sign extension during implicit conversions is where the quiet bugs live — never parse bytes through plain char.
  • Hardware wraps; C calls it UB anyway. Check for overflow before the operation, not after.