Two's complement & integer representation
The previous note said a number is just bytes. Now: which bytes? For unsigned integers it's obvious — plain base-2. For signed integers nearly every machine uses two's complement, an encoding chosen for one beautiful property: the same addition and subtraction circuitry works for signed and unsigned values, with no special-casing for the sign. The cost is a deliberate asymmetry — one more negative number than positive — and a set of sharp edges (overflow, signed/unsigned conversion) that cause some of the most common and dangerous bugs in C. This note makes the encoding concrete so those edges stop being surprises.
The reset: there is no "minus sign" stored anywhere. Negativity is just a bit pattern the hardware agreed to interpret a certain way.
-1and4294967295are the same 32 bits — only the type decides which one you meant.
How two's complement works
For an N-bit signed integer, the top bit has negative weight. In 32 bits the value is:
value = -b31·2^31 + b30·2^30 + ... + b1·2^1 + b0·2^0
So the high bit being 1 doesn't mean "subtract" as a flag — it contributes −2³¹ directly.
The rule to negate a value is "invert all bits, then add 1" (-x == ~x + 1):
| Value | 32-bit hex | Top bit |
|---|---|---|
0 |
0x00000000 |
0 |
1 |
0x00000001 |
0 |
-1 |
0xFFFFFFFF |
1 |
INT_MAX (2147483647) |
0x7FFFFFFF |
0 |
INT_MIN (−2147483648) |
0x80000000 |
1 |
Two consequences fall straight out:
- −1 is all ones. Every bit set is the additive inverse of 1 — which is why bitmasks
and
-1show up interchangeably in low-level code. - The range is asymmetric.
INT_MIN = −2³¹butINT_MAX = 2³¹−1. There are 2³¹ negatives and only 2³¹−1 positives, so−INT_MINis not representable — negating it overflows. This single fact breaks naiveabs(), naive parsing, and more.
The payoff: because the encoding wraps modulo 2ᴺ, a + b and a − b produce the correct
bit pattern whether you read the operands as signed or unsigned. The CPU has one add,
not two. (This is why an add instruction sets flags for both interpretations; the
ISA doesn't track signedness — your types
do.)
Signed overflow is UB; unsigned wraps
This is the part that bites hardest, and it's asymmetric in C:
- Unsigned overflow is defined. Unsigned arithmetic is modular:
0u - 1 == UINT_MAX,UINT_MAX + 1 == 0. Guaranteed by the standard, perfectly portable. - Signed overflow is undefined behavior.
INT_MAX + 1is UB — not "wraps to INT_MIN." The compiler is allowed to assume signed overflow never happens and optimize on that basis, so an overflowing signed expression can do literally anything, including miscompiling the surrounding code.
This trips up everyone coming from languages where int silently wraps. In C, for (int i
= 0; i <= n; i++) where n == INT_MAX is an infinite loop the optimizer may delete or
mangle, because the exit test assumes no overflow. The fix is to know which integers can
overflow and use unsigned (or wider types, or -fwrapv, or checked arithmetic) where it
matters. See C from the metal for the full UB contract.
The signed/unsigned conversion trap
When you mix signed and unsigned in one expression, C converts the signed operand to unsigned (the "usual arithmetic conversions"). The bits don't change — but their meaning flips, and a negative number becomes a huge positive one:
int a = -1;
unsigned b = 0;
if (a > b) { /* THIS RUNS */ } // -1 converts to UINT_MAX (4294967295) > 0
-1 > 0u is true, because -1 reinterpreted as unsigned is 0xFFFFFFFF. The same trap
hides in for (unsigned i = n; i >= 0; i--) — an infinite loop, since unsigned i is never
< 0 — and in comparing a signed length to strlen's unsigned size_t. Modern compilers warn
(-Wsign-compare); heed it.
See the bits
// integers.c — bit patterns, negation, wraparound, and the comparison trap.
// gcc -O0 -Wall -Wextra integers.c -o integers && ./integers
#include <stdio.h>
#include <limits.h>
static void bits32(const char *name, unsigned int v) {
printf("%-8s = %11d 0x%08X ", name, (int)v, v);
for (int i = 31; i >= 0; i--) {
putchar((v >> i) & 1 ? '1' : '0');
if (i % 8 == 0) putchar(' ');
}
putchar('\n');
}
int main(void) {
printf("INT_MAX=%d INT_MIN=%d UINT_MAX=%u\n\n", INT_MAX, INT_MIN, UINT_MAX);
bits32("0", 0);
bits32("1", 1);
bits32("-1", (unsigned)-1); // all ones
bits32("INT_MAX", (unsigned)INT_MAX); // 0x7FFFFFFF
bits32("INT_MIN", (unsigned)INT_MIN); // 0x80000000
int x = 5;
printf("\nnegate 5 via ~x + 1 = %d\n", ~x + 1);
unsigned u = 0;
printf("unsigned 0u - 1 = %u (wraps to UINT_MAX)\n", u - 1);
int a = -1; unsigned b = 0; // the conversion trap
printf("in (a > b): (unsigned)(-1) = %u, so a > b is %s\n",
(unsigned)a, ((unsigned)a > b) ? "true (!)" : "false");
return 0;
}
Output confirms it: -1 is 0xFFFFFFFF, INT_MIN is 0x80000000, ~5 + 1 == -5,
0u - 1 == 4294967295, and the signed -1 becomes 4294967295 the instant it's compared
against an unsigned. (Note the (unsigned)a casts are deliberate — writing the comparison
as a > b directly is exactly what -Wsign-compare flags.)
Failure modes & trade-offs
abs(INT_MIN)is UB.-INT_MINoverflows because there's no positive twin. The same asymmetry breaks-x,x / -1, and naive integer-parsing of the most-negative value.- Assuming signed wrap.
INT_MAX + 1is undefined, notINT_MIN. Optimizers exploit this; "it worked in debug" is not safety. Use unsigned or checked math for values that may overflow. - Mixing signedness in comparisons/loops.
size_t(unsigned) vsint(signed) is the classic;i >= 0on an unsigned loop counter never ends. Keep loop counters and sizes the same signedness. - Right-shifting signed values.
>>on a negative signed int is implementation-defined (usually arithmetic shift). For bit work, shift unsigned types.
In practice
- Sizes and indices are
size_t(unsigned); use signedintfor values that go negative. Don't mix them in one comparison — convert explicitly so the reader sees it. - Treat
-Wsign-compareand-Wconversionas errors. They catch the conversion trap before it ships; in this atlas's OS work,-Werroris on. - For exact wraparound semantics, use unsigned; for trapping on overflow, use checked
helpers (
__builtin_add_overflow) or-fsanitize=signed-integer-overflowin tests. - Remember
-1 == 0xFFFF...Seeing all-ones in a debugger and recognizing "that's −1 (or an unsigned max, or a bitmask)" is everyday low-level fluency.
Connects to: Machine Model · Bits, bytes, words & addresses · Registers & the ISA · C from the Metal · Pointers & Memory
Sources
- Bryant & O'Hallaron — Computer Systems: A Programmer's Perspective (CS:APP), ch. 2 — integer representation, two's complement, overflow, and signed/unsigned conversions; the spine for this note. https://csapp.cs.cmu.edu/
- Jens Gustedt — Modern C — C's integer model, the usual arithmetic conversions, and why unsigned wraps but signed doesn't. https://gustedt.gitlabpages.inria.fr/modern-c/
- cppreference — Arithmetic operators & implicit conversions — the exact rules for usual arithmetic conversions and integer overflow. https://en.cppreference.com/w/c/language/operator_arithmetic
- Dietz et al. — Understanding Integer Overflow in C/C++ — empirical study of overflow bugs and why signed overflow UB matters in practice. https://www.cs.utah.edu/~regehr/papers/overflow12.pdf
- ISO/IEC 9899 (C standard), §6.2.5 & §6.3.1.8 — integer types, the conversion rank rules, and overflow semantics. https://www.open-std.org/jtc1/sc22/wg14/