上一章每个进程有了自己的地址空间。这一章实现 fork()——创建一个子进程,继承父进程的全部内存。
最朴素的 fork 实现
fork 的语义是"完整复制当前进程"。最直接的做法:遍历父进程页表,找到每一个物理页,分配新页,复制 4096 字节内容,给子进程建新映射。
能用,但很浪费。大多数 fork() 之后会紧接着 exec()——旧内存根本用不上,全拷了白拷。进程堆如果有几十 MB,每次 fork 都要等好几毫秒。
Copy-on-Write:先共享,写了再分
CoW 的思路是:fork 时不复制,让父子共享同一批物理页;等到谁要写,再给他一份新的。
实现上分两步:
第一步:fork 时打标记
遍历父进程用户页表,对每一页做两件事:
- 清掉 WRITABLE 位,变成只读
- 打上 PAGE_COW 标志(借用 x86 页表的 bit 9,这一位 CPU 不使用,留给软件自定义)
子进程页表复制同一个物理页地址,同样只读 + CoW。
fork 后:
父进程 → PT → 物理页 0xA000 (只读, CoW)
↑ 共享
子进程 → PT → 物理页 0xA000 (只读, CoW)
注意一个关键细节:PDPT/PD/PT 这三级结构页必须为子进程单独分配。如果父子共用同一棵结构树,后续 vmm_map_page 修改子进程时会把父进程的 PT 一起改掉,隔离失效。数据页可以共享,结构页不能。
另一个细节:改完父进程页表后必须刷新 TLB。否则 CPU 缓存里还是旧的可写映射,父进程写该页不会触发 fault,CoW 形同虚设。
第二步:写时分配新页
父进程或子进程尝试写 CoW 页时,CPU 发现页表里没有写权限,触发 Page Fault(int 14)。
Page Fault 触发时,CPU 把出错的虚拟地址自动存入 CR2 寄存器,err_code 的 bit0=1 表示页存在、bit1=1 表示写操作触发——这两个条件同时成立,加上页表项有 PAGE_COW 标记,就可以确认这是 CoW 触发,而不是真正的非法访问。
处理逻辑:
int vmm_cow_fault(uint64_t *pml4, uint64_t vaddr) {
uint64_t entry = /* 找到 PT 条目 */;
if (!(entry & PAGE_COW)) return -1; // 不是 CoW,真正的写保护违规
uint64_t new_phys = (uint64_t)pmm_alloc();
memcpy((void *)new_phys, (void *)ENTRY_ADDR(entry), 4096);
// 新页可写,清除 CoW 标记
pt[PT_IDX(vaddr)] = new_phys | (flags | PAGE_WRITABLE) & ~PAGE_COW;
__asm__ volatile ("invlpg (%0)" :: "r"(vaddr) : "memory");
return 0;
}
fault handler 返回后,CPU 重新执行触发 fault 的那条指令——这次页是可写的,正常完成写入。对程序来说,整个过程完全透明。
验证
[test] parent maps uaddr to phys = 0x10a000
[test] child maps uaddr to phys = 0x10a000 ← fork 后共享同一物理页
[test] child new phys = 0x112000 ← 写后子进程拿到新页
[test] parent phys unchanged: OK ← 父进程物理页不变
[test] child page data copied: OK ← 内容正确复制
[test] parent data unaffected after child write: OK ← 父子完全隔离
小结
CoW 的精髓是把"复制"这件事推迟到真正必要的时候。fork 之后如果双方都只读,物理页永远不需要复制。写操作触发 Page Fault,内核在 fault handler 里悄悄分配新页、复制内容、恢复写权限,用户程序感知不到任何中断。
| 时机 | 发生了什么 |
|---|---|
| fork 时 | 用户页标只读+CoW,父子共享物理页,刷 TLB |
| 任意一方写时 | Page Fault,分配新页,复制 4KB,恢复可写,CPU 重试指令 |
下一章:exec() — 加载 ELF 文件,替换地址空间,以用户态运行。