Linux内核学习轨迹第五部:缺页异常处理全链路深度解析(第七小节)
2026/6/7 10:58:07 网站建设 项目流程

7. 缺页异常处理全链路深度解析

缺页异常(Page Fault)是Linux虚拟内存机制的核心,是实现延迟分配、写时复制、页缓存、swap交换的基础。当进程访问的虚拟地址没有建立页表映射,或者访问权限不匹配时,CPU会触发缺页异常,陷入内核,由内核的缺页异常处理函数完成物理内存分配、页表映射、权限调整等操作,异常处理完成后,CPU重新执行触发异常的指令。
很多工程师对缺页异常的认知停留在「分配内存」的表层,却不理解异常的分类、全链路处理流程、不同场景的处理逻辑,最终导致程序性能问题、OOM、段错误无法定位。本章节基于Linux 6.6 LTS内核,完整拆解缺页异常的触发、分类、全链路处理流程、不同场景的实现细节。

7.1 缺页异常的触发与分类

7.1.1 异常触发的底层原理

当进程访问一个虚拟地址时,CPU的MMU硬件会遍历进程的页表,完成虚拟地址到物理地址的转换,如果出现以下两种情况,会触发缺页异常:
  1. 页不存在:虚拟地址对应的页表项为空(P位为0),没有映射到物理页,或者物理页已经被换出到swap分区;
  2. 权限错误:虚拟地址对应的页表项的权限位,与访问类型不匹配,比如访问只读的页、用户态访问内核地址、不可执行的页尝试执行指令。
CPU触发缺页异常后,会把触发异常的虚拟地址、访问类型(读/写/执行)、异常错误码存入对应的寄存器,然后陷入内核,执行内核的缺页异常处理函数。

7.1.2 缺页异常的分类

Linux内核根据异常的原因和处理方式,把缺页异常分为三大类:

次缺页异常(Minor Fault):不需要访问磁盘,直接从内存中分配物理页,或者复用已有的物理页,处理速度极快。常见场景:

  1. 匿名映射的首次访问,分配空白物理页;
  2. 写时复制(COW)的缺页异常,复制已有的物理页;
  3. 访问已经在页缓存中的文件页,只需要建立页表映射;
  4. 同一块物理页的共享映射,建立页表映射。

主缺页异常(Major Fault):需要访问磁盘,等待IO完成,处理速度慢,延迟是次缺页的上千倍。常见场景:

  1. 访问的文件页不在页缓存中,需要从磁盘读取;
  2. 访问的匿名页已经被换出到swap分区,需要从swap分区读入内存;
  3. 访问的大页需要从磁盘加载。

无效缺页异常(Invalid Fault):非法的内存访问,内核会给进程发送SIGSEGV信号,终止进程,也就是段错误。常见场景:

  1. 访问不存在的虚拟地址,没有对应的VMA;
  2. 访问权限不匹配,比如写只读的代码段、用户态访问内核地址;
  3. 访问已经释放的内存,VMA已经被munmap移除。

7.2 缺页异常的核心入口与错误码解析

x86_64架构下,缺页异常的内核入口是do_page_fault()函数,定义在arch/x86/mm/fault.c中,最终会调用通用的handle_mm_fault()函数,完成异常的核心处理。

7.2.1 异常错误码解析

CPU触发缺页异常时,会把异常的详细信息存入错误码寄存器,错误码的每个bit对应异常的类型,是内核判断异常处理逻辑的核心依据,x86_64架构的错误码核心位定义:

名称

含义

0

PF_PROT

0:页不存在导致的异常;1:权限错误导致的异常

1

PF_WRITE

0:读访问触发的异常;1:写访问触发的异常

2

PF_USER

0:内核态触发的异常;1:用户态触发的异常

3

PF_RSVD

1:页表项的保留位被设置,非法页表

4

PF_INSTR

1:取指令触发的异常,也就是执行不可执行的页,NX保护触发

5

PF_PK

1:保护密钥违规触发的异常

6

PF_SGX

1:SGX安全区访问违规

内核会根据错误码,快速判断异常的类型,决定处理逻辑:如果是权限错误,直接发送SIGSEGV信号;如果是页不存在,继续处理,分配物理页、建立页表映射。

7.2.2 缺页异常的整体执行流程

