Linux --- 虚拟内存 1.fork、2.COW、3.mmap、4.page fault、5.TLB、6.页表

在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 页表遍历的三种结果

硬件页表遍历完成后,会出现三种结果:

  1. PTE有效且权限允许:翻译成功,将结果填入TLB,访问物理内存,指令正常执行。
  2. 映射合法但PTE不存在:虚拟地址属于某个VMA,但页表项为空,触发Page Fault,由内核分配物理页并建立映射。
  3. 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 时内核做了什么

  1. 复制VMA结构 :为子进程创建一套独立的vm_area_struct,描述与父进程相同的地址空间布局。
  2. 复制页表结构:为子进程构建独立的页表层级,将PTE都指向与父进程相同的物理页帧。
  3. 设置写保护 :对于私有可写映射(如匿名数据段、MAP_PRIVATE映射),将父子进程对应PTE的写权限清空,标记为只读。
  4. 建立COW标记:在内核层面标记这些映射为写时复制映射。

完成后,父子进程拥有独立的地址空间视图、独立的页表,但物理页面是共享的。

4.3 写入触发 COW 故障的完整流程

当子进程(或父进程)尝试向共享页写入时:

  1. CPU检测权限冲突:写入指令经过MMU翻译时,发现PTE存在但写权限为0,触发保护型Page Fault。
  2. 陷入内核:CPU保存现场,将错误地址存入CR2,携带错误码进入内核缺页处理程序。
  3. 内核识别COW场景:内核查找地址对应的VMA,确认这是合法的私有可写映射,且当前处于写保护共享状态,判定为COW故障。
  4. 分配新物理页:分配一个新的物理页帧。
  5. 复制页面内容:将旧物理页的内容复制到新页(对于4KB页面即复制4096字节)。
  6. 更新页表项:修改写入方的PTE,使其指向新物理页,并恢复写权限。
  7. 刷新TLB:清除该虚拟地址对应的旧TLB条目,确保后续翻译使用新的PTE。
  8. 返回用户态:恢复现场,重新执行触发故障的写入指令。

至此,父子进程各自拥有独立的物理页,地址空间的隔离性得到保证。这就是"写时复制"名称的由来:复制不在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映射

映射可以从两个维度分类:

  1. 按后备存储分
    • 匿名映射(MAP_ANONYMOUS):没有普通文件作为后备存储,内容初始化为0。
    • 文件映射:以普通文件或设备文件作为后备存储,映射内容对应文件的指定偏移范围。
  2. 按共享语义分
    • 私有映射(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换入时,才是主缺页。

写在最后

掌握上述完整链路后,还可以继续向更深层拓展:

  1. x86-64地址编码细节:虚拟地址如何拆分为各级页表索引,PTE中各个标志位(Present、R/W、U/S、Accessed、Dirty等)的含义与作用。
  2. 反向映射(rmap):内核如何从物理页反向找到所有映射它的VMA,这是页回收、swap的基础。
  3. 透明巨页(THP):巨页模式下页表层级减少、TLB命中率提升,对COW与缺页路径的影响。
  4. 页回收与swap:物理内存不足时,内核如何选择回收页、如何写回磁盘或交换分区。

虚拟内存是操作系统最核心的抽象之一,是硬件架构、内核设计、进程模型、系统调用层层协作的系统工程。当把fork、mmap、page fault、TLB、页表、COW从六个孤立的知识点,串成一条从CPU指令到物理内存的完整执行链路时,才算真正理解了虚拟内存的运行机制。

相关推荐
你看我名字1 小时前
LAMP 架构演练:从云主机准备到 Apache、PHP、MySQL 上线验证
运维·ci/cd·架构·kubernetes·jenkins
服务器硬件项目经理1 小时前
第3章GPU vs CPU:为什么AI训练要用GPU
服务器·人工智能·嵌入式硬件·gru·硬件工程·硬件
Techblog of HaoWANG1 小时前
Ubuntu 下将 Ollama 服务配置为监听 0.0.0.0:11434 完整实践
linux·数据库·ubuntu·ollama
似水流年QC1 小时前
LangChain 入门与实战
服务器·数据库·langchain
小HANN1 小时前
Ceph分布式存储深度解析-下篇
linux·运维·服务器·经验分享
张文君2 小时前
ubuntu iscsi客户端 关闭磁盘
linux·运维·ubuntu
凯基迪科技2 小时前
硬件做授权保护,有哪些常见方案
运维·服务器
优化Henry2 小时前
5G站点AAS-3设备故障排查与处理
运维·学习·5g·信息与通信
Jtti2 小时前
香港服务器CPU不高但用户请求排队的原因有哪些?
运维·服务器·网络