Virtual Memory and Address Translation

Every process believes it has a huge private memory starting at address 0.

Learning Objectives

Explain why virtual memory exists and distinguish virtual from physical addresses.

Describe per-process address spaces, pages and frames, single- and multi-level page tables, and the TLB.

Explain page faults, demand paging, page replacement, working sets, swapping, copy-on-write, shared pages, memory-mapped files and anonymous mappings.

Explain page permissions and protection faults, ASLR, guard pages, overcommit, RSS versus virtual size, huge pages, NUMA, memory pressure and OOM behavior, and kernel virtual memory.

Describe embedded systems without an MMU and MPU-based protection.

Translate addresses by hand.

Plain-Language Explanation

Every process believes it has a huge private memory starting at address 0. That is an illusion maintained by the CPU and the operating system together. Memory is cut into equal pages (usually 4 KiB). For every process, the OS keeps a page table that says which real RAM frame — if any — holds each page. On every memory access the CPU's MMU looks up the page and swaps in the real address. Pages that are not in RAM yet cause a page fault, and the OS quietly fetches or creates them. The illusion buys isolation (processes cannot see each other), flexibility (memory need not be contiguous) and efficiency (only used pages consume RAM).

Precise Technical Explanation

Why virtual memory exists Isolation — a process can only reach frames its page table maps.

Simplicity — every program can be linked as if it owned the address space.

Efficiency — memory is allocated lazily, shared between processes (libraries, copy-on-write) and moved to disk when idle.

Protection — per-page read/write/execute and user/kernel permissions.

Virtual versus physical addresses A virtual address is what instructions use; a physical address selects actual RAM (or a device). The translation is invisible to user code: a pointer printed by your program is *always* virtual — never a RAM chip location.

Per-process address spaces Each process has its own page tables; switching processes switches the page-table root (CR3 on x86, TTBR0 on Arm). The kernel's half of the address space is mapped in every process but accessible only in kernel mode.

Pages and page frames A page is a fixed-size block of virtual memory; a frame is a page-sized block of physical memory. The page size is 4 KiB on x86-64 and most Arm Linux systems; Apple silicon uses 16 KiB; some Arm and POWER servers 64 KiB. A virtual address splits into virtual page number (VPN) and offset: with 4 KiB pages the low 12 bits are the offset.

A page table maps VPNs to page-table entries (PTEs): frame number plus flags — present, writable, user-accessible, executable (NX), accessed, dirty, global, cache attributes.

Multi-level page tables A flat table for a 48-bit address space would need 2³⁶ entries per process. Instead x86-64 uses a 4-level radix tree (5-level on new CPUs): bits 47–39 index the PML4, 38–30 the PDPT, 29–21 the page directory, 20–12 the page table, 11–0 are the offset. Unused regions need no lower-level tables. AArch64 uses a similar 4-level scheme with 4 KiB granules.

Translation Lookaside Buffer (TLB) Walking four levels for every access would be ruinous, so the TLB caches recent translations — typically 64 L1 entries per core and 1,500–2,000 in a shared L2 TLB. A TLB hit costs nothing extra; a miss triggers a hardware page walk (itself cached). TLB entries are tagged with address-space IDs (PCID/ASID) so context switches need not flush everything.

When translation fails — page not present or permission violated — the CPU raises a page fault with the faulting address (CR2 on x86, FAR_EL1 on Arm). The kernel decides:

minor fault — the page is in RAM or can be created without I/O (first touch of anonymous memory, copy-on-write, page cache hit): map it and resume;

major fault — data must be read from disk (swap or a file);

invalid access — no mapping or wrong permission: deliver SIGSEGV.

Pages are mapped only when first touched. malloc(1 GiB) reserves address space; physical frames are assigned page by page as you write (the experiment below shows RSS growing with touches, not with allocation).

Page replacement overview When RAM is full, the kernel evicts pages: clean file-backed pages are dropped (they can be re-read), dirty pages are written back, anonymous pages go to swap. Policies approximate LRU (Linux: active/inactive lists, multi-generational LRU since 6.1; clock algorithms in textbooks).

The working set is the set of pages a process actively uses over a time window. If the sum of working sets exceeds RAM, the system thrashes — spending its time paging instead of computing.

Swapping and backing store Every page has a backing store: its file (for file mappings), a swap area (for anonymous memory), or nothing (if it can be recreated as zeroes). Swapping out writes a page to its backing store and unmaps it; a later access major-faults it back in.

