Two Places a Variable Can Live

Every variable you have read in assembly so far has lived in one of two places. Local variables and parameters live on the stack, reached through rbp or rsp with a small offset like -4[rbp]. We have leaned on that pattern since Chapter 1. But global variables, and anything with static storage, live somewhere else entirely: in fixed regions of the program's memory image that exist for the whole run. They are reached with a completely different addressing style, RIP-relative addressing, and learning to recognise it tells you instantly whether a value is a local or a global.

Globals Are Reached RIP-Relative

Here is a global and two functions that touch it:

int g_counter{100};

int readGlobal()
{
    return g_counter;
}

void incrementGlobal()
{
    ++g_counter;
}

At -O2:

readGlobal():
    mov eax, DWORD PTR g_counter[rip]
    ret
incrementGlobal():
    add DWORD PTR g_counter[rip], 1
    ret

The operand g_counter[rip] is the new thing. rip is the instruction pointer, the register holding the address of the currently executing instruction. So g_counter[rip] means "the address of g_counter, expressed as a fixed distance from where this instruction is." The assembler and linker work out that distance for you; what matters for reading is that the variable is named directly in the operand.

That naming is the giveaway. A local variable never appears by name in the assembly; it is just an anonymous offset like -4[rbp]. A global appears by its symbol name next to [rip]. So g_counter[rip] reads plainly as "the global called g_counter." And notice incrementGlobal modifies it in place with a single add DWORD PTR g_counter[rip], 1: the global keeps its fixed home, so the increment reads and writes the same address.

Why RIP-Relative Instead of an Absolute Address

It would seem simpler to just write the global's absolute address into the instruction. Modern systems avoid that because of position-independent code: the operating system may load the program (or a shared library) at a different base address each time it runs, for security and flexibility. If instructions hard-coded absolute addresses, they would break whenever the load address changed.

RIP-relative addressing sidesteps the problem. The distance from an instruction to a nearby global is fixed at link time and never changes, no matter where the program is loaded, because the code and its globals move together. So the compiler encodes "this many bytes from here" rather than "this exact address." You do not need to compute the distance by hand; just read name[rip] as "the global name."

Locals on the Stack, Side by Side

To make the contrast concrete, here are a local-using function and a global-using function compiled together:

int g_value{7};

int useLocal(int n)
{
    int local{n + 1};
    return local * 2;
}

int useGlobal()
{
    return g_value * 2;
}

At -O0:

useLocal(int):
    push rbp
    mov  rbp, rsp
    mov  DWORD PTR -20[rbp], edi
    mov  eax, DWORD PTR -20[rbp]
    add  eax, 1
    mov  DWORD PTR -4[rbp], eax
    mov  eax, DWORD PTR -4[rbp]
    add  eax, eax
    pop  rbp
    ret
useGlobal():
    push rbp
    mov  rbp, rsp
    mov  eax, DWORD PTR g_value[rip]
    add  eax, eax
    pop  rbp
    ret

The difference jumps out. useLocal works entirely through rbp: the parameter goes to -20[rbp], the local local to -4[rbp], all anonymous stack slots that exist only while the function runs. useGlobal reaches straight for g_value[rip], naming the global directly, because it lives at a fixed location for the whole program. Stack offset versus name[rip]: that single distinction tells you the storage class of any variable in a listing.

Where Globals Actually Sit: .data, .bss, .rodata

Globals do not all live in the same section of the program image. The compiler sorts them into three areas depending on how they are initialised and whether they can change:

  • .data holds globals with a non-zero initial value that can change at run time, like our int g_counter{100};. The value 100 is stored in the program file and copied into memory at startup.
  • .bss holds globals that start at zero, like int g_total{};. Storing a block of zeros in the file would be wasteful, so .bss just records how many zero bytes are needed and the loader clears them at startup. The file stays smaller.
  • .rodata ("read-only data") holds constants that must never change, such as const int g_max{500}; and string literals like "hello". The system marks these pages read-only, so a stray write to them faults instead of silently corrupting data.

You do not usually see these section names inside a function body, only the name[rip] reference. But knowing the three buckets explains why a global is reachable at a fixed location at all: it was placed in one of these sections, which are part of the program image and mapped into memory before main runs. A string literal you pass to std::cout, for instance, lives in .rodata and is reached RIP-relative, exactly like a named global.

A Note on the Unoptimized Global

The global access barely changes between -O0 and -O2. Here is readGlobal at -O0:

readGlobal():
    push rbp
    mov  rbp, rsp
    mov  eax, DWORD PTR g_counter[rip]
    pop  rbp
    ret

Aside from the prologue and epilogue, it is the same mov eax, DWORD PTR g_counter[rip]. Unlike a local, a global has nothing to store-and-reload through the stack: its home is already fixed, so even unoptimized code just reads it directly. The RIP-relative form is a property of where the variable lives, not of the optimization level.

Key Takeaways

  • Variables live in two broad places: locals/parameters on the stack (anonymous offsets like -4[rbp]) and globals/statics in fixed sections of the program image.
  • Globals are reached with RIP-relative addressing, written name[rip], where rip is the instruction pointer.
  • The tell-tale sign of a global is that it appears by its symbol name in the operand; a local is just an offset from rbp/rsp.
  • RIP-relative addressing enables position-independent code: the distance from an instruction to a global is fixed even when the program loads at a different base address.
  • Globals are sorted into .data (non-zero, mutable), .bss (zero-initialised), and .rodata (read-only constants and string literals).
  • A global's RIP-relative access looks nearly the same at -O0 and -O2, because its location is fixed rather than reconstructed each call.