A Pointer Is Just an Address

In C++ a pointer feels like a special kind of variable: it has its own syntax (*, &), its own rules, and a reputation for being tricky. In assembly, all of that mystique evaporates. A pointer is simply a 64-bit number held in a register or a stack slot, and that number is a memory address. Dereferencing it (*p) is nothing more than a memory load or store using that address.

Let us prove it. Here are two tiny functions, one that writes through a pointer and one that reads through one:

void writeThrough(int* p, int value)
{
    *p = value;
}

int readThrough(const int* p)
{
    return *p;
}

At -O2 the compiler strips them to the bone:

writeThrough(int*, int):
    mov DWORD PTR [rdi], esi
    ret
readThrough(int const*):
    mov eax, DWORD PTR [rdi]
    ret

That is the whole story of pointers in two instructions. Let us read them.

The Brackets Mean "Go to Memory"

The pointer p arrived in rdi (the first argument register, covered in Chapter 4). Notice it is the full 64-bit register rdi, not the 32-bit edi: a pointer is an 8-byte address, so it needs a full register.

Now look at the operand [rdi]. In the previous chapters every memory operand had a displacement, like -4[rbp]. Here there is none: [rdi] means "the memory at exactly the address held in rdi". The square brackets are the key. A register name on its own means the value in the register; the same register wrapped in brackets means go to that address in memory.

So mov DWORD PTR [rdi], esi reads: "store the 4-byte value in esi into the memory pointed at by rdi." That is *p = value. And mov eax, DWORD PTR [rdi] reads: "load the 4-byte value from the memory pointed at by rdi into eax." That is return *p. The DWORD PTR tells us the access is 4 bytes wide, exactly the size of the int being pointed at.

This is the single most important idea in this chapter: a dereference is a memory access, and the address comes from a register.

Taking an Address

Dereferencing turns an address into a value. The reverse, taking an address with &, turns a variable into an address. You have already seen the instruction that does it, in passing. Here is main calling our functions:

int main()
{
    int x{10};
    writeThrough(&x, 99);
    std::cout << readThrough(&x) << '\n';
    return 0;
}

The relevant lines from the -O0 listing of main are:

mov  DWORD PTR -4[rbp], 10
lea  rax, -4[rbp]
mov  esi, 99
mov  rdi, rax
call writeThrough(int*, int)

The local x lives at -4[rbp] on the stack. To pass &x, the compiler uses lea rax, -4[rbp]. lea means "load effective address". Unlike mov, it does not touch memory: it simply computes the address -4[rbp] would refer to and puts that number in rax. So lea is the assembly form of the & operator: it produces an address without reading what is there. That address then goes into rdi as the first argument, and 99 goes into esi as the second.

The contrast is worth holding onto: mov rax, [rbp-4] would load the value at that slot, while lea rax, [rbp-4] loads the address of that slot. One brings back the contents; the other brings back the location.

References Compile to the Same Thing

C++ references look very different from pointers in source code: no *, no & at the call site, no risk of being null. It is tempting to imagine the compiler does something fundamentally different for them. It does not. Under the hood, a reference is implemented as a pointer, an address passed in a register, and accessing the reference is the same load you saw above.

Here are two functions that do the same job, one taking a pointer and one taking a reference:

int viaPointer(const int* p)
{
    return *p + 1;
}

int viaReference(const int& r)
{
    return r + 1;
}

Compiled at -O0, the two listings are identical instruction for instruction:

viaPointer(int const*):
    push rbp
    mov  rbp, rsp
    mov  QWORD PTR -8[rbp], rdi
    mov  rax, QWORD PTR -8[rbp]
    mov  eax, DWORD PTR [rax]
    add  eax, 1
    pop  rbp
    ret
viaReference(int const&):
    push rbp
    mov  rbp, rsp
    mov  QWORD PTR -8[rbp], rdi
    mov  rax, QWORD PTR -8[rbp]
    mov  eax, DWORD PTR [rax]
    add  eax, 1
    pop  rbp
    ret

Run your eyes down both columns: the same push/mov prologue, the same stash of the incoming address (rdi) into a stack slot, the same reload into rax, the same dereference mov eax, DWORD PTR [rax], the same add eax, 1. The only difference is the demangled name in the label, int const* versus int const&. The machine code is the same.

This is why people say "a reference is just a pointer that the compiler dereferences for you." At the source level the syntax is friendlier, but at the assembly level a reference is an address in a register, and reading r is a load through that address, exactly like *p.

Why the Stack Slots in the Unoptimized Version

You may wonder why the -O0 versions bother to store rdi into -8[rbp] and immediately read it back into rax. This is the same plumbing you saw in Chapter 1: at -O0 the compiler gives every parameter a home on the stack and reloads it whenever it is needed. The address gets a QWORD PTR slot (8 bytes) because it is a pointer. At -O2, as the first listing showed, all of that disappears and the address simply stays in rdi.

Key Takeaways

  • A pointer is a 64-bit address held in a register (or stack slot); pointers use full registers like rdi, not edi.
  • A register in brackets means "go to memory at that address". [rdi] is a dereference; rdi alone is just the address value.
  • Dereferencing (*p) is a memory load or store; the address operand supplies where.
  • lea ("load effective address") computes an address without accessing memory; it is the assembly form of the & operator.
  • mov rax, [...] fetches the value; lea rax, [...] fetches the address.
  • A reference compiles to the same code as a pointer: an address in a register, dereferenced on each use. Only the mangled type in the label differs.