Shared read-only mappings that are copied on the first write (Module 10's fork, private file mappings).

Several page tables map the same frame: shared libraries' code, MAP_SHARED mappings, kernel same-page merging (KSM) for identical pages in VMs.

A file mapping makes file pages appear in memory; the page cache holds them, so reading a mapped file and read()-ing it share the same physical pages.

Mappings not backed by a file (MAP_ANONYMOUS): heaps, stacks, large malloc blocks. They read as zero until written.

Page permissions and protection faults PTE bits enforce r/w/x and user/supervisor. Writing a read-only page, executing an NX page or touching kernel pages from user mode faults. mprotect changes permissions at page granularity — the demonstration below catches the resulting SIGSEGV safely.

Randomised placement of regions (Module 3) — implemented by choosing random virtual base addresses; the page tables make any base workable.

Pages mapped with no permissions (or deliberately left unmapped) around stacks and sensitive buffers, turning overruns into immediate faults.

Overcommit and allocation versus commitment (OS-specific) Linux by default (vm.overcommit_memory = 0) lets processes reserve more virtual memory than RAM + swap, betting they will not touch it all; if they do, the OOM killer terminates a process. Mode 2 enforces strict commit limits. Windows separates reserving address space (VirtualAlloc(MEM_RESERVE)) from committing it (MEM_COMMIT), and commit is always backed by RAM or page file — allocation fails up front instead of an OOM kill later.

Resident set size (RSS), virtual size and working set Virtual size (VSZ) — all mapped address space, touched or not.

RSS — pages currently in RAM (includes shared pages, counted in each process).

PSS — RSS with shared pages divided among their sharers (/proc/<pid>/smaps_rollup).

Windows working set / private bytes / commit size — the analogous counters.

Huge pages (overview) 2 MiB or 1 GiB pages (x86-64) cover more memory per TLB entry, cutting TLB misses for large data. Linux offers explicit hugetlbfs pages and transparent huge pages (THP), which the kernel uses automatically (always) or on request with madvise(MADV_HUGEPAGE) (madvise mode — the setting on the build machine). Trade-offs: memory waste, compaction latency.

On multi-socket (and some large single-socket) systems each CPU has local RAM; accessing another node's RAM is slower. The OS places pages on the node of the thread that first touches them; numactl and libnuma control placement. The build machine has one NUMA node.

Memory pressure and out-of-memory behavior As free memory falls the kernel reclaims page cache, compacts, swaps, and finally invokes the OOM killer, which picks a victim by oom_score (adjustable with oom_score_adj). cgroups set per-container limits and can OOM-kill inside the group. Windows instead fails commit requests when the commit limit is reached.

Kernel virtual memory concepts The kernel maps all of physical memory (direct map), plus vmalloc areas, module space and per-CPU areas, in its half of every address space. KPTI (Meltdown mitigation) unmaps most kernel pages while running user code.

Embedded systems without an MMU Small microcontrollers (Cortex-M0/M3/M4, AVR, most RISC-V MCUs) have no MMU: addresses are physical, there is one address space, no demand paging and no isolation by default. A stray pointer can overwrite anything, including the stack of another task.

MPU-based protection in microcontrollers A memory protection unit defines a handful (8–16) of regions with base, size and permissions (read-only, no-execute, privileged-only). An RTOS reprograms the MPU on each context switch to confine tasks, and can place a no-access region below each stack as a guard (Module 16).

Address translation exercises See the worked example and exercises below; the visualizer lets you change page size and address width.

Why It Matters in Real Systems

"Out of memory", high RSS, page-fault storms, swapping, THP latency spikes, NUMA slowdowns and container OOM kills are everyday production issues. Understanding virtual memory also explains copy-on-write fork, memory-mapped databases, and why touching memory — not allocating it — is what costs RAM.

Key Terms

Fixed-size block of virtual / physical memory Page table / PTE

Mapping structure / one entry with frame number and flags Hardware that translates virtual to physical addresses Cache of recent translations Page fault (minor/major)

Translation failure handled without / with disk I/O Mapping pages only when first accessed

Pages actively used during a time window RSS / VSZ / PSS Resident / virtual / proportional set size

Promising more virtual memory than can be backed 2 MiB / 1 GiB page reducing TLB pressure Non-uniform memory access across nodes

Region-based protection without translation

Memory Layout and Execution Flow

flowchart LR
  VA[virtual address] --> T{TLB hit?}
  T -- yes --> PA[physical address]
  T -- no --> W[page-table walk: PML4 → PDPT → PD → PT]
  W --> P{present and allowed?}
  P -- yes --> F[fill TLB] --> PA
  P -- no --> K[page fault → kernel]
  K --> M{valid mapping?}
  M -- yes: allocate / read page --> F
  M -- no --> S[SIGSEGV]

Syntax

Windows: VirtualAlloc(NULL, len, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE), VirtualProtect, VirtualFree.

long page = sysconf(_SC_PAGESIZE);                                   /* POSIX */
void *p = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
mprotect(p, page, PROT_READ);                                        /* make one page read-only */
madvise(p, len, MADV_HUGEPAGE);                                      /* ask for THP (Linux) */
munmap(p, len);

Complete Runnable Example

Demand paging measured: reserve 256 MiB, then touch it, watching resident memory and page faults. GCC 11.2, Linux (WSL2 kernel 6.18).

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

static long rss_kib(void)
{
    long pages_total = 0, pages_resident = 0;
    FILE *f = fopen("/proc/self/statm", "r");
    if (f == NULL || fscanf(f, "%ld %ld", &pages_total, &pages_resident) != 2) return -1;
    fclose(f);
    return pages_resident * (sysconf(_SC_PAGESIZE) / 1024);
}

static long minor_faults(void)
{
    struct rusage ru;
    getrusage(RUSAGE_SELF, &ru);
    return ru.ru_minflt;
}

int main(void)
{
    const size_t size = 256u << 20;                  /* 256 MiB */
    const long page = sysconf(_SC_PAGESIZE);
    printf("page size: %ld bytes\n", page);

    long rss0 = rss_kib(), f0 = minor_faults();
    unsigned char *p = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (p == MAP_FAILED) return 1;
    long rss1 = rss_kib(), f1 = minor_faults();

    for (size_t i = 0; i < size / 2; i += (size_t)page) {
        p[i] = 1;                                    /* first touch: one minor fault per page */
    }
    long rss2 = rss_kib(), f2 = minor_faults();

    unsigned long sum = 0;
    for (size_t i = 0; i < size / 2; i += (size_t)page) {
      
…

Line-by-Line Explanation

/proc/self/statm reports total and resident pages; getrusage reports the process's minor page faults so far.

mmap of 256 MiB changes the *virtual* size only: RSS barely moves and no faults occur.

Writing one byte per page of the first half causes one minor fault per page — the kernel allocates and zeroes a frame each time — and RSS grows by 128 MiB.

The second pass over the same pages causes no new faults: the translations exist and are cached in the TLB.

Expected Output

Output (one real run):

The fault count matches the 32,768 touched 4 KiB pages exactly, and RSS grew by exactly 128 MiB. With THP set to always, the kernel could use 2 MiB pages and take ~64 faults instead — it is madvise on the build machine.

page size: 4096 bytes
after mmap 256 MiB : RSS    +88 KiB, minor faults +0
after touching half: RSS +131072 KiB, minor faults +32768 (32768 pages touched)
reading them again : minor faults +0, sum 32768

Step-by-Step Execution Trace

Translating 0x00007f3a12345678 with x86-64 4-level paging and 4 KiB pages:

Each index selects one of 512 entries (9 bits) in a 4 KiB table of 8-byte PTEs. The offset passes through unchanged; only the page number is translated. (The frame number is invented for the exercise — user programs cannot see real frame numbers.)

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    uint64_t va = 0x00007f3a12345678u;
    printf("virtual address  0x%016" PRIx64 "\n", va);
    printf("PML4 index  (bits 47-39) = %3" PRIu64 "\n", (va >> 39) & 0x1FF);
    printf("PDPT index  (bits 38-30) = %3" PRIu64 "\n", (va >> 30) & 0x1FF);
    printf("PD index    (bits 29-21) = %3" PRIu64 "\n", (va >> 21) & 0x1FF);
    printf("PT index    (bits 20-12) = %3" PRIu64 "\n", (va >> 12) & 0x1FF);
    printf("page offset (bits 11-0)  = 0x%03" PRIx64 "\n", va & 0xFFF);
    printf("virtual page number      = 0x%" PRIx64 "\n", va >> 12);
    uint64_t frame = 0x1a2b3;                       /* suppose the PTE holds this frame number */
    printf("if the PTE maps frame 0x%" PRIx64 ", the physical address is 0x%" PRIx64 "\n",
           frame, (frame << 12) | (va & 0xFFF));
    printf("with 2 MiB pages: offset = 0x%05" PRIx64 " (21 bits), PT level skipped\n", va & 0x1FFFFF);
    return 0;
}
virtual address  0x00007f3a12345678
PML4 index  (bits 47-39) = 254
PDPT index  (bits 38-30) = 232
PD index    (bits 29-21) = 145
PT index    (bits 20-12) = 325
page offset (bits 11-0)  = 0x678
virtual page number      = 0x7f3a12345
if the PTE maps frame 0x1a2b3, the physical address is 0x1a2b3678
with 2 MiB pages: offset = 0x145678 (21 bits), PT level skipped

Interactive Visualization

Enter a virtual address, choose page size and table parameters, and walk a simulated page table with TLB hits, misses and page faults. All parameters are internally consistent; this is a simulation, not your machine's page tables.

Common Mistakes and Failure Modes

Believing malloc returning non-NULL means the memory is available (overcommit).

Reading a printed pointer as a physical RAM location.

Counting RSS of all processes and double-counting shared libraries.

Large page-fault storms at start-up from touching huge buffers in latency-sensitive paths.

Assuming 4 KiB pages; use sysconf(_SC_PAGESIZE).

Expecting memory protection on an MCU without an MPU configuration.

Corrected / Safe Pattern

Use page protection deliberately — here a read-only page and a no-access guard page, with the resulting protection faults caught and reported (a safe, self-contained demonstration):

The kernel delivered SIGSEGV with si_addr equal to the exact faulting address (offset 0 from the target in both cases). Real programs normally let such faults terminate the process; catching them is reserved for special runtimes (garbage collectors, JITs, user-space paging).

#include <setjmp.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

static sigjmp_buf recover;
static void *volatile fault_address;

static void on_segv(int sig, siginfo_t *info, void *ctx)
{
    (void)sig; (void)ctx;
    fault_address = info->si_addr;
    siglongjmp(recover, 1);                       /* leave the faulting instruction behind */
}

static void try_write(const char *label, volatile char *where)
{
    if (sigsetjmp(recover, 1) == 0) {
        *where = 'x';
        printf("%-28s write succeeded\n", label);
    } else {
        printf("%-28s SIGSEGV, fault address = target %+td\n", label,
               (const char *)fault_address - (const char *)where);
    }
}

int main(void)
{
    long page = sysconf(_SC_PAGESIZE);
    struct sigaction sa;
    memset(&sa, 0, sizeof sa);
    sa.sa_sigaction = on_segv;
    sa.sa_flags = SA_SIGINFO;
    sigaction(SIGSEGV, &sa, NULL);

    char *base = mmap(NULL, 3 * (size_t)page, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (base == MAP_FAILED) return 1;
    mprotect(base + page, (size_t)page, PROT_READ);       /* page 1: read-only */
    mprotect(base + 2 * pa
…
page 0 (read-write):         write succeeded
page 1 (read-only):          SIGSEGV, fault address = target +0
page 2 (guard, no access):   SIGSEGV, fault address = target +0
reading page 1 still works: byte = 0

Debugging and Diagnostic Techniques

Output (one real run):

More: vmstat 1 (page-ins/outs, swap), perf stat -e page-faults,dTLB-load-misses, /usr/bin/time -v (max RSS, faults), pmap -X, Windows Task Manager / RAMMap / VMMap.

echo "page size: $(getconf PAGESIZE)"
echo "overcommit mode: $(cat /proc/sys/vm/overcommit_memory)  (0 = heuristic, 1 = always, 2 = strict)"
echo "transparent huge pages: $(cat /sys/kernel/mm/transparent_hugepage/enabled)"
grep -E '^(MemTotal|MemAvailable|SwapTotal|AnonHugePages|Hugepagesize|CommitLimit|Committed_AS)' /proc/meminfo
echo "NUMA nodes: $(ls -d /sys/devices/system/node/node* | wc -l)"
echo "--- smaps_rollup of a running process (this shell) ---"
grep -E '^(Rss|Pss|Shared_Clean|Private_Dirty|Swap):' /proc/$$/smaps_rollup
page size: 4096
overcommit mode: 0  (0 = heuristic, 1 = always, 2 = strict)
transparent huge pages: always [madvise] never
MemTotal:        3931776 kB
MemAvailable:    3374332 kB
SwapTotal:       1048576 kB
CommitLimit:     3014464 kB
Committed_AS:     347576 kB
AnonHugePages:         0 kB
Hugepagesize:       2048 kB
NUMA nodes: 1
--- smaps_rollup of a running process (this shell) ---
Rss:                3448 kB
Pss:                1594 kB
Shared_Clean:       3092 kB
Private_Dirty:       280 kB
Swap:                  0 kB

Performance Implications

Minor faults cost roughly a microsecond each; major faults milliseconds (disk). TLB misses add page-walk latency to random access over large data — huge pages help. Pre-faulting (MAP_POPULATE, touching at start-up) moves fault cost out of latency-critical paths. NUMA-local allocation can double memory bandwidth on multi-socket servers.

Security and Safety Implications

Per-process page tables are the foundation of process isolation; NX and W^X stop code injection; guard pages stop linear overruns; ASLR hides addresses; KPTI and other mitigations address speculative side channels. Never map memory writable and executable at once, and keep secrets out of swap (mlock) and core dumps.

Platform and Language Caveats

Page size, levels of translation, overcommit policy and OOM behavior are OS- and architecture-specific.

WSL2 runs a real Linux kernel inside a VM, so there is a second translation layer (guest physical → host physical) managed by the hypervisor.

Managed runtimes reserve large virtual regions for their heaps and commit on demand; a Java process's VSZ is often far larger than its RSS.

Real-World Application

Databases memory-map files (LMDB, SQLite mmap mode); JVMs and .NET use page protection for GC write barriers or safepoints; Redis uses fork + copy-on-write for snapshots; Kubernetes enforces memory limits through cgroups and the OOM killer; game consoles and HPC codes use huge pages.

Best Practices Checklist

[ ] Query the page size; never hard-code it.

[ ] Monitor RSS/PSS, not virtual size.

[ ] Handle allocation failure *and* plan for OOM under overcommit.

[ ] Pre-fault or lock memory in real-time paths.

[ ] Use huge pages for large, long-lived, randomly accessed data — and measure.

[ ] Keep W^X; use guard pages around stacks and sensitive buffers.

[ ] Configure the MPU on microcontrollers that have one.

Exercises

E1 · Beginner. With 4 KiB pages, what are the page number and offset of virtual address 0x3A7F?

Answer: Offset = 0x3A7F & 0xFFF = 0xA7F; page number = 0x3A7F >> 12 = 3.

E2 · Intermediate. A 32-bit system uses 4 KiB pages and 4-byte PTEs in a single-level table. How large is each process's page table, and how does a two-level 10/10/12 split help?

Answer: 2²⁰ entries × 4 B = 4 MiB per process. With two levels, a 4 KiB directory of 1024 entries points to 4 KiB second-level tables that exist only for used 4 MiB regions — a small process needs only a few.

E3 · Advanced challenge. Modify the demand-paging program to use madvise(p, size, MADV_HUGEPAGE) (with a 2 MiB-aligned mapping) and compare the fault counts. Explain the result and when THP can hurt.

Answer: With THP honoured, each first touch of a 2 MiB region maps a huge page: about 64 faults for 128 MiB instead of 32,768, and RSS grows in 2 MiB steps. THP can hurt through memory bloat (sparse use of huge pages), and latency spikes from compaction and khugepaged — which is why many databases recommend madvise or never.

Quiz

? A program prints a pointer value 0x7ffd5e2c3a1c. What is it?
+ A virtual address in the process's address space :: User code only ever sees virtual addresses.
- The physical RAM location of the variable :: The MMU translates it; physical addresses are hidden.
- An offset into the swap file :: Unrelated.
- The address of a CPU register :: Registers have no memory address.

? What happens on the first write to a freshly mmap'ed anonymous page on Linux?
- Nothing special; the memory was allocated by mmap. :: mmap only reserved address space.
+ A minor page fault; the kernel allocates and zeroes a frame and maps it. :: Demand paging.
- A major page fault reading from disk. :: Anonymous memory has nothing to read the first time.
- SIGSEGV. :: The mapping is valid and writable.

? Which reduce TLB misses for a large, randomly accessed array? (select all that apply)
+ Huge pages :: Each TLB entry covers 2 MiB instead of 4 KiB.
+ Improving locality so fewer distinct pages are touched :: Fewer pages, fewer translations.
- Calling malloc more often :: Allocation count does not reduce translations.
- Disabling the TLB :: Not possible, and it would make every access slower.

? Linux overcomm
…

Summary

Virtual memory gives each process a private address space translated page by page to physical frames through multi-level page tables, cached by the TLB. Page faults implement demand paging, copy-on-write, file mapping and swapping — and turn invalid accesses into SIGSEGV. RSS, not virtual size, measures RAM use; overcommit and the OOM killer (Linux) or commit limits (Windows) govern exhaustion. Huge pages and NUMA placement tune performance; microcontrollers use MPUs instead of MMUs.

References and Further Reading

Arpaci-Dusseau, *OSTEP*, chapters 13–23 (address spaces, paging, TLBs, swapping).

Intel SDM Vol. 3A, chapter 4 "Paging"; Arm ARM, "The AArch64 Virtual Memory System Architecture".

Linux kernel documentation: admin-guide/mm/transhuge, vm/overcommit-accounting.

Mel Gorman, *Understanding the Linux Virtual Memory Manager*.