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:
| Class | Meaning | Example |
|---|---|---|
| Well-defined | Exact behavior specified | int a = 1 + 2; |
| Implementation-defined | Behavior specified; varies by platform | sizeof(int), char signed-ness |
| Unspecified | Some behaviors allowed; compiler chooses, needn't document | Evaluation order of function arguments |
| Undefined behavior | Standard imposes no requirements | Signed 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
// 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:
; Compiler output (GCC -O2):
will_overflow:
mov eax, 1 ; always returns true
retThe 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
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
// 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
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
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 alternative3. Use-after-free and dangling references
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_back4. 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*).
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
// 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 mutex6. Shifting by invalid amounts
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 complementDetecting 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
# 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.
==ASAN== ERROR: heap-buffer-overflow on address 0x...
READ of size 4 at ... thread T0
#0 0x... in main my_code.cpp:7UndefinedBehaviorSanitizer (UBSan) β logical UB
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 sizeOverhead: minimal, typically 10-50% slowdown.
ThreadSanitizer (TSan) β data races
g++ -fsanitize=thread -g -O1 -fPIE -pie my_code.cppOverhead: 5-15x slowdown, 5-10x memory. Cannot be combined with ASan.
MemorySanitizer (MSan) β uninitialized reads
clang++ -fsanitize=memory -fno-omit-frame-pointer -g my_code.cppDetects reads of uninitialized memory. Clang-only. Cannot be combined with ASan.
CMake integration for CI
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:
# 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
| Flag | Effect |
|---|---|
-fno-strict-aliasing | Disable aliasing optimizations; hides violations but slower |
-fwrapv | Make signed overflow wrap (two's complement); no longer UB but disables overflow-based opts |
-ftrapv | Trap on signed overflow at runtime (abort on overflow) |
-fsanitize=undefined | Detect UB at runtime (test builds only) |
-O0 | Fewer 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
// 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);
}// 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?
| Operation | UB? | Notes |
|---|---|---|
| Signed integer overflow | Yes | Use unsigned or check before |
| Unsigned integer overflow | No | Wraps modulo 2^N β well-defined |
arr[n] where n >= size | Yes | Use .at() or bounds-check |
| Null pointer dereference | Yes (implementation-defined on some platforms) | Always check first |
(int*)&float_var dereference | Yes | Use memcpy or bit_cast |
| Reading uninitialized var | Yes | Initialize everything |
| Shift by >= bit width | Yes | Check shift amount first |
| Data race | Yes | Use atomics or mutex |
| Infinite loop with no side effects | Yes | Add a volatile dummy or use atomics |
reinterpret_cast<int*>(char_arr) dereference | Depends on alignment | OK if aligned; UB if not |
Calling delete twice | Yes | Single ownership with unique_ptr prevents this |