在Linux系统编程与内核学习中,fork、mmap、page fault、TLB、页表、Copy-on-Write(写时复制,简称COW)是CPU硬件、内存管理单元(MMU)、Linux内核内存管理子系统、进程模型协同工作所构成的一整套虚拟内存运行机制。真正打通这条链路后,会发现从用户态的一次内存访问,到物理页的最终落地,每一步都有清晰的硬件与软件协作逻辑。
我们可以先在脑中建立这样一棵执行树:

一、虚拟地址空间的两层抽象:VMA 层与页表层
Linux内核维护了两套职责明确、互相关联的抽象:VMA(虚拟内存区域)层 负责描述地址空间的逻辑属性,页表层负责维护虚拟页到物理页的实际映射。
1.1 VMA:地址空间的逻辑视图
用户态的虚拟地址空间由 struct mm_struct 整体描述,而每一段属性相同的连续虚拟地址范围,由 struct vm_area_struct(简称VMA)来刻画。一个进程的所有VMA以Maple树(红黑树的演进版本)的结构组织在mm_struct中,覆盖进程的整个用户地址空间。
VMA回答的是逻辑属性问题:
- 这段地址的起始与结束位置在哪里?
- 这段内存的访问权限是什么(读/写/执行)?
- 它是匿名映射还是文件映射?
- 它是私有映射还是共享映射?
典型的进程地址空间会包含代码段(r-x)、只读数据段(r--)、数据段(rw-)、栈(rw-)、mmap映射区(rw-)等多个VMA。
1.2 页表:地址翻译的物理视图
页表解决的是实际映射问题:
- 这个虚拟页当前到底映射到哪个物理页帧?
- 该页是否已经在物理内存中?
- 硬件层面的访问权限与标志位是什么?
1.3 需求分页:虚拟内存 ≠ 物理内存
Linux虚拟内存的核心设计之一是惰性分配(lazy allocation)与请求调页(demand paging) :当我们通过mmap、malloc等接口建立虚拟映射时,内核只是创建了对应的VMA,并不会立即分配物理页,也不会立即填充页表项。只有当进程真正访问该地址时,才会通过Page Fault触发物理页的分配与页表的建立。
这也意味着:虚拟内存的大小绝不等于已占用的物理内存大小。
一个1GB的
mmap映射,在没有被访问前,可能不占用任何物理RAM。
二、CPU 侧的地址翻译:MMU、TLB 与 Page Walk
当CPU执行一条内存访问指令(如*p = 10)时,它拿到的是虚拟地址(线性地址),必须通过内存管理单元(MMU)翻译为物理地址才能访问内存。
2.1 TLB:地址翻译的高速缓存
Translation Lookaside Buffer(转译后备缓冲器,简称TLB) 是页地址翻译的硬件缓存。TLB中缓存了虚拟页号(VPN)到物理页帧号(PFN)的映射,同时还缓存了读/写、用户/内核、执行禁用等权限信息。
当TLB命中时,CPU可以直接得到物理地址,完全不需要访问内存中的页表,这是最高效的路径。对于4KB页面来说,地址翻译可以简化为:
物理地址 = (PFN << 12) | 页内偏移
如果没有TLB,一次内存访问需要先遍历多级页表(多次内存访问)才能得到物理地址,再访问目标数据,内存访问延迟会成倍上升。现代CPU还配备了Paging-Structure Caches(页表结构缓存),进一步降低页表遍历的开销。
2.2 TLB Miss 与硬件页表遍历
当TLB中没有找到对应虚拟页的映射时,发生TLB Miss。此时CPU会自动执行硬件页表遍历(Page Walk),按照页表层级逐级查找内存中的页表项。
Linux内核在软件层面抽象了五级页表 模型:
PGD(Page Global Directory)→ P4D(Page 4th Directory)→ PUD(Page Upper Directory)→ PMD(Page Middle Directory)→ PTE(Page Table Entry)
对于不支持某一层级的硬件架构,Linux会通过折叠(fold) 机制将该层逻辑消除,让软件可以统一使用五级模型。以最常见的x86-64架构为例,硬件通常实现4级分页(PML4→PDPT→PD→PT),Linux会将P4D层级折叠,最终对应到硬件的四级结构。
PGD、P4D、PUD、PMD、PTE 本质上构成了一棵多级页表索引树:虚拟地址会被拆成多段索引,CPU/MMU 先通过 PGD 找到下一层页表,再经过 P4D、PUD、PMD 逐级缩小地址范围,最终由 PTE 给出具体的物理页框地址以及读写、用户态访问等权限信息;这样做的核心目的,是不用为整个巨大虚拟地址空间一次性建立完整页表,而只为实际使用的区域分配下级页表,从而节省内存。
PGD、P4D、PUD、PMD 本质上都不是"存最终物理页地址"的,它们主要负责继续指向下一层页表;真正到 PTE,才通常得到最终物理页框和页权限
PTE (Page Table Entry)页表项:PTE 是页表里的一条记录,用来描述某个虚拟页映射到哪个物理页,以及这个页能不能读、写、执行
text
PTE
├── Physical Frame Number
│ └── 对应哪个物理页
│
├── Present
│ └── 当前映射是否有效
│
├── Read / Write
│ └── 是否允许写
│
├── User / Supervisor
│ └── 用户态是否可以访问
│
├── Execute 权限
│ └── 是否允许执行
│
├── Accessed
│ └── 这个页是否被访问过
│
└── Dirty
└── 这个页是否被写过
2.3 页表遍历的三种结果
硬件页表遍历完成后,会出现三种结果:
- PTE有效且权限允许:翻译成功,将结果填入TLB,访问物理内存,指令正常执行。
- 映射合法但PTE不存在:虚拟地址属于某个VMA,但页表项为空,触发Page Fault,由内核分配物理页并建立映射。
- PTE存在但访问权限违规:比如向只读页执行写入,触发保护型Page Fault,这是Copy-on-Write的入口。
页错误异常(Page-Fault Exception)的触发原因既包括页表中不存在有效翻译,也包括翻译存在但访问权限不匹配。CPU会将错误地址存入CR2寄存器,并通过错误码(Error Code)指示故障类型,关键字段包括:
P位:0表示页不存在,1表示保护违规W/R位:0表示读访问,1表示写访问U/S位:0表示内核态访问,1表示用户态访问I/D位:0表示数据访问,1表示指令取指
三、Page Fault
缺页异常是Linux虚拟内存得以运转的关键驱动机制。绝大多数的物理页分配、页表建立、写时复制、换入换出,都是通过Page Fault触发的。
3.1 Linux 缺页处理
Linux内核的缺页处理入口(如x86架构的do_page_fault)会执行大致如下的判断逻辑:
c
page_fault_handler(address, access_type, error_code) {
vma = find_vma(current->mm, address);
if (!vma) {
// 地址不属于任何VMA,非法访问
send_signal(SIGSEGV);
return;
}
if (!permission_check(vma, access_type)) {
// 访问权限与VMA属性不符
send_signal(SIGSEGV);
return;
}
if (pte_present(entry)) {
// 页存在但写保护 → COW故障
if (is_cow_mapping(vma) && access_type == WRITE) {
handle_cow_fault(vma, address);
} else {
send_signal(SIGSEGV);
}
} else {
// 页不存在 → 请求调页
handle_demand_paging(vma, address);
}
resume_instruction();
}
CPU只负责报告"地址翻译或权限检查失败",而这个故障是正常机制还是程序错误,由内核根据VMA的状态来判断。如果内核能够修复映射,程序就会透明地继续执行;如果确实是非法访问,才会向进程发送SIGSEGV等信号。
3.2 Minor Fault 与 Major Fault
Linux从性能统计的角度,将Page Fault分为两类:
- Minor Fault(次缺页):不需要磁盘I/O即可处理的缺页。例如匿名页的分配、写时复制、页缓存中已有页面的映射建立等。
- Major Fault(主缺页):处理过程中需要磁盘I/O的缺页。例如文件映射的首次读入、swap分区的换入等。
这一统计可以通过getrusage(2)系统调用获取,ru_minflt与ru_majflt分别对应两种缺页的计数,/proc/<pid>/stat中也暴露了对应字段。
CPU硬件层面的缺页分类(不存在/保护违规)与Linux统计层面的分类(minor/major)是两个不同维度。COW故障通常属于minor fault,因为它只涉及内存内的页面复制,不涉及磁盘I/O。
四、fork() 与 Copy-on-Write
Copy-on-Write是虚拟内存机制的应用之一,它的设计目标是在保证fork语义的前提下,尽可能降低进程创建的开销。
4.1 fork
fork创建的子进程拥有与父进程独立的地址空间,且fork瞬间的内存内容与父进程一致。
如果fork时直接复制所有物理页,对于大内存进程来说开销会极其高昂。Linux的实现采用了写时复制策略:fork时不复制物理页,只复制页表与VMA等元数据,让父子进程暂时共享同一批物理页,等到真正写入时再复制。
4.2 fork 时内核做了什么
- 复制VMA结构 :为子进程创建一套独立的
vm_area_struct,描述与父进程相同的地址空间布局。 - 复制页表结构:为子进程构建独立的页表层级,将PTE都指向与父进程相同的物理页帧。
- 设置写保护 :对于私有可写映射(如匿名数据段、
MAP_PRIVATE映射),将父子进程对应PTE的写权限清空,标记为只读。 - 建立COW标记:在内核层面标记这些映射为写时复制映射。
完成后,父子进程拥有独立的地址空间视图、独立的页表,但物理页面是共享的。
4.3 写入触发 COW 故障的完整流程
当子进程(或父进程)尝试向共享页写入时:
- CPU检测权限冲突:写入指令经过MMU翻译时,发现PTE存在但写权限为0,触发保护型Page Fault。
- 陷入内核:CPU保存现场,将错误地址存入CR2,携带错误码进入内核缺页处理程序。
- 内核识别COW场景:内核查找地址对应的VMA,确认这是合法的私有可写映射,且当前处于写保护共享状态,判定为COW故障。
- 分配新物理页:分配一个新的物理页帧。
- 复制页面内容:将旧物理页的内容复制到新页(对于4KB页面即复制4096字节)。
- 更新页表项:修改写入方的PTE,使其指向新物理页,并恢复写权限。
- 刷新TLB:清除该虚拟地址对应的旧TLB条目,确保后续翻译使用新的PTE。
- 返回用户态:恢复现场,重新执行触发故障的写入指令。
至此,父子进程各自拥有独立的物理页,地址空间的隔离性得到保证。这就是"写时复制"名称的由来:复制不在fork时发生,而在第一次写入时发生。
4.4 fork 不是 O(1)
fork的主要成本包括复制父进程的页表以及创建子进程的内核数据结构。对于一个拥有10GB驻留内存的进程,按4KB页面计算约有262万个页表项,复制这些页表项、建立VMA结构、维护反向映射、处理TLB同步等都会产生显著开销。
4.5 两种典型后续场景
- 场景A:fork后立即exec :子进程很快执行
execve替换地址空间,绝大多数共享页根本不会被写入,COW避免了几乎所有的数据复制,这也是传统fork()+exec()模式高效的核心原因。 - 场景B:子进程修改大量内存:如果子进程写入所有驻留页,每个页面都会触发一次COW故障,最终物理内存占用接近翻倍。此时COW只是把复制的时机从fork延迟到了写入,并没有消除复制本身。
COW优化的是"永远不会被修改的页"的复制开销,而不是消除所有复制
五、mmap()
mmap 的定义是:在调用进程的虚拟地址空间中创建一个新的内存映射。理解了VMA和页表的分层后,mmap就非常容易纳入整个体系。
5.1 mmap映射
映射可以从两个维度分类:
- 按后备存储分
- 匿名映射(
MAP_ANONYMOUS):没有普通文件作为后备存储,内容初始化为0。 - 文件映射:以普通文件或设备文件作为后备存储,映射内容对应文件的指定偏移范围。
- 匿名映射(
- 按共享语义分
- 私有映射(
MAP_PRIVATE):修改对其他进程不可见,文件映射不会写回底层文件。这类映射使用写时复制机制。 - 共享映射(
MAP_SHARED):修改对共享该映射的所有进程可见,文件映射会写回底层文件。这类映射不使用COW。
- 私有映射(
5.2 mmap 与 fork、COW 的关联
mmap :内存映射会跨fork保留,并且保持相同的属性。
结合COW机制可以得到完整的链路:
创建 MAP_PRIVATE | MAP_ANONYMOUS 私有匿名映射
│
▼
fork()
│
▼
父子共享物理页面,PTE只读
│
▼
任一方写入 → 保护缺页 → COW
匿名映射以及大多数MAP_PRIVATE的文件映射,都是可能发生COW故障的映射;而MAP_SHARED映射不属于COW映射。
六、fork 一个 10GB 进程会怎样?
问题:fork一个占用10GB虚拟地址空间、8GB物理驻留内存的进程,会立即复制10GB数据吗?
回答:
不会立即复制全部用户数据。Linux的fork()采用Copy-on-Write机制,fork完成后父子进程拥有独立的虚拟地址空间和独立的页表结构,但所有私有可写页面的PTE最初都指向相同的物理页帧,且被标记为只读。
当父进程或子进程尝试写入这些共享页时,MMU会检测到权限不匹配,触发保护型页错误。内核的缺页处理程序确认这是合法的COW映射后,会分配新的物理页、复制原页内容、更新写入方的页表项并刷新TLB,之后程序继续执行。
但fork也绝非O(1)操作。它仍然需要复制所有VMA元数据、复制各级页表条目、创建子进程的内核调度结构、维护反向映射,并且需要处理TLB刷新与缓存一致性问题。对于8GB驻留内存的进程,仅页表复制就需要处理数百万个PTE,会产生可观测的延迟。
如果子进程随后调用exec替换地址空间,那么大部分页面都不会被复制,COW的收益最大化;如果子进程修改大部分驻留内存,那么每个被修改的页面都会产生一次COW故障和页面复制,最终物理内存占用会接近翻倍。
延伸追问的应答链:
- 问:为什么写入会产生页错误?
答:因为fork时将共享页的PTE写权限清空了,写入操作与PTE权限冲突,触发保护型页错误。 - 问:修改页表权限后为什么要刷新TLB?
答:如果TLB中已经缓存了旧的可写映射,CPU会继续使用过期的TLB条目,导致COW机制失效。修改PTE后必须无效化对应的TLB条目。 - 问:TLB Miss就是页错误吗?
答:不是。TLB Miss后CPU会自动执行硬件页表遍历;只有页表遍历后发现PTE不存在或权限违规,才会触发页错误。 - 问:mmap和这个有什么关系?
答:mmap的作用是创建VMA,建立虚拟地址空间的逻辑映射;物理页和PTE可以通过请求调页在首次访问时建立。MAP_PRIVATE映射还会启用写时复制机制。 - 问:页错误一定会访问磁盘吗?
答:不一定。COW、匿名页分配、页缓存命中都属于次缺页,不需要磁盘I/O;只有需要从磁盘读入文件或从swap换入时,才是主缺页。
写在最后
掌握上述完整链路后,还可以继续向更深层拓展:
- x86-64地址编码细节:虚拟地址如何拆分为各级页表索引,PTE中各个标志位(Present、R/W、U/S、Accessed、Dirty等)的含义与作用。
- 反向映射(rmap):内核如何从物理页反向找到所有映射它的VMA,这是页回收、swap的基础。
- 透明巨页(THP):巨页模式下页表层级减少、TLB命中率提升,对COW与缺页路径的影响。
- 页回收与swap:物理内存不足时,内核如何选择回收页、如何写回磁盘或交换分区。
虚拟内存是操作系统最核心的抽象之一,是硬件架构、内核设计、进程模型、系统调用层层协作的系统工程。当把fork、mmap、page fault、TLB、页表、COW从六个孤立的知识点,串成一条从CPU指令到物理内存的完整执行链路时,才算真正理解了虚拟内存的运行机制。