Operating System · CS3423 · Fall 2026

Worksheet 8

The operating system gives each process the illusion that it owns all of the computer's memory, but in fact many processes share the same DRAM. This week, let's learn how this indirection works: the page table that maps each address to RAM, the kernel that writes it, the MMU (the CPU's translation hardware) that follows it, and the TLB that makes it fast. All of it is exactly what you need for xv6 Lab 2. Read the worksheet and use the self-check at the end to prepare for the next quiz. 英文疲倦?試試看中文版!

中文版
💸 Memory has always been expensive. 1953 vs 2026 (click to open)
A square frame strung with a fine grid of copper wires; a tiny ring sits at every crossing. Museum display at the MIT Museum.
1953: magnetic-core memoryA plane of core memory I photographed at the MIT Museum, from before DRAM existed. Each tiny ring stores 1 bit, and every ring was threaded onto its wires by hand. A plane of 32 × 32 rings holds 1,024 bits: just 128 bytes. The rings alone cost about 33 cents each.
A shiny silicon wafer in a gold frame, signed in white marker: Please make more, with a smiley, a signature and GTC Computex 2026. Next to it, cards describe SK hynix HBM4E, HBM4 and HBM3E memory.
2026: a wafer of HBM4E DRAMSK hynix's newest stacked DRAM for AI chips, which I photographed at Computex 2026 in Taipei. NVIDIA's CEO Jensen Huang signed it with a request: "Please make more." (Dear Jensen, we are proud of you. Your recent news broke our hearts and hurt our memory.) Memory makers could not keep up with the demand from AI.

There is never enough memory. That is why the operating system shares it so carefully.

Three cats (brown-and-white, yellow, orange) lean over a glowing light box where a memory stick lights up like an X-ray, showing a grid of small slate-blue and warm-colored squares; the orange cat points at one square while the others stare in amazement.

Part 1One address, two values

Here is a tiny C program. It stores its first argument in a global variable, counter, then prints the variable's address and its value three times, once per second.

int counter = 0;

int main(int argc, char **argv) {
    counter = atoi(argv[1]);
    for (int i = 0; i < 3; i++) {
        printf("[pid %d] &counter = %p, counter = %d\n", getpid(), (void *)&counter, counter);
        sleep(1);
    }
}

I built it as a fixed-address executable (gcc -no-pie), so every copy puts counter at the same virtual address, and started two copies at once:

The brown-and-white cat and the orange cat sit back to back at two laptops; one screen shows 111 and the other shows 222, and the cats glance over their shoulders, puzzled.
$ ./same 111 & ./same 222
[pid 1783045] &counter = 0x404044, counter = 111   ← process A
[pid 1783046] &counter = 0x404044, counter = 222   ← process B
[pid 1783045] &counter = 0x404044, counter = 111
[pid 1783046] &counter = 0x404044, counter = 222
[pid 1783046] &counter = 0x404044, counter = 222
[pid 1783045] &counter = 0x404044, counter = 111

Both print the address 0x404044, but process A reads 111 there and process B reads 222. One place in RAM cannot hold two values at once. So the same virtual address leads to two different places in RAM, one for each process:

process A sees physical memory (RAM) process B sees stack (nothing) heap datacounter 0x404044 code stack (nothing) heap datacounter 0x404044 code B: code A: 111 A: stack B: heap A: code B: 222 B: stack A: heap
The same address, 0x404044, in two processes: each is translated to a different place in RAM. The other regions land in scattered places too.

On every load, store and instruction fetch, the hardware translates the virtual address into a physical address, the real location in RAM, and the translation is different for each process. A good translation scheme has three goals:

Part 2The simplest scheme: one block per process

Before looking at how real systems do it, consider the simplest scheme: each process gets one block of RAM. Say process A's block starts at 320 KB and is 64 KB long. The hardware adds 320 KB to every address A uses, and refuses any address of 64 KB or more. Two numbers per process, a start and a length:

process A's addresses physical memory (RAM) A's memory 64 KB0 KB 64 KB and up: refused AfreefreeBkernel 512 KB384 KB320 KB0 KB + 320 KB
One block per process: a start (320 KB) and a length (64 KB). Translating an address is one addition and one comparison.

Now look at a real process. On my x86-64 Linux machine, a process can use virtual addresses from 0 up to (not including) 247, a range of 128 TiB. Linux shows which parts a process uses in /proc/self/maps, a file the kernel generates for every process.

Each line is one region: a start address, an end address (the first byte after the region), permissions, and the file it comes from. r, w and x mean read, write and execute. p means that changes to this region stay private to this process (pages nobody changes can still be shared). libc is the C library. Here is a shortened map:

5a8166aba000-5a8166abb000 r-xp  /home/me/where  ← code5a8166abc000-5a8166abd000 r--p  /home/me/where  ← constants5a8166abd000-5a8166abe000 rw-p  /home/me/where  ← globals, counter5a819dd7d000-5a819dd9e000 rw-p  [heap]  ← heap
7bfedd828000-7bfedd9b1000 r-xp  /usr/lib/x86_64-linux-gnu/libc.so.6  ← libc code, shared7bfedda04000-7bfedda06000 rw-p  /usr/lib/x86_64-linux-gnu/libc.so.6  ← libc globals
7ffd81640000-7ffd81661000 rw-p  [stack]  ← stack

A single block of RAM cannot hold these regions well because we need to take five situations into consideration:

  1. The gaps are huge. Between the heap (0x5a81…) and libc (0x7bfe…) lie more than 30 TiB of addresses that lead nowhere. One block would need RAM for all of them.
  2. Permissions differ per region. Code may be run but not changed. Data may be changed but not run. One block has one set of rights.
  3. Regions grow. The heap grows up and the stack grows down. A block that runs into its neighbor in RAM must be moved, and moving means copying all of it.
  4. Some memory is shared. Almost every process maps libc's code. One copy of it in RAM should serve them all.
  5. Free RAM breaks into holes. As programs start and exit, the free space between blocks has odd sizes. A new program may fit in no single hole, although enough RAM is free in total. This is external fragmentation.

Paging removes the need for one block: it maps memory in pages of 4 KiB, each page on its own. An unused gap then needs no frames of RAM, and a growing heap or stack just gets one more page, wherever RAM has room. Because all pages have the same size, any free page of RAM fits any page of any program, so no odd-sized holes appear. Separate pages also carry separate permissions: code pages can be read-only while data pages are writable, and several processes can map the same read-only page of libc's code.

However, because memory is allocated in units of 4 KiB, the last page of a region is usually only partly used. A region of 4097 bytes that starts at a page boundary takes two pages, 8192 bytes, and leaves 4095 of them unused. This waste inside a page is called internal fragmentation. For a region that starts at a page boundary, it is always less than one page.

Part 3Pages and the page table

Paging cuts both the virtual address space and physical RAM into units of the same size. xv6's ordinary pages, and all the examples here, are 4 KiB (4096 bytes):

Translation works on whole pages. It replaces the page number with a frame number and keeps the byte's position inside the page, called the offset. Nothing is copied: translation only decides which frame an address means.

Because 4096 = 212, the offset is exactly the lowest 12 bits of an address, which are the last three hex digits. The rest of the address is the virtual page number (VPN). Take counter: its address is 4,210,756 (0x404044) = 1028 × 4096 + 68, so it is byte 68 of page 1028. Process A's page table maps page 1028 to frame 37, so the physical address is byte 68 of frame 37: 37 × 4096 + 68 = 151,620.

process A's page table page 1025 → frame 30 page 1028 → frame 37 page 1029 → (nothing) … virtual address 0x404044 page 1028 byte 68 page number offset: 12 bits physical address: frame 37, byte 68 frame 37 byte 68 ① ② ③ copied unchanged
The address 0x404044 is page 1028 (0x404), byte 68 (0x044): the page number is everything in front of the last three hex digits. ① Look up the page number in process A's page table. ② The entry gives the frame. ③ The offset is copied unchanged. Process B's page table maps the same page 1028 to a different frame: that is how the two copies of same saw 111 and 222.

Who writes the page table, and who uses it?

A user program can never access its page table. It is the kernel and the MMU that read or update the page table:

The kernel edits a page table while the program runs, for example to map one more page when the heap grows.

Part 4One big table is too big: use a tree

The simplest page table is one big array with one entry per virtual page. Each entry is 8 bytes. How big is this array?

That is ridiculously big. 🫠 Worse, almost all of it is wasted. A real program uses a few small regions: some code and data near the bottom, libraries in the middle, a stack near the top. Nearly every entry of a flat page table would say "nothing here".

The fix: a tree

Sv39 splits the 27-bit page number into three indexes of 9 bits, and uses a tree of small tables.

How do the three indexes come from the page number? Page 1028 of 0x404044 is 0 × 512 × 512 + 2 × 512 + 4, so its indexes are 0, 2 and 4. In binary, that is just cutting the 27 bits into three groups of 9.

page number: 0x404 = 1028 offset: 0x044 = 68 hex 4 4 0 4 0 4 0 0 0 0 bits 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 1 0 0 0 1 0 0 38 30 29 21 20 12 11 0 level-2 index = 0 000000000 entry 0 in the root level-1 index = 2 000000010 entry 2 in level 1 level-0 index = 4 000000100 entry 4 in level 0 offset = 68 000001000100 byte 68 of the page
0x404044 cut into the pieces the MMU uses. Each hex digit stands for 4 bits. The offset is exactly the last three hex digits, 0x044 = 68. The 9-bit indexes do not line up with hex digits, so read them from the bits: 0, 2 and 4. Only the low 39 bits are drawn: the 25 bits above them repeat bit 38, which is 0 here.

If a whole 1 GiB region is unused, its root entry is simply marked invalid, and none of the tables below it exist. The empty gigabyte costs one 8-byte entry.

root table (frame 12): 512 entries, each covers 1 GiB addresses covered [0] V → frame 13 0–1 GiB level-1 table, frame 13 → level-0 … [1] invalid 1–2 GiB [2] invalid 2–3 GiB ⋮ entries 3–254: invalid [255] V → frame 15 255–256 GiB level-1 table, frame 15 → level-0 … [256]…[511] invalid Nothing below these entries: no level-1 table, no level-0 tables, no frames. Each empty gigabyte costs just its 8-byte entry.
Process A's root table. Only entries 0 and 255 are valid: entry 0 covers A's code, data, heap and stack, and entry 255 covers the top gigabyte, where xv6 keeps two pages of its own. The other 510 entries are invalid, and no tables exist below them.

The page walk

Page tables are not kept in some special place inside the CPU. Page tables are ordinary frames of RAM, mixed in with the data they map. Translating one address means following pointers through RAM: the root table names the frame of a level-1 table, that table names the frame of a level-0 table, and only that one names the data's frame. Each step is a real memory read (the CPU's caches may answer some of them quickly, but they are still reads). A few facts let you read the walk below. The CPU register satp holds the frame of the root table. In each entry, V = 1 means the entry is valid, R = 1 allows reads, and U = 1 allows user mode. If an entry is missing or a check fails, the CPU raises a page fault and hands control to the kernel. Step through one load, and count the reads.

