7 ms·
Undefined behavior, and the sledgehammer principle
- KingLancelot 3y ago[dead]
- kazinator 3y agoCompilers, when handling some expression B, should not make decisions based on the blind assumption that some other previously seen expression A is well-defined. Or, not by default, even with optimizations on. This is an unsafe behavior that should be opt-in, at a fine granularity, so that its benefit can be obtained when it matters. That's all there is to it.
- ivanbakel 3y agoThat's a terrible maxim. You'd forbid the Rust compiler from performing as simple an optimisation as changing: fn foo() { let x = 0; bar(); return x; } to fn foo() { bar(); return 0; } (which is valid, because the binding of `x` is immutable, and it's UB to mutate it in `bar`, even if you can guess the stack pointer.) Optimising compilers require the ability to make assumptions about your code - that's the basis by which program transformations are valid. Even something as simple as constant loop unrolling would be forbidden by your rules, since your code can (invalidly) choose to try to mess with loop variables in a way the compiler can't spot.
- kazinator 3y agoThis kind of optimization is a main motivator for lexical scope, going back to 1960-something, so we make an exception for it and educate the users: your local variables are aggressively optimized: subject to allocation in registers, constant propagation and folding. We are not relying on specifics about bar(), just the general assumption that up until the return 0, the program hasn't done anything incorrect which interferes with what we want to do. It's a different reasoning from "this loop cannot terminate because i++ never goes negative, so we can cheerfully remove the code after it, including the return instruction."
- uecker 3y agoWhy is it different? "program hasn't done anything incorrect" includes not overflowing i. And being able to even put variables in register relies on such assumptions in a very similar as certain loop transformations rely on the assumption that the loop terminates before i overflows.
- kazinator 3y agoThe language should make it impossible to access x through the stack unless the programmer goes out of their way to perpetrate fraud. It's fair for the optimizer to assume that fraud hasn't been perpetrated. If it's easy to overflow integer addition, then that must be regarded as an accident. Assuming absence of accidents is a poor default in ways that assuming the absence of fraud isn't. First make it so that perpetrating integer overflow is inconvenient, so that the programmer has to go out of his way to request it. If that has been done, then sure, blindly assume that i + 1 is greater than i.
- uecker 3y agoInteger overflow is relatively easy to detect and protect against at run-time using -fsanitize=signed-integer-overflow. (yes, needs testing. Doing it reliable at compile-time is hard.) But compilers also offer various options on how to deal with overflow. The users choose -O3 over other options. ISO C does not favor one over the other. But making it defined behavior that wraps would turn it into subtle correctness bugs which one can not easily screen for.
- kazinator 3y agoIt's possible to just leave it undefined, without introducing unwarranted assumptions, such as that i + 1 is positive if i is positive, made in the absence of any assurance that i < INT_MAX.
- uecker 3y agoThat a computed expression i + 1 can be assumed to be positive for non-zero i follows mathematically from overflow being undefined.
- plorkyeran 3y agoAll optimizations rely on the assumption that things have well-defined semantics. Even something like rewriting 1+1 into 2 is only valid if you assume that no one is going to patch your executable code to replace the add with a multiply. The concept of an optimizer that does not make any assumptions is just nonsense. There’s a lot of very reasonable assumptions that compilers make and some less reasonable ones, but there is no clean line between the two. Ideally the standard would have clearly specified what assumptions compilers could make, but this whole thing is the result of GCC following their interpretation of what the standard says they can do and people not liking the result.
- Animats 3y agoC/C++ has a historic preference for using signed "int" in places where it's not appropriate. Unsigned arithmetic is better defined - it wraps. So you don't have undefined behavior around arithmetic. Use size_t for indices. That's defined as an unsigned value big enough to index the largest possible array. Rust uses "usize" for the same purpose. Only use signed integers where negative values are meaningful.
- UncleMeat 3y agoExcept that because unsigned integers wrap the compiler needs to emit different instructions that can handle wrapping appropriately. This is a problem for vectorizing code and a problem for compiling to architectures where the hardware wrap happens at a different bit width than the data type size in software. So your code is often slower. And a wrapped unsigned integer is probably still a bug. Yes, your compiler can't blow up a branch that you thought would be there, but "hey I added two numbers and got back a smaller number" is almost always not actually the function that you were trying to implement. The wrap can even happily lead to a security vulnerability if you are computing some sort of buffer offset and think that the guarantees of arithmetic will keep you within the buffer bounds and didn't stop to think about modular arithmetic. So your program is slower and still buggy.
- gavinhoward 3y agoBut it's buggy in a way that doesn't destroy the meaning of the whole program. The Sledgehammer Principle is not enough; UB is a nuclear holocaust for a program. So I'll take the buggy behavior that slows my code and only uses a sledgehammer over the buggy behavior that treats my program as a suggestion.
- UncleMeat 3y agoIt is true that the damage is more unbounded when you trigger undefined behavior. I mentioned this in my comment. It is real, but I believe that people largely overstate the degree with which these two things are different (nasal demons).
- patrakov 3y agoEven if the signed integer operation overflow were not UB but implied wrapping, the purported post-factum check in the article is not semantically equivalent to properly checking the range beforehand. "if ((INT32_MAX / 0x1ff) <= x) { ... }" is not the same as "int32_t i = x * 0x1ff / 0xffff; if (i >= 0 && i < sizeof(tab)) { ... }". The reason for the difference is that the result might get into the permitted range accidentally. The function should have returned zero (as it does for out-of-range inputs) but returned some element of a table. The same bug exists in their Rust code.
- stncls 3y agoI agree with the author's general assessment, but I have issues with the... pedagogy of this post. 1. The tone of the article implies the author is taking sides. Specifically, they root for the poor developers, against the evil standard committee and compiler writers. But for context, the author is currently co-editor for the C standard committee ("WG14"). This is important because the post contains lots of humorous language and irony, which can be disorienting without this context. 2. The author correctly points out that some criticisms of UB come with exaggerate levels of passion. However, again, this verbose and "fun" article full of analogies is not helping the cause. 3. Namely, whichever side you are on, UB is subtle and would benefit from simpler exposition. The whole "Sledgehammer" explanation towards the end of the article could be summarized as: "UB does not invalidate just the statement or value that contains it. Invoking UB invalidates the whole program." The author being on the standard committee should add, I think, a sense of responsibility regarding how they communicate. In general I think that the whole discussion around UB would benefit from serious and dispassionate studies of the costs/benefits for the various types of UB, and avenues for improvement.
- uecker 3y agoI like to add that WG14 accepted my comment into the current draft standard that clarifies that UB does - in fact - not invalidate the whole program, and can not affect observable behavior that comes before it.
- stncls 3y agoYes, a step in the right direction for C2x, thank you for that.
- tialaramex 3y agoMmm. I am not enthusiastic about choices to explain UB as actually not that bad. Even when they're strictly correct they're too often likely to mislead exactly the audience who needed the warning. There's a C++ talk by Alistair Meredith which ends up saying that UB can't delete client files unless you the programmer wrote code to delete those files...
- uecker 3y agoWell, I use UB to let the compiler check for overflow. Works for me. https://godbolt.org/z/h3rvaz5Gn https://godbolt.org/z/h3rvaz5Gn
- nuancebydefault 3y agoComputer languages are languages in which is written by people and interpreted by machines (and people). The argument: 'you shouldn't have written the code that way, because it breaks a rule stated in this large UB book' is a bit petty to say the least. What about making it (almost) impossible to write UB code, wouldn't that be better for all? In the example shown, the compiler could simply throw an error stating that signed integers are not fit to be used as array indexes. Yes, such change of rules in the language would break backward compatibility, but adapting such code to the new rules would be a benefit anyway.
- UncleMeat 3y ago> What about making it (almost) impossible to write UB code This is not possible without pessimizing a significant portion of the architecture targets and also causing major slowdowns across all architectures. Dereferencing a pointer to a deleted object is UB. How are you going to test for this at runtime. ASan is already a huge amount of overhead and it isn't even guaranteed to detect UaF. > In the example shown, the compiler could simply throw an error stating that signed integers are not fit to be used as array indexes. Are unsigned indexes fit to be used? Reading an array out of bounds is UB and arrays don't carry their lengths with them.
- nuancebydefault 3y ago>this is not possible without... Hence the (almost) > Are unsigned indexes fit to be used? More fit than signed ones, clearly shown in the example in the article where a check was removed by the compiler's optimizer.
- UncleMeat 3y agoI don't think that the (almost) is right here. UaF is a huge source of UB in C and C++. There are two ways to make UaF almost impossible: either a total adoption of Rust-style lifetime checking, which is not a possible transition for the large majority of existing code, or runtime checks that have large and unacceptable performance overhead. The overhead doesn't come from some a few weird corner cases that you can avoid in ordinary development. The overhead would come from core components.
- weinzierl 3y ago"I’ve personally developed a very simple rule called the ‘Sledgehammer Principle’. Whenever you commit undefined behavior, you have to imagine that you’ve taken a sledgehammer and smashed it into a large, very expensive vase your mother (or father, or grandmother, or whoever happens to be closest to you) owns. This means that if you needed that vase (e.g., the result of that signed integer multiply), you have to imagine that it is now completely unrecoverable. The vase is goddamn smashed, after all; only through brutally difficult craftsmanship could you ever imagine putting it back together." Doesn't undefined behaviour more appropriately mean that once you used the sledgehammer on whatever item in your house there is a chance that not only the vase is gone but the structural integrity of the whole building is compromised?
- kevincox 3y agoYes, that is correct. Any use of the sledgehammer makes your while program invalid. Maybe "minefield principle" would be more accurate. Because one wrong step and you are finished.
- wyldfire 3y agoDo we really need such an obscure metaphor? Why not talk about how the compiler reacts to undefined behavior in real concrete terms?
- sfink 3y agoI will probably say something stupid and subtly wrong here, but my understanding is that the only concrete thing that you can say about the compiler's reaction is that it will assume that whatever would trigger the UB will never happen. Equivalently, that it is impossible to exhibit undefined behavior, and what code gets executed to perform that behavior is irrelevant because it'll never happen. What this means in practice is not concrete at all, or rather it is only meaningful in relation to a rather large set of things: the compiler, its version, the optimizations enabled and applicable, the platform, etc. The optimizations applicable are dependent in your program too, so even though the ramifications are usually (but not always) deterministic, even that may not help. You might add one more call, now something is inlined that wasn't before, and because you compute `i+5` 300 lines later, the compiler can assume `i <= MAX_INT - 5` in the body of the now-inlined function and so it can now just drop a comparison you were making to prevent overflow since it knows the later overflow Can Never Happen so there's no point emitting code that will never execute.
- twoodfin 3y agoTo the extent anything’s to “blame” for the seemingly massive escalation of the “UB Wars”, I think it’s C++. C++’s main contribution to the C evolutionary line is rich, zero-cost abstraction. The main determinants of the residual cost of your abstractions in a language like C++ are the effectiveness of heuristic or profile-driven inlining and the aggressive application of fairly basic optimizations (constant propagation, common subexpression elimination, dead-code elimination, …) to the resulting inlined code, which for good reason “looks” like no code any self-respecting developer would actually write. This is where reasoning from UB becomes essential. The developer didn’t write this code, didn’t express a direct intention: The compiler synthesized a new program from inlined and rewritten pieces of the AST. “Warning” about possible UB in this code doesn’t make any sense, nor does being conservative about “should-be-impossible” states.
- the_mitsuhiko 3y agoTrivia For the German crowd: the mentioned "felix-gcc" is fefe (known for his blog and in C circles died libc). I love the addition of `<stdckdint.h>` in modern C. What I definitely love less is that it's quite easy to hit undefined behavior with arithmetic and I'm personally too dumb to fully avoid it. On the Rust side Miri is shaping up to be a reasonable tool to help you figure out when you do dumb stuff, maybe something like this for C would make this a more enjoyable experience. I learned that `-fsanitize=undefined` is a reasonable tool on some compilers that adds runtime checks into the binary that at least tells you what's going on in some of those cases but that requires the right kind of input making it down such a code path. Miri on the other hand is running fully at compile time. Generally though I'm really happy to see that there are folks on the C standard that have this sort of stuff in mind. > We deserve multiplies and subtracts and adds that don’t punch us in the face. We deserve code that actually respects what we write and what we say when we say it, so we can build the necessary safety guarantees into what I know C programmers are shipping to millions of people all over the globe daily. Impossible to disagree
- uecker 3y agoTry -fanalyzer with new GCCs. Not finished, but already impressive.
- the_mitsuhiko 3y agoUnfortunately it does not yet manage to pick up something with overflow related undefined behavior. The example from the blog post even on trunk passes `-fanalyzer` :(
- uecker 3y agoAh, ok. But it is developing rapidly...
- meinersbur 3y agoHere is Fefe's blog entry about filing this bug: https://blog.fefe.de/?ts=bb5517a4 https://blog.fefe.de/?ts=bb5517a4 > Update: Here's the bug, and as you can see, the gcc guy who's to blame for the problem (he admits as much in the other piece of code) thinks that's fine if people get outed because he can justify an optimization in formal legal terms. Sick. Makes you wonder what you're actually doing audits for if you're then torpedoed like this. Oh, by the way: with icc (the Intel compiler) you get an assertion correctly. (translation by DeepL)