MIT 6.1810 · 第 8 课

页错误处理

页错误不是 bug,而是机会。通过捕获页错误并更新页表,内核可以实现惰性分配、写时复制、按需分页等强大机制——提升性能、简化编程模型。

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

虚拟内存的多个视角

视角说明
隔离每个进程有自己的地址空间——页表的原始目的
间接层页表给内核提供了"做酷事情"的机会

已有的"酷事情"

核心思想:在页错误时修改页表

页错误是一种 trap(类似系统调用)。xv6 默认 panic,但你可以:

  1. 检查错误原因和地址
  2. 分配物理页并更新页表
  3. 重新执行触发错误的指令

页错误 + 页表更新 = 强大的抽象工具!

RISC-V 页错误基础

16 个异常中有 3 个与分页相关:

scause类型
12Instruction page fault
13Load page fault
15Store page fault

页错误时可获得的信息

信息来源
触发错误的虚拟地址stval 寄存器
错误类型(指令/Load/Store)scause 寄存器
发生错误的指令地址trapframe->epc
用户/内核模式隐含在 usertrap/kerneltrap 中

惰性/按需页分配(Lazy/On-Demand Allocation)

问题:sbrk() 的浪费

sbrk() 是 UNIX 的传统 API,用于扩展堆。但它预先分配内存:

现代方案:惰性分配

计划:
  1. sbrk() 只增加 p->sz(地址空间大小),不实际分配
  2. 应用访问内存时触发 page fault
  3. 内核在 page fault 处理中分配物理页、安装 PTE
  4. 重新执行触发错误的指令

好处:
  - 未使用的内存不分配 → 节省内存
  - 分配开销分散到多次 page fault,而非集中在 sbrk()

Demo:xv6 的 sbrklazy()

// user/lazy.c
int main() {
    // buf 预留 10 页的空间,但不实际分配
    sbrklazy(buf, 10 * PGSIZE);
    // 只使用 3 页
    buf[0] = 'a';       // 第 1 次 page fault
    buf[4096] = 'b';    // 第 2 次 page fault
    buf[8192] = 'c';    // 第 3 次 page fault
    // 只触发 3 次 fault,分配 3 个物理页
}

vmfault() 实现

// kernel/trap.c
void vmfault(uint64 va, struct proc *p) {
    // 1. 检查 va 是否在 p->sz 范围内
    if (va >= p->sz || va < p->stack_bottom)
        panic("vmfault: invalid va");

    // 2. 查找对应的 PTE
    pte_t *pte = walk(p->pagetable, va, 0);

    // 3. 分配物理页
    char *mem = kalloc();
    memset(mem, 0, PGSIZE);

    // 4. 安装 PTE
    mappages(p->pagetable, PGROUNDDOWN(va), PGSIZE,
             (uint64)mem, PTE_W | PTE_R | PTE_U);
}

copyin/copyout 的复杂性

如果应用把 buf 传给 read()/write(),内核需要写入 buf 的物理页:

对 lazy 应用来说,这一切都是透明的——它不知道发生了 page fault。

一个零页:按需填零(Zero Fill on Demand)

问题:大量零初始化内存

应用程序常有大量必须为零的内存(全局数组、BSS 段)。exec() 必须为 BSS 段分配页并清零——浪费时间和内存。

解决方案:共享零页

计划:
  1. memset 一个物理页为零
  2. 需要零页时,把零页映射为只读(无 PTE_W)
  3. 应用尝试写入 → page fault
  4. 分配新页、清零、映射为 R/W

好处:
  - 多个进程共享同一个零页
  - 只有真正写入的页才需要分配

Demo:vmfault0

void vmfault0(uint64 va, struct proc *p) {
    pte_t *pte = walk(p->pagetable, va, 0);

    if (是写错误 && 当前是零页) {
        // 分配新页、清零、映射为 R/W
        char *mem = kalloc();
        memset(mem, 0, PGSIZE);
        mappages(p->pagetable, ..., mem, PTE_W | PTE_R | PTE_U);
    } else {
        // 第一次 fault:映射零页为只读
        mappages(p->pagetable, ..., zero_page, PTE_R | PTE_U);
    }
}

写时复制(Copy-On-Write)Fork

观察:fork + exec 的浪费

fork()    → 复制父进程所有内存(可能很大)
exec()    → 丢弃刚复制的内存,加载新程序
→ 复制白做了!

解决方案:COW Fork

计划:
  1. fork() 不复制物理页,而是共享
  2. 父/子进程的 PTE 设为只读(使用 RSW 位标记 COW)
  3. 任一进程尝试写入 → page fault
  4. 分配新页、复制内容、映射为 R/W
  5. 需要维护物理页的引用计数

PTE 格式(RSW 位):
  [10 保留(含 2 位 RSW)| 44 PPN | 10 标志]
  RSW = Reserved for Software,xv6 用来标记 COW

COW 的挑战

按需分页(Demand Paging)

问题:exec 加载整个文件

exec() 从磁盘加载整个可执行文件到内存——可能很大,且可能只用了一部分。

解决方案:按需加载

计划:
  1. exec() 只分配 PTE,标记为"按需"(不加载实际内容)
  2. 应用访问某页 → page fault
  3. 从磁盘读取该页到内存、安装 PTE
  4. 需要 VMA(Virtual Memory Area)结构记录每页在磁盘的位置

VMA:Virtual Memory Area

struct vma {
    uint64 start;       // 起始虚拟地址
    uint64 end;         // 结束虚拟地址
    int prot;           // 读/写/执行权限
    struct file *f;     // 对应的文件
    uint64 offset;      // 文件中的偏移
};

虚拟内存大于物理内存

问题:应用需要比物理内存更多的内存

128 MB RAM 的机器如何运行需要 512 MB 的程序?

解决方案:换页(Paging)

计划:
  1. 把不常用的页换出到磁盘
  2. 需要时再换入
  3. 不写回未修改的页(D=dirty 位帮助识别)
  4. 替换策略:LRU(Least Recently Used)
     - A(Accessed)位帮助实现 LRU

工作集(Working Set):
  - 如果活跃页能放入物理内存 → 工作正常
  - 如果工作集 > 物理内存 → 抖动(thrashing)

应用可以影响替换策略:

内存映射文件(Memory-Mapped Files)

思想:用 load/store 访问文件

// mmap 系统调用
void *mmap(void *addr, size_t length, int prot,
           int flags, int fd, off_t offset);

// 使用
int fd = open("bigfile", O_RDWR);
char *p = mmap(NULL, filesize, PROT_READ | PROT_WRITE,
                MAP_SHARED, fd, 0);
// 现在可以用 p[i] 直接访问文件内容!
p[0] = 'x';  // 直接写入文件

实现


知识检查

Q1:页错误处理的核心思想是什么?

Q2:惰性分配(lazy allocation)的好处是什么?

Q3:COW(写时复制)fork 的关键机制是什么?

Q4:按需分页(demand paging)需要什么额外数据结构?

Q5:mmap 的主要优势是什么?


本课要点

页错误 = 机会

页错误不是 bug,而是更新页表、实现高级抽象的入口。

惰性分配节省内存

只在真正使用时分配物理页——未使用的地址空间不占物理内存。

COW 优化 fork

共享物理页、标记只读——写入时才复制,避免 fork+exec 的浪费。

mmap 简化文件访问

把文件映射到地址空间,用 load/store 直接访问——无需显式 read/write。

延伸阅读