4. 缺页异常路径分析

缺页异常(Page Fault)是MMU与内核交互最频繁的路径之一。当CPU访问的虚拟地址在TLB或页表中不存在有效映射时,MMU触发缺页异常,由内核的缺页异常处理程序接管。本章深入分析核心处理函数 handle_mm_fault 的完整路径,并重点区分内核态缺页(fault_in_kernel_space)与用户态缺页(fault_in_user_space)的差异。

性能要点:缺页异常路径是内存子系统中最耗时的同步路径之一。一次缺页处理可能涉及VMA查找、物理页分配、页表填充、TLB刷新等操作,延迟通常在微秒到毫秒级。理解路径分支是性能优化的前提。

4.1 缺页异常入口与分发

缺页异常在x86架构中由中断向量14(#PF)触发,内核通过 do_page_fault 函数接收异常信息,包括触发地址(address)、错误码(error_code)和寄存器状态。核心分发逻辑如下:

// arch/x86/mm/fault.c (简化示意)
static inline bool fault_in_kernel_space(unsigned long address)
{
    return address >= TASK_SIZE_MAX;
}

static noinline void
__do_page_fault(struct pt_regs *regs, unsigned long error_code,
                unsigned long address)
{
    struct vm_area_struct *vma;
    struct mm_struct *mm = current->mm;
    unsigned int flags = FAULT_FLAG_DEFAULT;

    // 1. 判断地址空间
    if (fault_in_kernel_space(address)) {
        // 内核空间缺页路径
        if (vmalloc_fault(address) >= 0)
            return;
        // 处理内核空间其他异常
        handle_kernel_space_fault(regs, error_code, address);
        return;
    }

    // 2. 用户空间缺页路径
    mmap_read_lock(mm);
    vma = find_vma(mm, address);
    if (!vma || vma->vm_start > address) {
        // 无效地址 -> SIGSEGV
        mmap_read_unlock(mm);
        return;
    }
    // 3. 进入核心处理
    handle_mm_fault(vma, address, flags, regs);
    mmap_read_unlock(mm);
}
关键判断:fault_in_kernel_space 通过比较地址与 TASK_SIZE_MAX(x86_64上通常为0x00007fffffffffff)快速区分地址归属。内核空间地址(>= TASK_SIZE_MAX)走独立路径,用户空间地址走VMA查找路径。

4.2 handle_mm_fault 核心路径

handle_mm_fault 是缺页处理的核心枢纽,负责将缺页请求分发到具体的底层处理函数。其内部包含多个关键阶段:

阶段 函数 主要操作 性能开销
1. 页表层级遍历 __handle_mm_fault 逐级查找/创建页表项(PGD→P4D→PUD→PMD→PTE) 中等(内存访问+锁)
2. 写时复制(COW) do_wp_page 复制物理页、更新页表、TLB刷新 高(页分配+拷贝)
3. 匿名页缺页 do_anonymous_page 分配零页或新页、映射到用户空间 中(页分配+清零)
4. 文件映射缺页 do_fault 从文件系统读取页缓存、建立映射 高(I/O+页缓存操作)
5. 交换缺页 do_swap_page 从交换分区/文件读回物理页 非常高(块I/O)
// mm/memory.c (核心路径简化)
static vm_fault_t handle_pte_fault(struct vm_fault *vmf)
{
    pte_t entry = vmf->orig_pte;

    if (!pte_present(entry)) {
        // 页表项不存在或已换出
        if (pte_none(entry)) {
            // 匿名页缺页
            if (vma_is_anonymous(vmf->vma))
                return do_anonymous_page(vmf);
            else
                return do_fault(vmf); // 文件映射缺页
        } else {
            // 交换缺页
            return do_swap_page(vmf);
        }
    }

    // 页表项存在但权限不匹配
    if (vmf->flags & FAULT_FLAG_WRITE) {
        if (!pte_write(entry))
            return do_wp_page(vmf); // 写时复制
    }
    return 0;
}
handle_mm_fault 核心路径流程图 handle_mm_fault __handle_mm_fault (页表遍历) pte_present(entry)? 否 (缺页) 是 (权限检查) pte_none(entry)? 是 do_anonymous_page 否 do_fault (文件映射) pte_none为假 do_swap_page (交换缺页) FAULT_FLAG_WRITE? 是 do_wp_page (COW) 否 正常访问 (返回0) 返回 vm_fault_t 说明:缺页路径根据页表项存在性(pte_present)和权限标志分流,不同分支对应不同的物理页获取策略 性能开销从低到高:正常访问 < 匿名页 < COW < 文件映射 < 交换缺页

4.3 fault_in_kernel_space 路径分析

内核空间缺页(fault_in_kernel_space)通常由以下场景触发:

  • vmalloc区域缺页:vmalloc分配的虚拟地址在首次访问时,页表尚未建立,需要通过 vmalloc_fault 同步页表。
  • 内核模块代码/数据缺页:模块加载后,其映射的页表可能尚未完全填充。
  • fixmap/ioremap区域缺页:设备I/O内存映射的页表缺失。
  • 内核栈溢出:当内核栈扩展到guard page时触发。
// arch/x86/mm/fault.c
static noinline int vmalloc_fault(unsigned long address)
{
    pgd_t *pgd, *pgd_k;
    p4d_t *p4d, *p4d_k;
    pud_t *pud, *pud_k;
    pmd_t *pmd, *pmd_k;
    pte_t *pte_k;

    // 获取当前进程的页表
    pgd = pgd_offset(current->mm, address);
    // 获取内核主页表(init_mm)
    pgd_k = pgd_offset_k(address);

    if (!pgd_present(*pgd_k))
        return -1;

    // 逐级同步页表项
    p4d = p4d_offset(pgd, address);
    p4d_k = p4d_offset(pgd_k, address);
    if (!p4d_present(*p4d_k)) return -1;
    // ... 类似操作到 pte 级别
    return 0;
}
性能陷阱:内核空间缺页虽然发生频率远低于用户空间,但一旦发生,往往意味着内核路径存在设计缺陷(如vmalloc频繁缺页)。每次vmalloc_fault需要遍历内核主页表并同步到进程页表,在多核系统上可能引发TLB shootdown开销。

4.4 fault_in_user_space 路径分析

用户空间缺页是缺页异常的主要来源,占比通常超过99%。其路径包含以下关键步骤:

  1. VMA查找:通过 find_vma 在红黑树中查找地址对应的虚拟内存区域,时间复杂度O(log n)。
  2. 权限验证:检查VMA的读写执行权限与触发缺页的访问类型是否匹配。
  3. 页表填充:根据缺页类型(匿名页、文件映射、交换缺页、COW)执行具体操作。
  4. TLB刷新:在必要时通过 flush_tlb_page 或 flush_tlb_mm_range 刷新TLB。
缺页类型 触发条件 典型延迟 优化方向
匿名页缺页 首次访问堆/栈/BSS段 ~1-5 µs 预分配、透明大页
文件映射缺页 mmap文件首次访问 ~10-100 µs (含I/O) 预读、页缓存预热
写时复制缺页 fork后写操作 ~2-10 µs 大页、减少fork频率
交换缺页 访问被换出的页 ~100 µs-10 ms 减少交换、内存充足
// mm/memory.c - 匿名页缺页核心
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
    struct page *page;
    pte_t entry;

    // 1. 分配物理页(GFP_HIGHUSER_MOVABLE)
    page = alloc_zeroed_user_highpage_movable(vmf->vma, vmf->address);
    if (!page)
        return VM_FAULT_OOM;

    // 2. 建立页表项
    entry = mk_pte(page, vmf->vma->vm_page_prot);
    if (vmf->flags & FAULT_FLAG_WRITE)
        entry = pte_mkwrite(pte_mkdirty(entry));

    // 3. 设置页表
    set_pte_at(vmf->vma->vm_mm, vmf->address, vmf->pte, entry);

    // 4. 更新LRU和统计
    lru_cache_add_inactive_or_unevictable(page, vmf->vma);
    return 0;
}

