Opening: every process lives inside its own illusion

Lesson 7 sped up access to PHYSICAL memory using caches. But the address a program SEES (a pointer in C, a variable inside a JS engine) is rarely the real PHYSICAL address on the RAM stick β€” the operating system inserts a layer of indirection: each process gets its own VIRTUAL address space, fully isolated from every other process, translated into a real PHYSICAL address on every single access. What that indirection costs β€” and how the hardware hides it β€” is this lesson's subject.


πŸ“š Prerequisites
Read Lesson 7 (caches) β€” the TLB in this lesson IS a cache, differing only in what it stores (address translations instead of program data).

1. Virtual memory & paging

Virtual memory allows: (1) running a program LARGER THAN physical RAM (the unused parts live on disk and are loaded into RAM on demand), and (2) safe ISOLATION β€” process A cannot accidentally (or deliberately) read or write process B's memory, because A's and B's virtual addresses translate into entirely different physical REGIONS. Memory is divided into fixed-size pages β€” typically 4KB by default β€” and a Page Table records which PHYSICAL page (a page frame) each VIRTUAL page currently points at.

virtual_address_layout.txt (virtual address layout, 4KB pages)
A virtual address (32-bit example):
+----------------------------+---------------+
|  VPN (Virtual Page Number) |   Offset      |
|          20 bits           |    12 bits    |
+----------------------------+---------------+
   looks up the Page Table     byte WITHIN the page (unchanged by translation)

# The VPN indexes the Page Table -> PFN (Physical Frame Number)
# Physical address = (PFN << 12) | Offset
#   the offset is carried over UNCHANGED; only the VPN is translated
⚠️ Pitfall: thrashing when physical RAM runs short
When the total memory demand of all processes exceeds physical RAM, the operating system has to keep shuffling pages between RAM and disk (swapping). If the program accesses pages with poor locality (Lesson 7), the CPU spends nearly ALL its time shuffling pages instead of doing real computation β€” this is called thrashing, and it makes the system look "frozen" even though the CPU is at full load (running page-shuffling work, not useful work).

2. The MMU, and what a page table entry really contains

Section 1 said the Page Table "records which physical page each virtual page points at", but it did not say WHO does the looking up, nor what an entry in that table actually holds. Those two questions decide the rest of the lesson.

The one doing the lookup is the MMU (Memory Management Unit): a piece of hardware inside the CPU, not a part of the operating system. Every time a program reads or writes an address, the MMU intercepts that virtual address, consults the page table, and only then releases the corresponding physical address. The operating system merely builds the page table; the MMU is what enforces it on every access, so a program has no way to route around it.

And a page table entry β€” a PTE (Page Table Entry) β€” is not just a PFN. That is why Section 3 is about to compute "4 bytes per entry" rather than the 20 bits a PFN alone would need. Alongside the PFN, a PTE carries several flag bits that the MMU reads on EVERY access:

pte_layout.txt (one page table entry, simplified after x86-64)
One Page Table Entry (PTE), simplified:
+--------------------------+-----+-----+-----+-----+
|  PFN (physical frame)    |  NX | R/W |  D  |  P  |
+--------------------------+-----+-----+-----+-----+
                              |     |     |     |
   no-execute -----------------+    |     |     |
   writable? (0 = read-only) -------+     |     |
   dirty: page was written to ------------+     |
   present: mapping is valid at all ------------+

# The MMU reads these flags on EVERY access, in hardware.
# P = 0  -> the mapping does not exist -> raise a page fault
# R/W= 0 -> a write to this page raises a fault (read-only page)
# NX = 1 -> executing code from this page raises a fault
# D  = 1 -> the page differs from its copy on disk (see Lesson 7: write-back)

With those four bits, three things Section 1 only asserted now have a concrete mechanism:

  • How a page fault is DETECTED. Not by "failing to find" anything β€” but by the P (present) bit. The MMU reads the PTE, sees $P=0$, and immediately raises an exception for the operating system to handle (load the page from disk into RAM, update the PTE, then re-run the faulting instruction). The program never learns this happened β€” it only sees one of its instructions take a few million cycles longer than usual.
  • How isolation is ENFORCED. Section 1 said virtual memory provides "safe isolation", but a private page table is only half of it: it stops process A from reaching process B's memory. The other half is the R/W and NX bits, which stop a process from harming ITSELF β€” writing into a read-only region, or executing data as though it were code. This is exactly the mechanism Lesson 2 promised when it said the operating system marks code regions "read-only and non-writable (the NX/XD bit) right down at the MMU hardware level" β€” this NX bit in the PTE is precisely what makes Lesson 2's self-modifying code impossible on a modern system.
  • Why swapping a page out is sometimes FREE. The D (dirty) bit records whether the page has been WRITTEN since it was loaded β€” the same meaning as the cache dirty bit in Lesson 7, just at page granularity instead of cache-line granularity. If $D=0$, the copy on disk is still identical, so the operating system can simply DROP the page from RAM and write nothing at all. If $D=1$, it must write the page out to disk first β€” thousands of times slower. The same operation, "free up one page", costs wildly differently depending on exactly one bit.
