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

ScenarioCompile timeTest timeIn the code
1 unsigned countdown-Wtype-limitsaddress sanitizer, when the stray index is usedloop for (i = n; i-- > 0;), or a signed index
2 127 + 1no-fsanitize=signed-integer-overflowwider type, or ckd_add
3 200 into int8_t-Wconversion-fsanitize=implicit-conversion (Clang)range check before narrowing
4 −(−128)no-fsanitize=signed-integer-overflowtest for INT_MIN first; compute abs in a wider type
5 parsing −128nosanitizer, with the edge value in the testsaccumulate negatively, or use strtol and check errno
6 detecting a wrapnot a bug: the idiom is the protectionckd_sub says it directly
7 %d and %u-Wformatmatch the format to the type
8 INT_MIN / −1nosanitizer; a crash on x86 in any casetest both operands before dividing
9 the vanished checksometimes: -Wstrict-overflowthe sanitizer fires at the add the check was meant to guardcheck before, or ckd_add, or -fwrapv
10 255 + 1no-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