4. 写时复制(COW)机制原理:只读页表项设置、页故障处理中的COW路径、缺页异常处理函数do_wp_page
写时复制(Copy-on-Write,COW)是Linux内核实现高效fork()系统调用的核心机制。其基本思想是:父进程与子进程在fork()后共享同一物理内存页,但将这些页的页表项标记为只读。当任一进程尝试写入共享页时,触发写保护页故障(Page Fault),内核在故障处理中为写入进程分配新的物理页并复制原页内容,从而完成“写时复制”的延迟拷贝。
fork()调用时推迟到实际写入发生时。对于只读或写后即废弃的内存页(如exec()后的代码段),避免了不必要的拷贝,显著降低了fork()的开销。
4.1 只读页表项设置:fork() 中的页表操作
在fork()系统调用的核心函数copy_process()中,内核会调用copy_mm()来复制父进程的内存描述符(mm_struct)。关键步骤发生在dup_mmap()函数中,它遍历父进程的虚拟内存区域(VMA),并为子进程创建相同的VMA结构。对于每个映射,内核会调用copy_page_range()来复制页表。
copy_page_range()函数是COW页表项设置的起点。其核心逻辑如下:
- 遍历页表层级: 从PGD(Page Global Directory)开始,逐级遍历P4D、PUD、PMD,直到PTE(Page Table Entry)层级。
- 复制PTE: 对于每个有效的PTE,调用
copy_pte_range()。该函数会检查PTE的权限和类型。 - 设置只读与清除脏位: 对于可写的匿名页(Anonymous Page),内核会执行以下操作:
- 清除PTE中的
_PAGE_RW(可写)标志位。 - 设置
_PAGE_PTE标志位(表示PTE有效)。 - 清除
_PAGE_DIRTY(脏页)标志位。 - 设置
_PAGE_ACCESSED(已访问)标志位。 - 在PTE的软件可用位(如
_PAGE_SOFT_DIRTY)中标记该页为COW页。
- 清除PTE中的
- 增加物理页引用计数: 父进程和子进程的页表现在指向同一个物理页框(
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()后父进程与子进程共享物理页,且页表项均被设置为只读的状态:
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完成的完整路径:
4.3 缺页异常处理函数 do_wp_page()
do_wp_page()是COW机制的核心实现函数,位于mm/memory.c文件中。它的主要职责是:为触发写操作的进程分配一个新的物理页,将原共享页的内容复制到新页,并更新页表项使其可写。同时,它需要处理物理页的引用计数和映射计数,确保共享页在不再被需要时能被正确释放。
do_wp_page()的执行步骤如下:
- 锁定页表: 获取故障地址对应的页表锁(PTL),防止并发修改。
- 重新读取PTE: 再次读取PTE,确认页表项仍然有效且为写保护状态(防止竞态条件)。
- 获取物理页: 通过PTE中的页框号(PFN)获取对应的
struct page结构体。 - 检查映射计数: 检查物理页的
_mapcount。如果_mapcount等于1(即只有当前进程映射该页),则无需复制,可以直接将PTE修改为可写。这是一种优化,常见于kswapd或madvise将页标记为只读后的场景。 - 分配新页: 如果
_mapcount大于1(多个进程共享),调用alloc_page_vma()分配一个新的物理页(GFP_HIGHUSER_MOVABLE标志)。 - 复制内容: 使用
copy_user_highpage()将原物理页的内容复制到新分配的物理页。该函数会处理内核空间与用户空间地址的映射。 - 更新页表:
- 将新页的物理地址写入PTE。
- 设置
_PAGE_RW标志位,使新页可写。 - 设置
_PAGE_DIRTY标志位(因为即将写入)。 - 清除原页的COW相关软件位。
- 更新引用计数:
- 新页的引用计数初始为1(映射到当前进程)。
- 原共享页的引用计数减1(当前进程不再映射原页)。
- 解锁页表: 释放页表锁。
- 返回: 返回
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()中的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页表设置和物理页复制。