页错误 = 机会
页错误不是 bug,而是更新页表、实现高级抽象的入口。
MIT 6.1810 · 第 8 课
页错误不是 bug,而是机会。通过捕获页错误并更新页表,内核可以实现惰性分配、写时复制、按需分页等强大机制——提升性能、简化编程模型。
| 视角 | 说明 |
|---|---|
| 隔离 | 每个进程有自己的地址空间——页表的原始目的 |
| 间接层 | 页表给内核提供了"做酷事情"的机会 |
页错误是一种 trap(类似系统调用)。xv6 默认 panic,但你可以:
页错误 + 页表更新 = 强大的抽象工具!
16 个异常中有 3 个与分页相关:
| scause | 类型 |
|---|---|
| 12 | Instruction page fault |
| 13 | Load page fault |
| 15 | Store page fault |
| 信息 | 来源 |
|---|---|
| 触发错误的虚拟地址 | stval 寄存器 |
| 错误类型(指令/Load/Store) | scause 寄存器 |
| 发生错误的指令地址 | trapframe->epc |
| 用户/内核模式 | 隐含在 usertrap/kerneltrap 中 |
sbrk() 是 UNIX 的传统 API,用于扩展堆。但它预先分配内存:
计划:
1. sbrk() 只增加 p->sz(地址空间大小),不实际分配
2. 应用访问内存时触发 page fault
3. 内核在 page fault 处理中分配物理页、安装 PTE
4. 重新执行触发错误的指令
好处:
- 未使用的内存不分配 → 节省内存
- 分配开销分散到多次 page fault,而非集中在 sbrk()
// 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 个物理页
}
// 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);
}
如果应用把 buf 传给 read()/write(),内核需要写入 buf 的物理页:
copyin()/copyout() 需要先调用 vmfault() 确保 buf 有物理页对 lazy 应用来说,这一切都是透明的——它不知道发生了 page fault。
应用程序常有大量必须为零的内存(全局数组、BSS 段)。exec() 必须为 BSS 段分配页并清零——浪费时间和内存。
计划:
1. memset 一个物理页为零
2. 需要零页时,把零页映射为只读(无 PTE_W)
3. 应用尝试写入 → page fault
4. 分配新页、清零、映射为 R/W
好处:
- 多个进程共享同一个零页
- 只有真正写入的页才需要分配
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);
}
}
fork() → 复制父进程所有内存(可能很大)
exec() → 丢弃刚复制的内存,加载新程序
→ 复制白做了!
计划:
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
exec() 从磁盘加载整个可执行文件到内存——可能很大,且可能只用了一部分。
计划:
1. exec() 只分配 PTE,标记为"按需"(不加载实际内容)
2. 应用访问某页 → page fault
3. 从磁盘读取该页到内存、安装 PTE
4. 需要 VMA(Virtual Memory Area)结构记录每页在磁盘的位置
struct vma {
uint64 start; // 起始虚拟地址
uint64 end; // 结束虚拟地址
int prot; // 读/写/执行权限
struct file *f; // 对应的文件
uint64 offset; // 文件中的偏移
};
128 MB RAM 的机器如何运行需要 512 MB 的程序?
计划:
1. 把不常用的页换出到磁盘
2. 需要时再换入
3. 不写回未修改的页(D=dirty 位帮助识别)
4. 替换策略:LRU(Least Recently Used)
- A(Accessed)位帮助实现 LRU
工作集(Working Set):
- 如果活跃页能放入物理内存 → 工作正常
- 如果工作集 > 物理内存 → 抖动(thrashing)
应用可以影响替换策略:
madvise():告诉内核预期的访问模式mlock():锁定页面不被换出// 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,而是更新页表、实现高级抽象的入口。
只在真正使用时分配物理页——未使用的地址空间不占物理内存。
共享物理页、标记只读——写入时才复制,避免 fork+exec 的浪费。
把文件映射到地址空间,用 load/store 直接访问——无需显式 read/write。