一、从一次缺页异常说起

当CPU访问一个虚拟地址时,发现该地址对应的页表项不存在或权限不足,硬件会触发缺页异常(Page Fault),CPU陷入内核态,将控制权交给操作系统的缺页异常处理程序。这个看似"异常"的机制,实际上是现代操作系统虚拟内存系统最核心的基石——它实现了按需分配物理内存、共享内存、写时复制、内存映射文件等一系列关键能力。

在Linux内核中,缺页异常处理的代码路径长达数千行,涉及硬件相关层(架构特定处理)、管理层(VMA查找/扩展)和底层(物理页分配与数据读取)。本文从硬件机制出发,深入剖析x86-64架构下的页表结构、缺页异常分类与处理全链路、mmap内存映射机制、写时复制(COW)、透明大页(THP)以及生产环境调优实战。

二、x86-64 四级页表结构

x86-64架构使用四级页表将虚拟地址转换为物理地址:

Virtual Address (48-bit effective):
┌─────────┬─────────┬─────────┬─────────┬──────────┐
│  PML4   │  PDPT   │   PD    │   PT    │  Offset  │
│ 9 bits  │ 9 bits  │ 9 bits  │ 9 bits  │ 12 bits  │
└────┬────┴────┬────┴────┬────┴────┬────┴────┬─────┘
     │         │         │         │         │
     ▼         ▼         ▼         ▼         ▼
  PML4表    PDPT表     PD表      PT表    物理页内偏移
  (512项)   (512项)   (512项)   (512项)   (4KB)
  CR3寄存   每个PML4项  每个PDPT项 每个PD项
  器指向    指向PDPT   指向PD    指向PT

每一级都有512个条目(9 bits),每个条目8字节,正好是一页(4KB)。每个页表项包含:

  • 物理页帧号(PFN):目标页的物理地址
  • Present (P) 位:1表示映射存在,0触发Page Fault
  • Read/Write (R/W):0表示只读,写操作触发Page Fault
  • User/Supervisor (U/S):0表示仅内核可访问,用户态访问触发#PF
  • Accessed (A) 位:硬件自动置1,内核用其判断页面活跃度
  • Dirty (D) 位:硬件在写入时置1,用于判断是否需要写回
  • PCD/PWT:控制页级缓存策略

当MMU遍历四级页表时,任何一级发现Present=0,就会触发Page Fault(中断向量14),错误码压栈包含:bit 0 (P)、bit 1 (W/R)、bit 2 (U/S)、bit 4 (I/D)。

三、缺页异常的三种类型

3.1 次要缺页(Minor Page Fault / Soft Fault)

页面已在内存中(page cache或swap cache),但页表项未建立映射。典型场景:

  • 首次访问mmap映射的文件,数据已在page cache但未映射到进程地址空间
  • 共享内存重新连接(shmat)后的首次访问
  • fork后COW页面的首次写入(页面已分配,只需复制页表项权限)

处理代价极低:只需在内核态查找page cache中的物理页,映射到进程页表,耗时微秒级。

3.2 主要缺页(Major Page Fault / Hard Fault)

页面不在内存中,需要从磁盘(块设备/文件系统)读取。典型场景:

  • 首次读取文件内容(mmap或read()导致按需分页)
  • 被swap出去的页面再次被访问
  • 可执行文件/动态库的代码段首次执行

处理代价高昂:需要发起块I/O请求,进程阻塞等待磁盘读取完成(机械盘ms级,SSD百μs级)。内核通过预读(readahead)和页缓存命中率来缓解。

3.3 无效缺页(Invalid Page Fault / Segmentation Fault)

访问的地址没有任何合法的VMA映射。内核向进程发送SIGSEGV信号,导致段错误。典型场景:

  • 空指针解引用 (NULL pointer dereference)
  • 栈溢出突破了栈的VMA边界
  • 访问已munmap的内存区域
  • 越界访问(buffer overflow到未映射地址)

四、do_page_fault 全链路深度剖析

4.1 入口:硬件陷入到内核

硬件自动完成:保存RIP/RSP/错误码到栈 → 切换到内核栈 → 跳转到架构特定入口 page_fault(汇编)。入口代码(arch/x86/entry/entry_64.S)将错误码和故障地址(CR2寄存器)打包,调用C函数 do_page_fault()。

4.2 第一阶段:查找VMA

