I think the blog should mention `-fno-delete-null-pointer-checks`
https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html#ind...
> [...]
> This option is enabled by default on most targets.
What a footgun.
I understand that, in an effort to compete with other compilers for relevance, GCC pursued performance over safety. Has that era passed? Could GCC choose safer over fast?
Alternatively, has someone compiled a list of flags one might want to enable in latest GCC to avoid such kinds of dangerous optimizations?
https://gcc.gnu.org/onlinedocs/gcc/Common-Function-Attribute...
That's what makes it dangerous!
Normally, it only deletes null checks after actual null pointer dereferences. In principle this can't change observable behavior. Null dereferences are guaranteed to trap, so if you don't trap, it means the pointer wasn't null. In other words, unlike most C compiler optimizations, -fdelete-null-pointer-checks should be safe even if you do commit undefined behavior.
This once caused a kerfuffle with the Linux kernel. At the time, x86_64 CPUs allowed the kernel to dereference userspace addresses, and the kernel allowed userspace to map address 0. Therefore, it was possible for userspace to arrange for null pointers to not trap when dereferenced in the kernel. Which meant that the null check optimization could actually change observable behavior. Which introduced a security vulnerability. [1]
Since then, Linux has been compiled with `-fno-delete-null-pointer-checks`, but it's not really necessary: Linux systems have long since enforced that userspace can't map address 0, which means that deleting null pointer checks should be safe in both kernel and userspace. (Newer CPU security features also protect the kernel even if userspace is allowed to map address 0.)
But anyway, I didn't know that -fdelete-null-pointer-checks treated "memcpy with potentially-zero size" as a condition to remove subsequent null pointer checks. That means that the optimization actually isn't safe! Once GCC is updated to respect the newly well-defined behavior, though, it should become truly safe. Probably.
The same can't be said for most UB optimizations – most of which can't be turned off.
<laughs in embedded-system-with-no-MMU>
It wasn't an embedded system, but rather an x86 BIOS boot loader, which is sort of halfway there. Protected mode enabled without paging, so there's nothing to trap a NULL.
Completely by accident I had dereferenced a pointer before doing a NULL check. I think the dereference was just printing some integer, which of course had a perfectly sane-looking value so I didn't even think about it.
The compiler, I can't remember if it was gcc or clang by this point, decided that since I had already successfully dereferenced the pointer it could just elide the null check and the code path associated with it.
Finally I ran it in VMware and attached a debugger, which skipped right over the null check even though I could see in the debugger the value was null. So then I went to look at the assembly the compiler generated, and that's when I started to understand what had happened.
It was a head-slapper when I found the dereference above. I added a second null check or moved that code or some such, and that was it.
Interestingly, replacing len in the memcpy() call results in gcc instead removing the memcpy() call and retaining the check - presumably a different optimisation routine decides that it's a no-op in that case. https://godbolt.org/z/cPdx6v13r is, therefore, interesting - despite this only ever calling test() with a len of 0, the elision of the dest == NULL check is still there, but test() has been inlined without the memcpy (because len == 0) but with do_thing2() (because the behaviour is undefined and so it can assume dest isn't NULL even though there's a NULL literally right there!)
Fucking compilers, man.
They're just acting as agents that derive the logical consequences of the code.
The fact that the given example code is "surprising" is analogous to this mathematical derivation:
a = b
a*a = b*a
a*a - b*b = b*a - b*b
(a - b)(a + b) = b(a - b)
(a - b)(a + b)/(a - b) = b(a - b)/(a - b)
^ Divide by 0, undefined behavior!
Everything below is not necessarily true.
a + b = b
b + b = b
2b = b
2 = 1
2 - 1 = 1 - 1
1 = 0
The source of truth about what is/isn't allowed is the C standard, not your personal simplified model of it that may contain dangerous misconceptions. The fact that your mental model doesn't match the document is an education problem, not a problem with the compiler.In a particularly pedantic, uptight, and sometimes un-helpful way, yes.
Compilers don't have to be designed this way; in fact it is a relatively recent development in the history of such tools.
Or it is a problem with the document, which is the entire reason we are having this discussion: N3322 argued the document should be fixed, and now it will be for C2y.
In something like C++ or Rust, even their bare metal "What do you mean Operating System?" modes quietly require memcpy and so on because we're not savages, clearly somebody should provide a way to copy bytes of memory, Rust is so civilised that even on bare metal (in Rust's "core" library) you get a working sort_unstable() for your arbitrary slice types!
On a related note, GCC optimizing away things is a problem for memset when zeroing buffers containing sensitive data, as GCC can often tell that the buffers are going to be freed and thus the write is deemed unnecessary. That is a security issue and has to be resolved by breaking the compiler’s optimization through a clever trick:
https://github.com/openzfs/zfs/commit/d634d20d1be31dfa8cf06e... 12352
Similarly, GCC may delete a memcpy to a buffer about to be freed, although I have never observed that as you generally don’t do that in production code.
It's not that crazy. You could have a refcounted object that poisons itself when the refcount drops to zero, but doesn't immediately free itself because many malloc implementations can have bad lock contention on free(). So you poison the object to detect bugs, possibly only in certain configurations, and then queue the pointer for deferred freeing on a single thread at a better time.
(Ok, this doesn't quite do it: poisoning is much more likely to use memset than memcpy, but I assume gcc would optimize out a doomed memset too?)
The whole idea that undefined behavior cannot happen and you can therefore do optimization based on "knowing" it cannot happen is incredibly bonkers.
int foo() {
int x = 1;
havoc();
return x;
}
Can this function be compiled to store x in a register? Can it be compiled to remove x entirely and return the constant 1? That relies on "knowing that undefined behavior cannot happen." This program will behave differently if we store x on the stack and then return it after we call havoc() than if we call havoc() and then return the constant 1, if havoc() just writes to out of bounds memory addresses or whatever.In this case the undefined behavior just feels "more extreme" to most people, but it is remarkably hard for people to rigorously define the undefined behavior that should and should not be considered when making optimizations.
No it doesn't.
A core problem with this discussion in almost all circumstances is that people have a vibe for which of these things it feels okay for a compiler to make logical deductions from and which it feels not okay but if you actually sit down and try to formalize this in a way that would be meaningful to compiler vendors, you can't.
This example is not "I know that UB doesn't happen, therefore ...", which is what the memcpy() case is.
It is "I don't care that UB might happen, I am going to act as if it didn't. If the UB then makes the program behave differently than without the UB, that's not my problem".
Which, incidentally, is one of the suggested/permitted responses to UB in the standards text (that was made non-binding).