上一章每个进程有了自己的地址空间。这一章实现 fork()——创建一个子进程,继承父进程的全部内存。

最朴素的 fork 实现

fork 的语义是"完整复制当前进程"。最直接的做法:遍历父进程页表,找到每一个物理页,分配新页,复制 4096 字节内容,给子进程建新映射。

能用,但很浪费。大多数 fork() 之后会紧接着 exec()——旧内存根本用不上,全拷了白拷。进程堆如果有几十 MB,每次 fork 都要等好几毫秒。

Copy-on-Write:先共享,写了再分

CoW 的思路是:fork 时不复制,让父子共享同一批物理页;等到谁要写,再给他一份新的。

实现上分两步:

第一步:fork 时打标记

遍历父进程用户页表,对每一页做两件事:

  1. 清掉 WRITABLE 位,变成只读
  2. 打上 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 文件,替换地址空间,以用户态运行。