09. Undefined behaviour and the optimizer¶
Time: ~3 hours Prerequisites: modules 01 (how C works), 02 (integers and conversions), 03 (pointers)
1. In plain words¶
The C standard is a contract between you and the compiler. You promise to follow its rules.
The compiler promises that your program behaves as written, but only for runs that follow the
rules. When a run breaks a rule, for example a signed int overflows or a null pointer is
dereferenced, the standard says the behaviour is undefined. It does not mean "the hardware
does whatever it does". It means the contract is void. The compiler owes you nothing for that run.
The surprise is not that broken code crashes. The surprise is that the compiler uses the rules
as facts while optimising. If you dereference p, the compiler concludes "p is not null, or
the programmer broke the contract", and it deletes your later if (p == NULL) check. If you
write a + b < a, it concludes "signed addition cannot overflow", and it folds the whole check
to false. Your safety check disappears from the binary without a warning.
An analogy: a bank teller who is told "customers never present forged cheques". She does not check for forgery, because she was told it cannot happen. Every rule you break is a forged cheque the system was told it would never see. Usually nothing happens. Then one day, after a compiler upgrade, it does, and only in the release build.
This module teaches the categories, the catalogue of undefined behaviour (UB), real GCC 13 examples you can reproduce, and the flags, warnings and sanitizers that defend you.
2. The real thing¶
2.1 Three kinds of "the standard does not say exactly"¶
| Kind | Meaning | Must the compiler document it? | Examples |
|---|---|---|---|
| Implementation-defined | The compiler picks one behaviour and documents it | Yes | sizeof(int), whether plain char is signed, right shift of a negative int, converting an out-of-range value to a signed type |
| Unspecified | One of several allowed behaviours, chosen silently, may differ per call | No | order of evaluation of function arguments (f(a(), b())), whether two identical string literals share memory, the value of padding bytes |
| Undefined | No requirements at all, for the whole run | No | signed overflow, null dereference, out-of-bounds access, data race |
The difference matters. Implementation-defined code is portable if you check the documented choice (GCC documents its choices in the "C Implementation-Defined Behavior" chapter of its manual). Unspecified code is fine as long as every allowed outcome is acceptable to you. Undefined code is never fine, even when it "works".
Two more facts that interviewers like:
- UB is a property of a run, not of the source file.
a + bis perfectly defined fora = 1, b = 2. It is undefined only for the inputs that overflow. - UB can travel backwards in time. Because the compiler may assume UB never happens, it can reason "the code after this point would be UB if x were 0, so x is not 0", and that conclusion can change code that runs before the bad operation. You cannot rely on "it printed my log line before crashing, so everything before the crash ran as written".
2.2 The catalogue¶
These are the classes you must be able to name, show a one-line example of, and say how to catch.
| UB | One-line example | What the optimiser does with it | Catch it with |
|---|---|---|---|
| Signed integer overflow | INT_MAX + 1, x * 2 |
assumes it never happens: folds overflow checks, widens loop counters | UBSan signed-integer-overflow, -ftrapv, -fwrapv to define it |
| Null pointer dereference | int n = p->len; with p == NULL |
concludes p != NULL afterwards, deletes later null checks |
UBSan null, ASan, the crash itself |
| Out-of-bounds access | a[4] for int a[4]; also forming p + 5 for a 4-element array |
assumes indexes stay in range: may delete loop exits or return constants | ASan, UBSan bounds, -Warray-bounds |
| Reading an uninitialised local | int v; if (c) v = 1; return v; |
picks whatever value is convenient (often the other branch's value) | Valgrind, MSan (clang), -Wmaybe-uninitialized (not reliable) |
| Strict-aliasing violation | *(float *)&some_int = 0.0f; |
assumes an int * and a float * never point to the same object, keeps stale values in registers |
-Wstrict-aliasing (at -O2), -fno-strict-aliasing to switch the assumption off |
| Shift too wide or negative | 1u << 32, 1 << -1, -1 << 1 |
assumes the count is in range | UBSan shift, -Wshift-count-overflow for constants |
| Unsequenced modification | i = i++ + 1;, a[i] = i++; |
any order, or both, or neither | -Wsequence-point (in -Wall) |
| Data race | two threads, one writes, no lock and no atomic | assumes no other thread writes: hoists loads out of loops, merges stores | TSan |
Division by zero, INT_MIN / -1 |
x / 0 |
assumes the divisor is valid | UBSan integer-divide-by-zero, SIGFPE on x86 |
| Misaligned access | *(int *)(buf + 1) |
may emit instructions that require alignment (SSE, ARM) | UBSan alignment |
| Use after the lifetime ends | using freed memory, a dangling pointer to a local | anything (see 05_Memory_Debugging_RCA/04_the_catalogue_of_memory_bugs.md) | ASan, Valgrind |
| Bad arguments to library functions | memcpy(dst, NULL, 0), overlapping memcpy |
uses glibc's nonnull attributes as facts |
UBSan nonnull-attribute, ASan |
| Modifying a string literal | char *s = "abc"; s[0] = 'X'; |
literal is in read-only memory: SIGSEGV | the crash; declare const char * |
The last few are covered in modules 03, 04 and 05 of this course; this module concentrates on the ones where the optimiser turns a latent bug into a different program.
2.3 How the optimiser uses UB, step by step¶
Compilers do not hunt for UB in order to punish you. They run ordinary optimisations that are only correct if the rules hold:
- Value-range reasoning. For signed
x,x + 1 > xis "always true" in a world without overflow. GCC's constant folder rewrites it to1. With overflow defined as wrapping (-fwrapv) it cannot. - Facts from dereferences and attributes. After
*por after passingpto a parameter declarednonnull, the compiler records "p is not null" and deletes any later test of it. GCC's flag for this is-fdelete-null-pointer-checks, on by default at-O2. - Type-based alias analysis (TBAA). The strict aliasing rule (C17 6.5p7) says an object may
only be accessed through its own type, a compatible type, a signed or unsigned variant, or a
character type. So a store through
float *cannot change anint, and the compiler keeps theintin a register across it. - Loop analysis. A loop
for (int i = 0; i <= n; i++)with a signed counter cannot wrap, so it runs exactlyn + 1times andican be promoted to a 64-bit register without sign-extension on every iteration. Withunsigned ithe compiler must allow for wraparound. This is the legitimate performance reason signed overflow is UB.
None of these is a bug in the compiler. All of them turn your bug into surprising machine code.
2.4 The flags that change the contract¶
| Flag | Effect | Cost |
|---|---|---|
-fwrapv |
Signed overflow wraps (two's complement) and is no longer UB | Loses some loop optimisations |
-ftrapv |
Signed overflow aborts at run time | Slow; GCC's implementation is incomplete; prefer UBSan |
-fno-strict-aliasing |
Disables TBAA; any pointer may alias any object | Fewer values kept in registers; often a few percent in hot loops (typical, measure yours) |
-fno-delete-null-pointer-checks |
Keeps null checks after dereferences | Tiny |
-fno-strict-overflow |
In current GCC, the same as -fwrapv plus pointer wrapping |
As -fwrapv |
The Linux kernel builds with -fno-strict-aliasing, -fno-delete-null-pointer-checks and
-fno-strict-overflow: kernel code does type punning and low-address tricks on purpose, and
its maintainers decided the safety was worth the speed. These flags are a safety net for
legacy code, not a licence to write new UB. Code that needs them is still non-portable: MSVC
and Clang have their own, different sets of switches.
2.5 Tools: warnings first, sanitizers second¶
Warnings catch the forms that are visible in one function. This is real GCC 13 output for a file
with four classic mistakes (gcc -std=c17 -Wall -Wextra -O2 -c w1.c):
w1.c:2:22: warning: operation on 'i' may be undefined [-Wsequence-point]
2 | int unseq(int i) { i = i++ + 1; return i; }
w1.c:3:41: warning: 'sizeof' on array function parameter 'a' will return size of 'int *' [-Wsizeof-array-argument]
3 | size_t param(int a[10]) { return sizeof a; }
w1.c:4:28: warning: dereferencing type-punned pointer will break strict-aliasing rules [-Wstrict-aliasing]
4 | float pun(int x) { return *(float *)&x; }
The fourth mistake was int maybe(int c) { int v; if (c) v = 1; return v; }. GCC 13 printed
no warning for it at -O2: the optimiser had already decided that v is 1 on both paths
(it is allowed to, because reading the uninitialised v is UB) and then had nothing left to warn
about. Lesson: -Wmaybe-uninitialized is useful but not a proof. Valgrind and MSan are the tools
for uninitialised reads.
Sanitizers check at run time, on the inputs you actually run:
| Sanitizer | Flag | Catches | Overhead (typical) |
|---|---|---|---|
| UBSan | -fsanitize=undefined |
overflow, shifts, null, bounds (static arrays), alignment, bad nonnull args, invalid bool/enum loads |
small, usable in test builds |
| ASan | -fsanitize=address |
out-of-bounds heap/stack/global, use-after-free, double free, leaks | about 2x CPU, 2-3x memory |
| TSan | -fsanitize=thread |
data races, some lock-order problems | 5-15x CPU |
| MSan | -fsanitize=memory (clang only) |
uninitialised reads | about 3x |
Make UBSan fatal in CI with -fno-sanitize-recover=all, otherwise it prints and carries on and a
test that "passed" may have hit UB. The deep dive on all sanitizers is
05_Memory_Debugging_RCA/08_valgrind_and_sanitizers.md; the flag reference is
06_Linux_Toolchain_DevOps/03_gcc_and_clang_flags_that_matter.md.
3. Code¶
Four programs in code/. Each says in its header comment that it contains deliberate UB, gives
the exact build line, and still compiles cleanly with
gcc -std=c17 -Wall -Wextra -Wpedantic -Werror. The UB functions are marked
__attribute__((noinline)) and fed through volatile variables so the optimiser cannot fold
the whole demo at compile time.
3.1 The overflow check that disappears (m09_overflow_check.c)¶
/* WRONG: by the time we compare, the overflow (UB) has already happened. */
__attribute__((noinline)) bool naive_add_overflows(int a, int b)
{
return b > 0 && a + b < a;
}
/* RIGHT 1: compare against the limit BEFORE adding. Works on every compiler. */
static bool add_overflows_precheck(int a, int b)
{
if (b > 0) return a > INT_MAX - b; /* INT_MAX - b cannot overflow when b > 0 */
if (b < 0) return a < INT_MIN - b; /* INT_MIN - b cannot overflow when b < 0 */
return false;
}
/* RIGHT 2: let the compiler do it. C23 standardises this as ckd_add() (GCC 14+). */
static bool add_overflows_builtin(int a, int b, int *sum)
{
return __builtin_add_overflow(a, b, sum);
}
/* RIGHT 4: the size_t check you need before every malloc(count * size). */
static bool mul_size_overflows(size_t count, size_t size)
{
return size != 0 && count > SIZE_MAX / size;
}
Real results for naive_add_overflows(INT_MAX, 1) with GCC 13.3 on x86-64:
| Build | Result | Why |
|---|---|---|
-O0 |
0 |
folded even without optimisation: GCC's front end rewrites a + b < a to b < 0 |
-O2 |
0 |
same, and the function body becomes xorl %eax,%eax; ret |
-O2 -fwrapv |
1 |
overflow is now defined as wrapping, so the fold is not allowed |
-O0 -g -fsanitize=undefined |
1 plus a report |
UBSan instruments the add, which blocks the fold |
The -O2 assembly, from gcc -std=c17 -O2 -S -o - m09_overflow_check.c:
The UBSan report:
m09_overflow_check.c:30:23: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
The point to remember: "it fails at -O2 but works at -O0" is a common sign of UB, but not a
reliable test. Here even -O0 was affected.
3.2 The null check that disappears (m09_null_check_deleted.c)¶
/* The CVE-2009-1897 shape (Linux tun driver): use the pointer first, check it later. */
__attribute__((noinline)) int record_len(const struct record *r)
{
int n = r->len; /* line A: if r were NULL, this is already UB ... */
if (r == NULL) /* line B: ... so GCC deletes this at -O2 */
return -1;
return n;
}
/* A version whose deletion you can SEE at run time without crashing. */
__attribute__((noinline)) int copy_then_check(char *dst, const char *src, size_t n)
{
memcpy(dst, src, n); /* UB if src == NULL, even with n == 0 */
if (src == NULL) /* GCC: "src went to a nonnull parameter, so it is not NULL" */
return -1;
return 0;
}
The assembly of record_len at -O2, then with -fno-delete-null-pointer-checks:
record_len: record_len:
endbr64 endbr64
movl (%rdi), %eax testq %rdi, %rdi
ret movl $-1, %eax
cmovne (%rdi), %eax
ret
copy_then_check(dst, NULL, 0) at run time:
| Build | Result |
|---|---|
-O0 |
-1 (check survived) |
-O2 |
0 (check deleted) |
-O2 -fno-delete-null-pointer-checks |
-1 |
| UBSan | -1, plus runtime error: null pointer passed as argument 2, which is declared to never be null |
memcpy(dst, NULL, 0) "works" on every C library you will meet, which is exactly why this bug
survives code review. The fix is boring: validate first, and skip the call when n == 0.
3.3 Strict aliasing (m09_strict_aliasing.c)¶
__attribute__((noinline)) int store_both(int *i, float *f)
{
*i = 1;
*f = 0.0f; /* a float store "cannot" change an int ... */
return *i; /* ... so at -O2 this returns the cached 1 */
}
Called as store_both(&x, (float *)&x):
-O0 store_both returned 0, x now holds bits 0x00000000
-O2 store_both returned 1, x now holds bits 0x00000000
-O2 -fno-strict-aliasing store_both returned 0, x now holds bits 0x00000000
At -O2 the function returns 1 while memory holds 0. That is the shape of the real bug: a value
in a register disagrees with memory. The three legal ways to reinterpret bytes:
uint32_t u; memcpy(&u, &f, sizeof u); /* 1. memcpy: compiles to one move */
union { float f; uint32_t u; } pun = { .f = f }; /* 2. union: legal in C, not in C++ */
unsigned char first = *(const unsigned char *)&one; /* 3. character types may alias anything */
The same file parses a wire header that sits at an odd offset in a buffer with memcpy into a
local struct, which is also the cure for misaligned access.
3.4 Let UBSan name each class (m09_ubsan_catalogue.c)¶
With no argument it runs only safe helpers (checked_add, safe_shl, safe_div, safe_get).
With an argument it triggers one class. Real output of the UBSan build
(gcc -std=c17 -Wall -Wextra -Wpedantic -Werror -O0 -g -fsanitize=undefined ..., then ./m09_ubsan_catalogue_ub all):
--- add
m09_ubsan_catalogue.c:34:56: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
returned -2147483648
--- mul
m09_ubsan_catalogue.c:35:56: runtime error: signed integer overflow: 2147483647 * 2 cannot be represented in type 'int'
returned -2
--- negate
m09_ubsan_catalogue.c:36:46: runtime error: negation of -2147483648 cannot be represented in type 'int'; cast to an unsigned type to negate this value to itself
returned -2147483648
--- shift_wide
m09_ubsan_catalogue.c:37:53: runtime error: shift exponent 32 is too large for 32-bit type 'unsigned int'
returned 1
--- shift_neg
m09_ubsan_catalogue.c:38:48: runtime error: shift exponent -1 is negative
returned -2147483648
--- shift_lneg
m09_ubsan_catalogue.c:39:55: runtime error: left shift of negative value -1
returned -2
--- bounds
m09_ubsan_catalogue.c:43:15: runtime error: index 4 out of bounds for type 'int [4]'
m09_ubsan_catalogue.c:43:15: runtime error: load of address 0x7fff75a10040 with insufficient space for an object of type 'int'
returned 1973485664
--- misaligned
m09_ubsan_catalogue.c:49:12: runtime error: load of misaligned address 0x7fff75a10051 for type 'int', which requires 4 byte alignment
returned 0
(The hex-dump lines UBSan prints under the bounds and alignment reports are left out; addresses
and the garbage value from bounds change from run to run.)
Look at shift_wide: 1u << 32 returned 1, not 0. The x86 shl instruction masks the count
to 5 bits, so the hardware computed 1 << 0. Another CPU, or a constant the compiler folds, gives
0. "What the hardware does" is not a definition. The cases that trap after the report:
$ ./m09_ubsan_catalogue_ub null
m09_ubsan_catalogue.c:54:12: runtime error: load of null pointer of type 'int'
Segmentation fault (core dumped)
$ ./m09_ubsan_catalogue_ub div0
m09_ubsan_catalogue.c:56:49: runtime error: division by zero
Floating point exception (core dumped)
Build with -fno-sanitize-recover=all and the first report ends the run with exit status 1,
which is what a CI job needs.
4. Under the hood (senior-level)¶
Where in GCC each example happened. The overflow fold is done by fold-const.cc while the
front end builds the expression tree, which is why it appears even at -O0. The null-check
deletion is value-range propagation (the tree-vrp and related passes) using "non-null" facts
derived from dereferences and from __attribute__((nonnull)) on glibc's memcpy declaration.
GCC goes further with -fisolate-erroneous-paths-dereference (on at -O2): if a path is certain
to dereference null, it replaces that path with a trap instruction (ud2), so you get SIGILL
instead of SIGSEGV. The aliasing result comes from type-based alias analysis: every type gets an
alias set, and loads and stores from disjoint sets are allowed to be reordered or forwarded.
char, signed char and unsigned char have alias set 0, which conflicts with everything.
Why int loop counters are faster. On x86-64 an int index must be sign-extended to 64
bits before it can be used in an address. If i could wrap, the compiler would have to redo
that extension every iteration. Because signed overflow is UB, GCC can compute the index in a
64-bit register once ("induction variable widening") and vectorise the loop knowing its exact
trip count. With -fwrapv or an unsigned counter that can wrap, some of these transformations
are lost. This is the honest argument for why the language keeps signed overflow undefined.
How UBSan works. It is compile-time instrumentation, not a library trick. For a + b with
-fsanitize=signed-integer-overflow, GCC emits the add with an overflow-flag check and, on
overflow, calls __ubsan_handle_add_overflow with a static descriptor holding the file, line,
column and type. That is why the report is so precise, and why instrumented code is not folded
the same way: the instrumentation itself uses the result. -fsanitize-trap=undefined replaces
the handler calls with a trap instruction and needs no runtime library, which makes it usable in
constrained environments.
LLVM's model, for completeness. Clang represents "this value came from UB" as poison.
Poison propagates through arithmetic and only becomes true UB when it reaches something that
matters (a branch, a memory address, a call). That model explains why a compiler can delete
code before the bad operation: the branch that depends on poison may be resolved either way.
The data-race rule is the same idea. C11 5.1.2.4 makes a data race UB so the compiler may
keep a shared variable in a register inside a loop. A while (!stop) {} loop on a plain
int stop may legally become an infinite loop that never reloads stop. volatile stops that
particular reload problem but still leaves a race; _Atomic is the fix (module 14).
5. In production¶
- MSVC to GCC ports expose UB that "worked for years". MSVC does not perform type-based
alias analysis, so code that casts
charbuffers tostructpointers or punnsintandfloatusually behaves as written there. Built with GCC or Clang at-O2on Linux, the same code can read stale values. If you port parts of a Win32 server to Linux, either fix the punning withmemcpyor build those files with-fno-strict-aliasinguntil you have. - Compiler upgrades are a risk event. Each GCC release learns new facts to use. A length
check like
if (offset + len < offset)on signed values can pass review and testing for years and silently vanish after an upgrade. Run the test suite under UBSan before and after any compiler change, and keep-Wall -Wextra -Werroron. - "Only fails in the release build" playbook. (1) Rebuild the release flags plus
-fsanitize=undefined,addressand rerun the failing case. (2) If that is clean, toggle the contract flags one at a time: if-fno-strict-aliasingmakes it disappear, hunt for punning; if-fwrapvdoes, hunt for overflow; if-fno-delete-null-pointer-checksdoes, look for a check after a use. (3) Bisect the optimisation level per file (#pragma GCC optimizeor per-object flags) to find which translation unit is affected. Record the finding in the RCA as "latent UB exposed by optimisation", not "compiler bug": it almost never is the compiler. - Untrusted lengths make UB a security problem. In a banking server, lengths come from
network messages and files. Signed overflow in a length check, or a size multiplication that
wraps, is the first step of a remotely triggered memory corruption. Do size arithmetic in
size_twith theSIZE_MAX / sizecheck from 3.1, or with__builtin_mul_overflow. - Do not "fix" UB with
volatileor by lowering the optimisation level. That hides the symptom on today's compiler. Fix the operation, then add the UBSan run to CI so it stays fixed.
6. Try it¶
- Easy - classify. For each, say implementation-defined, unspecified or undefined, with one
line of reasoning:
sizeof(long);f(g(), h())call order;-1 >> 1;INT_MAX + 1;(int)3000000000u;char *s = "hi"; s[0] = 'H';. Done when all six are classified and you can name the clause of the contract each one breaks or relies on. - Easy - reproduce. Build
m09_overflow_check.cfour ways (-O0,-O2,-O2 -fwrapv, UBSan) and fill in the table from 3.1 yourself. Done when your numbers match and you can explain the-O0result. - Medium - see the deletion. Generate assembly for
m09_null_check_deleted.cat-O2with and without-fno-delete-null-pointer-checks. Done when you can point at the instruction that holds the null check in one listing and say why it is missing from the other. - Medium - aliasing in a parser. Write
uint32_t read_be32(const unsigned char *p)three ways: pointer cast plusntohl,memcpyplusntohl, and shifts. Call each onbuf + 1. Build with-O2 -fsanitize=undefined. Done when you can say which version UBSan reports and why, and which one is portable withoutntohl. - Hard - the loop that never ends. Write a loop
for (int i = 1; i > 0; i += i)that counts iterations. Build at-O0and-O2, run each withtimeout 2. Explain the difference using section 2.3. Done when you have the two outputs (or the timeout) and a three-sentence explanation. - Hard - audit legacy code. Take any C file you know from work (or
m15_legacy_report.c) and build it with-O2 -Wall -Wextra -Wstrict-aliasing=2 -fsanitize=undefined. Run its tests. Done when you have a list of every warning and report with a one-line verdict each: real UB, false positive, or implementation-defined.
7. Interview questions¶
Q: What is the difference between undefined, unspecified and implementation-defined behaviour?
A: Implementation-defined behaviour is a choice the compiler must document, such as the size of
int or whether char is signed. Unspecified behaviour is one of several allowed outcomes
chosen without documentation, such as the order in which function arguments are evaluated.
Undefined behaviour has no requirements at all: the standard places no constraint on the whole
run, and the optimiser may assume it never happens.
Q: Why might if (a + b < a) fail to detect overflow?
A: For signed int, overflow is undefined, so the compiler may assume a + b does not overflow
and simplify a + b < a to b < 0. With b > 0 the check becomes constant false; GCC 13 does
this even at -O0. Check before adding (a > INT_MAX - b) or use __builtin_add_overflow or
C23 ckd_add.
Q: How can the compiler remove a null check?
A: If the pointer was already dereferenced, or passed to a function whose parameter is declared
nonnull such as glibc's memcpy, then a null value would already have caused UB, so the
compiler infers the pointer is non-null and deletes later tests. This was the root of the 2009
Linux tun driver exploit. Check before use, or build legacy code with
-fno-delete-null-pointer-checks.
Q: What is the strict aliasing rule and how do you type-pun legally?
A: An object may only be accessed through an lvalue of its own (compatible) type, a signed or
unsigned variant, a qualified version, an aggregate containing it, or a character type. Writing
an int through a float * breaks it, and GCC at -O2 may keep the old int in a register.
Pun with memcpy (portable, compiles to a register move), with a union in C (not in C++), or
read the bytes through unsigned char *.
Q: Your service works in debug and fails in release. How do you investigate?
A: Treat it as likely UB or a race exposed by optimisation. Rebuild release flags with UBSan and
ASan and rerun the failing input; for threads, run TSan. If the sanitizers are quiet, toggle
-fno-strict-aliasing, -fwrapv and -fno-delete-null-pointer-checks one at a time to find
the class, then narrow the translation unit by building files at different optimisation levels.
Q: Is volatile a fix for undefined behaviour?
A: No. volatile forces every access to happen in memory, which suppresses some optimisations,
but it does not make signed overflow, aliasing violations or data races defined. It is meant for
memory-mapped hardware and for volatile sig_atomic_t flags set by signal handlers. Fix the
operation instead.
Q: What does -fsanitize=undefined do, and why add -fno-sanitize-recover=all in CI?
A: It instruments operations such as signed arithmetic, shifts, array indexing and pointer
alignment with checks that call a runtime handler on failure and print the exact file, line and
values. By default it prints and continues, so a test can pass while hitting UB;
-fno-sanitize-recover=all makes the first report abort the run so CI fails.
8. Recap¶
- Implementation-defined = documented choice; unspecified = undocumented choice among allowed outcomes; undefined = no requirements for the whole run.
- The optimiser treats the rules as facts: no signed overflow, no null dereference, no out-of-bounds access, no aliasing across unrelated types, no data races.
- Real GCC 13 results: the naive overflow check folds to
false(even at-O0); a null check aftermemcpy(dst, NULL, 0)is deleted at-O2; anintwritten throughfloat *reads back stale at-O2. - Correct overflow checks compare against the limit before the operation, or use
__builtin_add_overflow/ C23ckd_add; size products usecount > SIZE_MAX / size. - Pun types with
memcpy, a union (C only) orunsigned char *; never by casting pointers. -fwrapv,-fno-strict-aliasingand-fno-delete-null-pointer-checkschange the contract; they are a safety net for legacy code, not a fix.- Warnings catch local forms (
-Wsequence-point,-Wstrict-aliasing,-Wsizeof-array-argument), but they are not proof; GCC 13 did not warn on an obvious uninitialised read. - UBSan for arithmetic and pointer UB, ASan for memory, TSan for races, Valgrind or MSan for
uninitialised reads;
-fno-sanitize-recover=allin CI. - A compiler upgrade or an MSVC-to-GCC port can turn latent UB into a production incident.