MIT 6.1810 · 第 5 课

页表与虚拟内存

页表是实现隔离的关键机制。本课深入 RISC-V 的三级页表结构,探讨内核页表与用户页表的布局,并通过 xv6 代码走读理解虚拟内存的实际实现。

授课:rtm (Robert Morris) 日期:2025-02-04 难度:较高

虚拟内存:为什么需要它?

假设 shell 有一个 bug:偶尔会写到随机内存地址。没有虚拟内存,shell 可能覆盖内核数据或其他进程的内存。

目标:隔离的地址空间

页表:寻址的间接层

CPU → MMU → RAM,MMU 通过页表把虚拟地址(VA)翻译为物理地址(PA):

CPU 发出 VA ──→ MMU 查页表 ──→ 得到 PA ──→ 访问 RAM
概念说明
satp 寄存器存放当前页表的物理地址,内核写 satp 切换页表
页(Page)RISC-V 的页大小 = 4 KB = \(2^{12}\) 字节
PTE页表条目(Page Table Entry),64 位

RISC-V Sv39 的地址空间限制

64 位地址中只使用低 39 位(Sv39),地址空间上限 512 GB:

PTE 结构

[10 保留位 | 44 位 PPN(物理页号)| 10 位标志]
标志位:V(Valid), R(Read), W(Write), X(Execute), U(User)

地址翻译:高 27 位是页号(索引页表),低 12 位是页内偏移(直接复制到物理地址)。

三级页表:节省空间

为什么不用单级页表?

如果使用单级数组,需要 \(2^{27}\) 个 PTE × 8 字节 ≈ 1 GB 每个地址空间——即使进程只使用几 KB 内存,也要占 1 GB 页表空间,完全不可接受。

三级树形结构

VA: [L2 索引 9位 | L1 索引 9位 | L0 索引 9位 | 页内偏移 12位]

Level 2 目录 ──→ Level 1 目录 ──→ Level 0 目录 ──→ 物理页
     ↑                 ↑                 ↑
   9 bits            9 bits            9 bits

只为实际使用的地址分配中间目录页——未使用的部分不占内存。

为什么是 9 位?

9 bits → 512 个 PTE × 8 字节 = 4096 字节 = 正是一个页的大小。每个目录刚好占一页,方便分配与管理。

TLB:加速地址翻译

三级页表每次内存访问需要 4 次查表。CPU 用 Translation Look-aside Buffer (TLB) 缓存最近使用的翻译结果,避免频繁查表。

PTE 标志位

标志含义
VValid:该 PTE 是否有效
R/W/X可读/可写/可执行
UUser:用户态可访问
GGlobal:全局 TLB 条目(跨地址空间共享)

页错误(Page Fault)

当 V 位为 0,或写操作但 W 位为 0 时,硬件触发 page fault,强制进入内核。内核可以:

xv6 的虚拟内存实现

内核页表(图 3.3)

内核页表建立直接映射(direct mapping):虚拟地址 = 物理地址(大部分区域)。这使得内核可以直接把物理地址 cast 成 C 指针写入。

虚拟地址物理地址用途
0x100000000x10000000UART 控制台
0x100010000x10001000VIRTIO 磁盘
0x80000000 → 0x880000000x80000000 → 0x88000000128 MB RAM
0x3ffffff000-Trampoline(用户/内核过渡页)
0x3fffffe000-内核栈(每个进程一个)

注意:

用户地址空间(图 3.4)

地址范围内容
低地址text → data → heap(向上增长)
stack 下方stack guard(无 PTE_U,不可访问)
stack用户栈(向下增长)
0x3ffffff000Trampoline(无 PTE_U)
0x3fffffe000Trapframe(无 PTE_U)

代码走读:xv6 页表实现

1. 建立内核页表:kvmmake()