// arch/x86/mm/fault.c (简化流程)
static noinline void
do_page_fault(struct pt_regs *regs, unsigned long error_code)
{
    struct vm_area_struct *vma;
    unsigned long address = read_cr2();  // 获取故障虚拟地址

    // 1. 在进程地址空间中查找包含该地址的VMA
    vma = find_vma(mm, address);
    if (!vma || vma->vm_start <= address)
        goto bad_area;  // 无有效VMA → SIGSEGV

    // 2. 权限检查
    if (error_code & PF_WRITE) {  // 写操作
        if (!(vma->vm_flags & VM_WRITE))
            goto bad_area;  // 写只读区域 → SIGSEGV
    }
    if (error_code & PF_USER) {   // 用户态访问
        if (!(vma->vm_flags & VM_READ))
            goto bad_area;
    }
    ...
}

find_vma()使用VMA红黑树(mm_rb)进行O(log n)查找,同时利用 mmap_cache(最近访问的VMA缓存)做O(1)的快速路径优化。

4.3 第二阶段:处理缺页(handle_mm_fault)

通过权限检查后,进入通用的 handle_mm_fault(),这是架构无关的缺页处理核心:

// mm/memory.c
vm_fault_t handle_mm_fault(struct vm_area_struct *vma, unsigned long address,
                           unsigned int flags)
{
    // 1. 处理透明大页 (THP)
    if (transparent_hugepage_enabled(vma))
        return do_huge_pmd_anonymous_page(...);  // 尝试分配2MB大页

    // 2. 四级页表逐级分配与填充
    //    pgd → p4d → pud → pmd → pte
    return __handle_mm_fault(vma, address, flags);
}

4.4 第三阶段:__handle_mm_fault 页表遍历

static vm_fault_t __handle_mm_fault(struct vm_area_struct *vma, ...)
{
    // 逐级遍历页表,按需分配各级页表结构
    pgd = pgd_offset(mm, address);       // 获取PML4项
    p4d = p4d_alloc(mm, pgd, address);   // 若不存在则分配
    pud = pud_alloc(mm, p4d, address);   // PDPT项不存在则分配
    pmd = pmd_alloc(mm, pud, address);   // PD项不存在则分配

    // 最终到达PMD层,根据类型分发
    if (flags & FAULT_FLAG_WRITE) {
        // 写时复制处理
        if (vma_is_anonymous(vma))
            return do_wp_page(...);     // COW: 复制物理页
    }
    // 文件映射的缺页
    return do_fault(vma, address, pmd, pte, flags);
}

4.5 do_fault:文件-backed缺页的三种细分

static vm_fault_t do_fault(struct vm_area_struct *vma, ...)
{
    // 1. do_read_fault: 文件映射的读缺页
    //    - 先从page cache查找,找到直接映射(minor fault)
    //    - 未找到则从磁盘读取,可能触发readahead预读
    //
    // 2. do_cow_fault: 文件映射的写缺页(私有映射 MAP_PRIVATE)
    //    - 复制物理页 → 允许写入 → 原始页保持干净
    //    - 这就是写时复制在文件映射中的体现
    //
    // 3. do_shared_fault: 文件映射的写缺页(共享映射 MAP_SHARED)
    //    - 写入直接反映到page cache → 由pdflush写回磁盘
}

五、mmap 内存映射机制

5.1 mmap 的两种类型

文件映射(File-backed Mapping):将文件内容直接映射到进程虚拟地址空间,访问文件就像访问内存一样,无需read/write系统调用。内核使用page cache作为中间层,写入通过mmap直接修改page cache,由后台线程写回。

匿名映射(Anonymous Mapping):不对应任何文件,初始全部为零的物理页。用于malloc大内存(>128KB走mmap而非brk)、共享内存等场景。

5.2 VMA(Virtual Memory Area)核心结构

struct vm_area_struct {
    unsigned long vm_start;       // VMA起始地址
    unsigned long vm_end;         // VMA结束地址
    struct mm_struct *vm_mm;      // 所属进程内存描述符
    pgprot_t vm_page_prot;        // 访问权限
    unsigned long vm_flags;       // VM_READ/WRITE/EXEC/SHARED等
    struct rb_node vm_rb;         // 红黑树节点(按地址排序)
    struct vm_operations_struct *vm_ops;  // 操作函数表
    unsigned long vm_pgoff;       // 文件内偏移(文件映射时)
    struct file *vm_file;         // 映射的文件(匿名映射为NULL)
    const struct vm_operations_struct *vm_ops;
};

进程的所有VMA通过两种结构管理:红黑树(addr排序,支持O(log n)查找)和链表(支持顺序遍历)。find_vma优先查mmap_cache缓存,命中则O(1)返回。

5.3 mmap 生命周期与缺页驱动的懒加载

mmap调用本身不分配物理页——它只是创建VMA。物理页面的分配完全由缺页异常驱动(demand paging):

用户调用: mmap(NULL, 40960, PROT_READ, MAP_PRIVATE, fd, 0)
    ↓
内核: sys_mmap() → do_mmap() → mm->mmap() → 创建VMA
    ↓ (无物理页分配,不读文件)
