Undefined behavior (UB) is a tricky concept in programming languages and compilers. Over the many years I've been an industry mentor for MIT's 6.172 Performance Engineering course, An excellent class that I highly recommend. It's very thorough and hands-on, at the expense of also requiring a lot of work at a very fast pace. When I took it as an undergrad, that was a great tradeoff, but YMMV. I've heard many misconceptions about what the compiler guarantees in the presence of UB. This is unfortunate but not surprising!
For a primer on undefined behavior and why we can't just "define all the behaviors," I highly recommend Chandler Carruth's talk "Garbage In, Garbage Out: Arguing about Undefined Behavior with Nasal Demons."
You might also be familiar with my Compiler Adventures blog series on how compiler optimizations work. An upcoming episode is about implementing optimizations that take advantage of undefined behavior like dividing by zero, where we'll see UB "from the other side."
Undefined behavior != implementation-defined behavior
Undefined behavior is not the same as implementation-defined behavior. Undefined behavior is also not the same as unspecified behavior, which is similar to implementation-defined behavior minus the requirement that the implementation document its choices and stick to them. Here we're focusing on undefined behavior, not unspecified behavior, so we'll lump unspecified behavior and implementation-defined behavior together. Program behaviors fall into three buckets, not two:
- Specification-defined: The programming language itself defines what happens. This is the vast majority of every program.
- Implementation-defined: The exact behavior is defined by your compiler, operating system, or hardware. For example: how many bits exactly are in a
intin C++. The specification guarantees at least 8 bits for
charand at least 16 bits for
int. The rest is implementation-defined.
- Undefined behavior: Anything is allowed to happen, and you might no longer have a computer left after it all happens. No outcome is a bug if caused by UB. For example: signed integer overflow in C, or using
unsafeto create two
&mutreferences to the same data in Rust. Wikipedia has an excellent list of examples if you'd like to see more.
Here's the list of guarantees compilers make about the outcomes of undefined behavior:
That's the whole list. No, I didn't forget any items. Yes, seriously.
It is possible to analyze how UB affects a specific program when compiled by a specific compiler or executed on a specific target platform. For example, there exist exotic compilers, operating systems, and hardware that offer additional guarantees Like CHERI, with awesome powers around pointer safety. relative to most common platforms, which only guarantee OS-level process isolation. We aren't talking about those in this post.
The mindset for this post is this: "If my program contains UB, and the compiler produced a binary that does X, is that a compiler bug?"
It's not a compiler bug.
All of the following assumptions are wrong
Falsehoods about when UB "happens"
- Undefined behavior only "happens" at high optimization levels like
- If I turn off optimizations with a flag like
-O0, then there's no UB.
- If I include debug symbols in the build, there's no UB.
- If I run the program under a debugger, there's no UB.
- Okay there's still UB with all of these, but my code will "do the right thing" regardless.
- It will either "do the right thing" or crash with a
- It will either "do the right thing" or crash somehow.
- It will either "do the right thing" or crash or infinite-loop or deadlock.
- At least it won't run some unrelated code from elsewhere in the program.
- At least it won't run any unreachable code the program might contain.
Falsehoods around the behavior of executing UB
- If a line with UB previously "did the right thing," then it will continue to "do the right thing" the next time we run the program.
- The UB line will at least continue to "do the right thing" while the program is still running.
- It's possible to determine if a previ