🐾
Walkthrough 1: The page walk in RAM. Process A loads counter. One memory read at a time, the MMU follows page tables from satp through three frames of RAM to the data, and you count the reads. Then two loads where the walk stops early: an empty entry, and a page with U = 0. Make the guess on the way.
Open walkthrough 1 ▶ 11 steps · use ← → keys · press E for the explanation

Grow the tree

The size of the tree depends on which pages are mapped. Let's walk through how the kernel maps the next 4 KiB page, a page 2 MiB above, and a page 1 GiB above.

🐾
Walkthrough 2: Grow the tree. The kernel maps three pages and unmaps one, and the tree of page tables and the RAM map show which frames it costs. Make the guess before the far page.
Open walkthrough 2 ▶ 7 steps · use ← → keys · press E for the explanation

Part 5What a page-table entry says

An entry of a page table is called a page-table entry (PTE). A PTE is 64 bits. In Sv39, bits 10 to 53 hold the physical page number (PPN): the frame of the next table, or, in a leaf, the frame of the data. The lowest 8 bits are flags, and the MMU checks them on every access. Seven of them matter here. The eighth, G (global), is not used by xv6, and bits 8 and 9 are left free for the kernel.

BitNameMeaning when it is 1Who sets it
VvalidThis entry means something. If V is 0, the MMU ignores every other bit: there is no page here.kernel
RreadThe page may be read.kernel
WwriteThe page may be written.kernel
XexecuteThe CPU may run instructions from this page.kernel
UuserUser mode may use the page. If U is 0, only the kernel may.kernel
AaccessedThe page was read, written or executed since the bit was last cleared.hardware
DdirtyThe page was written since the bit was last cleared.hardware

