ARM64 OS Handbook
🔍

Chapter 19: Page Tables

What You Will Learn in This Chapter
  • The structure of multi-level page tables on ARM64
  • How to navigate page table entries at each level
  • Block mappings vs page mappings
  • How to create and manage page tables in the kernel
  • How context switching changes page tables
  • Our page table management API

19.1 Page Table Hierarchy

ARM64 uses a radix tree structure for page tables. The root table is pointed to by TTBR0_EL1 or TTBR1_EL1. Each table is exactly one page (4 KB) and contains 512 entries of 8 bytes each. The 9-bit index (0-511) selects an entry at each level.

Level 0 (L0): 1 table, covers entire 48-bit VA space
  Each entry covers 512 GB (2^39 bytes)
Level 1 (L1): up to 512 tables, each entry covers 1 GB (2^30 bytes)
Level 2 (L2): up to 512K tables, each entry covers 2 MB (2^21 bytes)
Level 3 (L3): up to 256M tables, each entry covers 4 KB (2^12 bytes)

19.2 Entry Types by Level

LevelEntry Type (when bit 1 = 0)Entry Type (when bit 1 = 1)
L0Invalid (no table)Table pointer
L1InvalidTable pointer, or 1 GB block
L2InvalidTable pointer, or 2 MB block
L3Invalid4 KB page (must be page)

If bit 1 (the table/page bit) is 0 at L1 or L2, the entry is a block mapping: an entire 1 GB or 2 MB region is mapped to contiguous physical memory. Block mappings save memory (no L2/L3 tables needed) and TLB entries but waste space if the region is mostly empty.

19.3 Page Table API

Our kernel provides a set of functions for page table management:

/* Page table manager API */

/* Allocate a new page table (aligned to 4 KB) and zero it */
uint64_t *alloc_page_table(void) {
    uint64_t *table = page_alloc();
    for (int i = 0; i < 512; i++) table[i] = 0;
    return table;
}

/* Map a 4 KB page at the given virtual address */
void map_page(uint64_t *ttbr0, uint64_t va, uint64_t pa, int writable, int user);

/* Unmap a page at the given virtual address */
void unmap_page(uint64_t *ttbr0, uint64_t va);

/* Change permissions on an existing mapping */
void protect_page(uint64_t *ttbr0, uint64_t va, int writable, int user);

/* Check if a virtual address is mapped */
int is_mapped(uint64_t *ttbr0, uint64_t va);

/* Clone an entire page table (for fork()) */
uint64_t *clone_page_table(uint64_t *src);

/* Free an entire page table hierarchy */
void free_page_table(uint64_t *table, int level);

19.4 Context Switching and Page Tables

When the kernel switches from one process to another (Chapter 13), it must also switch page tables:

/* Context switch: switch page tables */
void switch_page_table(uint64_t *new_ttbr0) {
    uint64_t old_ttbr0;

    /* Read current TTBR0 (for debugging or save) */
    asm volatile("mrs %0, ttbr0_el1" : "=r"(old_ttbr0));

    /* Write new TTBR0 */
    asm volatile("msr ttbr0_el1, %0; isb" : : "r"(new_ttbr0));

    /* TLB invalidate is required (see Chapter 20) */
    asm volatile("tlbi vmalle1; dsb ish; isb");
}

At switch time, the kernel must invalidate all TLB entries for the old process. The new process starts with a clean TLB. The kernel mappings (TTBR1) are shared by all processes and are never invalidated during context switches.

19.5 Page Table Memory Layout

Each process's address space looks like this in our kernel:

0x0000_0000_0000_0000 - 0x0000_0000_0000_FFFF: Reserved (null page trap)
0x0000_0000_0001_0000 - 0x0000_00FF_FFFF_FFFF: User code, data, heap
0x0000_00FF_FFFF_FFFF - 0x0000_FFFF_FFFF_FFFF: Reserved (gap, unmapped)
0xFFFF_0000_0000_0000 - 0xFFFF_0000_0000_FFFF: Trap page (access to null kills)
0xFFFF_0000_0001_0000 - 0xFFFF_0000_07FF_FFFF: Kernel identity map
0xFFFF_0000_0800_0000 - 0xFFFF_0000_FFFF_FFFF: MMIO (UART, GIC, ...)
0xFFFF_0001_0000_0000 - 0xFFFF_0001_FFFF_FFFF: Kernel direct map (all physical RAM)
0xFFFF_FFFF_FFFF_FFFF: End of kernel space

19.6 Our Implementation

Page tables are the core data structure for memory management. Our kernel's page table module (mm/page_table.c) provides:

  • Boot-time page table: a statically allocated set of tables used during early boot (before the MMU is enabled)
  • Kernel page table: the shared TTBR1 mapping, created once at boot and never changed
  • Per-process page table: allocated when a process is created, cloned during fork(), and freed on process exit
  • Lazy allocation: intermediate tables are created only when needed (when a page is mapped within that range)
  • Copy-on-write: during fork, page tables are cloned but pages are shared as read-only until written to

The kernel also creates a direct map that maps all physical memory starting at a fixed virtual address. This allows the kernel to access any physical page by adding a constant offset, without needing identity mappings for each page.

19.7 Exercises

Exercise 1: Table Size Estimation

Calculate: how many pages are needed for page tables if a process has 100 MB of mapped memory, assuming 4 KB pages and all mappings are at L3 granularity? Answer: each level-0 through level-2 table covers 512 GB, 1 GB, and 2 MB respectively. For 100 MB, you need 4 L3 tables (one per 2 MB block), 4 L2 tables, 1 L1 table, 1 L0 table. Total = 10 pages (40 KB).

Exercise 2: Page Table Dump

Write a function that walks the page tables and prints every valid mapping (virtual address, physical address, size, permissions).

19.8 Summary

ARM64's page tables form a 4-level radix tree rooted in TTBR0 (user) or TTBR1 (kernel). Each level table is 4 KB with 512 entries. L1 and L2 entries can be block mappings (1 GB or 2 MB) for large contiguous regions, while L3 entries must be 4 KB pages. Page tables enable per-process address spaces, isolation, and efficient memory use. Our kernel provides a complete API for creating, managing, cloning, and freeing page tables with lazy allocation of intermediate levels.