CPU触发缺页异常 → 陷入内核 → 执行do_page_fault()
1. 读取触发异常的虚拟地址address、错误码error_code
2. 异常上下文合法性检查
├→ 如果是内核态异常,检查是否是内核预留地址、是否是copy_from_user等安全操作
├→ 如果是用户态异常,检查进程是否有有效的mm_struct,是否在原子上下文
└→ 非法上下文,直接跳转到异常处理
3. 查找虚拟地址对应的VMA
├→ 加mmap_lock读锁,在进程的mm_rb红黑树中查找address对应的VMA
├→ 如果没有找到对应的VMA,说明是非法地址,跳转到无效异常处理,发送SIGSEGV
└→ 找到VMA,继续检查访问权限是否合法
4. 访问权限检查
├→ 检查VMA的vm_flags是否匹配访问类型(读/写/执行)
├→ 如果是写访问,检查VMA是否有VM_WRITE标志,没有则跳转到无效异常
├→ 如果是执行访问,检查VMA是否有VM_EXEC标志,没有则跳转到无效异常
└→ 权限合法,进入核心处理函数handle_mm_fault()
5. handle_mm_fault()核心处理
├→ 处理透明大页的缺页异常
├→ 调用handle_pte_fault(),处理页表项级别的异常
│ ├→ 场景1:页表项为空,匿名映射 → 调用do_anonymous_page(),分配匿名页
│ ├→ 场景2:页表项为空,文件映射 → 调用do_fault(),从文件读取页缓存
│ ├→ 场景3:页表项存在,写时复制 → 调用do_wp_page(),处理COW异常
│ └→ 场景4:页表项存在,页被换出swap → 调用do_swap_page(),从swap读入页
├→ 建立页表映射,更新TLB
└→ 返回异常处理结果
6. 异常处理完成,释放mmap_lock读锁,返回用户态,CPU重新执行触发异常的指令
7. 异常处理失败:无效异常、OOM等,发送SIGSEGV/SIGBUS信号,终止进程

7.3 核心场景的缺页异常处理详解

我们拆解4种最常见的缺页异常场景的内核实现细节,覆盖绝大多数用户态程序的内存访问场景。

7.3.1 匿名页缺页异常:do_anonymous_page()

当进程首次访问匿名映射的虚拟地址(比如malloc分配的内存),页表项为空,触发匿名页缺页异常,由do_anonymous_page()函数处理,这是最常见的次缺页异常。
核心执行流程
  1. 检查VMA是否是匿名映射,vm_file为NULL,anon_vma不为NULL;
  2. 检查是否有足够的空闲内存,如果内存不足,触发内存回收、OOM Killer;
  3. 调用alloc_zeroed_user_highpage_movable(),从伙伴系统分配一个物理页,并且初始化为0,避免内核数据泄露;
  4. 调用page_add_new_anon_rmap(),建立反向映射,把物理页和VMA、虚拟地址关联起来;
  5. 调用mk_pte()创建页表项,设置页的权限位(可读、可写、用户态可访问);
  6. 把页表项写入进程的页表,刷新TLB;
  7. 更新进程的内存统计信息,total_vm、rss计数加1;
  8. 异常处理完成,返回用户态。
核心工程细节
  1. 匿名页缺页异常是次缺页,不需要访问磁盘,处理速度极快;
  2. 分配的物理页会被初始化为0,所以malloc分配的内存,首次访问时内容都是0,不需要手动初始化;
  3. 如果设置了MAP_POPULATE标志,mmap时会预分配匿名页,不会触发缺页异常。

7.3.2 写时复制缺页异常:do_wp_page()

写时复制(Copy-On-Write, COW)是Linux内核的核心优化机制,用于减少不必要的物理页复制,提升内存利用率,常见场景:
  1. fork()创建子进程后,父子进程共享所有物理页,页表项设置为只读,当其中一个进程尝试写入时,触发COW缺页异常;
  2. 私有文件映射(MAP_PRIVATE),进程写入映射的内存时,触发COW异常,复制物理页,修改不会同步到磁盘文件;
  3. 共享库的代码段,多个进程共享同一个物理页,只读访问。
COW异常的核心处理函数是do_wp_page(),属于次缺页异常,不需要访问磁盘。
核心执行流程

1.检查页表项是否是只读的,访问类型是写操作,确认是COW异常;

