C / UNDEFINED BEHAVIOUR AND DEFENSIVE C
Undefined behaviour and why the compiler exploits it
Explain what undefined behaviour means in C and predict how an optimiser turns your assumptions into deleted checks and results that look impossible.
What you will learn
- Tell undefined, unspecified and implementation-defined behaviour apart.
- Read each UB rule as a precondition you must establish before the operation.
- Spot guards that the optimiser can legally delete and move them before the first use.
- Explain why a UB bug can appear only at -O2 or only after inlining.
Understanding Undefined behaviour and why the compiler exploits it
The C standard is written as a contract: it says what a program means as long as the program obeys certain rules, and for a program that breaks one of those rules it explicitly imposes no requirements at all. That phrase is the whole definition of undefined behaviour, and it is a much stronger statement than the two neighbouring categories. Implementation-defined behaviour, such as the size of int or whether plain char is signed, must be chosen and documented by each compiler; unspecified behaviour, such as which of two function arguments is evaluated first, must be one of a fixed set of possibilities. Undefined behaviour has no such set to choose from, and the absence of requirements covers the entire execution, not merely the offending statement.
Compilers exploit this because optimisation is nothing but proving facts and rewriting code that follows from them. Every operation whose definedness depends on a condition hands the optimiser that condition for free: after *p executes, p was not null; after x / d, d was not zero; after x << k on a 32-bit int, k was below 32. Those facts flow forward through ordinary machinery like constant folding, range propagation and dead code elimination, so a later if (p == NULL) becomes if (0) and disappears. None of this is punitive. The compiler has no way to represent "p might be null even though the program already dereferenced it", because a program that does that has no meaning left to preserve.
So the working model is: treat every UB rule as a precondition and establish it before the operation, never after. That single shift explains the shape of real UB bugs, which surface when a build switches to -O2, when a function is finally inlined so that the premise and the check land in the same function where facts can propagate, or when a compiler upgrade adds one more inference. It also explains why experiments prove nothing here: "it happened to work" is one of the permitted behaviours, so the argument that your code is free of UB has to come from reading the code and from tooling, not from running it once.
<stdio.h>
/* Dereferencing p is a promise that p is not NULL, so the compiler is
allowed to treat the test below as always false and delete it. */
static int scale(int *p)
{
int v = *p;
if (p == NULL)
return 0;
return v * 2;
}
/* Same intent, but nothing is assumed about p until after the check. */
static int scale_safe(int *p)
{
if (p == NULL)
return 0;
return *p * 2;
}
int main(void)
{
int n = 21;
printf("scale(&n) = %d\n", scale(&n));
printf("scale_safe(&n) = %d\n", scale_safe(&n));
printf("scale_safe(NULL) = %d\n", scale_safe(NULL));
/* scale(NULL) is deliberately never called: it would be undefined
behaviour, and the guard inside scale cannot rescue it. */
return 0;
}
Undefined behaviour is not an error the compiler must report; it is a precondition the compiler is entitled to assume you satisfied, and it optimises on that assumption.
Worked examples
A shift count guard that survives optimisation
Shows a precondition established before the operation that depends on it, using a portable width computation.
<stdio.h>
<limits.h>
/* x << k is undefined when k is negative or at least the width of x. */
static unsigned shift_left(unsigned x, unsigned k)
{
if (k >= sizeof(unsigned) * CHAR_BIT)
return 0; /* our decision, not the standard's */
return x << k;
}
int main(void)
{
printf("%u\n", shift_left(1u, 3));
printf("%u\n", shift_left(1u, 1000));
return 0;
}
Example explained
Line 1sizeof(unsigned) * CHAR_BIT computes the width in bits at compile time, so the guard is right whether unsigned is 16, 32 or 64 bits wide.
Line 2The comparison runs before the shift, so the shift never becomes evidence that k was small and the test cannot be folded away.
Line 3Returning 0 for an oversized count is a choice we document; C defines no value for 1u << 1000, not zero and not a wrapped result.
Line 4Unsigned operands do not help: wrapping is defined for unsigned arithmetic, but the shift-count limit is a separate rule that still applies.
UB the type system happily accepts
Demonstrates that modifying a string literal is undefined even though the code compiles without complaint.
<stdio.h>
int main(void)
{
char *lit = "hello"; /* points at a literal you may not modify */
char buf[] = "hello"; /* a private, modifiable copy */
/* lit[0] = 'H'; would be undefined behaviour, and it compiles cleanly */
buf[0] = 'H';
printf("%s %s\n", lit, buf);
return 0;
}
Example explained
Line 1char *lit = "hello"; gives lit the type char *, so lit[0] = 'H' passes type checking; the prohibition lives in the standard, not in the compiler's rules for assignment.
Line 2char buf[] = "hello"; copies six bytes including the terminator into an array with automatic storage, so buf[0] = 'H' is fully defined.
Line 3Literals usually sit in a read-only section and the write faults, but "usually faults" is not a guarantee: equal literals may be shared, so a write that succeeds could change other strings.
Line 4Compiling with -Wwrite-strings gives literals the type const char[], which turns this whole family of mistakes into a diagnostic instead of silence.
Important notes
UB is a property of an execution, not of source text: a division that would be by zero is harmless if that branch never runs, which is exactly why such bugs survive years of testing.
Because nothing in an execution containing UB is guaranteed, lines already handed to stdout can vanish when the process dies with an unflushed buffer, so the last output you see is often not the last code that ran.
Common mistakes
Reading UB as "the program will crash": the code runs fine once, ships, and the deleted null check later shows up as a silent write through a null pointer.
Placing the guard after the first use, as in int v = *p; if (!p) return -1; — it reads as defensive, compiles cleanly, and is removed as dead code.
Concluding from one experiment that a UB construct is safe on this compiler; a later inlining decision or version bump changes the result, because the observed behaviour was never a promise.
Try it yourself
Change, predict, then run
Write a function int first(int *p) { int v = *p; if (p == NULL) return -1; return v; } and call it from main with the address of a local int, then write a second version with the NULL check first. Confirm both print the same value, and explain in a comment why that identical output is what makes the broken version easy to ship.
Open the C workspaceCheck your understanding
A function begins with int v = *p; and then has if (p == NULL) return -1;. At -O2 the check is gone from the generated code. What is the reason?
- The optimiser detected the undefined behaviour and stripped the error handling as a diagnostic.
- Comparing a pointer against NULL is itself undefined behaviour, so optimised code cannot contain such a test.
- *p is defined only when p is non-null, so the compiler may carry p != NULL as a fact and the later test folds to false.
- -O2 discards all NULL tests unless the pointer is declared volatile.
Show answer
Dereferencing p is defined only for a non-null p, so from that point the optimiser is entitled to treat p != NULL as known; the later comparison becomes a constant and ordinary dead code elimination removes the branch. The first option is the tempting one and has the direction backwards: the compiler is under no obligation to notice the bug and is not punishing you, it simply has nothing to preserve for a program with no defined meaning. Comparing a pointer with NULL is perfectly well defined, and volatile plays no role here.