U = 0 keeps user mode away from a page. The MMU refuses any user-mode access to it. In an xv6 process's page table, three pages have U = 0. Two sit at the very top: the trampoline, the code that switches between the program and the kernel, and the trapframe, which holds the program's saved registers. The third is the guard page right below the stack: a stack that grows too far touches it and faults, instead of silently overwriting other data. The rest of the kernel is not in the process's page table at all: the kernel runs on a page table of its own.

One more rule tells the MMU when to keep walking. If V is 1 but R, W and X are all 0, the entry is not a page: it points to the next-level table. Every level uses the same PTE format, and only these three bits tell a pointer from a leaf. Here are the three entries the MMU reads for 0x404044:

PPN D A G U X W R V the frame it names root table entry 0 13 0 0 0 0 0 0 0 1 pointer frame 13 level-1 table level-1 table entry 2 14 0 0 0 0 0 0 0 1 pointer frame 14 level-0 table level-0 table entry 4 37 0 0 0 1 0 1 1 1 leaf frame 37 data page (counter) R, W, X all 0 → pointer · any of them 1 → leaf
One PTE per level from the walk of 0x404044, in the same format (the bits above the PPN and bits 8 and 9 are 0 and not drawn). In the root and level-1 entries only V is set, so they point to the next table. The level-0 entry has R and W set: it is the leaf, and frame 37 holds counter.