2.获取物理页的struct page结构体,检查页的引用计数_mapcount和_refcount;

3.如果页的引用计数为1,说明只有当前进程映射了这个页,不需要复制,直接修改页表项为可写,刷新TLB,处理完成;

4.如果页的引用计数大于1,说明有多个进程共享这个页,需要执行复制:

  1. 调用alloc_page()分配一个新的物理页;
  2. 把原页的内容复制到新页;
  3. 调用page_add_new_anon_rmap(),为新页建立反向映射;
  4. 修改页表项,指向新的物理页,设置为可写,刷新TLB;
  5. 原页的引用计数减1,如果为0,释放原页;

5.更新进程的内存统计信息,异常处理完成。

核心工程细节
  1. fork()之后,父子进程共享所有物理页,只有写入时才会复制,极大降低了fork的开销,哪怕父进程占用几十GB内存,fork也能在毫秒级完成;
  2. 多进程共享同一个共享库的代码段,只读访问,不会触发COW,极大节省了物理内存;
  3. 避坑指南:fork之后,父子进程同时修改同一个全局变量,会触发大量的COW异常,导致性能下降,应该用进程间共享内存,或者fork之后立即exec,避免不必要的COW。

7.3.3 文件映射缺页异常:do_fault()

当进程访问文件映射的虚拟地址,对应的文件页不在页缓存中时,触发文件映射缺页异常,由do_fault()函数处理,通常是主缺页异常,需要访问磁盘。
核心执行流程

1.检查VMA的vm_file不为NULL,确认是文件映射,获取文件对应的address_space地址空间;

2.计算虚拟地址在文件内的偏移量,查找页缓存中是否有对应的页;

3.如果页已经在页缓存中(已经被其他进程预读/访问过),直接建立页表映射,次缺页处理完成;

4.如果页不在页缓存中,触发主缺页异常:

  1. 调用filemap_fault(),从磁盘读取文件内容到页缓存;
  2. 等待磁盘IO完成,页标记为PG_uptodate;
  3. 建立页表映射,设置对应的权限位;

5.如果是私有文件映射,页表项设置为只读,后续写入时触发COW异常;如果是共享映射,页表项设置为可写,修改会同步到页缓存;

6.更新进程的主缺页/次缺页统计,异常处理完成。

核心工程细节
  1. 文件映射的缺页异常,绝大多数是主缺页,需要等待磁盘IO完成,延迟极高,是程序性能瓶颈的常见来源;
  2. 内核会对文件映射做预读,提前读取后续的页到页缓存,减少主缺页异常的次数,提升顺序访问的性能;
  3. 最佳实践:大文件随机访问时,用madvise(MADV_RANDOM)关闭预读,避免不必要的磁盘IO;顺序访问时,用madvise(MADV_SEQUENTIAL)开启更大的预读窗口,提升性能。

7.3.4 Swap缺页异常:do_swap_page()

当系统内存不足时,内核会把不活跃的匿名页换出到swap分区,释放物理内存;当进程再次访问这些被换出的页时,触发swap缺页异常,由do_swap_page()函数处理,属于主缺页异常,需要访问swap分区。
核心执行流程

1.检查页表项,确认页被换出到swap,从页表项中获取swap入口地址(swap分区号+槽位号);

2.查找swap缓存中是否有对应的页,如果有,直接建立页表映射,次缺页处理完成;

3.如果页不在swap缓存中,触发主缺页异常:

4.从swap分区对应的槽位中,读取页的内容到新分配的物理页;

  1. 等待磁盘IO完成,页标记为PG_uptodate;
  2. 建立反向映射,设置页表项,刷新TLB;
  3. 释放swap分区对应的槽位;

5.更新进程的主缺页统计,异常处理完成。

核心工程细节
  1. swap缺页异常需要访问磁盘,延迟极高,是系统内存不足时出现卡顿、抖动的核心原因;
  2. 系统出现大量swap缺页异常时,说明物理内存严重不足,需要优化程序内存占用,或者扩容物理内存;
  3. 最佳实践:数据库等延迟敏感的服务,应该用mlock锁定内存,禁止换出到swap,避免swap缺页异常导致的性能抖动。

7.4 工程实践与避坑指南

1.缺页异常的性能监控与排查

