Virtual Memorypage tablemulti-levelradix treepage walkcr3

Page Tables

The page table is the per-process map from virtual pages to frames; because a flat table for a 48-bit space would be 512 GB, it is a four-level radix tree that exists only where the address space is populated, walked by the hardware from a base register the kernel loads at each context switch.

ConceptualLinux
▶ InteractiveInterview question
Progress

The problem

A process may touch any of 2³⁶ virtual pages, and the CPU needs to translate one in a few cycles. One entry per possible page is 512 GB of table per process. How do you build a map that is instant to consult and nearly empty for a process that uses 50 MB?

The table

Conceptually the page table is exactly what the interactive shows: a row per virtual page with a physical frame, a present bit and permissions. The CPU takes the virtual page number, reads the row, checks present and permissions against the access being made, and forms the physical address from the frame number and the page offset. That model is correct for every architecture; what differs is how the rows are stored.

Conceptual page table for a small process (4 kB pages; frame numbers are illustrative)
Virtual page   Physical frame   Present   Perms   Backing
0x00000        —                no        —       (unmapped: null page, access faults)
0x00400        81               yes       r-x     /usr/bin/server, code
0x00401        82               yes       r-x     /usr/bin/server, code
0x00402        —                no        r--     /usr/bin/server, not yet touched
0x00404        90               yes       rw-     /usr/bin/server, data (private copy)
0x00405        91               yes       rw-     [heap]
0x00406        —                no        rw-     [heap], never touched (zero page on demand)
0x7f3a4c000    7                yes       r-x     libc code (frame shared with every process)
0x7f3a4c1fc    —                no        rw-     libc data: swapped out, slot 4711
0x7ffd7a5e0    1203             yes       rw-     [stack]
0xffff8000…    (kernel)         yes       rw- (supervisor)

Why one flat table cannot work

Do the arithmetic. A 48-bit address space with 4 kB pages has 2³⁶ = 68 billion pages. At 8 bytes per entry a flat table is 2³⁹ bytes — 512 GB — per process, before the process has allocated anything. Even the 32-bit case, 4 MB per process, was too much in 1985 for a machine with 4 MB of RAM and twenty processes. And almost all of it would be empty: a typical process maps a few hundred regions totalling a few hundred MB, scattered across a space of 128 TB.

The address space is sparse, so the map should be sparse. The standard structure is a radix tree (a trie over the address bits, the same idea as the DSA Trie over characters): the virtual page number is split into fixed-width chunks, each chunk indexes one level of table, and a table at any level exists only if something below it is mapped. Empty subtrees cost nothing. A process with 50 MB mapped needs a few dozen 4 kB tables instead of 512 GB.

Multi-level: the walk

Conceptual

On x86-64 with 4-level paging a 48-bit address is split into 9 + 9 + 9 + 9 + 12 bits. Each 9-bit chunk indexes a table of 512 eight-byte entries — exactly one 4 kB page, which is not a coincidence: tables are themselves pages, allocated from the same frames as everything else. The walk starts at the top-level table, reads the entry selected by the first 9 bits, follows it to the next table, and so on; the fourth level yields the PTE with the frame number, and the final 12 bits are the offset. Linux names the levels PGD → PUD → PMD → PTE (with a P4D inserted for 5-level paging); Intel calls them PML4 → PDPT → PD → PT. ARM64 uses the same shape with level numbers.

Every level can short-circuit. An entry marked not-present at level 2 means an entire 1 GB range is unmapped, and the walk stops with a fault. An entry at level 3 with the page-size bit set maps a 2 MB huge page directly, skipping the last level — which is why huge pages save both table memory and walk steps (Paging). The walk is done by the CPU’s page-walker hardware, with no software involved, and the result is cached in the The TLB; the kernel only ever *writes* the tables.

Translating VA 0x00007f3a4c022a10 (x86-64, 4-level, 4 kB pages)
  1. CR3 → level-4 table (PGD / PML4)bits 47–39 = index 254 → entry points at a level-3 table
  2. Level-3 table (PUD / PDPT)bits 38–30 = index 233 → entry points at a level-2 table (or maps 1 GB directly)
  3. Level-2 table (PMD / PD)bits 29–21 = index 96 → entry points at a level-1 table (or maps 2 MB directly)
  4. Level-1 table (PTE / PT)bits 20–12 = index 34 → PTE: frame 0x1c4f2, present, r-x
  5. Physical addressframe 0x1c4f2 << 12 | offset 0xa10 = 0x1c4f2a10
  6. Cache in TLBVPN → PFN + perms, so the next access to this page skips all four reads

The base register: CR3 as a labelled example

Conceptual

The walk has to start somewhere, and that somewhere is a single privileged register holding the physical address of the top-level table: CR3 on x86-64, TTBR0_EL1 (user) and TTBR1_EL1 (kernel) on ARM64, SATP on RISC-V. Writing it is the address-space switch in Context Switching; reading it from user mode is impossible. Because the register holds a *physical* address, the top-level table is the one thing the kernel must know the physical location of directly — everything below is reached by walking.