Try to set/unset the bits, or just see the PTEs for different types of pages:

The PTE decoderclick a flag to flip it, or pick an example

The same walk in xv6

The MMU walks the tree in hardware. xv6 also walks it in software, with walk() in kernel/vm.c, to find the level-0 entry of a user address. It needs that entry when it builds a mapping, and when it copies data between the kernel and a user buffer: the kernel runs on its own page table, so copyout() must look up the user's mapping itself. walk() uses a few macros from kernel/riscv.h, which cut an index out of an address or convert between a PTE and a frame's physical address:

#define PGSHIFT 12   // bits of offset within a page#define PXMASK          0x1FF // 9 bits#define PXSHIFT(level) (PGSHIFT + (9 * (level)))#define PX(level, va)  ((((uint64)(va)) >> PXSHIFT(level)) & PXMASK)
#define PTE2PA(pte) (((pte) >> 10) << 12)#define PA2PTE(pa) ((((uint64)pa) >> 12) << 10)

A pte_t holds one 64-bit entry, and a pagetable_t points to a table of 512 of them. walk() follows two table pointers and returns the address of the level-0 PTE for va, which may still be invalid. It does not check whether an access is allowed: that is the MMU's job when the program runs. You will use walk() in Lab 2.

pte_t *
walk(pagetable_t pagetable, uint64 va, int alloc)
{
  if (va >= MAXVA)
    panic("walk");
  for (int level = 2; level > 0; level--) {    pte_t *pte = &pagetable[PX(level, va)];    if (*pte & PTE_V) {
      pagetable = (pagetable_t)PTE2PA(*pte);    } else {
      if (!alloc || (pagetable = (pde_t *)kalloc()) == 0)
        return 0;  // kalloc() returns 0 if all physical frames have been consumed
      memset(pagetable, 0, PGSIZE);
      *pte = PA2PTE(pagetable) | PTE_V;
    }
  }
  return &pagetable[PX(0, va)];
}
🐾
Walkthrough 3: Following an address through the page table. Step by step, the MMU translates process A's address 0x404044 through all three levels, with the bits, the tables and walk() side by side. Then three accesses that go wrong.
Open walkthrough 3 ▶ 15 steps · use ← → keys · press E for the explanation

