How Virtual Memory Works: Pages, Page Tables, and Page Faults
A process does not own a private bank of physical RAM chips. It owns a private address space: a range of virtual addresses the CPU is willing to use on its behalf. The operating system and the memory-management unit (MMU) translate those addresses into physical frames, or they refuse the access with a page fault.
This article explains that translation. The goal is a working mental model of pages, page tables, the TLB, page faults, demand paging, and what happens when RAM is full. The topic is distinct from how processes and threads are scheduled. Isolation of address spaces is why two processes can both use address 0x400000 without colliding.
What Virtual Memory Actually Provides
Virtual memory is not "disk pretending to be RAM," although swap is one of its tools. It is an address translation and protection system with four practical jobs:
- Isolation. Process A cannot read or write process B's pages unless the kernel deliberately maps the same physical frame into both spaces (shared memory, copy-on-write after fork, memory-mapped files).
- A contiguous illusion. A program can treat its heap, stack, and mapped files as large linear ranges even when physical frames are scattered.
- Overcommit and sharing. The sum of virtual mappings can exceed physical RAM. Unused pages need not occupy a frame. Read-only text pages of libc can be shared by every process.
- Protection bits. Pages can be readable, writable, executable, or none of those. A write to a read-only page or an execute of a non-executable page becomes a fault, which is how copy-on-write and W^X policies are enforced.
The unit of translation is the page. On most current general-purpose systems the base page size is 4 KiB. Huge pages (2 MiB, 1 GiB) exist to shrink page-table pressure for large mappings. A frame is a page-sized chunk of physical memory.
Virtual Addresses Are Indexes, Not Locations
On a 64-bit process the programmer writes 64-bit pointers. The hardware does not use all 64 bits as a physical index. Typical user-space canonical addresses on x86-64 use 48 bits (or 57 with five-level paging). The kernel splits a virtual address into:
- a series of page-table indexes (one per level)
- an offset inside the page (12 bits for a 4 KiB page)
Example with 4 KiB pages and four-level paging: bits [11:0] are the offset. The remaining bits select entries in the PML4, page-directory-pointer, page-directory, and page-table. The last entry holds a physical frame number plus flags (present, writable, user-accessible, no-execute, accessed, dirty, and others).
The offset is copied unchanged onto the physical address. Translation never rearranges bytes inside a page. That is why page alignment matters for DMA, file mapping, and hugepage use.
Page Tables Are Trees, Not One Giant Array
A flat table of every 4 KiB page in a 48-bit space would need 2^36 entries. That is unusable. Hardware therefore walks a sparse tree. Empty subtrees are simply missing. A process that maps 8 MiB of heap does not allocate page-table pages for the rest of its 128 TiB theoretical range.
Each process has its own top-level page-table pointer. On x86-64 that pointer lives in CR3. A context switch loads a new CR3 (or an equivalent ASID/PCID-tagged root) so the next user instruction translates against a different tree. That is the hardware meaning of "address space."
Kernel mappings are usually present in every process tree so a syscall can run without swapping trees. Those kernel pages are marked supervisor-only. Meltdown-class attacks showed why merely marking them supervisor-only was not enough on some CPUs; kernels now isolate or unmap most kernel secrets from user page tables (KPTI).
The TLB Makes Translation Cheap Enough
A four-level walk is four extra memory reads per load or store if done naively. Processors cache recent translations in the Translation Lookaside Buffer (TLB). A TLB hit turns a virtual address into a physical frame without touching page tables.
TLB entries are finite and often split between instruction and data, small and large pages. A context switch that changes the address space must not let the new process use the old process's translations. Older designs flushed the TLB on every CR3 write. Tagged TLBs (PCID on x86, ASID on ARM) keep entries from several address spaces and match the current tag, which makes short context switches cheaper.
When the kernel changes a mapping — unmapping a page, flipping a writable bit off for copy-on-write, installing a new anonymous page — it must invalidate the corresponding TLB entries on every CPU that might have cached them. That shootdown is a real cost in multithreaded programs that remap memory often.
What a Page Fault Actually Is
A page fault is the MMU saying: this virtual address has no usable translation right now. The CPU saves the faulting instruction's address and the access type (read, write, fetch), then vectors into the kernel.
Not every fault is a bug. The kernel classifies them:
- Invalid. The address is outside any VMA (virtual memory area) the process created with mmap, brk, or stack growth. This becomes SIGSEGV on Unix-like systems.
- Protection. The mapping exists but the access violates flags: write to a read-only page, execute on a non-exec page. Sometimes this is also a signal. Sometimes it is an intentional trigger for copy-on-write: the kernel allocates a private frame, copies the old contents, marks the new page writable, and retries the instruction.
- Not present, but valid. The VMA exists; the page was never faulted in, or it was evicted to swap or dropped because it was a clean file page. The kernel finds or allocates a frame, fills it (zeros, file read, swap read), installs a present PTE, and returns to the same instruction.
That last case is demand paging. Starting a program does not copy every byte of the binary into RAM. The loader maps the file. The first instruction fetch on each page pulls that page from the executable. The same pattern applies to large memory-mapped datasets: you pay for the pages you touch.
Anonymous Pages, File Pages, and Swap
Two families of pages dominate user space.
File-backed pages come from an executable, a shared library, or an explicit mmap of a file. Their source of truth is the file (plus any dirty modifications). If RAM is tight and the page is clean, the kernel can drop it. A later fault rereads the file. If the page is dirty, it must be written back first.
Anonymous pages have no file: heap allocations, stack, MAP_ANONYMOUS, CoW private copies. Their source of truth is RAM or swap. If RAM is tight, dirty anonymous pages go to a swap device or swap file. A later fault reads them back. If swap is full and the kernel cannot reclaim anything else, allocation fails (or a victim is chosen by the OOM killer).
Swap is not a required part of virtual memory. Embedded systems and some latency-critical servers run with swap off. They still use page tables, protection, and demand paging from files. What they refuse is the extra latency of paging anonymous memory to disk.
A Worked Walk Through One Load
Suppose a process executes mov rax, [rbx] and rbx holds 0x7f12_3400_2008. Assume 4 KiB pages.
- The CPU splits the address: offset 0x008, page number derived from the high bits.
- It probes the TLB. On a hit, it uses the cached frame, checks cached permission bits, and completes the load.
- On a miss, the page-table walker reads the four (or five) table levels using the current root. If every Present bit is set and permissions allow a user read, the walker fills the TLB and the load completes.
- If a Present bit is clear, the CPU raises a page fault. The kernel finds the VMA for 0x7f12_3400_2000. Suppose it is a private anonymous mapping that has never been touched. The kernel allocates a free frame, zeros it (or uses a shared zero page and relies on a later write fault to CoW), writes a PTE with the new frame number, flushes that TLB entry if needed, and returns. The instruction runs again and now hits.
From the program's point of view the pointer was always valid. The first access paid for the frame. That is why touching a freshly allocated 1 GiB buffer in a tight loop can look like a mysterious stall: you are faulting and zeroing pages, not just writing cache lines.
Copy-on-Write After fork
When a process calls fork, the child needs its own address space but not an immediate copy of every page. The kernel duplicates the page-table tree and marks writable pages read-only in both parent and child. Physical frames stay shared.
The first write in either process faults. The handler allocates a new frame, copies the old page, installs a private writable mapping for the writer, and leaves the other process pointing at the original. Pages that are never written remain shared. That is why fork of a large process is cheap until the child or parent dirties memory, and why a fork-heavy server can explode RSS after workers start mutating heaps.
What Developers Actually Debug
Most "memory" bugs that look like allocator problems are mapping problems:
- SIGSEGV on a pointer that "should" be valid. Use-after-free, an munmap that raced, stack overflow past the guard page, or a truncated file mapped too far. On Linux,
/proc/pid/mapsandcoredump_filtershow what was mapped. Address sanitizers catch many of these before the fault. - Sudden RSS growth after fork. CoW. Workers inherit a large heap and then write it.
- Latency spikes on first touch. Demand paging and zero-fill. Pre-fault with a walk,
MAP_POPULATE, ormlockif the latency budget forbids first-touch faults. - Thrashing. The working set does not fit in RAM. The machine spends its time in swap in/out. Adding RAM, shrinking the working set, or turning off swap (and failing fast) are the real options. Faster disks only hide mild cases.
- TLB shootdown and mmap churn. Frequent remap, mprotect, or JIT permission flips show up as kernel time and cross-CPU IPIs, not as cache misses in your profiler.
Related Ideas This Article Does Not Cover
Virtual memory is the translation layer. It is not the user-space allocator. malloc and the garbage collector decide which virtual pages to request and how to reuse them; they do not replace page tables. NUMA placement, huge-page pools, and IOMMU translation for devices are related hardware stories with their own tradeoffs.
On this site, process and thread isolation is covered in Processes vs Threads Explained. Caching of data (not address translations) is a different mechanism: How Caching Works.
Common Misconceptions
"Virtual memory means swap." Swap is optional overflow for anonymous pages. Laptops with 32 GiB and swap off still run a full virtual-memory system.
"64-bit pointers mean 16 exabytes of RAM." They mean a large virtual address space. Physical RAM, the number of page-table pages you can afford, and OS limits are much smaller. Most OSes also reserve large canonical holes.
"malloc returns physical memory." It returns a virtual address. The frame often appears at first write.
"A page fault is always a crash." Minor faults are the normal way pages are installed. Only faults with no legal VMA, or illegal permissions that the kernel will not fix, become signals.
Takeaways
Every user pointer is translated. The page table says which frame, if any, backs that page and what the CPU may do with it. The TLB caches that answer. A fault is the kernel's chance to install a page, copy a page, or reject the access. Isolation between processes is not a software convention; it is a different page-table root.
Once that model is solid, allocator behavior, fork cost, first-touch latency, and OOM reports stop looking like superstition. They are consequences of when frames are actually bound to virtual pages.