Skip to content
C++
Language
since C++11
Expert

Undefined Behavior: What the Compiler Assumes

How UB works in practice: what compilers assume, which optimizations it enables, the most dangerous UBs, and how sanitizers catch them at runtime.

Undefined behavior (UB) is not just "your program might crash." It means the compiler is allowed to assume UB never happens and optimize accordingly β€” producing code that is provably correct on all paths that don't trigger UB, but catastrophically wrong on paths that do.

Understanding UB is what separates intermediate C++ from expert C++.


The four outcome classes

The C++ standard distinguishes four levels of defined-ness:

ClassMeaningExample
Well-definedExact behavior specifiedint a = 1 + 2;
Implementation-definedBehavior specified; varies by platformsizeof(int), char signed-ness
UnspecifiedSome behaviors allowed; compiler chooses, needn't documentEvaluation order of function arguments
Undefined behaviorStandard imposes no requirementsSigned integer overflow, null dereference

UB is special: the standard gives the compiler full permission to do anything, including generating code that is logically inconsistent with what you wrote.


How compilers exploit UB: the key insight

Modern compilers perform UB-based reasoning: if a code path would trigger UB, the compiler treats that path as unreachable and eliminates it. This enables real optimizations but creates invisible traps.

Example 1: Signed integer overflow

cpp
// Source:
bool will_overflow(int x) {
    return x + 1 > x;
}

On a naive reading, this should check whether x + 1 > x. With large x, signed overflow would make x + 1 wrap to INT_MIN, which is less than x, so you'd expect false. But:

asm
; Compiler output (GCC -O2):
will_overflow:
    mov eax, 1    ; always returns true
    ret