Segmentation Fault 😵 ‼️

The orange cat leaps back in surprise from a laptop showing a big red X, as a coffee mug tips over and sheets of paper fly.

On every access, the MMU checks the PTE it reaches. If the entry is invalid, or its bits do not allow this access, the MMU stops the access and raises a page fault. On Linux, a load through a NULL pointer finds no valid entry. A store to a page with W = 0, or a user-mode access to a page with U = 0, is not allowed. The page fault hands control to the kernel. If the program had no right to make the access, Linux sends it the signal SIGSEGV, which by default ends the program, and the shell typically prints Segmentation fault (core dumped). (Not every page fault is a bug. Some the kernel repairs, and the program goes on.)

Real world
Why page 0 stays empty, on purpose

Address 0 is NULL. By default, Linux keeps the lowest 64 KiB of every process unmapped (/proc/sys/vm/mmap_min_addr is 65536 on my machine), so dereferencing a NULL pointer will fault, instead of reading something wrong.

In 2009, Google researchers showed how one NULL pointer let any user become root on almost every Linux machine in the world. The attacker's program put its own code at address 0, then made a system call that reached a kernel function pointer nobody had filled in. The kernel called NULL, jumped to address 0, and ran the attacker's code with full power. The bug was fixed, and today Linux also stops ordinary programs from mapping the lowest pages, so a NULL in the kernel faults instead.

Sources: LWN, "Linux NULL pointer dereference due to incorrect proto_ops initializations", 2009; Linux mm/Kconfig.

Break it yourself

Each step of the next walkthrough shows one pattern, named at the top: two accesses that work, then four that break, each with the PTE bit that decides it.

🐾
Walkthrough 4: Break it. Process A loads, stores and runs the page that holds counter, while the kernel clears one bit of its PTE at a time. The MMU's three checks are shown on every access. Make the guess on the way.
Open walkthrough 4 ▶ 9 steps · use ← → keys · press E for the explanation

The kernel sets V, R, W, X and U. The last two bits work the other way around: the hardware turns on A when a page is accessed and D when it is written, and the kernel turns them off again. (This is how QEMU, the machine of Lab 2, behaves. The RISC-V standard also allows CPUs that raise a fault instead and let the kernel set the bits.) By clearing the bits and looking again later, the kernel learns two facts it could not see otherwise.

Part 6The TLB: a cache of translations

The tree has a cost. When no translation is cached, a load first reads three PTEs, one per level, and only then reads its data: four memory reads where a load used to need one. The fix is the usual one when the same answers are needed again and again: cache them.

The yellow cat holds up a big stopwatch next to a laptop while the brown-and-white cat takes notes on a clipboard, both concentrating.

The TLB (translation lookaside buffer) is a small cache inside the CPU. Each entry remembers one recent translation: a virtual page number, its frame number and its flags. On each access, the MMU checks the TLB first.

A TLB with only a few dozen entries works because one entry covers a whole page. In a page-aligned array of 4-byte ints, 1024 neighbors share one translation. A loop over them misses on the first one and then, if the entry stays in the TLB, hits on the other 1023. This is locality, the reason every cache works.

