TRT
Sign in

Foundations · Module 3 · Lesson 1 of 9

Virtual Memory Layout and Pointer Addressing

Understand the virtual memory segments of a C process, inspect how the MMU translates addresses, and master pointer referencing and dereferencing.

FreeAbout 19 minutes

By the end you can

  • Map the standard memory segments (.text, .rodata, .data, .bss, heap, and stack) of a running process
  • Explain how the Memory Management Unit (MMU) translates virtual addresses to physical RAM frames
  • Explain why process IDs and stack addresses change between program runs
  • Manipulate memory addresses using the address-of (&) and dereference (*) operators
  • Differentiate pointer variable size from the byte width of the referenced data type

Builds on Storage Classes, Scope, and Linkage.

01 · A question first

Why do two runs of the same program print different process IDs and different stack addresses, such as 0x16dad6208 and 0x16b176208, while the address remains stable during each run? And why does writing to *ptr crash with a segmentation fault when pointing to a string literal, even though the pointer contains a valid, non-null address?

Make a guess, then step through the trace below to check it.

02 · Watch it run

Pointer referencing and dereferencing in virtual memorystep 1 of 4
int val = 42;int *ptr = &val;*ptr = 99;
  1. slot 0val (0x7ffd04): 42(looked at in this step)
  2. slot 1ptr (0x7ffd08): unset
  3. slot 2deref: none
  4. slot 3state: initialized

Stack allocates 4 bytes for integer variable val at virtual address 0x7ffd04 and stores 42.

Machine work: movl $42, -4(%rbp)

03 · The idea

Start with the problem

To many programmers learning C, pointers seem like an arbitrary syntactic puzzle: stars and ampersands sprinkled across function calls.

This confusion stems from viewing pointers as language symbols rather than physical addresses in a running machine.

Consider a simple test. Write a short POSIX program that prints the process ID and the address of a local variable:

c
#include <stdio.h>
#include <unistd.h> /* POSIX: declares getpid */
 
int main(void) {
    int counter = 100;
    printf("Process PID: %ld, Counter Address: %p\n",
           (long)getpid(), (void *)&counter);
    getchar(); /* Pause execution */
    return 0;
}

getpid() is a POSIX function, so this exact program is intended for Unix-like systems such as macOS and Linux. The cast to long makes the example's printf format match the converted value. Compile it, then run it twice, pausing each process at getchar():

text
Run 1: Process PID: 22872, Counter Address: 0x16dad6208
Run 2: Process PID: 22423, Counter Address: 0x16b176208

This difference is expected:

  • The process ID is assigned by the operating system. A new process normally receives a different ID; IDs can also be reused later after an older process exits.
  • The local variable's address belongs to that process's virtual address space. The operating system's address-space layout randomization (ASLR) changes the locations of stacks, libraries, heaps, and other mappings between executions.
  • The stack address can also change because of compiler options, architecture, environment size, thread startup details, or the operating system.
  • The address is valid for that process while counter is alive. It is not a permanent address and cannot be used by another process to access this variable.

On one system, two processes may still print the same virtual address. That is also safe: each process has a separate page table, so the same number is interpreted within a different address space. On another system, ASLR may produce different addresses like the two runs above. In both cases, the important rule is the same: a user-space pointer is meaningful only together with the process that owns it.

Now consider another common puzzle:

c
char *str = "Read Only";
*str = 'W'; /* CRASH: Segmentation fault */

Here, str holds a valid, non-null pointer address. Printing str prints "Read Only" cleanly. Yet the instant code attempts to write through that address using *str = 'W', the operating system terminates the process with a segmentation fault.

How can processes use virtual addresses without colliding, why can those addresses change between runs, and why can a valid memory address be readable but instantly trigger a crash upon writing?

To understand pointers, you must stop imagining memory as a physical array of hardware chips and understand virtual memory layout and pointer mechanics.


How the idea works

When your C program runs, it does not touch physical RAM directly. Every memory access is performed through virtual memory, mediated by the CPU hardware Memory Management Unit (MMU).

text
Process A (Virtual Memory)           Process B (Virtual Memory)
┌─────────────────────────┐         ┌─────────────────────────┐
│ Virtual: 0x7ffeef04b8bc │         │ Virtual: 0x7ffeef04b8bc │
└────────────┬────────────┘         └────────────┬────────────┘
             │ (MMU Page Table A)                │ (MMU Page Table B)
             ▼                                   ▼
    Physical RAM Frame:                 Physical RAM Frame:
        0x10A4B000                          0x39E8F000

Because each process has its own isolated page table, virtual address 0x7ffeef04b8bc in Process A can map to physical RAM frame 0x10A4B000, while the identical virtual address in Process B can map to physical RAM frame 0x39E8F000. If ASLR chooses different virtual addresses, each address is mapped separately instead. The processes never collide because the kernel controls which virtual pages each process may access.

1. The Virtual Memory Segments of a Process

Operating systems provide each process with a private virtual address space, but the exact layout and address range are platform-specific. On many 64-bit Unix-like systems, user-space addresses occupy a lower portion of the architecture's address range; do not assume the exact upper limit or segment positions shown below. This diagram is a simplified mental model:

text
High Memory (0x7FFFFFFFFFFF)
┌──────────────────────────────────────────┐
│ Kernel Space (Inaccessible to User Space)│
├──────────────────────────────────────────┤
│ The Call Stack                           │  Grows Downward ▼
│   (Local automatic variables, frames)    │
├──────────────────────────────────────────┤
│                ▲                         │
│                │ Free Memory Gap         │
│                ▼                         │
├──────────────────────────────────────────┤
│ Memory Mapping Segment                   │
│   (Shared libraries, mmap allocations)   │
├──────────────────────────────────────────┤
│ Dynamic Heap                             │  Grows Upward ▲
│   (malloc, calloc, realloc)              │
├──────────────────────────────────────────┤
│ BSS Segment (.bss)                       │
│   (Uninitialized global/static variables)│
├──────────────────────────────────────────┤
│ Data Segment (.data)                     │
│   (Initialized global/static variables)  │
├──────────────────────────────────────────┤
│ Read-Only Data (.rodata)                 │
│   (String literals, const data)          │
├──────────────────────────────────────────┤
│ Text Segment (.text)                     │
│   (Executable machine instructions)      │
├──────────────────────────────────────────┤
│ Null Pointer Guard Page (Unmapped)       │  0x0000000000000000
└──────────────────────────────────────────┘
Low Memory
  • Text Segment (.text): Holds the compiled machine code instructions. Marked executable and read-only. Attempting to write into .text triggers a segmentation fault.
  • Read-Only Data (.rodata): Holds string literals ("Hello, world") and constants. Marked read-only. Writing to char *s = "text" triggers a crash because s points into this section.
  • Initialized Data (.data): Holds global and static variables initialized with non-zero values. Readable and writable.
  • Uninitialized Data (.bss): Holds global and static variables that are zeroed. Occupies zero disk space in the binary file; allocated as zero-filled pages by the OS loader.
  • Heap: Dynamically managed memory requested via malloc(). Its growth direction and placement are implementation details; do not compare heap addresses as if they were guaranteed to increase.
  • Memory Mappings: Area where shared libraries (libc.so) and files mapped via mmap() reside.
  • Stack: Stores function activation frames, return addresses, and local variables. Many common ABIs grow the stack downward, but C does not guarantee a stack or its growth direction.
  • Null Pointer Guard Page: Operating systems commonly leave low pages unmapped so many null-pointer accesses trap immediately. C does not require a particular page size or guarantee that every invalid pointer access produces the same signal.

2. Pointers as Typed Addresses

A pointer is a typed value used to refer to an object or function. On ordinary hosted systems it is represented using an address-like value, but C does not require every pointer to be exactly 8 bytes or every pointer to be directly convertible to an integer.

c
int val = 42;
int *ptr = &val;

In this snippet:

  1. val is an integer occupying 4 bytes of stack memory.
  2. The address-of operator (&) produces a pointer to val; the displayed address is an implementation-specific example.
  3. ptr is a variable of type int * (pointer to int). On the 64-bit systems used by this lesson it is typically 8 bytes, but portable C code should use sizeof ptr rather than assuming a pointer width.

In the trace above:

  • Step 1 shows val placed at address 0x7ffd04 with value 42.
  • Step 2 and 3 show ptr allocated at 0x7ffd08 and assigned the address 0x7ffd04.
  • Step 4 shows the dereference operator (*). When the code executes *ptr = 99;, the CPU reads the address stored inside ptr (0x7ffd04), travels to that address in memory, and writes 99 into those 4 bytes. val is now 99.

3. Why Pointer Types Matter

Even when several object pointers have the same size on a 64-bit system, why does C require different pointer types (int *, char *, double *)? Why not use a generic address type for everything?

The type of a pointer tells the compiler two things:

  1. Dereference Width: When evaluating *ptr, how many bytes must the CPU read or write?
    • If ptr is char *: read/write 1 byte.
    • If ptr is int *: read/write 4 bytes.
    • If ptr is double *: read/write the size of a double on that implementation.
  2. Value Interpretation: How should the bits at that address be decoded? The same bytes can represent an integer or floating-point value only when the access obeys C's type, alignment, lifetime, and aliasing rules.

Why it is correct

Pointers and virtual addressing provide two essential computing invariants: hardware protection and indirection.

Page-Level Protection Rings

Modern CPU architectures do not verify every pointer dereference through software checks. Doing so in software would make every memory read dozens of instructions slower.

Instead, memory is divided into fixed-size blocks called pages (typically 4096 bytes, or 4 KB). The operating system configures permissions in the MMU page table for each page:

  • PROT_READ (Read permission)
  • PROT_WRITE (Write permission)
  • PROT_EXEC (Execute permission)

When your code dereferences a pointer:

  1. The CPU sends the virtual address to the MMU.
  2. The MMU looks up the corresponding page table entry.
  3. If an instruction tries to write to an address whose page lacks PROT_WRITE (such as a string literal in .rodata), the CPU hardware raises a Page Fault Exception.
  4. The operating system catches the exception and sends signal 11 (SIGSEGV) to your process, stopping execution before corrupted writes reach physical memory.