缺页异常,尤其是主缺页异常,是程序性能的核心瓶颈,我们可以通过工具监控进程的缺页异常情况:
  1. 全局监控:vmstat 1,查看si/so(swap换入换出)、bi/bo(磁盘IO)、in(中断),判断系统是否有大量主缺页异常;
  2. 进程级监控:ps -o min_flt,maj_flt ,查看进程的累计次缺页/主缺页次数;pidstat -d 1 -p ,查看进程的磁盘IO情况;
  3. 实时监控:perf stat -e page-faults,minor-faults,major-faults ./your_program,统计程序运行期间的缺页异常次数;
  4. trace跟踪:用ftrace跟踪handle_mm_fault函数,查看缺页异常的触发频率、处理耗时;
  5. 排查流程:如果程序运行卡顿,CPU利用率低,先查看主缺页次数是否过高,如果是,再排查是文件IO还是swap导致的,针对性优化。

2.减少缺页异常的性能优化手段

  1. 预分配物理内存:mmap时设置MAP_POPULATE标志,或者用mlock()锁定内存,预分配物理内存,建立页表映射,避免运行时触发缺页异常,适用于延迟敏感的实时程序;
  2. 优化文件预读:大文件顺序访问时,用madvise(MADV_SEQUENTIAL)开启更大的预读窗口,提前把文件内容加载到页缓存,减少主缺页异常;随机访问时,用madvise(MADV_RANDOM)关闭预读,避免不必要的磁盘IO;
  3. 大页优化:使用HugePage大页,一个大页对应2MB/1GB的虚拟地址空间,减少页表项的数量,降低缺页异常的次数,同时减少TLB miss,提升内存访问性能;
  4. 避免不必要的COW:fork之后,子进程立即调用execve(),避免父子进程同时修改内存,触发大量COW异常;多进程共享数据时,用共享内存(MAP_SHARED)替代私有映射,避免COW;
  5. 内存池复用:频繁分配释放的小内存,用内存池复用,避免频繁的mmap/munmap,减少缺页异常的次数。

3.OOM与缺页异常的关系

缺页异常处理时,如果系统没有足够的空闲内存,内核会先触发内存回收,如果回收后还是没有足够的内存,会触发OOM Killer,杀死进程释放内存。

避坑指南:程序启动时预分配内存,不会触发OOM,因为预分配只是创建VMA,没有分配物理内存;只有当真正访问内存,触发缺页异常时,才会分配物理内存,此时内存不足才会触发OOM,这就是为什么程序启动时正常,运行一段时间后被OOM杀死的核心原因。

4.段错误的精准定位

无效缺页异常会触发段错误,我们可以通过内核日志精准定位异常原因:

a.查看dmesg日志,找到类似如下的记录:

a.out[1234]: segfault at 0 ip 0000555555555123 sp 00007fffffffd120 error 4 in a.out[555555554000+1000]

b.解析日志:

segfault at 0:触发异常的虚拟地址是0,也就是空指针访问;

ip 0000555555555123:触发异常的指令地址;

error 4:错误码,对应PF_USER | PF_INSTR,用户态取指令触发的异常,也就是执行了非法地址;

常见错误码:

  1. error 6:PF_USER | PF_WRITE,用户态写访问触发的异常,比如写只读内存;
  2. error 7:PF_USER | PF_WRITE | PF_PROT,用户态写访问权限错误,比如写只读的代码段;
  3. error 14:PF_USER | PF_WRITE | PF_PROT,常见的写越界、释放后使用。

c.定位方法:用addr2line -e a.out 0000555555555123,把指令地址转换为对应的代码行号,精准定位崩溃位置。

5.缺页异常的安全机制

缺页异常是内核安全机制的核心载体,很多内存安全保护都是通过缺页异常实现的:

  1. NX位保护:不可执行的页(比如数据段、堆、栈),尝试执行时会触发权限错误的缺页异常,内核发送SIGSEGV信号,防范缓冲区溢出攻击;
  2. 栈保护页:栈的底部有不可访问的保护页,栈越界访问时触发缺页异常,终止进程,防止栈溢出攻击;
  3. KASLR:地址空间随机化,让攻击者无法预测内存地址,防范ROP攻击;
  4. Copy-on-Write的安全检查:COW异常处理时,会严格检查页的权限和引用计数,防止越权访问。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询