⚠️ Pitfall: assuming a page fault is always an error
The word "fault" sounds like a malfunction, but most page faults are NORMAL, expected operation. When launching a program, the operating system usually does NOT preload the whole executable into RAM β€” it just builds a page table with $P=0$ and lets the program run, loading each page at the moment it is first touched (demand paging). Those page faults are how the program gets loaded, not a sign of breakage. Only when a page is accessed WITHOUT valid permission β€” writing to an $R/W=0$ page, executing code on an $NX=1$ page, or touching an address never allocated at all β€” does the operating system conclude this is a genuine error and kill the process. On Linux the familiar message for that case is Segmentation fault.

3. Computing page table structure & size

For an $A$-bit virtual address with an $O$-bit page offset ($2^O$ bytes per page), the MAXIMUM possible number of virtual pages is $2^{A-O}$. The most naive SINGLE-LEVEL page table allocates room for EVERY possible virtual page β€” including the ones the program will NEVER touch:

$$\text{Single-level Page Table Size} = 2^{A-O} \times \text{Entry Size}$$

Verified for real: with a 32-bit virtual address, 4KB pages ($O=12$) and a 4-byte entry (PTE), a single-level page table costs exactly 4,194,304 bytes = 4MB per process. That sounds tolerable β€” but for a 64-bit address (real modern CPUs use 48 usable bits), the SAME formula yields 256GB β€” utterly impossible for a data structure that must exist SEPARATELY for EVERY running process.

single_level_page_table.js (excerpt from the shared cpu-core.js engine)
function pageTableEntryCount(addressBits, pageOffsetBits) {
  return Math.pow(2, addressBits - pageOffsetBits);
}
function singleLevelPageTableSizeBytes(addressBits, pageOffsetBits, entryBytes) {
  return pageTableEntryCount(addressBits, pageOffsetBits) * entryBytes;
}
// Verified: singleLevelPageTableSizeBytes(32, 12, 4) = 4.194.304 byte (4MB)
// Verified: singleLevelPageTableSizeBytes(48, 12, 4) / 1024^3 = 256 (GB - impossible!)

The real solution: a MULTI-LEVEL page table (x86 32-bit splits it 10-10-12 bits) β€” the level-1 table is ALWAYS allocated (small, fixed size), but a level-2 table is allocated ONLY for regions that genuinely have pages in use. Verified for real: with 512 pages in use (2MB of address space actually touched, out of the 4GB available in a 32-bit space) β€” a 2-level page table costs just 8,192 bytes (8KB), exactly 512 times cheaper than single-level (4MB).

two_level_page_table.js (excerpt from the shared cpu-core.js engine)
function twoLevelPageTableSizeBytes(numUsedPages, entriesPerTable, entryBytes) {
  const firstLevelBytes = entriesPerTable * entryBytes;              // ALWAYS allocated
  // only the regions actually in use
  const numSecondLevelTables = Math.ceil(numUsedPages / entriesPerTable);
  const tableBytes = entriesPerTable * entryBytes;
  return firstLevelBytes + numSecondLevelTables * tableBytes;
}
// Verified: twoLevelPageTableSizeBytes(512, 1024, 4) = 8192 bytes (8KB)
// Against 4MB single-level for the SAME 32-bit space -> 512x cheaper
⚠️ Pitfall: a single-level page table wastes RAM on untouched pages
The vast majority of real programs use only a VERY SMALL portion of the available address space (4GB on 32-bit, terabytes on 64-bit) β€” the code region, the heap and the stack together take a few MB to a few hundred MB. A single-level page table is forced to allocate room for EVERY possible page, including the 99.9% that are NEVER touched β€” which is exactly why every real operating system uses a multi-level structure (or another variant such as an inverted page table).

4. The TLB: the address-translation accelerator

The problem: the Page Table ITSELF lives in RAM β€” meaning that EVERY time the CPU needs to translate a virtual address into a physical one, it must spend an EXTRA RAM access reading the page table, BEFORE the SECOND RAM access that fetches the data it actually wanted. The TLB (Translation Lookaside Buffer) solves this by acting as a cache FOR the page table: it stores recently translated (VPN β†’ PFN) pairs, using exactly the Set-Associative + LRU structure of Lesson 7.

It is worth pausing on precisely what a TLB miss costs, because that is the entire reason the TLB exists. When the TLB has no translation, the MMU has to go and consult the page table in RAM itself β€” an operation with its own name: a page table walk. And the number of RAM trips equals the NUMBER OF LEVELS in the table, because each level lives somewhere different:

page_table_walk.txt (the cost of one translation, counted in RAM trips)
TLB HIT  -> 1 RAM access total:
             (translation comes from the TLB, on-chip)
             [1] read the data itself

TLB MISS -> 3 RAM accesses total, with the 2-level table of Section 3:
             [1] read the level-1 table   -> address of the level-2 table
             [2] read the level-2 table   -> the PFN
             [3] read the data itself

# So a TLB miss costs 3x the memory traffic of a hit, not 2x - the walk is
# EXTRA work on top of the access you actually wanted.
# On x86-64 the page table has FOUR levels, so a miss there costs 5 accesses.

