1.缺页异常
1.1.缺页异常(Page Fault)概述
1.1.1.什么是缺页异常
缺页异常是 x86 架构中的 #PF(中断向量 14),属于一种同步异常。当 CPU 访问某个虚拟地址时,发现该地址对应的页表项(PTE)存在以下情况之一,就会触发缺页异常:
| 触发条件 | 说明 |
|---|---|
| 页不存在 | PTE 的 Present 位为 0,物理页尚未分配 |
| 权限不足 | 写只读页、用户态访问内核页、执行不可执行页等 |
| 保留位被置位 | 页表项格式非法 |
CPU 会将出错虚拟地址存入 CR2 寄存器,并在栈上压入错误码(Error Code),包含:
- P 位:0=页不存在,1=权限错误
- W/R 位:0=读操作,1=写操作
- U/S 位:0=内核态,1=用户态
- I/D 位:0=数据访问,1=指令取指
1.1.2.内核入口:do_page_fault
cpp
// arch/x86/mm/fault.c
dotraplinkage void notrace
do_page_fault(struct pt_regs *regs, unsigned long error_code)
{
// CR2 中存放触发异常的虚拟地址
unsigned long address = read_cr2();
// 进入核心处理逻辑
__do_page_fault(regs, error_code, address);
}
1.2.缺页异常处理流程
cpp
┌─────────────────┐
│ 触发缺页异常 │
│ (CR2=故障地址) │
└────────┬────────┘
▼
┌─────────────────┐
│ do_page_fault │
│ (arch相关入口) │
└────────┬────────┘
▼
┌─────────────────────────┐
│ 查找 vma(find_vma) │
│ 地址是否在某个vma范围内? │
└────────┬────────────────┘
否 / \ 是
▼ ▼
┌──────────┐ ┌────────────────────────┐
│ 非法访问 │ │ 调用 handle_mm_fault │
│ (SIGSEGV)│ │ 进入通用缺页处理逻辑 │
└──────────┘ └────────┬───────────────┘
▼
┌─────────────────────┐
│ pgd → p4d → pud │
│ → pmd → pte 逐级 │
│ 分配/查找页表项 │
└────────┬────────────┘
▼
┌─────────────────────┐
│ handle_pte_fault │
│ 根据pte状态分发 │
└────────┬────────────┘
▼
┌───────────┼───────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│pte为空 │ │pte存在 │ │pte存在 │
│(新分配)│ │但不可写│ │且可写 │
└────┬───┘ └────┬───┘ └────┬───┘
▼ ▼ ▼
do_anonymous do_wp_page 正常访问
/do_fault (COW入口) (不应发生)
2.写时复制(Copy-on-Write, COW)机制
2.1.COW 的设计动机
核心问题:fork() 创建子进程时,如果立即复制父进程的全部地址空间:
- 耗时巨大(GB 级内存)
- 很多内存子进程根本不会修改(如代码段、只读数据)
COW 解决方案:
- 子进程共享父进程的物理页,但将共享页标记为只读。当任一进程尝试写入时,才触发缺页异常,此时才真正复制一份私有副本。
2.2.COW 的实现细节
- 步骤 1:fork() 时的页表设置
cpp
// 在 copy_one_pte() 中
static inline unsigned long
copy_one_pte(struct mm_struct *dst_mm, struct mm_struct *src_mm,
pte_t *dst_pte, pte_t *src_pte, ...)
{
pte_t pte = *src_pte;
// 如果页是可写的,将其标记为"写保护 + COW"
if (pte_write(pte)) {
ptep_set_wrprotect(src_mm, addr, src_pte); // 父进程页表置只读
pte = pte_wrprotect(pte); // 子进程页表也置只读
}
set_pte_at(dst_mm, addr, dst_pte, pte);
}
关键:父子进程的 PTE 都被清除了 _PAGE_RW(可写位),但保留了 _PAGE_PRESENT。
-
步骤 2:写操作触发缺页异常
当子进程(或父进程)尝试写入共享页时:
- CPU 检查 PTE,发现 Present=1 但 Writable=0
- 触发 #PF,错误码的 W/R=1(写操作)
- 进入 do_wp_page() ------ Write Protect 处理
-
do_wp_page() 核心逻辑
cpp
// mm/memory.c
static vm_fault_t do_wp_page(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
// 1. 获取旧的 pte 和对应的页
old_page = vmf->pte_page(*vmf->pte);
// 2. 如果页引用计数为 1(只有当前进程在用)
// 直接恢复写权限即可,无需复制!
if (page_ref_count(old_page) == 1) {
pte_t entry;
entry = pte_mkyoung(*vmf->pte); // 标记访问过
entry = maybe_mkwrite(pte_mkdirty(entry), vma); // 恢复写权限
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
return VM_FAULT_WRITE;
}
// 3. 引用计数 > 1,需要真正的 COW 复制
// 分配新页,复制数据
new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, vmf->address);
copy_user_highpage(new_page, old_page, vmf->address, vma);
// 4. 建立新的映射,赋予写权限
entry = mk_pte(new_page, vma->vm_page_prot);
entry = maybe_mkwrite(pte_mkdirty(entry), vma);
set_pte_at_notify(vma->vm_mm, vmf->address, vmf->pte, entry);
// 5. 减少旧页引用计数
page_ref_dec(old_page);
return VM_FAULT_WRITE;
}
2.3.COW 的完整流程图解
cpp
父进程 P 子进程 C
│ │
│ fork() │
├─────────────────────────►│
│ │
│ 共享物理页 Page X │ ← 同一物理页
│ (PTE: R/O, COW) │ (PTE: R/O, COW)
│ │
│◄──────── 共享 ──────────►│
│ │
│ 写入 Page X │
│ ↓ │
│ #PF (写保护异常) │
│ ↓ │
├─ do_wp_page() ──────────┤
│ ↓ │
│ 引用计数=2 > 1 │
│ ↓ │
│ 分配新页 Page X' │
│ 复制 Page X → X' │
│ ↓ │
│ Page X': PTE 可写 │
│ Page X: 引用计数=1 │
│ ↓ │
│ 继续写入 X' ✓ │
│ │
│◄──── X 仍只读共享 ─────►│ ← 子进程仍共享 Page X
2.4.关键要点总结
| 要点 | 说明 |
|---|---|
| COW 不是内核"主动"复制 | 而是被动触发------通过写保护页 + 缺页异常实现 |
| 延迟复制 | 只有真正写入时才复制,未写入的页永远共享 |
| 引用计数优化 | 如果引用计数为 1,直接恢复写权限,连复制都省了 |
| 页表级保护 | 利用硬件 MMU 的页表权限位实现,无额外开销 |
| 透明性 | 对应用程序完全透明,进程以为自己拥有独立地址空间 |