The compiler reasons: signed overflow is UB, therefore I can assume x + 1 > x is always true (since overflow can't happen in a correct program). Result: always returns true, no matter the input.

Example 2: Null pointer UB enables dead code elimination

cpp
void process(int* p) {
    *p = 42;         // (1) dereferences p
    if (p == nullptr) {   // (2) checks if p is null
        handle_error();
    }
}

At (1), the compiler dereferences p. Dereferencing a null pointer is UB β€” so the compiler assumes p is not null at (1). By (2), since p was already used as non-null, the null check is dead code and is eliminated entirely. handle_error() is never called.

Example 3: Infinite loops

cpp
// Loop with no side effects and no defined exit:
while (true) { }   // UB in C++ if the loop has no observable effect and can't terminate

// Compiler is allowed to delete this loop entirely.
// Result: code after the loop runs immediately.

The most dangerous UBs

1. Signed integer overflow

cpp
int x = INT_MAX;
int y = x + 1;   // UB: signed overflow

// Safe alternatives:
// Check before adding:
if (x > INT_MAX - 1) { /* overflow */ }
// Use unsigned (wrapping is defined):
unsigned int u = (unsigned int)x + 1;
// Use __builtin_add_overflow (GCC/Clang):
int result;
if (__builtin_add_overflow(x, 1, &result)) { /* overflow */ }

2. Out-of-bounds access

cpp
int arr[10];
arr[10] = 42;   // UB: one past the end is readable but NOT writable
arr[-1] = 0;    // UB

std::vector<int> v(10);
v[10] = 42;     // UB: operator[] doesn't bounds-check
v.at(10) = 42;  // throws std::out_of_range β€” safe alternative

3. Use-after-free and dangling references

cpp
std::string& get_greeting() {
    std::string s = "hello";
    return s;   // UB: returning reference to local variable that's about to be destroyed
}

int* p = new int(42);
delete p;
*p = 99;   // UB: use-after-free

// Dangling iterator:
std::vector<int> v = {1, 2, 3};
auto it = v.begin();
v.push_back(4);   // may reallocate
*it = 5;          // UB: iterator was invalidated by push_back

4. Strict aliasing violations

The strict aliasing rule: you may not access an object through a pointer to an incompatible type (exceptions: char*, unsigned char*, std::byte*).

cpp
float f = 3.14f;

// UB: accessing float storage through int*
int* p = (int*)&f;
int bits = *p;     // UB! strict aliasing violation

// Safe: use memcpy (compilers optimize this to a register move)
int bits;
std::memcpy(&bits, &f, sizeof(bits));

// Safe: std::bit_cast (C++20)
int bits = std::bit_cast<int>(f);

Why this matters: compilers assume int* and float* can't alias the same memory. This enables a load hoisting optimization that assumes *p doesn't change when f is modified β€” which breaks if you actually alias them.

5. Data races

cpp
// Concurrent read + write without synchronization = UB:
int counter = 0;

std::thread t1([&]{ counter++; });  // read-modify-write
std::thread t2([&]{ counter++; });  // read-modify-write
t1.join(); t2.join();
// final value of counter is undefined β€” could be 0, 1, or 2

// Fix: use atomic
std::atomic<int> counter = 0;
// Or protect with a mutex

6. Shifting by invalid amounts

cpp
int x = 1;
int y = x << 32;   // UB: shift amount >= bit width of type (32-bit int)
int z = x << -1;   // UB: negative shift amount
int w = -1 << 1;   // UB in C++11 (signed left shift of negative value)
                   // well-defined in C++20 using two's complement

Detecting UB: the sanitizer suite

Never ship code with UB, but detecting it before shipping requires runtime tools. Compile with these flags during testing:

AddressSanitizer (ASan) β€” memory errors

bash
# Compile flags:
g++ -fsanitize=address -fno-omit-frame-pointer -g -O1 my_code.cpp

# Or in CMake:
target_compile_options(my_target PRIVATE -fsanitize=address -fno-omit-frame-pointer -g)
target_link_options(my_target PRIVATE -fsanitize=address)

Detects: heap buffer overflow, stack buffer overflow, use-after-free, use-after-return, double-free, memory leaks (via LeakSanitizer, enabled by default).

Overhead: ~2x slowdown, ~2x memory.

cpp
==ASAN== ERROR: heap-buffer-overflow on address 0x...
READ of size 4 at ... thread T0
    #0 0x... in main my_code.cpp:7

UndefinedBehaviorSanitizer (UBSan) β€” logical UB

bash
g++ -fsanitize=undefined -g -O1 my_code.cpp

# Most useful individual checks:
-fsanitize=signed-integer-overflow   # signed overflow
-fsanitize=null                       # null dereference
-fsanitize=bounds                     # array out of bounds
-fsanitize=alignment                  # misaligned pointer
-fsanitize=shift                      # invalid shift amounts
-fsanitize=vla-bound                  # VLA with negative size

Overhead: minimal, typically 10-50% slowdown.

ThreadSanitizer (TSan) β€” data races

bash
g++ -fsanitize=thread -g -O1 -fPIE -pie my_code.cpp

Overhead: 5-15x slowdown, 5-10x memory. Cannot be combined with ASan.

MemorySanitizer (MSan) β€” uninitialized reads

bash
clang++ -fsanitize=memory -fno-omit-frame-pointer -g my_code.cpp

Detects reads of uninitialized memory. Clang-only. Cannot be combined with ASan.

CMake integration for CI

cmake
option(ENABLE_SANITIZERS "Build with sanitizers" OFF)

if(ENABLE_SANITIZERS)
    # Check for incompatible combinations:
    # TSan + ASan don't mix; MSan is clang-only
    target_compile_options(my_target PRIVATE
        -fsanitize=address,undefined
        -fno-omit-frame-pointer
        -g
    )
    target_link_options(my_target PRIVATE
        -fsanitize=address,undefined
    )
endif()

Run in CI with both sanitizer builds and release builds:

bash
# Sanitizer build β€” catch bugs
cmake -DENABLE_SANITIZERS=ON -DCMAKE_BUILD_TYPE=RelWithDebInfo ..
ctest

# Release build β€” verify performance
cmake -DENABLE_SANITIZERS=OFF -DCMAKE_BUILD_TYPE=Release ..

Compiler flags that silence vs expose UB

FlagEffect
-fno-strict-aliasingDisable aliasing optimizations; hides violations but slower
-fwrapvMake signed overflow wrap (two's complement); no longer UB but disables overflow-based opts
-ftrapvTrap on signed overflow at runtime (abort on overflow)
-fsanitize=undefinedDetect UB at runtime (test builds only)
-O0Fewer UB-exploiting optimizations, but UB is still UB

Warning: -fno-strict-aliasing and -fwrapv are workarounds, not fixes. They hide UB in your code while preventing the compiler from applying legitimate optimizations. Always fix the underlying UB instead.


UB that looks correct but isn't

cpp
// Looks like it checks for overflow before it happens:
bool add_overflows(int a, int b) {
    if (a + b < a) return true;   // UB: a+b itself may overflow before the check!
    return false;
}

// Correct:
bool add_overflows(int a, int b) {
    return b > 0 && a > INT_MAX - b  ||
           b < 0 && a < INT_MIN - b;
}

// Or use __builtin_add_overflow:
bool add_overflows(int a, int b) {
    int result;
    return __builtin_add_overflow(a, b, &result);
}
cpp
// Looks like it handles both aligned and unaligned reads:
uint32_t read_u32(const uint8_t* p) {
    return *(uint32_t*)p;   // UB: unaligned access + aliasing violation
}

// Correct: memcpy reads bytes without aliasing or alignment UB
uint32_t read_u32(const uint8_t* p) {
    uint32_t v;
    std::memcpy(&v, p, sizeof(v));
    return v;
}

Quick reference: is this UB?

OperationUB?Notes
Signed integer overflowYesUse unsigned or check before
Unsigned integer overflowNoWraps modulo 2^N β€” well-defined
arr[n] where n >= sizeYesUse .at() or bounds-check
Null pointer dereferenceYes (implementation-defined on some platforms)Always check first
(int*)&float_var dereferenceYesUse memcpy or bit_cast
Reading uninitialized varYesInitialize everything
Shift by >= bit widthYesCheck shift amount first
Data raceYesUse atomics or mutex
Infinite loop with no side effectsYesAdd a volatile dummy or use atomics
reinterpret_cast<int*>(char_arr) dereferenceDepends on alignmentOK if aligned; UB if not
Calling delete twiceYesSingle ownership with unique_ptr prevents this