前言:从“暴力复制”到“惰性求值”

        在上一章中,我们实现了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处理。分阶段验证永远比一步到位更高效。下一章,我们让用户程序学会“自己管理内存”!

Logo

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

更多推荐