The puzzle
Two counters, both eight bits wide, both holding 255. Add one to each. The first reads 0, exactly as the hardware clock that feeds it intends. The second is a signed variable, and the C standard says the result is not 0, not −256, not anything: the program has no defined meaning from that point on, and the compiler is entitled to have assumed it could never happen. Same bits, same adder, different contract. What is the contract, and where is it written down?
STEP 1
Fixed widths and the ring
A microcontroller register, a memory cell and a C integer variable all have a fixed number of bits. An -bit unsigned value can represent to ; an 8-bit uint8_t holds 0 to 255, a 16-bit uint16_t holds 0 to 65 535, a 32-bit uint32_t holds 0 to 4 294 967 295. There is no ninth bit to carry into. The natural picture is not a number line but a ring: one step past the top lands on the bottom.
↑ This step uses the figure at the top of the page.
For unsigned types C makes this exact. §6.3.1.3 of the standard says a value that does not fit is reduced modulo , so
That is a definition, not an accident, and firmware relies on it. For signed types the same standard (§6.5 ¶5) says an unrepresentable result is undefined behaviour. The processor will still produce a bit pattern, and on every current architecture it is the two’s-complement wrap you would expect, but the language refuses to promise it. Lesson 6 explains why that refusal has teeth: the optimiser is allowed to delete code that could only matter if overflow occurred.
STEP 2
Name the width you mean
int is at least 16 bits; on 32-bit Cortex-M it is 32, on an 8-bit AVR it is 16. long is at least 32. char may be signed or unsigned depending on the compiler and target: it is signed on x86 GCC and unsigned on Arm GCC by default. Firmware that talks to hardware cannot live with “at least”. The header <stdint.h> (C11 §7.20) provides types whose width is part of the name:
| Type | Bits | Min | Max |
|---|---|---|---|
uint8_t | 8 | 0 | 255 |
int8_t | 8 | −128 | 127 |
uint16_t | 16 | 0 | 65 535 |
int16_t | 16 | −32 768 | 32 767 |
uint32_t | 32 | 0 | 4 294 967 295 |
int32_t | 32 | −2 147 483 648 | 2 147 483 647 |
Use these for anything that maps onto hardware: register contents, buffer bytes, sample values, protocol fields. Keep plain int for loop counters and arithmetic where the exact width does not matter. size_t is the unsigned type for sizes and array indices.
STEP 3
Two’s complement
Signed integers reuse the same bits by making the most significant bit negative: in an 8-bit signed value bit 7 weighs instead of , and the other seven weigh as before.
In an 8-bit two’s-complement number the most significant bit weighs −128 instead of +128; the other seven weigh what they always did. That single change gives one unambiguous zero, a range of −128 to +127, and lets the same adder hardware handle signed and unsigned values. Slide the value to watch the weights combine.
Three consequences follow from that one change of weight:
- The range is to : −128 to 127 for eight bits. There is one more negative number than positive.
- Zero is unique (
0x00), and to negate a value you invert every bit and add one: . - The adder does not care. Adding the bit patterns for and produces the bit pattern for , with a carry out of the top that is simply discarded. Signed and unsigned addition are the same instruction; only the interpretation of the result, and the overflow condition, differ.
C11 allowed two other signed representations in principle, but C23 (N3096 §6.2.6.2) finally requires two’s complement, matching every processor you will meet. What C23 did not change is the rule that signed overflow is undefined.
STEP 4
Promotions and the −1 < 1u trap
C never does arithmetic in types narrower than int. Before any operation, a uint8_t or int16_t operand is promoted to int (§6.3.1.1). This is why (uint8_t)0x80 << 1 is 0x100 and not 0: the shift happens in int. Only when the result is stored back into a uint8_t is it truncated to 0x00.
When a signed and an unsigned operand of the same rank meet, the usual arithmetic conversions (§6.3.1.8) convert the signed one to unsigned. Hence the classic trap:
int a = -1;
unsigned b = 1;
if (a < b) { … } /* never taken: -1 becomes 4294967295u */
The comparison is done in unsigned int, where −1 wraps to the maximum value. Most compilers warn about signed/unsigned comparison; the fix is to keep both operands the same signedness, and the habit is to keep quantities that can never be negative unsigned from the start.
STEP 5
Worked example: elapsed time across a wrap
A free-running 16-bit hardware timer counts up at 1 MHz and wraps from 65 535 to 0. Firmware records start = 65 000, waits, and later reads now = 1 200. How long elapsed?
Naively : wrong, and in a signed 16-bit type it would also overflow. In uint16_t arithmetic the subtraction wraps by definition:
So 1 736 µs elapsed, which is correct: 536 ticks to reach the wrap, then 1 200 more. The single rule that makes this work is subtract in the same unsigned width as the counter. In C the subtraction happens in int after promotion, so cast the result back: (uint16_t)(now - start). This works for any elapsed time shorter than one full period (65.536 ms here); longer intervals are ambiguous, and you need a wider counter or an overflow interrupt that counts the wraps.
The same reasoning tells you when a sum will not fit. Averaging 100 ADC samples of up to 4 095 each gives a maximum sum of 409 500, which exceeds 65 535: accumulate in uint32_t, not uint16_t, or the average will be silently wrong for bright inputs.
MYTHS AND FACTS
Common misconceptions
Overflow always wraps around
Unsigned wrap is defined. Signed overflow is undefined behaviour; wrap is what the hardware does, not what the program is guaranteed.
int is 32 bits
It is at least 16. On many 8-bit and 16-bit targets it is 16; use <stdint.h> when the width matters.
char is signed
It is whichever the compiler chooses for the target; Arm GCC picks unsigned. Use int8_t or uint8_t for small numbers and char only for text.
Arithmetic on uint8_t happens in 8 bits
It happens in int; the result is truncated only when stored back.
Comparing a signed and an unsigned value is fine if both are small
Both operands are converted to unsigned first; negative values become huge.
Two’s complement is a trick for storing negatives
It is the representation that lets one adder serve both signed and unsigned arithmetic; that is why it won and why C23 mandates it.
Check yourself
Answer in your head, then open the card.
What does uint8_t x = 200; x += 100; leave in x?
300 does not fit. Unsigned wrap: . Defined, and probably not what was intended.
The byte 0xF6 is read as an int8_t. What value is that?
1111 0110: bit 7 contributes −128, the rest 64 + 32 + 16 + 4 + 2 = 118. Total −10. Check: invert 0xF6 → 0x09, add 1 → 0x0A = 10. So −10.
A 32-bit millisecond counter started at 4 294 967 000 and now reads 500. How many milliseconds passed, and what C expression gives it?
. Expression: (uint32_t)(now - start); both operands are already 32-bit unsigned, so the subtraction wraps correctly without a cast, but the cast documents the intent.
Why is if (-1 < 1u) false?
The signed −1 is converted to unsigned for the comparison and becomes 4 294 967 295, which is not less than 1.
Sources (5)
- ISO/IEC 9899:2011 (C11), committee draft N1570, §6.2.5 types, §6.2.6.2 integer types (representations), §6.3.1.1 integer promotions, §6.3.1.3 signed and unsigned integers, §6.3.1.8 usual arithmetic conversions, §6.5 ¶5 exceptional conditions, §7.20 <stdint.h> — §6.3.1.3 ¶2 defines unsigned wrap as reduction modulo one more than the maximum value; §6.5 ¶5 makes an unrepresentable signed result undefined behaviour
- ISO/IEC 9899:2024 (C23), working draft N3096, §6.2.6.2 integer types — C23 requires signed integers to use two’s complement; C11 still allowed sign-magnitude and ones’ complement
- SEI CERT C Coding Standard, INT30-C “Ensure that unsigned integer operations do not wrap” — defined wrap is still a bug when it is not intended; shows precondition and postcondition tests
- SEI CERT C Coding Standard, INT32-C “Ensure that operations on signed integers do not result in overflow” — lists which operations can overflow and how to check before performing them
- GCC manual, “Options That Control Optimization”: -fwrapv, -ftrapv, -fstrict-overflow — compiler switches that change signed-overflow behaviour from undefined to wrapping or trapping, and the default assumption that overflow does not occur