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.

Flow diagram in four steps. Step 1: the source contains an operation that would be undefined for some input, such as x + 1 for INT_MAX. Step 2: the optimizer assumes undefined behavior never happens. Step 3: it derives a fact, such as x + 1 > x is always true. Step 4: it simplifies code, here to return 1. A side branch from step 1 shows -O0: the CPU simply wraps and the function returns 0. A dashed branch shows UBSan reporting the line at run time.
How undefined behavior turns into a different -O2 result. Each step is a legal deduction; the outcome looks like the compiler ignored the 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
Bar chart in milliseconds. -O0: 30.22. -O1: 9.77. -O2: 9.63. -O3: 6.97. -O2 with -fwrapv: 10.03. -O2 with UBSan: 25.22. -O2 with ASan: 17.25. -O0 with ASan and UBSan: 241.33, about 25 times the -O2 time.
One 20-million-element loop under different builds. Apple clang 21, Apple Silicon Mac (M1 Pro), best of 7, fixed seed, identical checksum in every build.

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/-O2 outputs 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> and ckd_add are C23. clang 21 also accepted them with -std=c17 -Wpedantic without a warning; other compilers may not, so compile with -std=c23 when 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 because libclang_rt.tysan_osx_dynamic.dylib is 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

  1. Predict what same_after_double(21) and same_after_double(INT_MAX) print at -O0 and at -O2, where int same_after_double(int x) { return (x * 2) / 2 == x; }. Then say what UBSan reports.
  2. The grade function above: make the build fail instead of warn, without changing the code.

Answers

  1. -O0 printed 1 0: INT_MAX * 2 wrapped to -2, and -2 / 2 is -1. -O2 printed 1 1: the compiler simplified (x * 2) / 2 to x because the multiplication "cannot overflow". UBSan: ex5.c:3:68: runtime error: signed integer overflow: 2147483647 * 2 cannot be represented in type 'int'.
  2. Add -Werror=sometimes-uninitialized (or -Werror). clang then printed error: 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.

  • #c
  • #advanced
  • #undefined-behavior
  • #ubsan
  • #optimization
  • #clang
  • #strict-aliasing

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