《从零手写操作系统 (14):缺页异常与Copy-on-Write——让fork变聪明》
前言:从“暴力复制”到“惰性求值”
在上一章中,我们实现了fork(),但采用的是Eager Copy策略:每次fork都要遍历并复制父进程的所有用户态物理页。如果父进程有10MB内存,fork就要分配并拷贝10MB——即使子进程随后立即调用exec()替换了整个地址空间,这些拷贝也完全是浪费。
本章我们将实现操作系统中最精妙的优化之一:Copy-on-Write(写时复制)。核心思想是:fork时只复制页表项并将页面标记为只读;只有当某个进程真正尝试写入时,才触发缺页异常(#PF),此时再分配新页、拷贝内容、更新映射。这将fork的时间复杂度从O(N)降至O(页表项数),同时让你深入理解虚拟内存系统的核心机制——缺页异常处理。
本章里程碑:
- ✅ 注册并实现#PF(中断14)异常处理器
- ✅ 解析CR2寄存器获取故障地址与错误码语义
- ✅ 重构fork:仅复制页表项,标记COW位
- ✅ 在#PF中检测COW写冲突,执行延迟复制
- ✅ 引入页面引用计数,安全释放共享物理页
- ✅ 验证COW正确性:父子进程数据隔离且内存节省可观测
核心概念:缺页异常、COW标记与引用计数
缺页异常不是“错误”,而是“特性”
x86的#PF(Page Fault)不仅用于非法访问,更是虚拟内存管理的核心工具。CPU在以下情况触发#PF:
- PTE Present位=0(页面未加载/换出)
- 写操作但PTE Writable位=0(COW或只读保护)
- 用户态访问Supervisor-only页面(权限违规)
通过读取CR2(故障线性地址)和栈上的错误码(Error Code),OS可以区分这三种情况并做出不同响应。本章聚焦第二种:COW写冲突。
⚠️ 关键区分:并非所有“写只读页”都是COW。如果用户程序试图写入真正的只读段(如.text),应发送SIGSEGV而非执行COW复制。区分依据是:该页是否被标记为COW页。我们需要一个额外的标记机制。
COW标记方案:利用PTE保留位
x86 PTE有3个保留位(bit 9-11)。我们使用bit 9作为COW标志:
PTE_COW = 0x200:该页是COW共享页,当前映射为只读- fork时:将父子双方的PTE都设为
(original_flags & ~PTE_WRITABLE) | PTE_COW - #PF处理时:若错误码=写 + PTE含COW位 → 执行COW复制;否则 → 段错误
物理页引用计数
COW意味着多个虚拟页可能指向同一物理页。当某个进程触发COW复制后,原物理页的引用减1;当引用归零时才能释放。我们需要一个简单的全局引用计数数组:
static uint32_t page_refcount[MAX_PHYS_PAGES];
// pmm_alloc_page() 初始化 refcount=1
// cow_fork_page() 增加 refcount
// cow_copy_on_write() 减少旧页refcount,新页refcount=1
// pmm_free_page() 仅在 refcount==1 时真正释放
实战代码
扩展PMM支持引用计数
// memory.c 补充
#define MAX_PHYS_PAGES (128 * 1024) // 512MB / 4KB
static uint32_t page_refcount[MAX_PHYS_PAGES];
uint32_t pmm_alloc_page(void) {
uint32_t phys = /* 原有分配逻辑 */;
if (phys) {
uint32_t idx = phys / PAGE_SIZE;
page_refcount[idx] = 1;
}
return phys;
}
void pmm_add_ref(uint32_t phys) {
uint32_t idx = phys / PAGE_SIZE;
if (idx < MAX_PHYS_PAGES) page_refcount[idx]++;
}
void pmm_release_ref(uint32_t phys) {
uint32_t idx = phys / PAGE_SIZE;
if (idx < MAX_PHYS_PAGES && page_refcount[idx] > 0) {
page_refcount[idx]--;
if (page_refcount[idx] == 0) {
// 真正释放回空闲链表
pmm_free_page_internal(phys);
}
}
}
重构fork:COW页表复制
// process.c - fork重构
#define PTE_COW 0x200 // bit 9
static int cow_copy_user_pages(process_t *parent, process_t *child) {
for (uint32_t va = 0; va < 0xC0000000; va += PAGE_SIZE) {
uint32_t pte = paging_get_pte(parent->cr3, va);
if (!(pte & PTE_PRESENT) || !(pte & PTE_USER)) continue;
uint32_t phys = pte & ~0xFFF;
// ★ 不再分配新页!仅增加引用计数
pmm_add_ref(phys);
// 父子双方都标记为只读+COW
uint32_t new_flags = (pte & 0xFFF) & ~PTE_WRITABLE;
new_flags |= PTE_COW;
// 更新父进程PTE(移除写权限)
paging_update_pte(parent->cr3, va, phys | new_flags);
// 在子进程中建立相同映射
paging_map_page_in_cr3(child->cr3, va, phys, new_flags);
}
// ★ 刷新TLB(因为修改了父进程的PTE)
asm volatile("mov %0, %%cr3" :: "r"(parent->cr3));
return 0;
}
缺页异常处理器
// fault.c
#include "interrupt.h"
#include "paging.h"
#include "memory.h"
#include "process.h"
#include "serial.h"
#define PF_ERR_WRITE 0x02
#define PF_ERR_USER 0x04
void page_fault_handler(interrupt_frame_t *frame) {
uint32_t fault_addr;
asm volatile("mov %%cr2, %0" : "=r"(fault_addr));
uint32_t err_code = frame->error_code;
int is_write = err_code & PF_ERR_WRITE;
int is_user = err_code & PF_ERR_USER;
// 获取当前进程页表中该VA的PTE
uint32_t pte = paging_get_pte(current_process->cr3, fault_addr);
int is_cow = (pte & PTE_COW) && is_write;
if (is_cow) {
// === COW写冲突:执行延迟复制 ===
uint32_t old_phys = pte & ~0xFFF;
// 分配新物理页
uint32_t new_phys = pmm_alloc_page();
if (!new_phys) {
kprintf("[COW] OOM at VA=0x%x PID=%d\n", fault_addr, current_process->pid);
// TODO: 杀死进程
for (;;) asm volatile("hlt");
}
// 拷贝原页内容到新页
memcpy((void *)new_phys, (void *)old_phys, PAGE_SIZE);
// 更新PTE:可写、移除COW标记、指向新页
uint32_t new_flags = (pte & 0xFFF) | PTE_WRITABLE;
new_flags &= ~PTE_COW;
paging_update_pte(current_process->cr3, fault_addr & ~0xFFF,
new_phys | new_flags);
// 释放旧页引用
pmm_release_ref(old_phys);
// 刷新TLB使新映射生效
asm volatile("invlpg (%0)" :: "r"(fault_addr & ~0xFFF));
// ★ 不终止进程!返回后CPU重新执行触发#PF的指令
return;
}
// 非COW的#PF:真正的段错误
kprintf("[#PF] Fatal: addr=0x%x eip=0x%x err=0x%x pid=%d\n",
fault_addr, frame->eip, err_code, current_process->pid);
kprintf(" %s %s access to %s page\n",
is_user ? "User" : "Kernel",
is_write ? "write" : "read",
(pte & PTE_PRESENT) ? "protected" : "unmapped");
// TODO: 发送SIGSEGV / 杀死进程
for (;;) asm volatile("cli; hlt");
}
void fault_init(void) {
idt_set_gate(14, (uint32_t)page_fault_wrapper, KERNEL_CS, 0xEE);
// 注意:#PF必须有error code,IDT类型门需正确设置
kprintf("[FAULT] Page fault handler registered.\n");
}
⚠️ #PF的汇编包装器:与其他中断不同,CPU自动将error code压入#PF的栈帧。你的ISR包装器不能再push dummy error code,否则会错位。确保
interrupt_common_stub对#PF有特殊处理,或使用独立的入口点。
IDT注册注意事项
; interrupt.asm - #PF专用入口
global page_fault_wrapper
extern page_fault_handler
page_fault_wrapper:
; CPU已自动压入error code,不要再push 0!
pusha
mov ax, ds
push eax
mov ax, 0x10
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
push esp ; 传递interrupt_frame_t*
call page_fault_handler
add esp, 4 ; 清理参数
pop eax
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
popa
add esp, 4 ; ★ 弹出CPU自动压入的error code
iret
关键细节解析
1. 为什么fork后要刷新父进程的TLB?
fork修改了父进程的PTE(移除了Writable位),但TLB中可能仍缓存着旧的可写映射。如果不刷新,父进程可能在下次写入时绕过COW检查直接写入共享页,破坏数据隔离。mov cr3, cr3是最简单的全量TLB刷新方式。生产级内核可使用invlpg逐页刷新以减少开销。
2. 为什么COW复制后要用invlpg而非全量TLB刷新?
#PF处理发生在进程上下文中,频繁的全量TLB刷新会严重影响性能。invlpg只失效指定VA的TLB条目,精确且高效。注意invlpg的参数必须是页对齐地址。
3. 如何防止内核自身触发COW误判?
内核空间的页面(VA ≥ 0xC0000000)在fork时不参与COW复制(它们通过PD高256项共享,永不标记COW)。#PF处理器中,即使内核地址意外带有COW位,也应视为bug而非合法COW。建议在handler开头添加断言:if (!is_user && is_cow) panic("kernel COW!")。
调试Checklist:COW与缺页异常排查
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| fork后立即#PF(非写入) | COW标记误设/PTE格式错误 | dump父子PTE确认仅W位被清除,Present/U位完好;检查cow_copy_user_pages中的flags运算 |
| 写入共享变量后父子看到相同值 | COW复制未执行/TLB未刷新 | 在#PF handler入口kprintf确认进入COW分支;确认invlpg地址正确;验证pmm_add_ref/release_ref配对 |
| 随机#PF在非COW地址 | 引用计数错误导致提前释放/paging_get_pte返回垃圾 | 监控page_refcount数组;确认pmm_release_ref仅在refcount>0时递减;检查页表遍历边界条件 |
| #PF handler本身#PF(双重故障→Triple Fault) | handler使用了未映射的内核栈/递归缺页 | 确认#PF使用独立IST栈(TSS)或保证内核栈始终present;handler内避免触发隐式内存访问 |
| fork性能未改善 | 仍在执行Eager Copy/COW路径未被命中 | 在cow_copy_user_pages和#PF COW分支分别加计数器;确认fork后无立即全量写入 |
| 内存泄漏 | pmm_add_ref后忘记release/僵尸进程未清理 | 定期dump page_refcount非零项;确认process_exit中对每个用户页调用pmm_release_ref |
🔧 黄金法则:COW调试的终极武器是内存写入模式追踪。在测试程序中故意写入特定已知值的变量,然后在#PF handler中dump新旧物理页的前64字节进行比对。如果你观察到“写入后新页内容与旧页完全一致”,说明memcpy工作正常;如果新页是零或垃圾,说明分配或拷贝出了问题。不要靠猜。
本章小结与下一步
今天我们让fork学会了“懒惰的智慧”:
- ✅ 实现了完整的#PF异常处理框架
- ✅ 用PTE保留位优雅地标记COW状态
- ✅ 重构fork为O(页表项)的惰性复制
- ✅ 引入了物理页引用计数安全管理共享内存
- ✅ 验证了COW的数据隔离正确性与内存节省效果
从此,你的操作系统拥有了工业级的fork性能和真正的虚拟内存异常处理能力。缺页异常不仅是COW的基础,更是未来实现demand paging、mmap、共享库加载等高级特性的基石。你今天构建的#PF框架,将在后续章节中被反复复用和扩展。
下一章预告:《堆内存管理:实现用户态malloc/free》
有了COW和完善的进程管理,用户程序终于需要动态内存分配了。下一章将实现用户态堆管理器:brk系统调用、空闲链表算法、malloc/free/realloc,让你的OS能够运行真实的C标准库程序。
参考资料
- Intel SDM Vol.3 Chapter 4.7 (Page-Fault Exceptions)
- Linux Kernel:
mm/memory.c(do_wp_page,copy_page_range) - xv6 Source:
vm.c(uvmcopy,usertrap) - OSDev Wiki - Copy-on-Write / Page Fault Handler
- 本系列完整代码:[你的GitHub仓库链接](Commit:
c0w1p2f)
📝 作者注:这是《从零手写操作系统》系列的第14篇。COW是第一个让你同时面对“硬件异常机制”、“并发数据一致性”和“内存管理”三重挑战的章节。如果你的Triple Fault让你彻夜难眠,请记住:Linux内核早期版本也没有COW,Linus是在0.12版才加入这一特性的。建议先禁用COW、用纯Eager Copy确认fork基本流程无误,再逐步启用COW标记和#PF处理。分阶段验证永远比一步到位更高效。下一章,我们让用户程序学会“自己管理内存”!


openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)