Undefined Behaviour
Some C code has no defined meaning at all: the standard says the program may do anything. This undefined behaviour (UB) isn't just "a crash" — optimising compilers assume it never happens and can transform your program in surprising ways. Knowing the common cases is essential for writing correct C.
Three kinds of "not fully defined"
| Category | Meaning | Example |
|---|---|---|
| Implementation-defined | Each compiler chooses, and documents, the behaviour | The size of int; whether char is signed |
| Unspecified | One of several outcomes; may differ each time | The order function arguments are evaluated |
| Undefined | No requirements at all | Signed overflow, out-of-bounds access, null dereference |
Implementation-defined and unspecified behaviour are fine as long as your program doesn't depend on a particular outcome. Undefined behaviour must be avoided completely.
Why UB is so dangerous
Because the compiler may assume UB never happens, it can remove checks that only make sense if it did. A classic example is an overflow check written after the overflow:
int will_overflow(int x) { return x + 1 < x; // signed overflow is UB, so the compiler may } // assume x + 1 < x is never true and return 0
With optimisation on, GCC and Clang typically compile this to "return 0". The program then behaves differently with -O0 and -O2, or after a compiler upgrade — the hallmark of UB bugs. The correct check compares before the operation: x == INT_MAX (lesson 8).
The most common cases
| Undefined behaviour | Lesson |
|---|---|
| Signed integer overflow | 8 |
| Division or remainder by zero | 13 |
| Shifting by a negative amount or by the type's width or more | 14 |
| Reading an uninitialised variable | 6 |
| Accessing an array or buffer out of bounds | 26, 38 |
| Dereferencing a null, dangling or freed pointer | 31, 38 |
| Modifying a string literal | 28 |
Modifying a variable twice without sequencing: i = i++ | 13 |
Mismatched printf/scanf format and argument types | 11 |
| A non-void function falling off the end when its value is used | 21 |
| Converting an out-of-range float to an integer | 12 |
| Accessing an object through a pointer of an incompatible type | 31 |
Catching UB with the sanitizer
-fsanitize=undefined (UBSan) adds run-time checks for many of these and reports each one with its location. The program keeps running afterwards, so you see every problem:
#include <stdio.h> #include <limits.h> volatile int sink; // results are stored here, never printed: their values are meaningless int main(void) { int big = INT_MAX; sink = big + 1; // signed overflow int shift = 40; sink = (int) (1u << shift); // shift wider than the type int numbers[4] = { 1, 2, 3, 4 }; int index = 4; sink = numbers[index]; // out of bounds printf("the program kept running\n"); return 0; }
the program kept running main.c:8:16: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int' main.c:11:22: runtime error: shift exponent 40 is too large for 32-bit type 'unsigned int' main.c:15:12: runtime error: index 4 out of bounds for type 'int[4]'
gcc -g -fsanitize=undefined main.c -o main && ./main
Each message names the exact line and column and what went wrong. (The program stores its results in a volatile variable instead of printing them, because undefined results can be anything — even different on every run.) Combine it with AddressSanitizer while testing: -fsanitize=address,undefined.
Unsequenced modifications
Changing a variable twice in one expression, or changing and reading it, without a sequence point between, is undefined. Compilers warn about the obvious cases:
#include <stdio.h> int main(void) { int i = 1; i = i++ + 1; int a[3] = { 0 }; int j = 0; a[j] = j++; printf("%d %d\n", i, a[0]); return 0; }
main.c:5:10: warning: multiple unsequenced modifications to 'i' [-Wunsequenced] main.c:8:13: warning: unsequenced modification and access to 'j' [-Wunsequenced]
The fix is always the same: split it into separate statements so the order is obvious.
Strict aliasing
Reading an object through a pointer to a different type (other than char/unsigned char) breaks the strict aliasing rule, and optimisers rely on it. To reinterpret bytes — say, the bits of a float — copy them with memcpy, which compilers optimise into a single instruction:
#include <stdio.h> #include <stdint.h> #include <string.h> int main(void) { float f = 1.0f; uint32_t bits; memcpy(&bits, &f, sizeof bits); // well-defined, unlike *(uint32_t *) &f printf("1.0f is stored as 0x%08X\n", (unsigned) bits); return 0; }
1.0f is stored as 0x3F800000
Defences
- Turn on warnings (
-Wall -Wextra, and consider-Wconversion) and treat them as errors. - Test with sanitizers —
-fsanitize=address,undefined— on every build of your test suite. - Check before you compute: validate divisors, indexes, shift amounts and ranges first.
- Prefer simple code: one side effect per statement, explicit casts, unsigned types for bit manipulation.
- Use static analysers such as
clang --analyze,cppcheckor GCC's-fanalyzer, which find bugs without running the code.
Try it yourself
Write a safe average function that can't overflow, even for very large int values, by doing the arithmetic in a wider type.
2147483646 -2