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?
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.
The whole idea that undefined behavior cannot happen and you can therefore do optimization based on "knowing" it cannot happen is incredibly bonkers.
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.[N3322] https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3322.pdf
Imagine you’re working in real mode on x86, in the compact or large memory model[1]. This means that a data pointer is basically struct{uint16_t off,seg;} encoding linear address (seg<<4)+off. This makes it annoying to have individual allocations (“objects”) >64K in size (because of the weird carries), so these models don’t allow that. (The huge model does, and it’s significantly slower.) Thus you legitimately have sizeof(size_t) == 2 but sizeof(uintptr_t) == 4 (hi Rust), and God help you if you compare or subtract pointers not within the same allocation. [Also, sizeof(void *) == 4 but sizeof(void (*)(void)) == 2 in the compact model, and the other way around in the medium model.]
Note the addressing scheme is non-bijective. The C standard is generally careful not to require the implementation to canonicalize pointers: if, say, char a[16] happens to be immediately followed by int b[8], an independently declared variable, it may well be that &a+16 (legal “one past” pointer) is {16,1} but &b is {0,2}, which refers to the exact same byte, but the compiler doesn’t have to do anything special because dereferencing &a+16 is UB (duh) and comparing (char *)(&a+16) with (char *)&b or subtracting one from the other is also UB (pointers to different objects).
The issue with NULL == NULL and also with NULL - NULL is that now the null pointer is required to be canonical, or these expressions must canonicalize their operands. I don’t know why you’d ever make an implementation that has non-canonical NULLs, but I guess the text prior to this change allowed such.
[1] https://devblogs.microsoft.com/oldnewthing/20200728-00/?p=10...
Yikes! This particular oddity seems annoying but sort of harmless in x86 real mode, but not necessarily in protected mode. Imagine code that wants to load a pointer into a register: it loads the offset into an ordinary register and the selector portion into a segment register. It’s permissible to load the 0 (null) selector, but loading garbage will fault immediately. So, if you allow non canonical NULL, then knowing that a pointer is either valid or NULL does not allow you to hoist a segment load above a condition that might mean you never actually dereference the pointer.
(I have plenty of experience with low-level OS code in all kinds of nasty x86 modes but, thankfully, not so much experience writing ordinary C code targeting protected mode. It sometimes boggles my mind that anyone ever got decent performance with anything involving far data pointers. Segment loads are slow, and there are not a lot of segment registers to go around.)
Thus:
T1* a = NULL;
T2* b = NULL
a == b; /* may be undefined at present, depending on the nature of T1 & T2 */> Note that, apart from contrived examples with deleted null checks, the current rules do not actually help the compiler meaningfully optimize code. A memcpy implementation cannot rely on pointer validity to speculatively read because, even though memcpy(NULL, NULL, 0) is undefined, slices at the end of a buffer are fine. [And if the end of the buffer] were at the end of a page with nothing allocated afterwards, a speculative read from memcpy would break
> memcpy(dest, src, count)
> Copies count bytes from src to dest. [...] Note this is not a plain function, but a special form that applies the constraints dest != NULL and src != NULL to the surrounding scope. Equivalent to:
assume(dest != NULL)
assume(src != NULL)
actual_memcpy(dest, src, count)
The conflation of both concepts breaks the mental model of many programmers, especially ones who learned C/C++ in the 90s where it was common to write very different code, with all kinds of now illegal things like type punning and checking this != NULL.I'd love to have a flag "-fno-surprizing-ub" or "-fhighlevel-assembler" combined with the above `assume` function or some other syntax to let me help the compiler, so that I can write C like in the 90s - close to metal but with less surprizes.
'If len is zero, no bytes are copied.'
Seems reasonable.
No, because ISO never said it must behave this way.
Yes, because every libc I've personally encountered acts this way. At a glance, glibc's x86 implementation[1, 2], musl, and picolibc all handle 0-length memcpy as you'd expect. I'm sure other folks could dig up the code for Newlib, uclibc, and others, and they'd see the same thing.
On a related note, ISO C has THREE different things that most people tend to lump together as "undefined behavior." They are:
Implementation-defined behavior: ISO doesn't require any particular behavior, but they do require implementations to consistently apply a particular behavior, and document that behavior.
Unspecified behavior: ISO doesn't require any particular behavior, but they do require implementations to consistently use a particular behavior, but they don't require that behavior to be documented.
Undefined behavior: ISO doesn't require any particular behavior, and they don't require implementations to define any particular behavior either.
[1]: https://github.com/lattera/glibc/blob/master/string/memcpy.c [2]: https://github.com/lattera/glibc/blob/895ef79e04a953cac14938...
How does this make any sense? We don't want to remove a low hanging footgun because static analyzers can no longer detect it?
memcpy(NULL, NULL, 0); // Formerly bad, now ok.
memcpy(NULL, NULL, s); // Formerly bad, now unknown (unless it can be proven that s != 0).
and memcpy(NULL, b, c); // Same issue.
(where "NULL" == "statically known to be NULL", not necessarily just a literal NULL. Not that that changes the difficulty here.)Previously: warn if either address might be NULL.
Now: warn if either address might be NULL and the length might be nonzero, and prepare for your users to be annoyed and shut this warning off due to the false alarms.
Any useful static analysis tool does a careful balance between false positives and false negatives (aka false alarms and missed bugs). Too many false positives, and that warning will be disabled, or users will get used to ignoring it, or it will be routinely annotated away at call sites without anyone bothering to figure out whether it's valid or not. Soon the tool will cease to be useful and may be entirely abandoned. In actual practice, the sophistication of a static analysis tool is far less relevant than its precision. It's quite common to have an incredibly powerful static analysis tool that is used for only a small handful of blazingly obvious warnings, sometimes ones that the compiler already has implemented! (All the tool's fancy warnings got disabled one by one and nobody noticed.)
1. False positive on code that would have been an issue previously
2. False negative on a ton of similar footguns
3. Add complexity to differentiate between these cases
None of these options are fun.
That is why I tend to wrap my system calls with my own internal function (which can be inlined in certain PLs), where I can standardize such tests. Otherwise, the resulting code that performs the checks and does the requisite error handling is bloated.
Note that I am also loath to #DEFINE such code because C is already rife with them and my perspective is that the less of them the better.
At the end of the day, quick and dirty fixes will prove the adage "short cuts make long delays", and OpenBSD's approach is the only really viable long-term solution, where you just have to rewrite your code if it has ill-advised constructs.
For designing libraries such as C's stdlib, I don't believe in 'undefined behavior', clearly define your semantics and say, "If you pass a NULL to memcpy, this is what will happen." Same for providing a (n == 0), or should (src == dst).
And if, for some strange reason, fixing the semantics breaks calling code, then I can't imagine that their code wasn't f_cked in the first place.
every time you introduce something nonstandard, you add one little hardship to anyone trying to read or modify your code.
if a programmer is familiar with the language, it's standard library, and the normal idioms, then they should be able to just jump in.
Why? It's 2024. Make it not be? Sure, some older stuff already written might no longer compile and need to be updated. Put it behind a "newer" standard flag/version or whatever.
Or is it that it can't be caught at compile time and only run time... hmm...
e.g?
But it can go further than that. Dereferencing a NULL pointer is undefined behavior, so if a pointer is dereferenced, it can be assumed by the compiler not to be NULL and the code can be optimized. For example:
void foo(int *p) {
*p++;
if (p == NULL) {
printf("val is NULL\n");
} else {
printf("val is %d\n", *p);
}
}
can be optimized to: void foo(int *p) {
*p++;
printf("val is %d\n", *p);
}
Note that static analyzers will most likely issue a warning here as such a trivial case is most likely a mistake. But the check for NULL may be part of an inline function that is used in many places, and thanks to the undefined behavior, the code that handles the NULL case will only be generated when relevant. The problem, of course, is that it assumes that the programmer knows what he is doing and doesn't make mistakes.In the case of memcpy(NULL, NULL, 0), there probably isn't much to gain making it undefined. It most likely doesn't help with the memcpy implementation (len=0 is a generally no-op), and inference based on the fact that the arguments can't be NULL is more likely to screw the programmer up than to improve performance.
int my_function() {
int x = 1;
another_function();
return x;
}
The compiler can optimize that to: int my_function() {
another_function();
return 1;
}
Because it's UB for another_function() to use an out-of-bounds pointer to access the stack of my_function() and modify the value of x.And the most important example of a compiler optimization enabled by UB is related to that: being UB to access local variables through out-of-bounds pointers allows the compiler to place them in registers, instead of being forced to go through the stack for every operation.
More examples here: http://blog.llvm.org/2011/05/what-every-c-programmer-should-...
In a real world program removing all UB is some cases impossible without adding new breaking features to the C language. But, taking a real world program and removingh all UB which IS possible to remove will introduce an overhead. In some programs this overhead is irrelevant. In others, it is probably the reason why C was picked.
If you want speed without overhead, you need to have more statically checked guarantees. This is what languages such as Rust attempt to achieve (quite successfully).