UB or not UB: How gcc and clang handle statically known undefined behaviour
25 June 2024Recently, 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:
int main(int argc) { int div = 5 / argc; if (argc == 0) { printf("A\n"); } else { printf("B\n"); } return div; }
.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
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
must not be zero to
entirely remove the condition if (argc == 0)
, knowing this
case can never happen [2].
Statically known undefined behaviour
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 statically detect the existence of UB, 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.I need a hzero
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:
int main(int argc) { int ub = argc / 0; return ub; }
main: ud2
main: ret
During compilation, both gcc and clang give a warning:
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:
int main(int argc) { int i = 0; int ub = argc / i; return ub; }
main: ud2
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:
int main(int argc) { int i = 0; printf("before"); int ub = argc / i; printf("%d", ub); return ub; }
main: sub rsp, 8 mov edi, OFFSET FLAT:.LC0 xor eax, eax call printf ud2
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:
int main(int argc) { int i = 0; int ub = argc / i; return 1; }
main: mov eax, 1 ret
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:
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; }
main: mov eax, DWORD PTR ds:0 ud2
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
-fsanitize=integer-divide-by-zero
. However, this comes with a
performance overhead, and doesn't otherwise change the program: gcc still
crashes with ud2
while clang ignores the division.