UB or not UB: How gcc and clang handle statically known undefined behaviour 25 June 2024

Recently, we had a discussion in our team about undefined behaviour (UB) in C. For those unfamiliar: We say that a program has undefined behaviour when we write code where the language specification doesn't define what should happen during execution. That means compilers can do whatever they like if they encounter such code and there is no guarantee that execution will behave in a predictable way. Thus, undefined behaviour must be avoided at all cost as it not only makes programs misbehave but is also a common source of security vulnerabilities. Examples of code that has undefined behaviour are out-of-bounds indexing of an array, integer overflow, division by zero, and nullpointer dereferencing [1].

Compilers often use undefined language semantics to make assumptions about the program. For example, if we write something like int x = y/z , then the compiler may assume that z must not be zero, since division by zero is undefined, and programmers surely wouldn't write undefined code. It can then use that information to further optimise the program:

Program int main ( int argc ) { int div = 5 / argc ; if ( argc == 0 ) { printf ( "A

" ); } else { printf ( "B

" ); } return div ; } gcc -O2 .LC0: .string "A" .LC1: .string "B" main: mov eax , 5 xor edx , edx push rbx idiv edi mov ebx , eax test edi , edi jne .L2 mov edi , OFFSET FLAT : .LC0 call puts .L1: mov eax , ebx pop rbx ret .L2: mov edi , OFFSET FLAT : .LC1 call puts jmp .L1 clang -O2 main: push rbx mov ebx , edi lea rdi , [ rip + .Lstr ] call puts@PLT mov eax , 5 xor edx , edx idiv ebx pop rbx ret .Lstr: .asciz "B"

argc

if (argc == 0)

Statically known undefined behaviour

I need a h zero

In this example, we can see that clang used the fact that division by zero is undefined and thusmust not be zero to entirely remove the condition, knowing this case can never happen [2] While I knew that compilers can do clever optimisations when assuming that no UB may exists in the program, I was wondering what they do when they, or in other words, when we force the compiler to compile code that both we and the compiler know is undefined. Eager to find excuses to use Compiler Explorer , I did some quick experiments. For many these results may not be surprising (and the experiments, if you can even call them that, are certainly not exhaustive), but they satisfied my curiosity, and, by putting this out there, I hope others may get some value from this too.

The simplest program I could think of that forces UB in C is division by zero with a constant. The program and its output by gcc (v14.1) and clang (v18.1) compiled to x86_64 are shown below:

Program int main ( int argc ) { int ub = argc / 0 ; return ub ; } gcc -O2 main: ud2 clang -O2 main: ret

During compilation, both gcc and clang give a warning:

:2:17: warning: division by zero [-Wdiv-by-zero] 2 | int ub = argc / 0; | ^

However, while gcc compiled the program to a single (illegal instruction) ud2 , clang reduced it to a ret . Under UB, both approaches are valid, yet they are very different: one crashes the program, while the other ignores the problematic code [3].

What if we changed the program slightly by replacing the constant inside the division with a variable:

Program int main ( int argc ) { int i = 0 ; int ub = argc / i ; return ub ; } gcc -O2 -Wall main: ud2 clang -O2 -Wall main: ret

While the compiled programs stayed the same, we no longer get a warning (even with -Wall ), even though both compilers can easily work out statically (e.g. via constant folding) that a division by zero occurs [4].

No guarantees

Let's add some more code before the division-by-zero line and see how this affects the output:

Program int main ( int argc ) { int i = 0 ; printf ( "before" ); int ub = argc / i ; printf ( "%d" , ub ); return ub ; } gcc -O2 main: sub rsp , 8 mov edi , OFFSET FLAT : .LC0 xor eax , eax call printf ud2 clang -O2 main: push rax lea rdi , [ rip + .L.str ] xor eax , eax call printf@PLT lea rdi , [ rip + .L.str.1 ] xor eax , eax pop rcx jmp printf@PLT

Somewhat expectedly, gcc remains faithful to its crash approach, though note that it only inserts the crash when it compiles the division-by-zero, not earlier, like at the beginning of the function. Clang on the other hand compiled both prints, before and after the division, simply removing the division itself. As with the code after the division-by-zero, there are also no guarantees for the code leading up to it. The mere existence of UB in the program means all bets are off and the compiler could chose to crash the function immediatley upon entering it. [5].

If there's UB in a program but no one is around to use it, does it still make a sound?

Do compilers treat code that exhibits undefined behaviour but is never used, like the proverbial soundless tree in the forest, and ignore it? Let's find out:

Program int main ( int argc ) { int i = 0 ; int ub = argc / i ; return 1 ; } gcc -O2 main: mov eax , 1 ret clang -O2 main: mov eax , 1 ret

We can see that the answer to our question is "yes", and now both compilers have optimised the division away. Most likely dead code elimination will have removed the division before the compiler figured out it is UB. Again, it is important to understand that this is something the compilers chose to do (and only if we enable optimisations, otherwise the division is compiled as is). Even if the UB "isn't used", that doesn't mean the program has no UB. We just got "lucky" that the compiler removed the dead code before realising it had UB. There is no guarantee other compilers will do the same, nor that this behaviour will be consistent between different versions of the compilers. It would have been equally valid to crash the program or open your CD-ROM drive.

That girl value is poison

We are now left with two questions: 1) Why do we often not get warnings about UB in a program even if the compiler was able to work out that it exists? 2) Why are clang (and sometimes gcc) lenient when handling UB, compiling (and running) code instead of making it crash (e.g. by inserting an illegal instruction)?

We can find answers for both questions in a blog post by Chris Lattner. In regards to the warnings, he explains that it would often generate too many warnings to be useful (with lots of false positives). It's also difficult to know when people want these warnings and when not (e.g. nobody cares about UB in dead code). In regards to the leniency, especially in relation to our programs above, the following paragraph from the blog post gives some insight:

“Arithmetic that operates on undefined values is considered to produce a undefined value instead of producing undefined behavior. The distinction is that undefined values can't format your hard drive or produce other undesirable effects.”

These days LLVM uses mostly ‘poison’ values which enable more optimisations than ‘undef’, but the idea is the same: just because a value is the result of undefined behaviour, that doesn't mean we need to immediatley invalidate any code using that value. For example, taking a poison value and and ing it with 0, we may assume that the result will always be 0, no matter what the actual poison value is.

This makes sense when, for example, the result of an undefined operation is irrelevant for the execution of the remainder of the program as the following example shows:

Program int main ( int argc ) { int i = 0 ; // nullptr dereference int ub = * ( int * ) i ; int p = ub | 1 ; printf ( "print" ); if ( p ) { printf ( "%d" , ub ); } return 1 ; } gcc -O2 main: mov eax , DWORD PTR ds : 0 ud2 clang -O2 main: push rax lea rdi , [ rip + .L.str ] xor eax , eax call printf@PLT lea rdi , [ rip + .L.str.1 ] xor eax , eax call printf@PLT mov eax , 1 pop rcx ret .L.str: .asciz "print" .L.str.1: .asciz "%d"

Since a bit-wise or with a non-zero value will always evaluate to true, the if-condition will always succeed, no matter what the value of ub is. In LLVM, arithmetic with poison values doesn't necessarily produce another poison value. This is the case here, where the compiler can thus remove the condition. Gcc on the other hand bailed with a ud2 as soon as it saw the null-pointer dereference.

Conclusion

While these were all very cherry-picked examples, they weren't selected in order paint one compiler in a worse light. The goal was to show a difference in philosophies when handling UB: LLVM just carries on compiling when it can, crossing its fingers that this won't cause problems later on, in an attempt to to make more programs run and to closer match what it believes a developer, unaware of undefined behaviour in their code, might expect. Gcc, at least in the examples above, appears to be more conservative and prefers to crash the program instead, making it more obvious to developers when their programs contain UB. Neither approach is objectively better than the other and both are equally valid in the face of UB, and which one to choose ultimately comes down to personal preference of the compiler developers and their users.

Acknowledgements: Thanks to Edd Barrett and Laurence Tratt for comments.

Footnotes

CONTENT

Copyright © Lukas Diekmann