That is this section's number to remember: with the 2-level table, a failed translation costs 3 RAM trips instead of 1, and the real 4-level table of x86-64 costs 5. Section 3 showed that a multi-level structure is the ONLY thing that makes a page table fit in RAM at all β€” but its price is that each lookup grows longer with each level. The TLB is what buys that price back: as long as the translation is still in the TLB, the whole walk is skipped entirely.

translate_address.js (excerpt from the shared cpu-core.js engine)
function translateAddress(virtualAddress, pageOffsetBits, tlb, pageTable) {
  const { vpn, offset } = splitVirtualAddress(virtualAddress, pageOffsetBits);
  let pfn = tlb.lookup(vpn);
  if (pfn !== null) {
    const physicalAddress = (pfn << pageOffsetBits) | offset;
    return { physicalAddress, tlbHit: true, pageFault: false };
  }
  if (pageTable.has(vpn)) {
    pfn = pageTable.get(vpn);
    tlb.insert(vpn, pfn); // remember it for next time
    const physicalAddress = (pfn << pageOffsetBits) | offset;
    return { physicalAddress, tlbHit: false, pageFault: false };
  }
  return { physicalAddress: null, tlbHit: false, pageFault: true }; // VPN is not mapped
}
// Verified: first access to a VPN  -> tlbHit=false (the Page Table is consulted)
// Verified: SECOND access, same VPN -> tlbHit=true  (now cached in the TLB)
// Verified: access to an unmapped VPN -> pageFault=true
⚠️ TLB flush on a context switch
Each process has its OWN page table — process A's VPN→PFN translations are completely MEANINGLESS (and downright DANGEROUS if used by mistake) for process B. So when the operating system switches the CPU from process A to process B (a context switch), the TLB has to be FLUSHED — every learned translation is lost, and process B pays for it with a burst of TLB misses on its FIRST accesses after being scheduled again. This is why a context switch has a hidden cost far larger than "just swapping a few registers".

5. Hands-on: translating virtual addresses live & simulating the TLB

Enter a virtual address (in hex) to translate it through a REAL TLB + Page Table (3 pages are pre-mapped: VPN 5, 6 and 9) β€” try the SAME address twice to watch the TLB go from MISS to HIT, or try another VPN to see a page fault. The calculator below compares single-level against two-level page table size directly:

πŸ—ΊοΈ Virtual address translator + page table size calculator

Translate a virtual address (mapped pages: VPN 5, 6, 9 β€” try another VPN for a page fault)

β€”

Page table size calculator

β€”

Summary

  • βœ… Virtual memory lets a program exceed physical RAM and isolates processes safely, through a Page Table translating VPN β†’ PFN.
  • βœ… The MMU is the HARDWARE block inside the CPU that enforces the page table on every access; a PTE holds not just the PFN but P/R-W/NX/D bits β€” P is how a page fault gets detected, R/W and NX are how isolation gets enforced, and D decides whether swapping a page out requires a write at all.
  • βœ… A TLB miss forces the MMU to walk the page table itself: a 2-level table costs 3 RAM trips instead of 1, and x86-64's 4-level table costs 5 β€” which is the entire reason the TLB exists.
  • βœ… Verified: a 32-bit single-level page table costs 4MB; a 48-bit space (real 64-bit) would need 256GB β€” impossible, forcing a multi-level structure.
  • βœ… Verified: a 2-level page table costs only 8KB when 2MB of address space is actually used β€” 512 times cheaper than single-level, because level-2 tables are allocated only for regions genuinely in use.
  • βœ… The TLB is a cache FOR the page table β€” verified: the first access to a page is a TLB miss, the next access to the SAME page is a TLB hit, and accessing an unmapped page raises a page fault.
  • βœ… Pitfall: the TLB must be flushed on every context switch β€” the hidden cost of changing process.

Review quiz

Question 1

Verified: a 32-bit single-level page table costs 4MB, but a 48-bit space needs 256GB. Why is the gap so enormous?

Question 2

By what mechanism does a 2-level page table save RAM over a single-level one?

Question 3

Verified: the first access to a VPN is a TLB MISS (the page table must be consulted), and the next access to the SAME VPN is a TLB HIT. Why does the TLB exist at all?

Question 4

Why must the TLB be flushed when the operating system context-switches between two processes?

Download the practice code for this lesson

The CPUJS JavaScript file β€” a mini computer-architecture library used across all 12 lessons. Lesson 8 adds splitVirtualAddress(), makeTLB(), translateAddress(), singleLevelPageTableSizeBytes() and twoLevelPageTableSizeBytes() β€” virtual address translation through a TLB plus page table, and the page table sizing formulas, with a self-test that checks every number quoted in this lesson (run node cpu-core.js; nothing to install):

Download cpu-core.js

πŸ“– References

Related lessons in this series

Lesson 7: The Memory Hierarchy & Cache Architecture Lesson 9: Apple Silicon & Unified Memory Architecture Back to the Computer Architecture roadmap

Comments