Home · part 4 of 9
Where C meets the wheel
Ten small C programs that go wrong at the edge of the range, stepped through one instruction at a time with the registers drawn as bits before and after, so you can see the wrap the language calls undefined.
Every scenario is at 8 bits with int8_t and uint8_t, so every bit is on screen. The same things happen at 32 bits with int and unsigned; the 32-bit numbers are named where they differ. The assembly is ARM. Step through each one and watch the registers and flags; changed bits are outlined, and the C line each instruction came from is highlighted above the listing.
1. The loop that never ends
uint8_t a[2]; // an array of n = 2 elementsuint8_t n = 2;uint8_t i;for (i = n - 1; i >= 0; i--) // clear a[1], then a[0], then stop a[i] = 0;
Counting down over an array with an unsigned index, from the last element a[n − 1] to the first a[0]. The programmer means "stop after 0", but 0 − 1 under the unsigned reading is 255, and 255 ≥ 0. The listing is a real loop: the branch at the bottom sends the highlight back to the top. (Lowercase n is the array length; it has nothing to do with the N flag.)
2. 127 + 1
int8_t x = 127;x = x + 1;
The largest signed value plus one. Under the unsigned reading 128 fits; under the signed reading it does not, and the register ends up reading as −128.
3. 200 into an int8_t
int v = 200;int8_t x = v;
No arithmetic at all. A wider value is stored into a narrower variable, and the bits that remain are read with the top one worth −128.
4. −(−128)
int8_t x = -128;int8_t y = -x; // or abs(x)
The one pattern that cannot be negated, from section 2 of the subtraction page, as it happens in a program that expects a positive result.
5. Parsing −128
// read digits as positive, negate at the endint8_t acc = 0;for each digit d: acc = acc * 10 + d;return negative ? -acc : acc;
The natural way to write a number parser, and the value that breaks it: the positive form of the most negative number never fits.
6. Detecting a wrap in C
uint8_t a = 3, b = 5;uint8_t d = a - b;if (d > a) /* wrapped */
C cannot read the carry flag, so the idiom asks a question whose answer can only be yes if the wheel was crossed.
7. %d and %u
int8_t x = -2;printf("%d", x); // -2printf("%u", x); // 254
The same register printed under both readings. Nothing is computed; the format string chooses what the top bit is worth.
8. INT_MIN / −1
int8_t x = -128, d = -1;int8_t q = x / d; // +128 does not fit
The one division that overflows. ARM and x86 do different things, and C permits both because it calls the case undefined.
9. The check that vanished
int8_t x = 127;if (x + 1 > x) puts("no overflow");
An overflow check written in C, compiled with optimisation. The listing shows what the compiler emitted; what is missing is the point.
10. 255 + 1, and detecting it
uint8_t a = 255, b = 1;uint8_t s = a + b;if (s < a) /* carried */
The addition twin of scenario 6: the carry out of the top bit, and the comparison that reveals it from C.
Catching these
At the machine level the answer is already in the flags row: V = 1 or C = 1 is the hardware saying so, and in assembly the next instruction would be BVS overflow or BCS carried. C has no spelling for that branch, so protection comes from the choices below, cheapest first. The table at the end says which one catches each scenario.
Choose types with headroom
Compute in a type wide enough that the edge is far away, and narrow only at the end if you must. Sizes and counts belong in size_t, not int. An int8_t should hold a value, not be the type you compute in; C promotes it to int for arithmetic anyway, so x + 1 on an int8_t is computed at 32 bits and only overflows at the store, which is scenario 3 rather than scenario 2.
Check before, not after
Checking after the operation is scenario 9: the compiler may assume the overflow never happened and delete the check. Write the test in terms that cannot overflow themselves:
if (b > 0 && a > INT_MAX - b) /* a + b would overflow */ if (b < 0 && a < INT_MIN - b) /* a + b would underflow */ if (x == INT_MIN) /* -x and abs(x) are unsafe */ if (d == -1 && x == INT_MIN) /* x / d is unsafe */
Ask the compiler to read the flag for you
GCC and Clang provide __builtin_add_overflow(a, b, &r), __builtin_sub_overflow and __builtin_mul_overflow, which store the wrapped result in r and return true when it did not fit. They compile to the add plus a test of V or C, whichever the operand types call for. C23 standardises them as ckd_add, ckd_sub and ckd_mul in <stdckdint.h>. This is the tool for the cases where overflow is a real possibility rather than a bug, such as sizes computed from input.
Let the compiler warn
-Wall -Wextra plus -Wconversion -Wsign-conversion -Wsign-compare -Wtype-limits catch most of this page at compile time. -Wtype-limits flags the i >= 0 on an unsigned in scenario 1 as always true; -Wconversion flags the narrowing store in scenario 3; -Wsign-compare flags a signed value compared with an unsigned one, the usual way scenario 7's two readings collide inside a program; -Wformat flags the %u given a signed argument.
Make the hardware's verdict fatal in test builds
-fsanitize=signed-integer-overflow, or all of -fsanitize=undefined, inserts the BVS that C lacks and aborts with a message naming the source line. Run the tests and any fuzzer with it on. -fsanitize=unsigned-integer-overflow does the same for unsigned wrap, which is not an error in C but is usually a bug in the program. -ftrapv is the older, blunter form.
Decide what wrap means
If you want modular arithmetic, do it in unsigned, where the wrap is defined and no flag is needed, and say so in a comment. If you want signed wrap to be a promise rather than undefined behaviour, -fwrapv makes it one, and scenario 9's check comes back; it costs some optimisations and is specific to GCC and Clang.
Test the edges on purpose
INT_MAX, INT_MIN, 0, −1, UINT_MAX, and one short of and one past every size limit in the program. Part 3's "does it fit" drill is this habit in miniature.
Which catches which
| Scenario | Compile time | Test time | In the code |
|---|---|---|---|
| 1 unsigned countdown | -Wtype-limits | address sanitizer, when the stray index is used | loop for (i = n; i-- > 0;), or a signed index |
| 2 127 + 1 | no | -fsanitize=signed-integer-overflow | wider type, or ckd_add |
| 3 200 into int8_t | -Wconversion | -fsanitize=implicit-conversion (Clang) | range check before narrowing |
| 4 −(−128) | no | -fsanitize=signed-integer-overflow | test for INT_MIN first; compute abs in a wider type |
| 5 parsing −128 | no | sanitizer, with the edge value in the tests | accumulate negatively, or use strtol and check errno |
| 6 detecting a wrap | not a bug: the idiom is the protection | ckd_sub says it directly | |
| 7 %d and %u | -Wformat | match the format to the type | |
| 8 INT_MIN / −1 | no | sanitizer; a crash on x86 in any case | test both operands before dividing |
| 9 the vanished check | sometimes: -Wstrict-overflow | the sanitizer fires at the add the check was meant to guard | check before, or ckd_add, or -fwrapv |
| 10 255 + 1 | no | -fsanitize=unsigned-integer-overflow (Clang) | the s < a idiom, or ckd_add |
Other languages, for comparison: Rust panics on overflow in debug builds and spells the intent in the code with checked_add, wrapping_add and saturating_add; Swift traps always; Java wraps silently, like C with -fwrapv; Python has no fixed width, so none of this exists there, which is why people arriving from Python meet it as a surprise.
Reading these
- Each trace runs on a machine with four 8-bit registers and the N Z C V flags, computed the way the Bit Lab does it: subtraction is A + NOT B + 1, C is the carry out of the top bit, V is the carry into the top bit XOR the carry out.
- The highlighted C line is the one the current instruction came from. Where one line compiles to several instructions, the part of the line being executed is underlined. Scenario 9 is the exception: its second instruction is what the compiler left of the whole
if. - Outlined cells changed in the last step. The flags row is highlighted when the last instruction set the flags; ARM instructions only do so when asked, which is the trailing
SonSUBSandADDS. - Where C says "undefined behaviour", the hardware still does something definite, and these traces show it. The catch is that the compiler is allowed to assume the undefined case never happens and may have rearranged the code before it reached the machine, so what you observe can differ from the trace and from run to run. Compile with
-fsanitize=undefinedto be told when a trace like these is happening in your program. -fwrapvtells GCC and Clang to treat signed overflow as a defined wrap, the way the hardware does it. Scenario 9 then compiles to the add and compare the programmer expected. It costs some optimisations and is not portable to other compilers, but it turns the undefined cases on this page into the traces shown.- To see the real instructions for your own C, paste it into Compiler Explorer with an ARM GCC at
-O1.