Falsehoods programmers believe about null pointers
19 comments
·February 1, 2025alain94040
nyanpasu64
One interesting failure mode is if (like the Linux kernel) a function returns a union of a pointer or a negative errno value, dereferencing a negative errno gives an offset (below or above zero) different from the field being accessed.
catlifeonmars
Now this is a really interesting one. I’m assuming this trivially applies to index array access as well?
tialaramex
In C, and this article seems to be almost exclusively about C, a[b] is basically sugar for (*((a) + (b)))
C does actually have arrays (don't let people tell you it doesn't) but they decay to pointers at ABI fringes and the index operation is, as we just saw, merely a pointer addition, it's not anything more sophisticated - so the arrays count for very little in practice.
kevincox
I think this technically wouldn't be a null pointer anymore. As array indexing `p[n]` is defined as `*(p + n)` so first you create a new pointer by doing math on a null pointer (which is UB in C) then dereferencing this new pointer (which doesn't even really exist because you have already committed UB).
mcdeltat
"falsehoods 'falsehoods programmers believe about X' authors believe about X"...
All you need to know about null pointers in C or C++ is that dereferencing them gives undefined behaviour. That's it. The buck stops there. Anything else is you trying to be smart about it. These articles are annoying because they try to sound smart by going through generally useless technicalities the average programmer shouldn't even be considering in the first place.
MathMonkeyMan
Useless, but interesting. I used to work with somebody who would ask: What happens with this code?
#include <iostream>
int main() {
const char *p = 0;
std::cout << p;
}
You might answer "it's undefined behavior, so there is no point reasoning about what happens." Is it undefined behavior?The idea behind this question was to probe at the candidate's knowledge of the sorts of things discussed in the article: virtual memory, signals, undefined behavior, machine dependence, compiler optimizations. And edge cases in iostream.
I didn't like this question, but I see the point.
FWIW, on my machine, clang produces a program that segfaults, while gcc produces a program that doesn't. With "-O2", gcc produces a program that doesn't attempt any output.
Hizonner
> Instead of translating what you’d like the hardware to perform to C literally, treat C as a higher-level language, because it is one.
Alternately, stop writing code in C.
mcdeltat
IMO one of the most disappointing things about C: it smells like it should be a straightforward translation to assembly, but actually completely is not because of the "virtual machine" magic the Standard uses which opens the door to almost anything.
Oh you would like a byte? Is that going to be a 7 bit, 8 bit, 12 bit, or 64 bit byte? It's not specified, yay! Have fun trying to write robust code.
tialaramex
Abstract. It's an Abstract machine, not a Virtual machine.
HeliumHydride
C++ has made efforts to fix some of this. Recently, they enforced that signed integers must be two's complement. There is a proposal currently to fix the size of bytes to 8 bits.
bobmcnamara
Ahem, it's specified to not be 7.
oguz-ismail
impossible
no serious alternative
tialaramex
For very small platforms, where it's a struggle to have a C compiler because a "long int" of 32 bits is already a huge challenge to implement, let alone "long long int" - stop using high level languages. Figure out the few dozen machine code instructions you want for your program, write them down, review, translate to binary, done.
For the bigger systems where that's not appropriate, you'll value a more expressive language. I recommend Rust particularly, even though Rust isn't available everywhere there's an excellent chance it covers every platform you actually care about.
oguz-ismail
nope
bitwize
Nowadays, UB is used pretty much as a license to make optimizer go brrrr. But back in the day, I think it was used to allow implementations wiggle room on whether a particular construct was erroneous or not -- in contrast to other specifications like "it is an error" (always erroneous) or "implementation-defined behavior" (always legitimate; compiler must emit something sensible, exactly what is not specified). In the null pointer case, it makes sense for kernel-mode code to potentially indirect to address 0 (or 0xffffffff, or whatever your architecture designates as null), while user-space code can be reasonably considered never to legitimately access that address because the virtual memory never maps it as a valid address. So accessing null is an error in one case and perfectly cromulent in the other. So the standard shrugs its shoulders and says "it's undefined".
lmm
The original motivation was to permit implementations to do the reasonable, friendly thing, and trap whenever the program dereferences a null pointer. Since C compilers want to reorder or elide memory accesses, you can't really define explicit semantics for that (e.g. you want it to be ok to move the memory access before or after a sequence point) - the JVM has to do a lot of work to ensure that it throws NullPointerException at the correct point when it happens, and this slows down all programs even though no-one sane has their program intentionally trigger one. But the intention was to permit Java-like behaviour where your code would crash with a specific error immediately-ish, maybe not on the exact line where you dereferenced null but close to it. Ironically compiler writers then took that standard and used it to do the exact opposite, making null dereference far more dangerous than even just unconditionally reading memory address 0.
EtCepeyd
> Dereferencing a null pointer always triggers “UB”.
Calling this a "falsehood" is utter bullshit.
rstuart4133
> In ye olden times, the C standard was considered guidelines rather than a ruleset, undefined behavior was closer to implementation-defined behavior than dark magic, and optimizers were stupid enough to make that distinction irrelevant. On a majority of platforms, dereferencing a null pointer compiled and behaved exactly like dereferencing a value at address 0.
Let me unpack that for you. Old compilers didn't recognise undefined behaviour, and so compiled the code that triggered undefined behaviour in exactly the same way they compiled all other code. The result was implementation defined, as the article says.
Modern compilers can recognise undefined behaviour. When they recognise it they don't warn the programmer "hey, you are doing something non-portable here". Instead they may take advantage of it in any way they damned well please. Most of those ways will be contrary to what the programmer is expecting, consequently yielding a buggy program.
But not in all circumstances. The icing on the cake is some undefined behaviour (like dereferencing null pointers) is tolerated (ie treated in the old way), and some not. In fact most large C programs will rely on undefined behaviour of some sort, such as what happens when integers overflow or signed is converted to unsigned.
Despite that, what is acceptable undefined behaviour and what is not isn't defined by the standard, or anywhere else really. So the behaviour of most large C programs is it legally allowed to to change if you use a different compiler, a different version of the same compiler, or just different optimisation flags. Consequently most C programs depend on the compiler writers do the same thing with some undefined behaviour, despite there being no guarantees that will happen.
This state of affairs, which is to say having a language standard that doesn't standardise major features of the language, is apparently considered perfectly acceptable by the C standards committee.
I would add one more: the address you are dereferencing could be non-zero, it could be an offset from 0 because the code is accessing a field in a structure or method in a class. That offset can be quite large, so if you see an error accessing address 0x420, it's probably because you do have a null pointer and are trying to access a field. As a bonus, the offending offset may give you a hint as to which field and therefore where in your code the bad dereferencing is happening.