Indirection and Zero-Copy Performance

Passing a pointer lets a function borrow or modify an existing object instead of copying the object's bytes. C still allows a function to copy data when a parameter is passed by value, and an implementation may pass small values in registers, so the exact calling convention is platform-specific.

If you have a 10-megabyte image buffer:

  • Passing a large structure by value may copy a large payload and can require substantial stack or register space.
  • Passing a pointer copies only the pointer value at the call boundary. On the System V AMD64 ABI, a pointer argument is commonly passed in a general-purpose register such as RDI; other ABIs use different registers or memory.
  • The function still pays the cost of reading the pointed-to bytes. A pointer is not a guarantee of one-cycle access or zero cache misses.

What it costs

Using pointers introduces concrete hardware performance factors:

OperationMechanismTypical Latency Cost
Register dereference (cached)Load from a nearby CPU cacheHardware- and workload-dependent
Main memory dereference (cache miss)DRAM access over memory controllerOften much slower than a cache hit
Pointer size footprintDepends on the target ABIMeasure with sizeof
Virtual address translationTLB lookup or page-table walkUsually hidden by hardware pipelining, but not free

Cache Locality vs. Pointer Chasing

CPUs do not read single bytes from RAM; they fetch 64-byte chunks into cache lines.

  • Contiguous Arrays: Elements sit next to each other in memory. Reading element 0 automatically loads elements 1 through 15 into L1 cache, making sequential processing fast.
  • Pointer Structures (Linked Lists / Trees): Nodes may be far apart in memory. Accessing node->next creates a dependent load and can miss in the cache, making pointer chasing slower than a contiguous array. A cache miss is not guaranteed on every node, because an allocator, access pattern, or prefetcher may keep some nodes nearby.

Pitfalls and variations

Pitfall 1: Uninitialized Wild Pointers

Declaring a pointer without an initializer leaves it filled with whatever garbage bits occupied that stack memory slot:

c
int *uninitialized_ptr;
/* BUG: dereferencing garbage address */
*uninitialized_ptr = 500; 

The pointer might hold 0x00000000 (crashing immediately), or it might hold a random valid address pointing into your stack or heap. In the latter case, writing to *uninitialized_ptr silently corrupts unrelated variables, producing bugs that show up far from the root cause. Always initialize unused pointers to NULL:

c
int *safe_ptr = NULL;

Pitfall 2: Modifying String Literals

As highlighted in the opening section, string literals are read-only constants:

c
/* POINTER TO CONSTANT DATA: */
char *str = "Hello";
str[0] = 'M'; /* RUNTIME ERROR: SIGSEGV */
 
/* WRITABLE STACK ARRAY: */
char str_arr[] = "Hello";
str_arr[0] = 'M'; /* VALID: modifies mutable stack copy */
  • char *str = "Hello" creates an 8-byte pointer targeting string data located in .rodata.
  • char str_arr[] = "Hello" allocates 6 bytes on the local stack frame and copies "Hello\0" into it. Modifying str_arr modifies the stack, which is writable.

To prevent accidental writes, qualify pointers to string literals with const:

c
const char *str = "Hello";
/* str[0] = 'M'; -> Compiler catches this with a compile-time error */

Pitfall 3: Pointer Sizing Misconceptions

A frequent mistake is assuming that sizeof(ptr) reflects the size of the allocated buffer:

c
int numbers[100];
printf("Size: %zu\n", sizeof(numbers));

This code prints 400 when sizeof(int) == 4, because numbers is still an array at the point where sizeof is applied. Once an array is passed to a function and decays to a pointer, sizeof reports the pointer size instead; it does not report the size of the original array.


Where it is used

Virtual memory addressing and raw pointer manipulation are central across computer engineering:

  • Operating System Kernels: During boot, the Linux kernel initializes the CPU CR3 control register on x86-64 with the root physical address of the kernel page table directory, establishing the virtual memory mappings that isolate every user process.
  • Embedded Memory-Mapped I/O (MMIO): Microcontrollers control hardware peripherals (such as UART serial ports or GPIO pins) by assigning physical peripheral registers to fixed memory addresses:
    c
    #define GPIO_PORTA_DATA (*((volatile uint32_t *)0x400043FC))
    GPIO_PORTA_DATA = 0xFF; /* Turn on all 8 physical LED pins */
  • High-Performance Memory Allocators: Custom memory engines (such as jemalloc in FreeBSD or TCMalloc in Google servers) map entire 2 MB or 1 GB memory arenas using mmap(), directly carving pointers out of virtual address spans to serve user requests without OS syscall overhead.

In the next lesson, we will see how pointers navigate through contiguous memory: mastering pointer arithmetic, byte scaling rules, and type stepping.

04 · Prove it

Question 1 of 3 · answer from memory

What physical entity does a pointer variable actually store in modern operating systems?

05 · Practice

Something wrong with this lesson? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save