4. 写时复制(COW)机制原理:只读页表项设置、页故障处理中的COW路径、缺页异常处理函数do_wp_page

写时复制(Copy-on-Write,COW)是Linux内核实现高效fork()系统调用的核心机制。其基本思想是:父进程与子进程在fork()后共享同一物理内存页,但将这些页的页表项标记为只读。当任一进程尝试写入共享页时,触发写保护页故障(Page Fault),内核在故障处理中为写入进程分配新的物理页并复制原页内容,从而完成“写时复制”的延迟拷贝。

核心思想: COW将物理内存的复制操作从fork()调用时推迟到实际写入发生时。对于只读或写后即废弃的内存页(如exec()后的代码段),避免了不必要的拷贝,显著降低了fork()的开销。

4.1 只读页表项设置:fork() 中的页表操作

在fork()系统调用的核心函数copy_process()中,内核会调用copy_mm()来复制父进程的内存描述符(mm_struct)。关键步骤发生在dup_mmap()函数中,它遍历父进程的虚拟内存区域(VMA),并为子进程创建相同的VMA结构。对于每个映射,内核会调用copy_page_range()来复制页表。

copy_page_range()函数是COW页表项设置的起点。其核心逻辑如下:

  1. 遍历页表层级: 从PGD(Page Global Directory)开始,逐级遍历P4D、PUD、PMD,直到PTE(Page Table Entry)层级。
  2. 复制PTE: 对于每个有效的PTE,调用copy_pte_range()。该函数会检查PTE的权限和类型。
  3. 设置只读与清除脏位: 对于可写的匿名页(Anonymous Page),内核会执行以下操作:
    • 清除PTE中的_PAGE_RW(可写)标志位。
    • 设置_PAGE_PTE标志位(表示PTE有效)。
    • 清除_PAGE_DIRTY(脏页)标志位。
    • 设置_PAGE_ACCESSED(已访问)标志位。
    • 在PTE的软件可用位(如_PAGE_SOFT_DIRTY)中标记该页为COW页。
  4. 增加物理页引用计数: 父进程和子进程的页表现在指向同一个物理页框(struct page)。内核调用get_page()增加该物理页的引用计数(_refcount),确保在任一进程释放映射前,物理页不会被回收。
关键数据结构: 物理页的struct page结构体中的_mapcount字段记录了映射到该页的PTE数量(匿名页场景)。当_mapcount大于0时,表示该页被多个进程共享,这是COW机制判断是否需要复制的重要依据。

下表总结了fork()后父进程与子进程页表项的关键差异:

属性 父进程(fork前) 父进程(fork后) 子进程(fork后)
物理页地址 物理页A 物理页A 物理页A
可写标志(_PAGE_RW) 1(可写) 0(只读) 0(只读)
脏标志(_PAGE_DIRTY) 可能为1 0 0
物理页引用计数 1 2 2
映射计数(_mapcount) 1 2 2

以下SVG图展示了fork()后父进程与子进程共享物理页,且页表项均被设置为只读的状态:

fork() 后 COW 页表项设置 父进程页表 (PTE) 物理页地址: 0x1000 子进程页表 (PTE) 物理页地址: 0x1000 共享物理页 (0x1000) 引用计数: 2 标志: 只读 (RW=0) 标志: 只读 (RW=0)

4.2 页故障处理中的COW路径:缺页异常处理流程

当父进程或子进程尝试写入一个被标记为只读的COW页时,CPU会触发一个写保护页故障(Page Fault)。x86架构下,页故障的错误码(Error Code)中的第1位(PF_WRITE)会被置位,表示本次故障是由写操作引起的。内核的缺页异常处理入口do_page_fault()会根据错误码和故障地址,将处理分派到不同的路径。

COW路径的触发条件如下:

  • 故障原因: 写操作(PF_WRITE = 1)。
  • 页表项存在: PTE的_PAGE_PRESENT标志位为1(页表项有效)。
  • 页表项不可写: PTE的_PAGE_RW标志位为0。
  • VMA可写: 对应的VMA(vm_area_struct)具有VM_WRITE标志。这是区分COW与真正的只读映射(如代码段)的关键。

当上述条件全部满足时,内核会调用handle_mm_fault(),进而进入handle_pte_fault()函数。在handle_pte_fault()中,对于写保护且VMA可写的情况,会调用do_wp_page()函数来执行实际的COW操作。

以下流程图展示了从页故障触发到COW完成的完整路径:

COW 页故障处理流程 进程写入只读页 CPU触发写保护页故障 do_page_fault() 处理 handle_mm_fault() handle_pte_fault(): 写保护 & VMA可写 do_wp_page() 执行COW

4.3 缺页异常处理函数 do_wp_page()

do_wp_page()是COW机制的核心实现函数,位于mm/memory.c文件中。它的主要职责是:为触发写操作的进程分配一个新的物理页,将原共享页的内容复制到新页,并更新页表项使其可写。同时,它需要处理物理页的引用计数和映射计数,确保共享页在不再被需要时能被正确释放。