// kernel/vm.c
pagetable_t kvmmake(void) {
    pagetable_t kpgtbl = (pagetable_t) kalloc();
    memset(kpgtbl, 0, PGSIZE);

    // UART0 直接映射
    kvmmap(kpgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);

    // VIRTIO0
    kvmmap(kpgtbl, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);

    // 物理 RAM 直接映射
    kvmmap(kpgtbl, KERNBASE, KERNBASE, PHYSTOP - KERNBASE,
           PTE_R | PTE_W);

    // 内核栈
    proc_mapstacks(kpgtbl);
    return kpgtbl;
}

2. 添加映射:kvmmap() → mappages()

// kernel/vm.c
void kvmmap(pagetable_t kpgtbl, uint64 va, uint64 pa,
            uint64 size, int perm) {
    mappages(kpgtbl, va, size, pa, perm);
}

// 为 [va, va+size) 的每个页建立映射
int mappages(pagetable_t pagetable, uint64 va, uint64 size,
              uint64 pa, int perm) {
    uint64 a, last;
    pte_t *pte;

    a = PGROUNDDOWN(va);
    last = PGROUNDDOWN(va + size - 1);
    for(;;) {
        // walk 找到 va 对应的 PTE 地址(必要时创建目录页)
        if((pte = walk(pagetable, a, 1)) == 0)
            return -1;
        if(*pte & PTE_V)
            panic("remap");
        // 设置 PTE:物理页号 + 权限 + Valid
        *pte = PA2PTE(pa) | perm | PTE_V;
        if(a == last)
            break;
        a += PGSIZE;
        pa += PGSIZE;
    }
    return 0;
}

3. 页表遍历:walk()

walk() 模拟硬件的页表遍历过程:

// kernel/vm.c
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)];  // 提取 9 位索引
        if(*pte & PTE_V) {
            // 下一级目录已存在
            pagetable = (pagetable_t)PTE2PA(*pte);
        } else {
            // 需要分配新的目录页
            if(!alloc || (pagetable = (pde_t*)kalloc()) == 0)
                return 0;
            memset(pagetable, 0, PGSIZE);
            *pte = PA2PTE(pagetable) | PTE_V;
        }
    }
    // 最后一级:返回叶 PTE 的地址
    return &pagetable[PX(0, va)];
}

4. 启用分页:kvminithart()

// kernel/vm.c
void kvminithart() {
    // satp 需要页表的物理地址!
    w_satp(MAKE_SATP(kernel_pagetable));
    sfence_vma();  // 刷新 TLB
}

手动计算示例:UART0 = 0x10000000

页号 = 0x10000000 >> 12 = 0x10000
L2 索引 = (0x10000 >> 18) & 0x1FF = 0x0
L1 索引 = (0x10000 >> 9) & 0x1FF = 0x80
L0 索引 = 0x10000 & 0x1FF = 0x0
页内偏移 = 0x10000000 & 0xFFF = 0x0

PTE 低 10 位 = 0x7 = 0b000111
→ PTE_R (0b00001) | PTE_W (0b00010) | PTE_V (0b00001)

调试技巧


知识检查

Q1:RISC-V Sv39 的虚拟地址空间大小是多少?

Q2:为什么 xv6 使用三级页表而不是单级页表?

Q3:为什么 Trampoline 页必须在用户页表和内核页表中映射到相同的虚拟地址?

Q4:当用户程序访问未映射的虚拟地址时,会发生什么?

Q5:内核页表使用"直接映射"(VA = PA)的主要好处是什么?


本课要点

页表 = 间接层

页表是寻址的间接层,内核通过它控制每个进程能看到什么内存。

三级页表节省空间

Sv39 的三级结构只为实际使用的地址分配目录页,避免单级页表的 1 GB 浪费。

Trampoline 是关键

Trampoline 页映射在用户和内核页表的相同 VA,使用户/内核切换时能继续执行。

walk() 模拟硬件

xv6 的 walk() 函数模拟硬件页表遍历,用于更新页表或访问用户指针。

延伸阅读