Virtual memory simulator
Every address a program uses is virtual. Step through a process’s memory accesses and see each one split into the four page-table indexes of an x86-64 address, found in the TLB or by a page walk, or turned into a page fault, a swap-in, a copy after fork() or a crash. Below it: how paging works, why faults cost what they do, and what Linux does.
| PID | Page | Frame | Writable |
|---|---|---|---|
| empty | |||
- –Press Next access to start.
- TLB hits
- 0
- Page walks (4 table reads each)
- 0
- Minor faults (no disk)
- 0
- Major faults (disk read)
- 0
- Copy-on-write faults
- 0
- Pages written to swap
- 0
- Segmentation faults
- 0
How to use the simulator
Pick a workload and press Next access to step through it, or Run to end. For each access, the panel shows the virtual address split into its four page-table indexes and the page offset, what happened (TLB hit, page walk, or one of the kinds of fault), and the physical address it ended up at. Below are the physical frames, with the process and page each one holds, and the TLB. You can also type any address in hex and read or write it, and call fork() once.
The machine is deliberately tiny: eight frames of physical memory and a four-entry TLB, so that faults and evictions happen after a few accesses. Things to try:
- First touch: the first write to each heap page is a minor fault, and the second access to it is a TLB hit.
- Scan bigger than memory: ten pages with eight frames. In the second pass every access is a major fault, because LRU always evicts the page needed next. Switch to 4 frames and compare.
- fork() and write: after the fork, the child’s read is free, its write copies the page, and the parent’s later write to the same page needs no copy.
- Write to
0x400040(code) or read0x10to see segmentation faults.
What is virtual memory?
Virtual memory gives each process its own private address space. A program’s pointers hold virtual addresses; on every load, store and instruction fetch, the CPU’s memory management unit (MMU) translates the virtual address into a physical one, using tables that the operating system sets up for that process.
The indirection pays for itself many times over. Processes cannot read or corrupt each other’s memory, because their tables only point at their own frames. Memory can be promised without being provided: a program can reserve gigabytes, and physical frames are only assigned to the pages it actually touches. Pages can be shared, such as the code of a library used by many processes, or moved to disk when memory is short. And every program can be laid out the same way, regardless of where its pages really are.
Pages and page tables
Memory is managed in fixed-size blocks: pages of virtual memory and frames of physical memory, usually 4 KiB. A virtual address is split into a page number and an offset within the page. Translation replaces the page number with a frame number and keeps the offset: with 4 KiB pages the low 12 bits pass through unchanged.
The mapping lives in a page table. Each entry (PTE) holds the frame number and flag bits: present, writable, user-accessible, no-execute, and the accessed and dirty bits that the hardware sets when the page is read or written, which the operating system uses to choose pages to evict and to know which ones must be written back. A single flat table for a 48-bit address space would need 236 entries per process, so real page tables are trees in which only the parts that are used exist.
The x86-64 four-level page table
x86-64 uses 48-bit virtual addresses with 4 KiB pages, split as in the simulator: bits 47–39 index the top-level table (PML4), bits 38–30 the page directory pointer table (PDPT), bits 29–21 the page directory (PD), bits 20–12 the page table (PT), and bits 11–0 are the offset. Each table is one 4 KiB page holding 512 eight-byte entries, which is why each index is 9 bits. The CR3 register holds the physical address of the current process’s PML4; switching processes means loading a different CR3.
Addresses must be canonical: bits 63–48 must copy bit 47, so user space on Linux runs from 0 up to 0x00007fffffffffff and the kernel lives at the top. Intel’s five-level paging (LA57) adds a fifth level for 57-bit addresses. Large pages stop the walk early: a PD entry can map a 2 MiB page directly, using the last 21 bits as the offset, and a PDPT entry can map a 1 GiB page.
Other architectures use the same idea with different shapes. 64-bit ARM supports 4, 16 and 64 KiB pages and three or four levels, and RISC-V’s Sv39 and Sv48 schemes use three and four levels.
The TLB
Walking four levels of tables means four dependent memory reads before the real access can start. To avoid paying that on every access, the CPU caches recent translations in the translation lookaside buffer (TLB). A hit costs almost nothing; a miss triggers a hardware page walk (x86 does the walk in hardware, and the page-table entries themselves are cached in the normal data caches, which softens the cost).
The TLB is small, typically from dozens to a few thousand entries across its levels, so a program that touches many pages scattered across memory misses often. Each 4 KiB entry covers 4 KiB; a 2 MiB huge page covers 512 times as much with one entry, which is why databases and JVMs often use huge pages. Entries are tagged with a process identifier (PCID on x86, ASID on ARM) so that a context switch does not have to flush the whole TLB; the simulator tags them by process ID. When the kernel changes a mapping, it must invalidate the stale entry, on every CPU that might hold it, which is called a TLB shootdown.
Page faults
If the walk finds no present entry, or the access breaks the entry’s permissions, the CPU raises a page fault and the kernel’s fault handler decides what it means:
- Minor fault. The page is valid but not mapped yet, and no disk read is needed: the first touch of anonymous memory (heap, stack) gets a zeroed frame; a file page already in the page cache, or a page shared with another process, only needs an entry. This is demand paging: memory from
mallocormmapcosts nothing until it is touched. - Major fault. The page’s contents are on disk, in swap or in the file it maps, such as a program’s code, and must be read in. Even on an SSD this takes tens of microseconds or more, compared with about a hundred nanoseconds for a memory access.
- Copy-on-write fault. A write to a page that is shared read-only after
fork(), covered below. - Invalid access. No mapping exists, or the access is not allowed, such as writing to code. The kernel sends
SIGSEGV, the segmentation fault.
On Linux, /usr/bin/time -v and getrusage() report minor and major fault counts for a process, and perf stat -e page-faults counts them system-wide.
Copy-on-write and fork()
Unix creates processes with fork(), which gives the child a copy of the parent’s whole address space. Copying gigabytes for each fork would be slow and wasteful, especially since most children immediately call exec() and throw the copy away. Instead, the kernel copies the page tables, marks every writable page read-only in both processes, and lets them share the frames. Reads cost nothing. The first write to a shared page faults; the kernel copies that one page, points the writer at the copy and makes it writable. If the other process has meanwhile stopped sharing the page, the kernel simply makes it writable again without copying, which the simulator shows when the parent writes after the child has its own copy.
The same mechanism lets the kernel share memory safely elsewhere: private file mappings, and the zero page that backs untouched anonymous memory in some cases. Redis uses fork() for its background snapshots, which is why a heavily written dataset needs extra memory while a snapshot runs: every page written during the snapshot is copied.
Swapping and thrashing
When free frames run out, the kernel takes frames from pages that have not been used recently. Clean pages backed by a file can simply be dropped and read again later; modified anonymous pages must first be written to swap. The simulator uses exact LRU to pick the victim; real kernels approximate recency with the accessed bit, using CLOCK-like scans or, in Linux, active and inactive lists and, since Linux 6.1, the multi-generational LRU.
If the pages a program actively uses, its working set (a term from Peter Denning’s 1968 work), fit in memory, faults are rare. If they do not, nearly every access can fault, the disk becomes the bottleneck and useful work collapses: thrashing. The scan workload shows the worst case: ten pages in a loop with eight frames make LRU evict exactly the page needed next. On Linux, when memory and swap run out, the out-of-memory killer ends a process to free memory.
Measured on Linux
To check that the simulator’s fault rules match a real kernel, we ran a small C program on Linux 6.18 that maps 1,000 pages of anonymous memory (with transparent huge pages turned off for the range), touches them, forks, and counts minor faults with getrusage():
| Step | Minor faults | Why |
|---|---|---|
| Write the first byte of each of 1,000 new pages | 1,000 | One demand-paging fault per page |
| Write them all again | 0 | Already mapped |
| Child reads 500 of the pages after fork() | 0 | Page tables were copied; shared pages are readable |
| Child writes the same 500 pages | 500 | One copy-on-write fault per page |
| Parent writes all 1,000 pages after the child exits | 1,000 | Still marked read-only; each fault just makes the page writable again |
The last row is the case the simulator labels “no other process shares the page any more”: no copy is made, but the fault still happens, once per page.
Virtual memory in interviews
Systems interviews ask how an address is translated, what the TLB is for, what happens on a page fault, how fork() can be fast, and what thrashing looks like. Strong answers walk through the steps in order (TLB, page walk, fault handler, frame allocation or disk read, retry the instruction) and know the costs at each step. A common follow-up is why a process’s memory use as reported by top differs: virtual size counts everything reserved, resident size counts only pages in memory, and shared pages are counted in every process that maps them.
In performance work the same ideas explain why touching memory in order is faster than jumping around, why huge pages help large databases, and why a container that hits its memory limit starts swapping or gets killed.
Virtual memory questions and answers
What is virtual memory?
Virtual memory gives every process its own address space. The addresses a program uses are virtual; on every memory access the CPU’s memory management unit translates them to physical addresses using page tables that the operating system maintains. This isolates processes from each other, lets memory be allocated lazily, lets pages be shared or moved to disk, and gives every program the same simple layout.
What is a page table?
A page table maps virtual page numbers to physical frames, with permission bits (present, writable, user, no-execute) and status bits (accessed, dirty) for each page. On x86-64 with 4 KiB pages it is a tree of four levels, PML4, PDPT, PD and PT, each indexed by 9 bits of the virtual address, with the last 12 bits giving the offset inside the page.
What is a TLB?
The translation lookaside buffer is a small cache in the CPU of recent virtual-to-physical translations. A TLB hit translates an address with no extra memory reads. A miss makes the hardware walk the page table, which on x86-64 means up to four dependent memory reads, so programs whose working set spans many pages run noticeably slower; huge pages help by covering more memory per TLB entry.
What is the difference between a minor and a major page fault?
Both happen when a page is not mapped in memory when it is accessed. A minor fault is handled without disk I/O: the first touch of newly allocated memory gets a zeroed frame, or the page is already in memory (in the page cache or shared with another process) and only needs mapping. A major fault needs to read the page from disk, from swap or from the file it maps, and is thousands of times slower.
How does copy-on-write make fork() fast?
fork() does not copy the parent’s memory. It copies the page tables and marks the writable pages read-only in both processes, sharing the same frames. When either process writes to such a page, the CPU raises a fault and the kernel copies just that page. Since most programs call exec() soon after fork(), most pages are never copied.
What is thrashing?
Thrashing is when the pages a program actively uses, its working set, do not fit in physical memory, so the system spends most of its time moving pages between memory and disk. Each access may cause a major fault, and throughput collapses. The fixes are more memory, fewer processes running at once, or a program that touches less memory.
What is a segmentation fault?
A segmentation fault (SIGSEGV on Linux) happens when a program accesses an address that is not mapped in its address space, such as a null or freed pointer, or accesses it in a way its permissions forbid, such as writing to read-only code. The hardware raises a page fault, and the kernel finds no valid mapping and sends the signal.
What page sizes does x86-64 use?
The base page size is 4 KiB. The CPU also supports 2 MiB pages, mapped one level higher in the page table, and 1 GiB pages, two levels higher. Linux uses large pages through hugetlbfs and transparent huge pages. Large pages reduce TLB misses and page-table size, at the cost of coarser allocation.
Further reading
- Arpaci-Dusseau, OSTEPPaging: IntroductionPages, frames and page tables from first principles; the following chapters cover TLBs, multi-level tables and swapping.
- Linux kernel documentationPage TablesHow Linux names and walks the levels on each architecture.
- IntelIntel 64 and IA-32 Architectures Software Developer’s ManualVolume 3, chapter 4: paging, the formats of every table entry, and the TLBs.
- Denning, 1968The Working Set Model for Program BehaviorWhere the working set and the explanation of thrashing come from.
- SemicolonyCache eviction simulatorLRU, CLOCK and their relatives, the same choice the kernel makes for pages.