The most memory the TLB can cover at once is its reach: number of entries × page size. On my CPU, each fast core has a first-level TLB with 64 entries for 4 KiB pages, a reach of 256 KiB, and behind it a second-level TLB with about 2048 entries, about 8 MiB. (These TLB levels are levels of a cache, not levels of the page table.) A program that keeps cycling through more pages than the TLB can hold misses often, and slows down even though nothing else changed.

Two problems with a cache of translations

1. A context switch. The same virtual page number means different frames in process A and process B. After a switch from A to B, the MMU must not use A's translations for B. xv6 solves this by flushing the TLB, throwing every entry away, whenever it switches page tables. The new process then starts with misses everywhere.

A better fix tags each TLB entry with an address-space ID (ASID). Entries of several processes can then stay in the TLB side by side, and the MMU uses only the ones whose tag matches the running process.

2. A changed page table. The TLB holds copies of a few PTEs, and nothing updates them automatically when the kernel changes a PTE. Suppose the kernel clears W to make a page read-only. If the old, writable translation is still in the TLB, the next store can go through as if nothing happened. So after changing a PTE, the kernel must flush the stale entry from the TLB.

🐾
Walkthrough 5: The TLB at work. A 4-entry TLB in front of the page tables. Each step names one pattern: a loop that hits, a loop over too many pages, a context switch with and without ASID tags, and a stale entry after the kernel changes a PTE. Make the two guesses on the way.
Open walkthrough 5 ▶ 14 steps · use ← → keys · press E for the explanation

Part 7How big should a page be?

Why is a page 4 KiB? In fact, it can be bigger. The choice is a trade-off.

Small pagesBig pages
TLB reachsmall: more misseslarge: fewer misses
Page-table sizemore entriesfewer entries
Waste inside the last pagelittlea lot
Smallest region with its own permissions, or copied as a unitsmalllarge

Most CPUs let the kernel mix page sizes. In Sv39, the walk can stop early: a valid level-1 entry with R or X set is already a leaf and maps a whole 2 MiB huge page, and a leaf at level 2 maps 1 GiB. A 2 MiB page takes two PTE reads to translate instead of three.

One TLB entry for a 2 MiB page covers as much memory as 512 entries for 4 KiB pages. The cost is in allocation: a 2 MiB page needs 2 MiB of physical RAM in one piece, and both its virtual and its physical address must be multiples of 2 MiB. Its permissions also cover all 2 MiB at once. The measurement below compares the two sizes on exactly the same accesses.

1 2 5 10 20 50 100 200 4 16 KiB 16 64 KiB 64 256 KiB 256 1 MiB 1,024 4 MiB 4,096 16 MiB 16,384 64 MiB 65,536 256 MiB places touched, 4 KiB apart, in random order (and the memory they span) ns per read (log scale) first-level TLB 64 entries second-level TLB ~2048 entries both curves rise: cause not isolated 4 KiB pages 2 MiB pages
Intel Core i9-13900KF, one fast core. Each run reads one 8-byte value in each of N regions of 4 KiB, in shuffled order, over and over: the red curve with 4 KiB pages, the blue curve with 2 MiB pages, on the same addresses. Median of three runs; hover over a point for its value.

In short: 2 MiB pages need fewer page-table lookups than 4 KiB pages, so reads are faster. The price is more internal fragmentation: a region that uses only part of a huge page wastes the rest of it, up to almost 2 MiB, where a 4 KiB page wastes less than 4 KiB.

Real world
Your next phone may use 16 KB pages

Android 15 added support for phones that use 16 KB pages. With the same number of TLB entries, 16 KB pages cover four times as much memory as 4 KB pages, and page tables need fewer entries. The price is more unused space inside partly filled pages. In Google's tests, app launch times were 3.16% lower on average under memory pressure; results vary by phone and app.

Google Play now requires 16 KB support from apps that target Android 15 or later on 64-bit devices: from February 1, 2027, updates without it cannot be released.

Source: Android Developers, "Support 16 KB page sizes".

Self-checkLearning goals

Check each item that you can do without looking at the worksheet. The next quiz covers these topics. (Progress is stored only in this browser.)

Reset checklist

AttributionCredits