The Pointer Mistake
Published:
pointer pointing to a value vs pointer pointing to a pointer
The Pointer Mistake
A pointer bug is often just a confusion between three things:
the value
the address of the value
the address of the variable holding that address
That sounds abstract, so let’s use a small example.
Suppose we have two integers:
int a = 1;
int b = 2;
And we have a pointer that tells us which one is currently active:
int *current = &a; // a pointer `current` pointing to the address of `a`. the address has data type `int`.
At this moment, current points to a.
Now suppose we write:
int *snapshot = current; // a pointer `snapshot` pointing to the value of `current`, the value of `current` is the address of `a`.
This copies the value of current.
So now:
current -> a
snapshot -> a
If we later update current:
current = &b;
then:
current -> b
snapshot -> a
The snapshot pointer does not automatically follow current.
It still contains the old address.
So:
*snapshot
still reads a, while:
*current
reads b.
This is the stale-update problem.
Pointer to the Thing vs Pointer to the Pointer
If we want to observe changes to current, we need a pointer to current itself:
int **live = ¤t; // ¤t stores the address of the pointer `current` itself. the address has data type `int *`.
Now live points to the current, which is an address store a pointer.
So after this:
current = &b; // update pointer `current` to point to a new address of `b`.
we get:
**live // dereference the pointer `live` to get the address of `current`, then dereference the address of `current` to get the value of `current`, which is the address of `b`. think of it as *(*live), which is equal to the value of `b`.
which means:
go to current
see where current points now
read that int
The key difference is that snapshot copies the address stored in current, while live points to the variable current itself.
Using the mailbox analogy:
currentis a mailbox that contains a card saying where the active integer lives.snapshotgets a copy of that card.livedoes not copy the card. It stores the address of thecurrentmailbox.
So if we later replace the card inside current, snapshot still has the old card, but live will see the new card because it goes back to the current mailbox each time.
That is why:
*snapshot // old target: a
**live // current target: b
One gives you a snapshot.
The other lets you follow updates.
The Same Shape as a Lower-Level Bug
This mistake can also appear when working with raw addresses.
For example, suppose we compute an address:
uintptr_t y = some_base + some_offset;
Now y contains an address.
The correct way to treat that address as an int * is:
int *x = (int *)y;
This means:
use the address stored inside y
But this is very different:
int *x = (int *)&y;
This means:
use the address of the variable y itself
That does not point to the object whose address is stored in y.
It points to y.
So these two are not equivalent:
(int *)y
and:
(int *)&y
The first one uses y as an address.
The second one takes the address of y.
That one extra & changes the meaning completely.
How Thread-Local Storage Led Me Here
I started thinking about this while looking at generated assembly for a C++ thread_local variable.
Consider:
int func()
{
thread_local int b = 2;
return b;
}
On x86-64 Linux, the compiler may generate something like:
mov eax, DWORD PTR fs:func()::b@tpoff
Conceptually, this is similar to:
eax = *(int *)(current_thread_tls_base + offset_of_b);
The expression:
current_thread_tls_base + offset_of_b
computes the address of this thread’s copy of b.
Then:
(int *)(current_thread_tls_base + offset_of_b)
treats that address as an int *.
Finally:
*(int *)(current_thread_tls_base + offset_of_b)
reads the integer stored there.
So this:
eax = *(int *)(current_thread_tls_base + offset_of_b);
can be expanded as:
int *x = (int *)(current_thread_tls_base + offset_of_b);
eax = *x;
That means:
x = address of this thread's b
*x = value stored in b
eax = copy of that value
But if we introduce a temporary address variable:
uintptr_t y = current_thread_tls_base + offset_of_b;
then y is only a variable containing an address.
The correct version is:
int *x = (int *)y;
eax = *x;
The wrong version is:
int *x = (int *)&y;
eax = *x;
Because &y points to the temporary variable y, not to the thread-local variable b.
The Main Lesson
The bug is not really about thread-local storage.
Thread-local storage is just a good example because the compiler computes an address using a base plus an offset.
The real lesson is this:
A variable containing an address is not the same as the object at that address.
And:
The address stored in a variable is not the same as the address of that variable.
In code:
(int *)y // use y as an address
(int *)&y // use the address of y
Those are completely different.
Similarly:
int *snapshot = current;
copies the pointer value and may become stale.
But:
int **live = ¤t;
points to the pointer variable itself and can observe updates.
The short version:
copying a pointer gives you a snapshot
pointing to the pointer lets you follow changes
