Virtual Memory: The Grand Illusion

Type this right now

// save as vmaddr.c — compile: gcc -o vmaddr vmaddr.c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    int x = 42;
    pid_t pid = fork();

    if (pid == 0) {
        // Child process
        printf("[child] &x = %p, x = %d\n", (void *)&x, x);
        x = 99;
        printf("[child] &x = %p, x = %d  (after modification)\n", (void *)&x, x);
    } else {
        wait(NULL);
        printf("[parent] &x = %p, x = %d  (unchanged!)\n", (void *)&x, x);
    }
    return 0;
}
$ gcc -o vmaddr vmaddr.c && ./vmaddr
[child] &x = 0x7ffd3a4b1c2c, x = 42
[child] &x = 0x7ffd3a4b1c2c, x = 99  (after modification)
[parent] &x = 0x7ffd3a4b1c2c, x = 42  (unchanged!)

Same address. Different values. That should break your brain a little. Both processes see 0x7ffd3a4b1c2c, but they're looking at different physical memory. Welcome to virtual memory.


The problem

Your system is running hundreds of processes right now. Each one believes it owns a vast, private stretch of memory — on x86-64, up to 128 TB of user-space addresses. But you probably have 16 GB of RAM. Maybe 32 if you're fancy.

    Process A thinks:  "I have 128 TB to myself"
    Process B thinks:  "I have 128 TB to myself"
    Process C thinks:  "I have 128 TB to myself"
    ...
    Process Z thinks:  "I have 128 TB to myself"

    Physical RAM:       16 GB total. That's it.

How is this possible? The same way a magician makes one card look like fifty. Indirection.


The solution: one layer of translation

Every address your program uses is a virtual address. It is never placed directly on the memory bus. Instead, hardware translates it to a physical address before the memory access happens.

    Your C code: int *p = (int *)0x4000;
                         │
                         ▼
              ┌─────────────────────┐
              │   Virtual Address   │
              │      0x4000         │
              └────────┬────────────┘
                       │
                       ▼
              ┌─────────────────────┐
              │        MMU          │  ◄── Hardware inside the CPU
              │   (translates via   │
              │    page tables)     │
              └────────┬────────────┘
                       │
                       ▼
              ┌─────────────────────┐
              │  Physical Address   │
              │     0x7A2000        │  ◄── Actual RAM location
              └─────────────────────┘

Your program never sees physical addresses. It never needs to. The translation happens on every single memory access — every load, every store, every instruction fetch.


Every process gets its own map

This is the key insight. Process A and Process B can both use virtual address 0x4000. The MMU consults different page tables for each process, so 0x4000 lands at different physical locations.

  Process A                                    Process B
  ─────────                                    ─────────
  Virtual: 0x4000                              Virtual: 0x4000
       │                                            │
       ▼                                            ▼
  ┌──────────────┐                            ┌──────────────┐
  │ A's Page     │                            │ B's Page     │
  │ Table        │                            │ Table        │
  │              │                            │              │
  │ 0x4000 ──────┼──┐                         │ 0x4000 ──────┼──┐
  └──────────────┘  │                         └──────────────┘  │
                    │                                           │
                    ▼                                           ▼
       ┌────────────────────────────────────────────────────────────┐
       │                    Physical RAM                            │
       │                                                            │
       │   Frame 0x1A2  ◄─── A's data        Frame 0x5F7 ◄─── B's │
       │   ┌──────────┐                      ┌──────────┐          │
       │   │ x = 42   │                      │ x = 99   │          │
       │   └──────────┘                      └──────────┘          │
       └────────────────────────────────────────────────────────────┘

Same virtual address. Different physical frames. Complete isolation.


Three gifts from virtual memory

1. Isolation. Process A cannot read or write Process B's memory. There is no virtual address in A's page table that maps to B's physical frames. The hardware enforces this — not the OS, not a runtime check, the MMU itself refuses the translation.

2. Convenience. Every process can use the same virtual address layout: code near the bottom, heap growing up, stack at the top. The linker doesn't need to know where in physical RAM the program will land. It just targets the standard virtual layout.

3. Overcommit. The OS can promise more memory than physically exists. malloc(1 GB) succeeds even with 2 GB of RAM — because no physical RAM is allocated until you actually touch each page. We'll see how in Chapter 17.

🧠 What do you think happens?

If 50 processes each malloc(1 GB), is that 50 GB of RAM consumed? What if none of them ever write to the memory? What if they all write to every byte simultaneously?


The MMU: translation in hardware

The Memory Management Unit lives inside the CPU die. It is not a separate chip. It is not software. It is transistors that execute the page-table walk on every memory access.

    ┌──────────────────────────────────────────────┐
    │                   CPU                         │
    │                                               │
    │   ┌──────────┐    ┌──────────┐               │
    │   │  Core 0  │    │  Core 1  │    ...        │
    │   │          │    │          │               │
    │   │  ┌─────┐ │    │  ┌─────┐ │               │
    │   │  │ TLB │ │    │  │ TLB │ │  ◄── Cache    │
    │   │  └──┬──┘ │    │  └──┬──┘ │     of recent │
    │   │     │    │    │     │    │     translations│
    │   │  ┌──┴──┐ │    │  ┌──┴──┐ │               │
    │   │  │ MMU │ │    │  │ MMU │ │  ◄── Walks    │
    │   │  └─────┘ │    │  └─────┘ │     page      │
    │   └──────────┘    └──────────┘     tables     │
    └──────────────────────────────────────────────┘

