C Undefined Behavior: Why -O0 and -O2 Disagree
C lecture 5: signed overflow, a vanishing loop, strict aliasing, uninitialized reads and a deleted NULL check, run at -O0 and -O2 with UBSan reports.
Series: C lecture 5/6 (advanced). For developers who can already write and debug C, and who have seen a program behave differently between a debug and a release build. After this lecture you can recognise the common forms of undefined behavior, explain what the optimizer did with them, find them with UBSan, and choose a fix that is defined C rather than a flag that hides the symptom.
"It passes every test on my machine, but the release build gives a different number" is one of the most expensive bug reports in C. The cause is often not a compiler bug but undefined behavior (UB): code the C standard places no requirements on. This lecture runs five small UB examples at -O0 and -O2, shows the machine code the optimizer produced, and shows what the sanitizer reports.
Environment: Apple clang 21.0.0 (clang -std=c17 -Wall -Wextra), Apple Silicon Mac (M1 Pro), macOS, arm64. Every output was copied from a real run. Disassembly was produced with objdump -d --no-show-raw-insn. Back to the programming learning roadmap.
What "undefined" means, and why the optimizer cares
The C standard sorts non-portable behavior into three bins:
- Implementation-defined: the compiler must pick one behavior and document it. Example:
sizeof(long), or what right-shifting a negative number does. - Unspecified: one of several behaviors happens, and the compiler need not tell you which. Example: the order in which
f() + g()calls the two functions. - Undefined: the standard imposes no requirements at all on the whole program run. Examples: signed integer overflow, reading an uninitialized local, accessing an array out of bounds, dereferencing a NULL pointer, accessing an object through a pointer of an incompatible type.
The last bin is the one that bites, and not because the hardware does something strange. On arm64, adding 1 to 2147483647 in a register gives -2147483648, every time. The surprise comes from the optimizer: it is allowed to assume UB never happens and reason forward from that assumption. "This addition cannot overflow" becomes "so x + 1 > x is always true", which becomes "so return 1". Each step is legal; the result looks like the compiler ignored your code.
Three functions, two answers
One file, three tiny functions. noinline keeps each one a real call so we can read its machine code.
/* ub.c: three kinds of undefined behavior that change with -O level.
clang -std=c17 -Wall -Wextra -O0 ub.c -o ub0 && clang -std=c17 -Wall -Wextra -O2 ub.c -o ub2 */
#include <stdio.h>
#include <limits.h>
/* 1. signed overflow: "is x+1 bigger than x?" */
__attribute__((noinline)) int plus_one_is_bigger(int x) { return x + 1 > x; }
/* 2. strict aliasing: writes through an int* and a float* */
__attribute__((noinline)) int alias(int *i, float *f) {
*i = 1;
*f = 0.0f; /* if f points at the same bytes, *i is now 0 */
return *i;
}
/* 3. a loop whose counter overflows */
__attribute__((noinline)) int count_steps(int start) {
int steps = 0;
for (int i = start; i > 0; i += 100000000) steps++; /* "until it wraps negative" */
return steps;
}
int main(void) {
printf("plus_one_is_bigger(INT_MAX) = %d\n", plus_one_is_bigger(INT_MAX));
int x = 0;
printf("alias(&x, (float*)&x) = %d\n", alias(&x, (float *)&x));
printf("count_steps(1) = %d\n", count_steps(1));
return 0;
}
It compiles with no warnings at either level. The output:
-- O0
plus_one_is_bigger(INT_MAX) = 0
alias(&x, (float*)&x) = 0
count_steps(1) = 22
-- O2
plus_one_is_bigger(INT_MAX) = 1
alias(&x, (float*)&x) = 1
count_steps(1) = 0
Every line changed. Here is what the -O2 binary contains (comments added by us):
<_plus_one_is_bigger>:
mov w0, #0x1 ; return 1
ret
<_alias>:
mov w8, #0x1
str w8, [x0] ; *i = 1
str wzr, [x1] ; *f = 0
mov w0, #0x1 ; return 1, without reading *i again
ret
<_count_steps>:
mov w0, #0x0 ; return 0: the loop is gone
ret
1. Signed overflow. At -O0 the CPU added and wrapped, -2147483648 > 2147483647 was false, result 0. At -O2 clang reasoned that x + 1 cannot overflow, so it is always greater than x, and replaced the function with return 1. With -fwrapv (which defines signed overflow as wrapping) the -O2 build printed 0 again.
2. Strict aliasing. C says an int object may not be accessed through a float lvalue (C17 6.5p7). So clang assumes *f = 0.0f cannot change *i and returns the 1 it just stored. With -fno-strict-aliasing, the -O2 build printed 0. Linux kernels and some large C codebases build with that flag for exactly this reason.
3. The loop that vanished. The only way out of for (int i = 1; i > 0; i += 100000000) is overflow. At -O0 it wrapped after 22 steps. At -O2, without overflow the loop can never end; a side-effect-free loop like this may be assumed to terminate (C11 6.8.5p6); the two assumptions together leave nothing to execute, and clang emitted return 0. Not 22, not an infinite loop: 0. -fwrapv brought back 22.
Uninitialized variables: the number changes every run
#include <stdio.h>
__attribute__((noinline)) int grade(int score) {
int bonus; /* forgot "= 0" */
if (score >= 90) bonus = 10;
return score + bonus;
}
__attribute__((noinline)) void scribble(void) { /* leaves junk on the stack */
volatile int junk[16];
for (int i = 0; i < 16; i++) junk[i] = 7777;
}
int main(void) {
printf("grade(95) = %d\n", grade(95));
scribble();
printf("grade(50) = %d\n", grade(50));
return 0;
}
This time clang does warn, and the note even shows the fix:
uninit.c:5:9: warning: variable 'bonus' is used uninitialized whenever 'if' condition is false [-Wsometimes-uninitialized]
5 | if (score >= 90) bonus = 10;
| ^~~~~~~~~~~
uninit.c:6:20: note: uninitialized use occurs here
uninit.c:4:14: note: initialize the variable 'bonus' to silence this warning
4 | int bonus; /* forgot "= 0" */
| ^
| = 0
What actually happens if you ignore it:
-- O0, three runs
grade(50) = 76989834
grade(50) = 47941002
grade(50) = 43156874
-- O2
grade(95) = 105
grade(50) = 60
At -O0, bonus is whatever was left in that stack slot, and with address-space randomization (ASLR) it was a different number on every run. At -O2 the answer is stable, and wrong in a new way: the machine code is add w0, w0, #0xa. Since reading bonus uninitialized would be UB, the compiler assumed the if branch always runs and adds 10 unconditionally. A student with 50 points gets 60, every time, which looks like a logic bug rather than a memory bug.
-ftrivial-auto-var-init=zero (accepted by clang 21) zero-fills such locals and printed grade(50) = 50. That is a useful hardening flag for release builds, but it turns a visible bug into a silent assumption; the real fix is int bonus = 0;. Make the compiler insist with -Werror=sometimes-uninitialized (or -Werror for everything):
uninit.c:5:9: error: variable 'bonus' is used uninitialized whenever 'if' condition is false [-Werror,-Wsometimes-uninitialized]
The NULL check the compiler deleted
Common mistake: dereference first, check later.
struct cfg { int retries; };
__attribute__((noinline)) int get_retries(struct cfg *c) {
int r = c->retries; /* dereference first ... */
if (c == NULL) return -1; /* ... check later: the compiler may delete this */
return r;
}
What actually happens at -O2:
<_get_retries>:
ldr w0, [x0]
ret
The check is gone. Because c->retries already dereferenced c, the compiler concludes c cannot be NULL, so c == NULL is false and the branch is dead code. At -O0 the compare is still there. If this function ever receives NULL, the -O2 build crashes on the load, and the "defensive" check you see in the source never ran. Fix: check before the first use. This exact pattern caused a well-known Linux kernel vulnerability in 2009 (the kernel now builds with -fno-delete-null-pointer-checks; not run here).
UBSan: making UB loud
-fsanitize=undefined (UndefinedBehaviorSanitizer) inserts a check before each operation that can be UB and prints a line when one fires. On ub.c at -O0:
ub.c:7:68: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ub.c:7:68
ub.c:19:34: runtime error: signed integer overflow: 2100000001 + 100000000 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ub.c:19:34
plus_one_is_bigger(INT_MAX) = 0
alias(&x, (float*)&x) = 0
count_steps(1) = 22
exit=0
Two things to notice. It found both overflows, with exact values, but not the aliasing violation: UBSan has no check for that. And it reported and continued (exit 0). In CI you want the build to fail, so add -fno-sanitize-recover=undefined; the same file then stopped at the first report with exit 134.
Three more checks from a second file, run with no arguments:
int n = argc > 1 ? atoi(argv[1]) : 32;
int table[4] = {10, 20, 30, 40};
int idx = argc > 2 ? atoi(argv[2]) : 4;
printf("1 << %d = %d\n", n, 1 << n); /* shift >= width */
printf("table[%d] = %d\n", idx, table[idx]); /* off by one */
int d = argc - 1; /* 0 when run with no args */
printf("100 / %d = %d\n", d, 100 / d);
more_ub.c:8:35: runtime error: shift exponent 32 is too large for 32-bit type 'int'
more_ub.c:9:37: runtime error: index 4 out of bounds for type 'int[4]'
more_ub.c:11:38: runtime error: division by zero
1 << 32 = 1
table[4] = -16
100 / 0 = 0
Look at the last line. Without the sanitizer, at both -O0 and -O2, this Mac also printed 100 / 0 = 0 and exited normally: the arm64 divide instruction returns 0 for a zero divisor instead of trapping. On x86-64 the same binary logic raises SIGFPE ("Floating point exception", although it is an integer division; not run here). A program "tested" only on an Apple Silicon Mac can therefore crash on the first Linux server it meets. table[4] printed -16, -178814720 and 1010573361 in three different builds.
Measured: what -O levels and sanitizers cost
The same 20-million-element loop (a histogram plus a weighted sum, data from a fixed-seed xorshift generator) under different flags. Best of 7 repetitions, Apple Silicon Mac (M1 Pro); a second full round gave the same numbers within 5%. The checksum was identical in every build, so no build skipped work.
| Build | Time (ms) | vs -O2 |
|---|---|---|
-O0 |
30.22 | 3.1x |
-O1 |
9.77 | 1.0x |
-O2 |
9.63 | 1.0x |
-O3 |
6.97 | 0.72x |
-O2 -fwrapv |
10.03 | 1.04x |
-O2 -fsanitize=undefined |
25.22 | 2.6x |
-O2 -fsanitize=address |
17.25 | 1.8x |
-O0 -fsanitize=address,undefined |
241.33 | 25x |
Three readings. -fwrapv cost almost nothing on this loop, so "we can't define overflow because it is slow" deserves a measurement on your own hot path before you believe it (it can matter for some loops, e.g. index widening; not shown here). Sanitizers at -O2 are cheap enough to run the whole test suite with them. And the debug-plus-sanitizer build people use day to day is 25 times slower than release, which is fine for tests and a reason not to benchmark in it.
/* bench5.c: the same code under different -O levels and sanitizers.
clang -std=c17 -O2 bench5.c -o b && ./b */
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <time.h>
enum { N = 20000000, REPS = 7 };
static double now_ms(void) {
struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t);
return t.tv_sec * 1e3 + t.tv_nsec / 1e6;
}
static int64_t work(const int *a, int n, int *hist) {
int64_t s = 0;
for (int i = 0; i < n; i++) { hist[a[i] & 255]++; s += (int64_t)a[i] * (i & 7); }
return s;
}
int main(void) {
int *a = malloc(N * sizeof *a);
uint32_t x = 42; /* fixed seed, xorshift32 */
for (int i = 0; i < N; i++) { x ^= x << 13; x ^= x >> 17; x ^= x << 5; a[i] = (int)(x % 1000); }
double best = 1e30; int64_t r = 0;
for (int k = 0; k < REPS; k++) {
int hist[256] = {0};
double t0 = now_ms();
r = work(a, N, hist);
double dt = now_ms() - t0;
if (dt < best) best = dt;
r += hist[7];
}
printf("best of %d: %.2f ms (checksum %lld)\n", REPS, best, (long long)r);
free(a);
return 0;
}
Decision table: symptom, tool, defined fix
| Symptom | First tool | Defined fix |
|---|---|---|
| Different result at -O0 and -O2 | -fsanitize=undefined, then -fno-sanitize-recover in CI |
remove the UB; flags like -fwrapv only as a stopgap |
| Arithmetic that may overflow | UBSan signed integer overflow |
check first, or C23 ckd_add/ckd_mul from <stdckdint.h> |
| Reading a float's bits, packet parsing | (no sanitizer catches it) | memcpy into the other type, or unsigned char access |
| Garbage that changes per run | -Wall (-Wsometimes-uninitialized) |
initialise at declaration; -Werror for that warning |
| A NULL/bounds check "doesn't work" | read the -O2 disassembly |
check before the first dereference |
| Crash only on another OS/CPU | UBSan on your machine | do not trust one platform's lucky result (arm64 x / 0 gave 0) |
The two defined replacements, run at -O0 and -O2 with identical output:
#include <stdckdint.h> /* C23 */
static uint32_t float_bits(float f) { /* the defined way to look at bytes */
uint32_t u;
memcpy(&u, &f, sizeof u);
return u;
}
/* ... */
int sum;
if (ckd_add(&sum, INT_MAX, 1)) puts("ckd_add: overflow detected");
if (!ckd_add(&sum, 40, 2)) printf("ckd_add: 40 + 2 = %d\n", sum);
printf("bits of 1.0f = 0x%08X\n", float_bits(1.0f));
ckd_add: overflow detected
ckd_add: 40 + 2 = 42
bits of 1.0f = 0x3F800000
The memcpy compiles to a single register move at -O2; it costs nothing.
Honest limits and version notes
- All results are from Apple clang 21.0.0 on arm64. Other compilers (GCC) and other versions may optimise the same UB differently, or not at all; that is the nature of UB. The
-O0/-O2outputs above are what this compiler did, not what C guarantees. - C23 requires two's-complement representation for signed integers, but signed overflow is still undefined in C23. Do not read "two's complement" as "wraps".
<stdckdint.h>andckd_addare C23. clang 21 also accepted them with-std=c17 -Wpedanticwithout a warning; other compilers may not, so compile with-std=c23when you use them.- UBSan did not report the strict-aliasing violation.
-fsanitize=type(TypeSanitizer, new in LLVM) was accepted by the driver but linking failed becauselibclang_rt.tysan_osx_dynamic.dylibis not shipped with Apple clang 21. UBSan also does not find uninitialized reads; MemorySanitizer does, but it is not available on macOS (not run here). - C11 6.8.5p6 (loops may be assumed to terminate) is in C11 and C17. The 2009 kernel NULL-check bug and x86 SIGFPE are described from documentation, not run here.
- Timings: best of 7, one machine, two rounds. Ratios matter more than the absolute numbers.
Exercises
- Predict what
same_after_double(21)andsame_after_double(INT_MAX)print at-O0and at-O2, whereint same_after_double(int x) { return (x * 2) / 2 == x; }. Then say what UBSan reports. - The
gradefunction above: make the build fail instead of warn, without changing the code.
Answers
-O0printed1 0:INT_MAX * 2wrapped to -2, and -2 / 2 is -1.-O2printed1 1: the compiler simplified(x * 2) / 2toxbecause the multiplication "cannot overflow". UBSan:ex5.c:3:68: runtime error: signed integer overflow: 2147483647 * 2 cannot be represented in type 'int'.- Add
-Werror=sometimes-uninitialized(or-Werror). clang then printederror: variable 'bonus' is used uninitialized whenever 'if' condition is false [-Werror,-Wsometimes-uninitialized]and produced no binary.
For UB that shows up as memory errors (out-of-bounds writes, use-after-free), see the ASan reports in lectures 3 and 4.
Previous: Lecture 4: strings, structs and buffer overflows · Next: Lecture 6: cache-friendly code, make and lldb
FAQ
Why does my C program work in a debug build but give wrong results with -O2?
Usually because it has undefined behavior that the -O0 build happened to tolerate. The optimizer is allowed to assume UB never happens and simplify code on that basis. In our runs, x + 1 > x returned 0 for INT_MAX at -O0 and 1 at -O2, and a loop that relied on overflow ran 22 times at -O0 and was deleted at -O2. Build with -fsanitize=undefined to find the line.
Does -fsanitize=undefined catch every kind of undefined behavior?
No. It caught signed overflow, oversized shifts, an out-of-bounds index on a local array and division by zero in our tests, but not the strict-aliasing violation, and it does not detect uninitialized reads. -fsanitize=type exists but its runtime library was not shipped with Apple clang 21, so linking failed.
How much slower is a sanitizer build?
On our 20-million-element loop, -O2 took 9.63 ms, -O2 with UBSan 25.22 ms (2.6x) and -O2 with ASan 17.25 ms (1.8x). -O0 with both sanitizers took 241 ms, 25 times -O2. Run sanitizers in tests and CI, not in release builds.