do_wp_page()的执行步骤如下:

  1. 锁定页表: 获取故障地址对应的页表锁(PTL),防止并发修改。
  2. 重新读取PTE: 再次读取PTE,确认页表项仍然有效且为写保护状态(防止竞态条件)。
  3. 获取物理页: 通过PTE中的页框号(PFN)获取对应的struct page结构体。
  4. 检查映射计数: 检查物理页的_mapcount。如果_mapcount等于1(即只有当前进程映射该页),则无需复制,可以直接将PTE修改为可写。这是一种优化,常见于kswapd或madvise将页标记为只读后的场景。
  5. 分配新页: 如果_mapcount大于1(多个进程共享),调用alloc_page_vma()分配一个新的物理页(GFP_HIGHUSER_MOVABLE标志)。
  6. 复制内容: 使用copy_user_highpage()将原物理页的内容复制到新分配的物理页。该函数会处理内核空间与用户空间地址的映射。
  7. 更新页表:
    • 将新页的物理地址写入PTE。
    • 设置_PAGE_RW标志位,使新页可写。
    • 设置_PAGE_DIRTY标志位(因为即将写入)。
    • 清除原页的COW相关软件位。
  8. 更新引用计数:
    • 新页的引用计数初始为1(映射到当前进程)。
    • 原共享页的引用计数减1(当前进程不再映射原页)。
  9. 解锁页表: 释放页表锁。
  10. 返回: 返回0表示成功处理,缺页异常处理程序会重新执行导致故障的指令。
关键竞态条件: 在do_wp_page()中,从读取PTE到分配新页之间,可能存在其他进程(如另一个子进程)也触发了同一物理页的COW。内核通过页表锁和重新检查PTE来防止重复分配。此外,_mapcount的检查必须在持有页表锁的情况下进行,以确保原子性。

以下代码片段展示了do_wp_page()的核心逻辑(简化版):

// mm/memory.c (简化)
static vm_fault_t do_wp_page(struct vm_fault *vmf)
{
    struct page *old_page, *new_page;
    pte_t entry;
    int ret;

    // 1. 锁定页表并获取旧页
    vmf->pte = pte_offset_map_lock(vmf->vma->vm_mm, vmf->pmd,
                                    vmf->address, &vmf->ptl);
    if (unlikely(!vmf->pte))
        return VM_FAULT_OOM;

    old_page = vm_normal_page(vmf->vma, vmf->address, *vmf->pte);
    if (!old_page)
        goto unlock;

    // 2. 检查映射计数:如果只有当前进程映射,直接设为可写
    if (page_mapcount(old_page) == 1) {
        entry = pte_mkyoung(*vmf->pte);
        entry = pte_mkdirty(entry);
        entry = pte_mkwrite(entry);
        set_pte_at(vmf->vma->vm_mm, vmf->address, vmf->pte, entry);
        ret = VM_FAULT_WRITE;
        goto unlock;
    }

    // 3. 分配新页
    new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vmf->vma,
                              vmf->address);
    if (!new_page) {
        ret = VM_FAULT_OOM;
        goto unlock;
    }

    // 4. 复制内容
    copy_user_highpage(new_page, old_page, vmf->address, vmf->vma);

    // 5. 更新页表:设置新页为可写、脏
    entry = mk_pte(new_page, vma->vm_page_prot);
    entry = pte_mkyoung(entry);
    entry = pte_mkdirty(entry);
    entry = pte_mkwrite(entry);
    set_pte_at(vmf->vma->vm_mm, vmf->address, vmf->pte, entry);

    // 6. 更新引用计数
    get_page(new_page); // 新页映射计数+1
    put_page(old_page); // 旧页引用计数-1

    ret = 0;

unlock:
    pte_unmap_unlock(vmf->pte, vmf->ptl);
    return ret;
}

以下SVG图展示了do_wp_page()执行前后的物理页状态变化:

do_wp_page() 执行前后物理页状态 执行前 父进程 PTE (只读) → 物理页A 子进程 PTE (只读) → 物理页A 物理页A 引用计数: 2 → 执行后 (子进程写入) 父进程 PTE (只读) → 物理页A 子进程 PTE (可写) → 物理页B 物理页A 引用计数: 1 物理页B (新) 引用计数: 1
性能关键点: do_wp_page()中的copy_user_highpage()是COW路径中开销最大的操作,因为它涉及一次完整的物理页拷贝(通常为4KB)。对于大页(HugeTLB或透明大页),拷贝开销会成倍增加。内核通过_mapcount检查(单映射优化)和pte_same()重检查来避免不必要的拷贝,这是COW性能优化的核心方向。

4.4 COW与匿名页反向映射

COW机制与匿名页的反向映射(Reverse Mapping)紧密相关。当内核需要回收或迁移一个共享的匿名页时,它必须找到所有映射了该页的PTE,并将这些PTE标记为只读或更新为新的物理页地址。这依赖于struct page中的mapping字段和anon_vma结构。

在COW场景中,反向映射用于:

  • 页回收(kswapd): 当内核决定回收一个共享的匿名页时,它会遍历该页的anon_vma链表,找到所有映射该页的VMA,并将对应的PTE标记为只读。这样,下次任何进程写入时都会触发COW,从而可以安全地回收原页。
  • 页迁移(compaction): 在内存规整(Memory Compaction)过程中,需要移动物理页。内核通过反向映射找到所有PTE,更新它们指向新的物理页地址,并保持只读状态以维持COW语义。
优化思路: 在fork()后,如果子进程立即调用exec(),则子进程的地址空间会被完全替换,之前COW设置的只读页表项变得毫无意义。内核可以通过MADV_WIPEONFORK或MADV_DONTFORK等madvise选项,或者通过vfork()系统调用,来完全避免不必要的COW页表设置和物理页复制。