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.

By
Published on

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:

CategoryWhat the standard saysExamples
implementation-definedseveral choices allowed, each compiler documents its ownwhether char is signed, right shift of a negative number, sizeof(int)
unspecifiedseveral possibilities, the compiler picks one without having to document itorder of evaluation of a function's arguments or of the operands of +
undefinedno requirementsint 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:

char.c
#include <stdio.h>
int main(void){    char c = -1;
    printf("c = %d\n", c);    return 0;}
Terminal
$ gcc -Wall -Wextra char.c -o char$ ./charc = 255$ gcc -Wall -Wextra -fsigned-char char.c -o char-signed$ ./char-signedc = -1

On 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:

order.c
#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;}
Terminal
$ gcc -Wall -Wextra order.c -o order$ ./orderf g = 3

GCC 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:

overflow.c
#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;}
Terminal
$ 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) = 0

Same 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:

  1. next = x + 1overflows if x is INT_MAX
  2. no overflowthe compiler is allowed to assume it
  3. next > xso always true
  4. return 0the check is replaced by a constant
How the check disappears at -O2

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.

Terminal
$ 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:

ckd.c
#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;}
Terminal
$ gcc -Wall -Wextra ckd.c -o ckd$ ./ckdINT_MAX + 1 overflowsINT_MAX - 1 + 1 = 2147483647

ckd_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.

C

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.

Open the exercise

A loop that becomes infinite at -O2

Apply the same reasoning to a loop and the result is more spectacular:

loop.c
#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;}
Terminal
$ 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 $?124

At -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:

Terminal
$ 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:	nop

A 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:

sign.c
#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;}
Terminal
$ 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) = 0

At -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;.

incr.cAvoid
#include <stdio.h>
int main(void){    int i = 5;
    i = i++;    printf("i = %d\n", i);    return 0;}
Terminal
$ 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 = 5

GCC 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:

Terminal
$ gcc -Wall -Wextra -O2 -fwrapv overflow.c -o overflow-wrapv$ ./overflow-wrapvoverflows(INT_MAX) = 1

The 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.

C

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.

Open the exercise

How to detect undefined behavior

No tool sees everything, and these four complement each other:

ToolWhenWhat it catches
-Wall -Wextra, at -O0 and at -O2at compile timei = i++, uninitialized variables, some out-of-bounds accesses
-fsanitize=undefinedat run timesigned overflow, out-of-bounds index into an array of known size, invalid shifts, division by zero
-fsanitize=addressat run timeaccesses outside allocated blocks, on the stack or the heap, use after free
Valgrindat run time, on the binary as it isuninitialized 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:

Terminal
$ 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 iterations

The 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:

Terminal
$ 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) = 0

The 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

Now write it yourself.