Undefined behavior in C: works on my machine, not on yours
Undefined behavior in C, with examples whose output changes between -O0 and -O2, and how to catch it with -fsanitize=undefined, Valgrind and GCC warnings.
Your program gives the right result on your machine and a different one on your teammate's. Or it works perfectly until the day you add -O2 and a loop never stops. The compiler has no bug: your code contains undefined behavior, and from that point on, the standard promises nothing about what the program will do.
In short
Undefined behavior (UB) is a situation for which the C standard imposes no requirements: signed integer overflow, reading an uninitialized variable, accessing outside an array, modifying the same variable twice in one expression. The compiler is allowed to assume it never happens and optimizes accordingly, so the result changes with the optimization level, the compiler or the machine. To track it down, compile with -Wall -Wextra, then test with -fsanitize=undefined, -fsanitize=address and Valgrind.
What does undefined behavior mean?
The C standard (C23, section 3.5.3) defines undefined behavior as the behavior, upon use of an erroneous program construct or erroneous data, "for which this document imposes no requirements". No requirements, literally: the program may appear to work, print garbage, crash, or do something else depending on the optimizer's mood. A note in the standard lists the possibilities, from ignoring the situation with unpredictable results all the way to stopping the compilation or the execution.
Don't confuse it with two neighboring categories that the standard defines right next to it. They are far less dangerous, because the result stays within a known set:
| Category | What the standard says | Examples |
|---|---|---|
| implementation-defined | several choices allowed, each compiler documents its own | whether char is signed, right shift of a negative number, sizeof(int) |
| unspecified | several possibilities, the compiler picks one without having to document it | order of evaluation of a function's arguments or of the operands of + |
| undefined | no requirements | int overflow, uninitialized variable, out-of-bounds access, i = i++ |
The signedness of char is a good example of the first category. This program stores -1 in a char:
#include <stdio.h>
int main(void){ char c = -1;
printf("c = %d\n", c); return 0;}$ gcc -Wall -Wextra char.c -o char$ ./charc = 255$ gcc -Wall -Wextra -fsigned-char char.c -o char-signed$ ./char-signedc = -1On this ARM64 machine, the ABI makes char an unsigned byte: -1 becomes 255. The -fsigned-char option forces the other choice, and the same code prints -1. Both results are correct and documented, and a program that assumes either one is not portable. But nothing here is undefined: you know in advance it will be one or the other.
Evaluation order illustrates the second category:
#include <stdio.h>
int f(void) { printf("f "); return 1; }int g(void) { printf("g "); return 2; }
int main(void){ printf("= %d\n", f() + g()); return 0;}$ gcc -Wall -Wextra order.c -o order$ ./orderf g = 3GCC called f before g, but the standard allows it to do the opposite and print g f = 3. The total, on the other hand, will always be 3. With undefined behavior, even that total is no longer guaranteed.
An overflow check that -O2 deletes
The most famous case is signed integer overflow. The standard says that if the result of an operation falls outside the range of its type, the behavior is undefined (C23, 6.5.1). Here is a function that thinks it can detect the overflow after the fact:
#include <limits.h>#include <stdio.h>
int overflows(int x){ int next = x + 1;
return next < x;}
int main(void){ printf("overflows(INT_MAX) = %d\n", overflows(INT_MAX)); return 0;}$ gcc -Wall -Wextra -O0 overflow.c -o overflow-O0$ ./overflow-O0overflows(INT_MAX) = 1$ gcc -Wall -Wextra -O2 overflow.c -o overflow-O2$ ./overflow-O2overflows(INT_MAX) = 0Same code, same compiler, no warning, two answers. At -O0, GCC translates the code almost line by line: the processor performs the addition, the result wraps around and turns negative, and the test yields 1. At -O2, the optimizer reasons differently:
- next = x + 1overflows if x is INT_MAX
- no overflowthe compiler is allowed to assume it
- next > xso always true
- return 0the check is replaced by a constant
If overflow never happens, x + 1 is always greater than x, and next < x is always false. The check is removed, and your safeguard goes with it. -O0 doesn't protect you either: written as a single expression, return x + 1 > x;, the same idea gets simplified to return 1; at compile time, with no optimization at all.
$ gcc -Wall -Wextra -O0 bigger.c -o bigger-O0$ ./bigger-O0bigger(INT_MAX) = 1$ objdump -d --no-show-raw-insn bigger-O0 | grep -A8 "<bigger>:"0000000000400644 <bigger>: 400644: sub sp, sp, #0x10 400648: str w0, [sp, #12] 40064c: mov w0, #0x1 // #1 400650: add sp, sp, #0x10 400654: ret
0000000000400658 <main>: 400658: stp x29, x30, [sp, #-16]!bigger.c is the same program, with return x + 1 > x; in a function called bigger. If the addition really wrapped around, the answer would be 0. The disassembly shows there is no addition left at all: the function puts 1 in the return register (mov w0, #0x1) and returns.
The right way is to test before computing, by comparing x with the limit: x > INT_MAX - 1 cannot overflow. Since C23, <stdckdint.h> does the work for you:
#include <limits.h>#include <stdckdint.h>#include <stdio.h>
int main(void){ int sum;
if (ckd_add(&sum, INT_MAX, 1)) printf("INT_MAX + 1 overflows\n"); if (!ckd_add(&sum, INT_MAX - 1, 1)) printf("INT_MAX - 1 + 1 = %d\n", sum); return 0;}$ gcc -Wall -Wextra ckd.c -o ckd$ ./ckdINT_MAX + 1 overflowsINT_MAX - 1 + 1 = 2147483647ckd_add computes as if integers were infinite, stores the result, converted, in sum, and returns true when it didn't fit. The program compiles with no special option under GCC 15, which defaults to C23.
add_overflows
Write add_overflows, which tells whether a + b would fall outside the range of an int, without ever computing a + b. This is exactly the trap in overflow.c: the optimizer deletes a check written after the addition, so you have to compare a with INT_MAX - b or INT_MIN - b depending on the sign of b. The tests go right up to the exact limits.
A loop that becomes infinite at -O2
Apply the same reasoning to a loop and the result is more spectacular:
#include <stdio.h>
int main(void){ int iterations = 0;
for (int i = 1; i > 0; i *= 2) iterations++; printf("%d iterations\n", iterations); return 0;}$ gcc -Wall -Wextra -O0 loop.c -o loop-O0$ ./loop-O031 iterations$ gcc -Wall -Wextra -O2 loop.c -o loop-O2$ timeout 3 ./loop-O2$ echo $?124At -O0, i doubles up to 2^30, then 2^30 * 2 overflows, turns negative on this processor, and the loop stops after 31 iterations. At -O2, the program never stops: timeout kills it after 3 seconds and returns exit code 124. The disassembly shows what became of main:
$ objdump -d --no-show-raw-insn loop-O2 | grep -A6 "<main>:"0000000000400500 <main>: 400500: b 400500 <main> 400504: nop 400508: nop 40050c: nop 400510: nop 400514: nopA single useful instruction: b 400500, a jump to its own address. Starting at 1 and only ever doubling without overflowing, i stays positive; so the condition i > 0 is always true, the loop never ends, and everything after it, printf included, is never reached. The compiler kept an empty loop and threw away the rest.
An uninitialized variable
Reading a local variable that you never initialized gives an indeterminate value, and the compiler owes you no particular value. Here, sign(0) goes through neither of the two assignments:
#include <stdio.h>
int sign(int x){ int s;
if (x > 0) s = 1; else if (x < 0) s = -1; return s;}
int main(void){ printf("sign(0) = %d\n", sign(0)); return 0;}$ gcc -Wall -Wextra -O0 sign.c -o sign-O0$ ./sign-O0sign(0) = 2097151$ ./sign-O0sign(0) = 1507327$ gcc -Wall -Wextra -O2 sign.c -o sign-O2sign.c: In function 'sign':sign.c:11:12: warning: 's' may be used uninitialized [-Wmaybe-uninitialized] 11 | return s; | ^sign.c:5:9: note: 's' was declared here 5 | int s; | ^sign.c: In function 'main':sign.c:16:5: warning: 's' is used uninitialized [-Wuninitialized] 16 | printf("sign(0) = %d\n", sign(0)); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~sign.c:5:9: note: 's' was declared here 5 | int s; | ^$ ./sign-O2sign(0) = 0At -O0, the function returns whatever was lying on the stack, and it isn't even the same value from one run to the next. At -O2, it returns 0. Notice also that -Wall -Wextra said nothing at -O0: GCC's documentation states that these warnings depend on optimization. Compiling with -O2 -Wall from time to time therefore surfaces warnings you never see in a debug build.
i = i++: modifying the same variable twice
The standard makes undefined any expression in which the same variable is modified twice with no ordering imposed between the two modifications, or modified and read in that way (C23, 6.5.1). Its own examples are i = ++i + 1; and a[i++] = i;.
#include <stdio.h>
int main(void){ int i = 5;
i = i++; printf("i = %d\n", i); return 0;}$ gcc -Wall -Wextra -O2 incr.c -o incrincr.c: In function 'main':incr.c:7:7: warning: operation on 'i' may be undefined [-Wsequence-point] 7 | i = i++; | ~~^~~~~$ ./incri = 5GCC prints 5, at -O0 and at -O2 alike. That proves nothing: the standard does not forbid any compiler from printing 6, or anything else. The -Wsequence-point warning, enabled by -Wall, is the only reliable piece of information in that output. If you meant to increment, write i++; and nothing more.
Why is the compiler allowed to do this?
Because a correct C program contains no undefined behavior, and the compiler optimizes for correct programs. Knowing that an int never overflows lets it, for example, simplify x * 2 / 2 to x, or establish that a loop for (i = 0; i <= n; i++) runs exactly n + 1 times, which it could not claim if i were allowed to wrap back to INT_MIN. The price of these optimizations is that a program containing UB has no defined meaning at all anymore, not only at the faulty line. C23 only specifies that everything observable the program does before the faulty operation happens normally.
Two common misconceptions need correcting. Since C23, the standard requires two's complement representation for signed integers, but overflow is still undefined behavior: the representation says how the bits are laid out, not what INT_MAX + 1 equals. And unsigned integers never overflow in the standard's sense: their arithmetic is done modulo 2^N. UINT_MAX + 1 is 0, which is defined, portable, and often just as wrong for your program.
GCC offers -fwrapv, which defines signed overflow as wrapping around in two's complement:
$ gcc -Wall -Wextra -O2 -fwrapv overflow.c -o overflow-wrapv$ ./overflow-wrapvoverflows(INT_MAX) = 1The check in overflow.c works again. It is a GCC option, not standard C: your code remains wrong for another compiler, or without the option. It helps with existing code; it is not a reason to write after-the-fact checks.
checked_size
The unsigned version of the problem: checked_size tells whether n * size fits in a size_t. Here overflow is not undefined: it wraps around modulo 2^64, which is defined and wrong (SIZE_MAX * SIZE_MAX is 1). So you have to decide with a division, size > SIZE_MAX / n, and leave *out untouched when the product doesn't fit.
How to detect undefined behavior
No tool sees everything, and these four complement each other:
| Tool | When | What it catches |
|---|---|---|
-Wall -Wextra, at -O0 and at -O2 | at compile time | i = i++, uninitialized variables, some out-of-bounds accesses |
-fsanitize=undefined | at run time | signed overflow, out-of-bounds index into an array of known size, invalid shifts, division by zero |
-fsanitize=address | at run time | accesses outside allocated blocks, on the stack or the heap, use after free |
| Valgrind | at run time, on the binary as it is | uninitialized values, invalid heap accesses, leaks |
UndefinedBehaviorSanitizer (UBSan) is turned on at compile time and adds checks around risky operations, which run along with the program:
$ gcc -g -fsanitize=undefined overflow.c -o overflow-ubsan$ ./overflow-ubsanoverflow.c:6:9: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'overflows(INT_MAX) = 1$ gcc -g -O2 -fsanitize=undefined loop.c -o loop-ubsan$ ./loop-ubsanloop.c:7:30: runtime error: signed integer overflow: 1073741824 * 2 cannot be represented in type 'int'31 iterationsThe message gives the file, the line, the column and the exact values: 1073741824 * 2 is 2^30 times 2. Even at -O2, the instrumented loop ends after 31 iterations this time: the instrumentation changed the generated code. By default, UBSan reports and lets the program continue. The program does not crash, so read what it prints.
UBSan does not see uninitialized variables. On sign.c, it says nothing and the program still prints a random value. Valgrind is the one that finds this, with --track-origins=yes to learn where the value comes from:
$ gcc -g sign.c -o sign-g$ valgrind -q --track-origins=yes ./sign-g==13== Use of uninitialised value of size 8==13== at 0x490B9F8: _itoa_word (_itoa.c:183)==13== by 0x4915227: __printf_buffer (vfprintf-process-arg.c:155)==13== by 0x4917027: __vfprintf_internal (vfprintf-internal.c:1544)==13== by 0x490CBC7: printf (printf.c:33)==13== by 0x4006A3: main (sign.c:16)==13== Uninitialised value was created by a stack allocation==13== at 0x400644: sign (sign.c:4)...sign(0) = 0The error is reported inside printf, at the moment the value is actually used, and the last two lines trace it back to its source: a stack variable of sign, reserved on entry to the function (line 4). Out-of-bounds array accesses, finally, have their own complete method, with AddressSanitizer, in Segmentation fault in C.
Frequently asked questions
Does undefined behavior always crash the program?
No, and that is what makes it dangerous. Most of the time, the program carries on with a wrong value, or seems to work. A crash, such as a segmentation fault, is actually one of the better outcomes, because it shows you there is a problem.
Does compiling at -O0 protect me?
No. -O0 makes the result more predictable on your machine, but the standard adds no guarantee for it. GCC simplifies x + 1 > x to 1 even at -O0, and an uninitialized variable returns whatever is lying on the stack. And one day your program will be compiled with other options.
Is unsigned overflow undefined behavior?
No. Arithmetic on unsigned types is done modulo 2^N, where N is their number of bits: UINT_MAX + 1 is 0, everywhere. It is defined, but it is still a bug if your program did not expect it.
Where can I find the list of undefined behaviors?
Annex J.2 of the standard goes through them, each with a reference to the relevant section. The N3220 draft, free to download, is a near-final version of it. For a more readable presentation with examples, the "Undefined behavior" page on cppreference is a good place to start.
Sources
cppreference: Undefined behavior, with optimization examples
Chris Lattner, "What Every C Programmer Should Know About Undefined Behavior", LLVM blog, May 2011
GCC: instrumentation options, -fsanitize=undefined and -fsanitize=address
GCC: warning options, including -Wmaybe-uninitialized and -Wsequence-point
Arm: Procedure Call Standard for the Arm 64-bit Architecture, C types and char