4.5 路径性能对比与优化切入点

从性能角度,缺页路径的优化需要针对不同分支采取差异化策略:

  • 减少缺页次数:通过大页(HugeTLB/透明大页)减少页表遍历深度和TLB缺失。
  • 加速VMA查找:使用VMA缓存(vma->vm_rb 缓存最近访问的VMA)减少红黑树遍历。
  • 页分配优化:使用per-CPU页缓存(pcp list)减少分配锁竞争。
  • 批量处理:对于文件映射缺页,通过 filemap_fault 的预读机制批量读取多页。
  • 避免交换:通过内存规整(compaction)和回收策略优化,减少交换缺页的发生。
性能指标参考:在典型4核x86_64服务器上,一次匿名页缺页处理耗时约1-3 µs,文件映射缺页(页缓存命中)约3-10 µs,交换缺页约100-500 µs。优化目标是将缺页处理延迟控制在10 µs以内。
缺页路径性能对比(典型延迟,对数尺度) 1 µs 10 µs 100 µs 1 ms 10 ms 匿名页缺页 ~3 µs COW缺页 ~5 µs 文件映射缺页 ~30 µs 交换缺页 ~500 µs 内核空间缺页 ~10 µs 注:延迟数据基于x86_64 4核服务器典型场景,实际值受硬件、负载、内存压力等因素影响

4.6 路径选择与性能调优建议

在实际性能分析中,可以通过perf工具统计缺页异常的分支分布:

# 统计缺页类型分布
perf stat -e page-faults,minor-faults,major-faults ./workload

# 跟踪缺页事件
perf record -e page-faults:u -ag -- ./workload
perf report --stdio

# 使用bpftrace动态跟踪
bpftrace -e 'kprobe:handle_mm_fault { @[kstack] = count(); }'
优化优先级:首先减少major fault(交换缺页)的发生,其次优化文件映射缺页的预读策略,最后考虑匿名页缺页的批量处理。内核空间缺页应作为异常情况监控,一旦频率异常需立即排查。

理解 handle_mm_fault 的路径分支是缺页性能优化的基础。在实际系统中,通过perf、tracepoint和bpftrace等工具定位热点分支,结合具体业务场景(数据库、Web服务器、HPC等)选择优化策略,才能有效降低缺页处理延迟,提升内存子系统整体性能。