C / UNDEFINED BEHAVIOUR AND DEFENSIVE C
Signed overflow, strict aliasing and optimiser surprises
Spot code that leans on signed overflow or type-punning casts, and rewrite it with limit checks, unsigned arithmetic and memcpy so -O2 cannot surprise you.
What you will learn
- Pre-check with INT_MAX/INT_MIN instead of inspecting a sum that already overflowed
- Do modular arithmetic in unsigned types; only unsigned wrap is defined by the standard
- Reinterpret bits with memcpy or a union, never with a cast between unrelated pointers
- Diff -O0 against -O2, then -fwrapv/-fno-strict-aliasing, to confirm a suspected UB
Understanding Signed overflow, strict aliasing and optimiser surprises
Signed overflow is undefined not because CPUs cannot add: x86's ADD wraps and so does ARM's. It is undefined because the standard leaves the result open, and optimisers convert that freedom into algebraic facts they may use unconditionally: for any int i, i + 1 > i, and i * 2 / 2 == i. Those facts are exactly what lets GCC promote a 32-bit loop counter into a 64-bit register or prove a loop terminates, so when you write if (a + b < 0) the compiler reasons that overflow is not a case it must handle, concludes the test is false for every input it is responsible for, and removes it. The wrapped value you saw at -O0 was never a guarantee; it was just the absence of that optimisation.
Strict aliasing is the same trade applied to memory. Every object has an effective type, and the compiler assumes an access through an lvalue of unrelated type never touches it, which is what permits a value to stay in a register across a store through some other pointer, or two loads of the same address to be merged into one. Character types and union members are deliberately carved out of the rule, so memcpy and a union declaring both members are legitimate reinterpretation, while *(unsigned int *)&some_float is not, and it typically appears to work right up until inlining puts the store and the load in the same function where the optimiser can see both.
The mental model that keeps you out of trouble: undefined behaviour narrows the set of inputs the compiler must be correct on, rather than picking a surprising result. Any check written inside the undefined region tests a case the compiler has already been told cannot occur, so it evaporates; the fix is to establish the fact before the operation happens, by comparing against INT_MAX and INT_MIN, doing modular work in unsigned, widening to long long, or copying bytes with memcpy. Flags like -fwrapv and -fno-strict-aliasing fill in the missing definitions and are excellent for bisecting a bug that looks like a miscompilation (the Linux kernel builds with -fno-strict-aliasing for that reason), but they are per-compiler promises, not portable ones.
<stdio.h>
<limits.h>
/* Ask whether the sum fits before forming it: every expression here stays
inside the range of int, so nothing below is undefined. */
static int add_fits(int a, int b)
{
if (b > 0) return a <= INT_MAX - b;
if (b < 0) return a >= INT_MIN - b;
return 1;
}
/* If wrap-around is what you really want, do it where it is defined. */
static int wrap_add(int a, int b)
{
return (int)((unsigned int)a + (unsigned int)b);
}
int main(void)
{
int a[] = { 2000000000, 2000000000, -2000000000, 7 };
int b[] = { 147483647, 200000000, -200000000, 5 };
int i;
printf("INT_MAX=%d INT_MIN=%d\n", INT_MAX, INT_MIN);
for (i = 0; i < 4; i++) {
if (add_fits(a[i], b[i]))
printf("%d + %d = %d\n", a[i], b[i], a[i] + b[i]);
else
printf("%d + %d overflows; wrap_add gives %d\n",
a[i], b[i], wrap_add(a[i], b[i]));
}
return 0;
}
Signed overflow and strict aliasing are promises you make to the optimiser, so breaking them deletes your own checks rather than merely producing a wrong number.
Worked examples
Punning float bits without breaking aliasing
Moves a value between float and unsigned int through memcpy, the reinterpretation the optimiser cannot invalidate.
<stdio.h>
<string.h>
int main(void)
{
float f = 1.0f;
unsigned int bits;
/* A byte copy makes no claim about the type of the storage,
so there is no aliasing assumption to violate. */
memcpy(&bits, &f, sizeof bits);
printf("1.0f -> 0x%08X\n", bits);
bits = 0xC0000000u; /* IEEE-754 single precision -2.0 */
memcpy(&f, &bits, sizeof f);
printf("0x%08X -> %.1f\n", bits, f);
printf("sizeof(float)=%zu sizeof(unsigned int)=%zu\n",
sizeof(float), sizeof(unsigned int));
return 0;
}
Example explained
Line 1memcpy(&bits, &f, sizeof bits) reads f as bytes, and access to an object's representation through character types is explicitly permitted, so no effective-type rule is broken.
Line 2*(unsigned int *)&f would print the same number today and a stale register value after inlining, because the compiler may assume an unsigned int * and a float * never designate the same storage.
Line 30xC0000000 decodes as sign 1, exponent field 128 (a factor of 2^1) and zero significand, hence exactly -2.0.
Line 4The sizeof line is not decoration: memcpy punning is only meaningful when the two types have the same width, and mainstream compilers turn a 4-byte memcpy into a single register move.
Multiplying without touching the overflow
Decides whether a * b fits in int using only division against the limits, never the product itself.
<stdio.h>
<limits.h>
static int mul_fits(int a, int b)
{
if (a == 0 || b == 0) return 1;
if (a > 0 && b > 0) return a <= INT_MAX / b;
if (a > 0) return b >= INT_MIN / a; /* b < 0 */
if (b > 0) return a >= INT_MIN / b; /* a < 0 */
return b >= INT_MAX / a; /* both negative */
}
int main(void)
{
int t[4][2] = { {46340, 46340}, {46341, 46341},
{1000, -2000000}, {-2, -2000000000} };
int i;
for (i = 0; i < 4; i++) {
if (mul_fits(t[i][0], t[i][1]))
printf("%d * %d = %d\n", t[i][0], t[i][1], t[i][0] * t[i][1]);
else
printf("%d * %d does not fit in int\n", t[i][0], t[i][1]);
}
return 0;
}
Example explained
Line 1a <= INT_MAX / b answers "does the product fit" without forming it; integer division truncates toward zero, which makes the comparison exact rather than merely conservative.
Line 246341 * 46341 is 2147488281, only 4634 past INT_MAX, and the check rejects it while a post-hoc sign test on the product could not.
Line 3The both-negative branch divides INT_MAX by a on purpose: INT_MIN / -1 is itself undefined, so a check must never form it.
Line 41000 * -2000000 is accepted because -2000000 >= INT_MIN / 1000, which is -2147483 after truncation.
Unsigned operands, signed overflow
Shows that unsigned short * unsigned short is a signed multiply, so it can overflow even though no variable is signed.
<stdio.h>
<limits.h>
int main(void)
{
unsigned short x = 65535;
unsigned int safe = (unsigned int)x * x;
printf("x = %u\n", (unsigned int)x);
printf("x * x needs %lld, INT_MAX is %d\n", 65535LL * 65535LL, INT_MAX);
printf("(unsigned int)x * x = %u\n", safe);
return 0;
}
Example explained
Line 1The integer promotions convert unsigned short to int, not to unsigned int, because int can represent every unsigned short value, so x * x is signed arithmetic.
Line 265535 * 65535 is 4294836225, well past INT_MAX, so the unfixed expression is undefined signed overflow with no signed declaration in sight.
Line 3Casting one operand makes the usual arithmetic conversions choose unsigned int for both, and unsigned arithmetic is defined modulo 2^32.
Line 4The same trap catches unsigned char and short accumulators; widen deliberately before the multiply, not after.
Important notes
Signed overflow is broader than + and *: -INT_MIN, INT_MIN / -1, INT_MIN % -1, abs(INT_MIN) and shifting a bit into or past the sign position are all undefined.
The numbers here assume a 32-bit int, and converting an out-of-range unsigned value back to int was implementation-defined before C23 (every mainstream compiler wraps; C23 defines it as reduction modulo 2^N).
Common mistakes
Detecting overflow after the fact with int s = a + b; if (s < 0) ...: at -O2 the compiler knows s cannot be negative for the inputs it must handle, drops the branch, and the corrupted sum flows on into an allocation size.
Concluding that overflow wraps because a debug build printed a wrapped value: the same source changes behaviour once optimisation, inlining or another compiler exposes the assumption, so the bug appears only in release.
Reading a buffer through a cast pointer, such as *(unsigned int *)&f or ((struct hdr *)buf)->len: the load can be reordered around the store or served from a stale register, and on strict-alignment targets the misaligned access faults outright.
Try it yourself
Change, predict, then run
Write int mid(int lo, int hi) that returns lo + (hi - lo) / 2 for 0 <= lo <= hi and print mid(2000000000, 2100000000). Then print (int)((unsigned)2000000000 + 2100000000u) / 2 and compare: you should see 2050000000 against -97483648.
Open the C workspaceCheck your understanding
Given int f(int x) { if (x + 100 < x) return -1; return x + 100; } built with gcc -O2, why can the guard never return -1?
- x86 ADD saturates at INT_MAX, so the sum never becomes negative
- The addition is promoted to unsigned int, so the comparison is done unsigned
- Signed overflow is undefined, so the compiler may assume x + 100 > x for every input it must handle and fold the test to 0
- gcc keeps x + 100 in a 64-bit register, so the 32-bit comparison sees the widened value
Show answer
The only inputs the compiler is obliged to handle are those where x + 100 fits in int, and for all of those x + 100 > x holds, so folding the comparison to 0 and deleting the branch is a legal transformation. The saturation option is tempting but factually wrong: x86 ADD wraps, which is precisely why the same source appears to work at -O0 and the check survives there; the deletion comes from the language rule, not from the ALU.