06 — Undefined Behavior¶
Every senior C++ study partner will, at some point, ask you a question whose real answer is “that is undefined behavior.” If you say “it segfaults” you sound junior. If you say “it might do anything the compiler wants, including deleting the check that would have caught it, because the standard says the compiler may assume this cannot happen” — you sound like someone who has read the spec. This file gets you there.
UB is not a bug. It is a contract violation. The C++ standard is a treaty between you and the optimizer: you promise never to do certain things; in exchange, the optimizer gets to assume you kept the promise and generate faster code. Break the treaty and the compiler owes you nothing — not a crash, not a wrong value, not even consistent behavior between runs.
1. The UB catalog you must know cold¶
The list of UB in C++ is over 200 items long (see cppreference’s dedicated UB page). You do not need all of them. You need these — every one of them shows up in studies or production incidents.
# |
Kind |
Example |
What the compiler is allowed to do |
|---|---|---|---|
1 |
Signed integer overflow |
|
Assume overflow never happens; delete range checks that depend on it. |
2 |
Null pointer dereference |
|
Assume every dereferenced pointer is non-null; delete null checks after first use. |
3 |
Out-of-bounds array access |
|
Anything. Often SEGV; often silent memory corruption. |
4 |
Use-after-free / use-after-return |
Returning a pointer to a local |
Reads may return old data, garbage, or a stack frame belonging to another function. |
5 |
Double free |
|
Heap corruption, later crash in an unrelated allocation. |
6 |
Data race |
Two threads, one writes, no sync |
Any observable value including impossible ones (torn reads, ghost writes). |
7 |
Strict aliasing violation |
|
Optimizer assumes different types don’t alias; may reorder loads/stores. |
8 |
Uninitialized read |
|
The value need not be consistent between reads; two |
9 |
Invalid |
|
Any bit pattern. |
10 |
Reaching end of a value-returning function without |
Missing |
Anything. Often returns whatever was in the return register. |
11 |
Modifying a |
|
The object may still read as 5; compiler may have constant-folded. |
12 |
Violating iterator invalidation rules |
|
Anything. |
13 |
Overlapping restrict pointers |
|
Anything (that’s why |
14 |
Infinite loop without side effects |
|
Compiler may delete the loop entirely (this is legal since C++11; a well-known one). |
15 |
Signed / unsigned mixing that overflows to a large positive |
|
Infinite loop; |
Category (15) is only implementation-defined on unsigned overflow (defined as modular) but the use of it is a bug 90% of the time. Learn to spot it.
What most people get wrong: they think UB “usually just crashes.” Modern optimizers (clang 18, gcc 14) do things that look like clairvoyance. If your code has
if (p != nullptr) *p = 1;and you call it withnullptr, the null check may have been deleted at-O2because a later*p“proved”pcannot be null. The check is gone. Your assert is gone. The bug lives.
2. Signed overflow — the study classic¶
bool safe_add(int a, int b) {
if (a + b < a) return false; // check for overflow
return true;
}
This does not work. a + b < a is UB when it would overflow, so the compiler assumes it never overflows, so the whole check folds to false ? false : true → true. Clang / GCC both do this at -O2.
Correct form:
#include <limits>
bool safe_add(int a, int b) {
if (b > 0 && a > std::numeric_limits<int>::max() - b) return false;
if (b < 0 && a < std::numeric_limits<int>::min() - b) return false;
return true;
}
// Or use the compiler builtin:
bool safe_add2(int a, int b, int* out) {
return !__builtin_add_overflow(a, b, out); // GCC/Clang; MSVC has SafeInt
}
__builtin_*_overflow is the pragmatic answer. In C++26 you’ll get std::add_sat, std::add_overflow, etc. Standardized in P0543. Until then, builtins.
3. Strict aliasing — the silent optimizer weapon¶
C++ says two pointers of different (unrelated) types cannot alias. If you punch through with a cast, the optimizer is allowed to reorder loads and stores across the punch. This kills float↔int bit-cast hacks and network-header casts.
Wrong:
uint32_t bits_of(float f) { return *reinterpret_cast<uint32_t*>(&f); } // UB
Right (C++20+):
#include <bit>
uint32_t bits_of(float f) { return std::bit_cast<uint32_t>(f); } // well-defined
Right (pre-C++20):
uint32_t bits_of(float f) { uint32_t u; std::memcpy(&u, &f, sizeof u); return u; }
memcpy and std::bit_cast are the only correct type-punning tools. Unions work in C, are formally UB in C++ (though most compilers accept them). Do not fight it — write bit_cast or memcpy and move on.
4. Uninitialized reads¶
int x;
if (x > 0) do_a(); else do_b();
The value of x is indeterminate. The optimizer is allowed to assume both branches are possible and inconsistent between reads. A single if (x > 0) may take one branch, and a later if (x > 0) in the same function may take the other. This is not a theoretical concern; MSan catches it in the wild every day.
Habit: always initialize. int x = 0; or int x{};. -Wuninitialized -Wmaybe-uninitialized in your CI. Turn on MSan for a full run once a week.
5. Sequence points → sequenced-before rules (C++11 model)¶
Old C++ said “there are sequence points, and between them, the evaluation order is unspecified.” C++11 replaced it with a per-expression sequenced-before graph. The practical rules that catch bugs:
i = i++;— UB. Two writes toiin the same expression, unsequenced.f(i++, i++);— UB before C++17; implementation-defined since C++17 (function arguments are indeterminately sequenced, not unsequenced, but their side effects can still race in surprising ways).a[i] = i++;— UB.++i + ++i— UB.
Rule of thumb: never modify the same variable twice in an expression without a sequence point between (,, &&, ||, ?:, ;).
6. Data race — precisely defined¶
The C++ standard’s definition:
Two actions on the same memory location conflict if at least one is a write and the actions are not synchronized by happens-before.
If a data race exists, the entire program has UB. Not just the racy operation — the whole program. That is why -fsanitize=thread catches races and refuses to continue; the standard has already given up on the run.
The one exception: std::atomic<T> operations never race with each other. Everything you touch from multiple threads either lives inside std::atomic, behind a mutex, or has an explicit acquire/release chain (see file 01).
7. Sanitizers — your UB detectors¶
You cannot audit UB by reading. You must instrument. Clang and GCC ship four sanitizers you should treat as compile flags for a second, “audit” build target:
Sanitizer |
Flag |
What it catches |
Cost |
|---|---|---|---|
AddressSanitizer |
|
Heap/stack buffer overflow, use-after-free, use-after-return, double free |
~2x slower, ~3x memory |
UndefinedBehaviorSanitizer |
|
Signed overflow, null deref, alignment, vla-bound, shift, unreachable |
~10-20% slower |
ThreadSanitizer |
|
Data races, deadlocks |
~5-15x slower, ~8x memory |
MemorySanitizer |
|
Uninitialized reads. Clang only; needs instrumented libc++ |
~3x slower |
Enable in your CMakePresets.json or Makefile as a sanitize build type:
# CMakeLists.txt sketch
option(SAN_ADDRESS "AddressSanitizer" OFF)
option(SAN_UB "UBSan" OFF)
option(SAN_THREAD "ThreadSanitizer" OFF)
if(SAN_ADDRESS)
add_compile_options(-fsanitize=address -fno-omit-frame-pointer -g -O1)
add_link_options(-fsanitize=address)
endif()
if(SAN_UB)
add_compile_options(-fsanitize=undefined -fno-omit-frame-pointer -g -O1)
add_link_options(-fsanitize=undefined)
endif()
if(SAN_THREAD)
add_compile_options(-fsanitize=thread -fno-omit-frame-pointer -g -O1)
add_link_options(-fsanitize=thread)
endif()
Rules for using them:
ASan + UBSan are compatible. Combine them:
-fsanitize=address,undefined.TSan is not compatible with ASan. Use a separate build.
MSan requires an MSan-built libc++, which is not shipped by Apple. On macOS use ASan/UBSan/TSan; run MSan in a Linux VM if you need it.
Add sanitizer runs to CI (P3.1 and P3.2 both require this). A weekly
make sanitizeon yourmainbranch catches drift.
Runtime flags worth knowing:
ASAN_OPTIONS=halt_on_error=1:detect_leaks=1:abort_on_error=1
UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1
TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1
8. Compiler warnings that catch UB before sanitizers do¶
-Wall -Wextra -Wpedantic is table stakes. Add these:
-Wuninitialized -Wmaybe-uninitialized
-Wnull-dereference
-Warray-bounds
-Wcast-align
-Wshadow
-Wconversion # noisy but catches signed↔unsigned bugs
-Wold-style-cast
-Wnon-virtual-dtor
-Wdouble-promotion
-Wformat=2
Static analysis on top: clang-tidy with the bugprone-*, cert-*, cppcoreguidelines-* check groups. Run it on every push in CI.
9. Reading list — the ones that actually change how you think¶
John Regehr’s blog —
blog.regehr.org. The single best UB writer alive. Start with “A Guide to Undefined Behavior in C and C++” parts 1-3. Then “Finding Undefined Behavior Bugs by Finding Dead Code”. Then his integer overflow paper.LLVM blog — Chris Lattner’s “What Every C Programmer Should Know About Undefined Behavior” parts 1-3 (blog.llvm.org, 2011). Still the clearest explanation of why the optimizer treats UB the way it does. Age has not diminished it.
cppreference — the UB page (
en.cppreference.com/w/cpp/language/ub). The canonical enumeration.Jonathan Müller — “Undefined behavior can result in time travel” (his blog and CppCon talk). The “the check gets deleted before the check that would catch the UB” phenomenon, in detail.
Herb Sutter — GotW #100+ on undefined behavior. Short, sharp, opinionated.
10. study-ready UB questions to be able to answer¶
You will be asked these. Practice out loud until each answer is 60 seconds tight:
“What is undefined behavior?” — contract violation; the standard permits any outcome; the optimizer exploits it.
“What happens if you signed-integer-overflow?” — UB. Compiler may assume it doesn’t happen; that deletes overflow checks. Show
__builtin_add_overflowas the fix.“Why is
strncpystill around?” — becausestrcpyon unbounded input is a buffer overflow (UB), and everyone learned the wrong fix. Real fix:std::stringorstd::string_view.“What is a data race, precisely?” — two conflicting accesses to the same memory, at least one write, not synchronized by happens-before. Program-wide UB.
“Why does
-O2make my bug disappear / appear?” — UB. Different optimizer decisions expose or hide the contract violation.“How would you detect a use-after-free in a large codebase?” — ASan in CI plus a targeted
AddressSanitizerLiterun on release-shaped binaries; static analysis viaclang-tidybugprone-use-after-move; code review guardrails aroundunique_ptr/shared_ptr.“What is strict aliasing and why is
reinterpret_cast<int*>(&f)dangerous?” — same-type-family rule; UB across unrelated types; usestd::bit_cast(C++20) ormemcpy.
If any of the seven above take you longer than a minute to answer cleanly, that’s your next study block.
Exercises¶
Write a 20-line program with signed overflow, compile at
-O0and-O2, and confirm the behavior changes. Add UBSan; watch it print the exact source line.Write a program that reads an uninitialized local and use MSan (in a Linux VM if needed) to prove the read is caught.
Take the SPSC queue from file 04 and deliberately break the memory ordering on the head store to
relaxed. Run under TSan on Linux/ARM (orqemu-aarch64). Confirm the race is caught.Turn on
-Wshadow -Wconversionon your NeetCode-150 repo (P2.1). Count how many new warnings appear. Fix or annotate all of them.
Nav: ← 05 Linux Syscalls for C++ · Phase 3 README · → Projects