Who sets up the mapping? The operating system kernel. It writes entries into the page tables in physical memory. It sets the CR3 register to point to the root of each process's page table.

Who enforces the mapping? The hardware. Every memory access goes through the MMU. If the page table says "no access," the CPU raises a page fault exception — even the kernel can't bypass the MMU without disabling it entirely (which no modern OS does).


Memory-mapped files

The same translation mechanism can point virtual pages at file contents on disk instead of anonymous RAM. This is mmap().

#include <sys/mman.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

int main() {
    int fd = open("/etc/hostname", O_RDONLY);
    char *data = mmap(NULL, 4096, PROT_READ, MAP_PRIVATE, fd, 0);
    printf("Hostname: %s\n", data);  // Reading the file by just reading memory!
    munmap(data, 4096);
    close(fd);
    return 0;
}

No read() call. You access the file like it's a normal array in memory. When you touch a page, the kernel loads the file's contents into a physical frame and maps it in. This is how the kernel loads your program's .text section — it memory-maps the ELF binary.

💡 Fun Fact: Shared libraries (.so files) are memory-mapped once and shared between every process that uses them. If 100 processes use libc.so, there is only ONE copy of its .text section in physical RAM, mapped into 100 different virtual address spaces.


Copy-on-write: the fork() trick

When you call fork(), the kernel does NOT copy the parent's entire memory. That would be absurdly expensive. Instead:

  1. Clone the page tables — child gets the same virtual-to-physical mapping as parent
  2. Mark ALL pages read-only in both parent and child
  3. Both processes continue running, sharing the exact same physical frames

When either process writes to a page: 4. The CPU raises a page fault (the page is marked read-only) 5. The kernel sees it's a copy-on-write page 6. The kernel copies just that one page to a new physical frame 7. Updates the writer's page table to point to the new copy, marks it writable 8. The other process still points to the original frame

    After fork() — before any writes:

    Parent page table          Physical RAM            Child page table
    ┌────────────┐          ┌──────────────┐          ┌────────────┐
    │ 0x4000 ────┼────RO───►│ Frame 0x1A2  │◄───RO────┼──── 0x4000 │
    │ 0x5000 ────┼────RO───►│ Frame 0x1A3  │◄───RO────┼──── 0x5000 │
    │ 0x6000 ────┼────RO───►│ Frame 0x1A4  │◄───RO────┼──── 0x6000 │
    └────────────┘          └──────────────┘          └────────────┘

    Child writes to 0x5000:

    Parent page table          Physical RAM            Child page table
    ┌────────────┐          ┌──────────────┐          ┌────────────┐
    │ 0x4000 ────┼────RO───►│ Frame 0x1A2  │◄───RO────┼──── 0x4000 │
    │ 0x5000 ────┼────RO───►│ Frame 0x1A3  │          │ 0x5000 ────┼──┐
    │ 0x6000 ────┼────RO───►│ Frame 0x1A4  │◄───RO────┼──── 0x6000 │  │
    └────────────┘          │ Frame 0x2B7  │◄───RW────────────────────┘
                            └──────────────┘
                              (copied page)

Only the page that was written gets duplicated. If a child process calls exec() immediately after fork() (which is common), most pages are never written — so almost nothing gets copied.


Rust's perspective

Rust doesn't have fork() in its standard library — partly because fork is fundamentally unsafe in multithreaded programs. But the virtual memory system works identically underneath.

// Rust uses the same virtual address space layout
use std::alloc::{alloc, Layout};

fn main() {
    let stack_var = 42;
    let heap_var = Box::new(99);

    println!("Stack: {:p}", &stack_var);    // High address
    println!("Heap:  {:p}", &*heap_var);    // Lower address
    println!("Code:  {:p}", main as *const ());  // Low address

    // Same /proc/self/maps underneath
    let maps = std::fs::read_to_string("/proc/self/maps").unwrap();
    for line in maps.lines().take(5) {
        println!("{}", line);
    }
}

Every concept in this chapter — page tables, the MMU, isolation between processes — applies to Rust programs identically. Rust's safety guarantees operate on top of virtual memory, not instead of it.


🔧 Task: Observe copy-on-write in action

Write this program in C. Before running, predict what you'll see:

// save as cow.c — compile: gcc -o cow cow.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    int *data = malloc(sizeof(int));
    *data = 42;

    printf("Before fork: &data[0] = %p, value = %d\n", (void *)data, *data);

    pid_t pid = fork();
    if (pid == 0) {
        // Child
        printf("[child] &data[0] = %p, value = %d\n", (void *)data, *data);
        *data = 99;  // This triggers copy-on-write!
        printf("[child] &data[0] = %p, value = %d (modified)\n", (void *)data, *data);
        free(data);
        _exit(0);
    } else {
        wait(NULL);
        printf("[parent] &data[0] = %p, value = %d (still original!)\n",
               (void *)data, *data);
        free(data);
    }
    return 0;
}

What to observe:

  1. The address printed by parent and child is identical — same virtual address.
  2. The child modifies *data to 99, but the parent still sees 42.
  3. The addresses never change. Only the physical backing changes, invisibly.

Now try adding this before and after the child's modification:

char cmd[64];
snprintf(cmd, sizeof(cmd), "grep -A1 'heap' /proc/%d/smaps", getpid());
system(cmd);

Watch the Private_Dirty field increase after the write — that's the copy-on-write page becoming a private copy.