进程访问 *ptr = 0x7f0000 → #PF(Present=0)
    ↓
do_page_fault() → handle_mm_fault()
    ↓
do_read_fault(): 分配物理页 → 读文件4KB到page cache → 映射到页表
    ↓
返回用户态,指令重新执行(硬件自动重试)

5.4 MAP_PRIVATE vs MAP_SHARED 的缺页差异

场景首次读首次写页回收
MAP_PRIVATE映射page cache(minor)COW:复制页(minor→需分配)丢弃(重新从文件读)
MAP_SHARED映射page cache(minor)直接写page cache(minor)写回脏页到文件

六、写时复制(Copy-on-Write)

6.1 fork 的本质

fork()并不复制父进程的物理内存。内核创建子进程的mm_struct,复制父进程的所有VMA,将父子进程的页表项全部标记为只读(清W位)。父子共享相同的物理页(引用计数+1)。任一方尝试写入时触发COW Page Fault:

进程写操作(#PF, error_code: P=1, W=1)
    ↓
do_page_fault() → handle_mm_fault()
    ↓
wp_page_reuse() 或 do_wp_page():
    ├─ 如果页面引用计数=1(独占):直接恢复写权限(reuse优化)
    └─ 如果页面引用计数>1(共享):
        1. 分配新的物理页
        2. 复制原页面内容到新页面
        3. 将新页面映射到写者页表,标记为可写
        4. 原页面引用计数-1

6.2 独立映射优化

Linux内核在wp_page_copy()中利用page->_mapcount实现了一个巧妙优化:当引用计数为-1(表示没有其他映射)时,内核直接恢复写权限,避免了不必要的物理页复制。因为如果一个页面只被当前进程映射,"只读"限制就是多余的了。

七、透明大页(Transparent Huge Pages)

7.1 2MB大页对缺页性能的提升

标准4KB页表需要4级遍历(~4次内存访问),2MB大页只需3级遍历(跳过PT级且使用PMD层的Huge Page标志)。收益包括:

  • 减少页表遍历次数,TLB命中效率更高(一个TLB条目覆盖2MB而非4KB)
  • 减少缺页异常次数(一次大页故障覆盖2MB,相当于512次4KB故障的映射)
  • 减少页表内存开销(大页只需要pgd+pud+pmd,不分配pt表)

7.2 MADV_HUGEPAGE 与 khugepaged

内核通过两种机制使用大页:

  • 显式:madvise(addr, len, MADV_HUGEPAGE) 建议内核对该区域使用大页
  • 隐式:khugepaged内核线程定期扫描进程内存,后台合并符合条件的4KB页为2MB大页

但大页对某些负载有负面影响(内存浪费、碎片化、compaction开销),数据库和虚拟机监控程序常建议关闭THP(echo never > /sys/kernel/mm/transparent_hugepage/enabled)。

八、内核关键函数调用链

CPU #PF中断
  → entry_SYSCALL (entry_64.S: 汇编入口,压错误码)
    → do_page_fault() [arch/x86/mm/fault.c]
      → find_vma() / vma_lookup()     — VMA查找(红黑树+缓存)
      → handle_mm_fault() [mm/memory.c]
        → create_huge_pmd()            — THP路径
        → __handle_mm_fault()
          → pmd_alloc() / pud_alloc()  — 页表结构分配
          → handle_pte_fault()
            ├── do_anonymous_page()    — 匿名页首次访问
            │     → alloc_pages() + 零初始化
            ├── do_fault()
            │     → do_read_fault()     — 文件读缺页
            │     │     → filemap_fault() — 查找page cache
            │     ├── do_cow_fault()    — COW
            │     │     → alloc_page() + copy_page()
            │     └── do_shared_fault() — 共享写
            └── do_wp_page()           — 写时复制

九、生产环境调优实战

9.1 系统级参数

# 页缓存回收倾向:0-100,越低越倾向于回收page cache而非swap
vm.swappiness = 10      # 数据库/缓存场景设为10
vm.swappiness = 60      # 桌面/通用场景默认60

# 脏页写回策略
vm.dirty_ratio = 20             # 单个进程脏页占系统内存的最大比例
vm.dirty_background_ratio = 10  # 后台写回触发阈值
vm.dirty_expire_centisecs = 3000  # 脏页过期时间(30秒)
vm.dirty_writeback_centisecs = 500  # pdflush唤醒间隔(5秒)

# mmap最小映射地址(影响ASLR)
vm.mmap_min_addr = 65536

# overcommit策略:0=启发式 1=总是允许 2=严格检查
vm.overcommit_memory = 0

9.2 进程级诊断工具

# 查看进程缺页统计
$ ps -o min_flt,maj_flt -p $(pidof nginx)
MINFL  MAJFL
123456 789        # min=minor fault次数, maj=major fault次数

# /proc/pid/smaps_rollup: 详细内存映射统计
$ cat /proc/$(pidof myapp)/smaps_rollup | grep -E "Rss|Pss|Swap|LazyFree"

# /proc/vmstat: 系统级缺页计数器
$ grep -E "pgfault|pgmajfault|pgfree" /proc/vmstat
pgfault 123456789    # 总缺页次数(含minor)
pgmajfault 12345     # 主要缺页次数(需读盘)

# perf统计缺页事件
$ perf stat -e page-faults,minor-faults,major-faults -p PID

# /proc/pid/pagemap: 查看虚拟地址到物理地址的映射
# 需解析64-bit pagemap entry

9.3 mass预加载:消除启动时Major Fault

应用启动时大量Major Fault导致延迟尖刺?用madvise(MADV_WILLNEED)预加载热数据:

// 提示内核:即将访问此区域,触发异步预读
madvise(addr, length, MADV_WILLNEED);

// 对于私有映射的内存,提示内核:即将写入,提前触发COW避免运行时中断
madvise(addr, length, MADV_HUGEPAGE);  // 合并2MB大页减少TLB miss

9.4 perf+ltrace追踪缺页完整路径

# 追踪缺页异常的完整内核栈
perf record -e page-faults -ag -- ./myapp
perf report --sort=comm,dso,symbol

# ftrace 追踪handle_mm_fault延迟
echo handle_mm_fault > /sys/kernel/debug/tracing/set_ftrace_filter
echo function > /sys/kernel/debug/tracing/current_tracer

十、典型场景故障排除

10.1 应用启动缓慢

症状:进程启动需要数秒,ps -o maj_flt持续增长。原因:大量文件映射的首次访问导致Major Fault排队。

解决:启用readahead(madvise MADV_WILLNEED)、预加载关键数据文件、确保link顺序热代码优先。

10.2 周期性卡顿

症状:运行数十分钟后突然卡顿数秒。原因:khugepaged尝试合并大页时触发内存compaction,阻塞缺页路径。

解决:数据库场景关闭THP(madvise MADV_NOHUGEPAGE关键数据区),使用静态大页(hugetlbfs)替代。

10.3 OOM前的异常模式

症状:swap使用稳步上升,Major Fault频率急剧增加。原因:工作集超过物理内存,page回收速率跟不上分配速率。

解决:降低swappiness延长page cache驻留、检查内存泄漏(smaps中持续增长的匿名映射)、限制进程cgroup内存。

10.4 COW风暴(fork-intensive场景)

症状:fork密集导致大量Minor Fault,CPU利用率偏高。原因:大量进程共享同一组物理页,任何写入都触发页面复制。

解决:关键大内存区域使用MAP_SHARED避免COW、vfork替代fork+exec模式、posix_spawn替代fork。

十一、与前序文章的关联

本文讨论的缺页异常正是虚拟内存系统的"最后一公里"——前面姊妹篇《Linux内核内存页回收与Swap机制深度实战》(ID 18005)讨论了物理内存不足时如何选择牺牲页并写回Swap,而本文探讨的正是当数据被换出后如何恢复映射、以及首次访问时如何把文件数据搬进page cache。

结合《Linux内核进程调度器深度实战》(ID 18008)中的进程唤醒机制:当进程从I/O阻塞(等待major fault的磁盘读完成)中被唤醒时,正是通过scheduler的pick_next_task选择一个就绪进程继续执行,I/O-bound进程的vruntime增长较慢从而获得更多CPU时间——这是CFS/EEVDF与缺页机制的协同。

三篇文章构成了内核三大子系统系列的完整闭环:分配(Swap/Buddy) → 映射(mmap/PF) → 调度(CFS/EEVDF)。

十二、总结

缺页异常从表面看是CPU的"异常处理",实质上它是操作系统实现虚拟内存按需分配、共享、写时的核心机制。理解do_page_fault的全链路——从硬件陷入、VMA查找、页表遍历到物理页分配——是诊断内存相关性能问题和段错误的基础。

生产环境中的关键认知:

  • Minor Fault频繁是正常的(demand paging设计使然),Major Fault才是性能瓶颈的信号
  • THP不是万能的——数据库和高性能网络栈常有反直觉的THP关闭需求
  • COW优化(独立映射reuse)是fork性能的关键
  • swap配置需要与具体负载特性匹配合,不存在通用最优值

全文以x86-64平台为参考,ARM64/ARM架构的页表结构和异常向量有差异但核心逻辑一致。掌握缺页异常的全链路,是通往Linux内核精深理解的关键一步。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部