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:
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_MAXThat’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. Soabs(INT_MIN)returns a negative number, andINT_MIN / -1traps (SIGFPE on x86 — the one division that overflows).- Sign extension. Widening a signed value must replicate the top bit
(
0xFFint8 →0xFFFFFFFFint32, both -1). Mixing signed and unsigned types makes C do this when you didn’t expect it — the classic beingchar(signed on x86) holding byte 0x80+, compared against an int, suddenly negative. Parsers reading “lengths” fromcharbuffers 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 > xto true, which deletes overflow checks written in exactly that style). Write checks asx > INT_MAX - y— test before the operation — or use__builtin_add_overflow. - Unsigned “negative” indexes.
size_t i; ... i - 1when i is 0 gives 18446744073709551615, not -1, andfor (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 + 1falls 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 / -1traps. - 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.