On x86-64 the kernel half of the space lives in the top 256 entries of every process’s top-level table, and Linux simply copies those entries into each new process’s PGD so that all processes share the same kernel subtree. ARM64 avoids even that copy by giving the kernel its own base register (TTBR1) that never changes on context switch. Both are ways of expressing "the kernel is mapped everywhere" from The Virtual Address Space.

The kernel keeps more than the hardware tables. Linux’s VMAs describe regions with a backing and permissions and are the source of truth; the hardware tables are a cache of them, populated on fault and torn down on munmap. Reverse maps (rmap) let the kernel go from a physical frame to every PTE referencing it, which reclaim needs when it wants to unmap a shared page from every process at once.

  • x86-64: CR3, 4 or 5 levels of 512 entries, 4 kB / 2 MB / 1 GB pages.
  • ARM64: TTBR0/TTBR1, 3–4 levels depending on VA size and granule (4/16/64 kB).
  • RISC-V Sv39/Sv48: SATP register, 3 or 4 levels; same radix-tree idea.
  • Table memory: ~2 MB of page tables per 1 GB of 4 kB mappings; visible per process as VmPTE in /proc/<pid>/status.

Cost, and where the TLB comes from

A four-level walk is four dependent memory reads before the actual load or store can even be issued — five memory accesses for one. If each hit L1 cache the walk costs ~20 cycles; if the tables are cold it is four DRAM round trips, ~300 ns, for a load that should take 1 ns. Doing that on every access would make virtual memory unusable, which is why the walk result is cached in the TLB and why the CPU also caches intermediate levels in page-walk caches (so that a TLB miss to a nearby page redoes only the last level). The The TLB lesson is where those numbers become the dominant term.

The size of the tables is a cost of its own: a database with a 200 GB shared buffer mapped by 500 backend processes has 500 copies of the page tables for that region — 400 MB each at 4 kB pages, 200 GB in total, which is more than the buffer. That is not a hypothetical; it is the case PostgreSQL’s huge_pages setting exists for, and huge pages cut it by 512×.

Key points

  • A page table maps virtual page → frame with present and permission bits; conceptually one row per page, physically a radix tree.
  • A flat table for 48-bit addresses would be 512 GB per process; multi-level tables exist only where the address space is populated.
  • x86-64: 9 + 9 + 9 + 9 + 12 bits, each level a 4 kB table of 512 entries; Linux names them PGD, PUD, PMD, PTE.
  • A not-present entry at a high level skips a whole subtree; a page-size bit at a middle level maps a huge page directly.
  • The walk starts at a privileged base register (CR3, TTBR, SATP) written at context switch; hardware walks, the kernel only writes.
  • A walk is 4 dependent reads (~20 cycles warm, ~300 ns cold), so translations are cached in the TLB; table memory is ~2 MB per GB mapped and multiplies per process.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why a tree instead of a hash table?

The tree is walked by hardware in fixed steps with no collisions or resizing, its levels are exactly page-sized, and it supports huge pages and range short-circuits naturally. Some architectures (PowerPC, Itanium) did use hashed tables and inverted tables; radix trees won for simplicity.

Why 9 bits per level?

Because 512 entries × 8 bytes = 4 kB: each table is exactly one page, so tables are allocated, freed and cached like any other page.

Why does the kernel need its own view (VMAs) as well as the hardware tables?

The hardware entries say only where a page is. The kernel needs to know what a region *is* — which file, which offset, what permissions to apply on a fault — and the VMAs hold that; PTEs are derived from them lazily.

Page table

Page table: translate one address
Page size 4 kB, so the low 12 bits are the offset and the rest is the page number. The offset is never translated — only the page is.
bits 31..12 · page
2 (0x2)
bits 11..0 · offset
0xA7C
Walk
single level
Real x86-64 uses four (or five) levels of 512 entries each; a level that has no mapped pages needs no table at all, which is why a sparse 256 TB address space costs only a few kB of tables.
Virtual pagePhysical framePresentPermsNote
0 · 0x00000x2A1R-Xcode
1 · 0x10000x2B1R-Xcode
2 · 0x20000x131RW-data
3 · 0x30000x071RW-heap
4 · 0x40000---nothing mapped
5 · 0x50000RW-heap, swapped out
6 · 0x60000---guard page
7 · 0x70000x3F1RW-stack
Try 0x2A7C (read), 0x0010 (write), 0x5000, 0x4000, 0x9000.
Conceptual

How it fails

What the failure looks like from inside real software.

  • A PostgreSQL host with hundreds of backends and a large shared_buffers spends gigabytes on page tables; huge_pages = on recovers it.
  • A fork of a process with a huge mapped region is slow because the page tables must be copied, even though no data is (Copy-on-Write).
  • VmPTE in /proc/<pid>/status in the hundreds of MB for a JVM with a 100 GB heap on 4 kB pages: the JVM’s -XX:+UseTransparentHugePages or -XX:+UseLargePages is the intended fix.
  • Random access over a working set larger than the TLB and page-walk caches: every access is a walk; the profile shows dtlb_load_misses dominating, and huge pages or better locality are the only fixes.