Linux 内核缺页中断(Page Fault)处理机制深度工程实战
引言
缺页中断(Page Fault)是 Linux 内存管理子系统中最为核心的机制之一。每当 CPU 访问一个虚拟地址,而该地址对应的页表项不存在或访问权限不足时,硬件 MMU 就会触发缺页中断,将控制权转交给操作系统内核处理。理解缺页中断的完整处理路径,不仅是深入掌握虚拟内存管理的关键,更是排查 OOM、性能抖动、内存泄漏等生产问题的基础。
本文将从硬件机制出发,深入剖析 x86_64 架构下的缺页中断处理全流程,涵盖页表遍历、do_page_fault 主处理函数、匿名页与文件页的按需调页(Demand Paging)、写时复制(Copy-on-Write)、巨页(HugePages)以及 Swap 回写等核心机制,最后结合 perf 工具和 /proc 接口给出生产环境下的调优实践。
一、硬件基础:MMU 与页表
1.1 虚拟地址到物理地址的翻译
现代处理器通过 MMU(Memory Management Unit)完成虚拟地址到物理地址的翻译。x86_64 架构使用 4 级页表(5 级页表需启用 LA57):
CR3 → PGD → P4D → PUD → PMD → PTE → 物理页帧
每一级页表项中包含下一级页表的物理基址,以及关键的标志位:
- P(Present):该页是否在物理内存中
- R/W(Read/Write):是否可写
- U/S(User/Supervisor):用户态是否可访问
- A(Accessed):该页是否被访问过(硬件置位)
- D(Dirty):该页是否被写入过(硬件置位)
当 MMU 在翻译过程中发现 Present 位为 0 或权限校验失败时,就会触发 #PF 异常(Page Fault,中断向量 14)。
1.2 Page Fault 错误码
触发缺页时,CPU 将错误码压入栈中,由软件读取。错误码的位定义如下:
| 位 | 名称 | 含义 |
|---|---|---|
| 0 (P) | 不存在 | 0=页不存在,1=权限错误 |
| 1 (W/R) | 写操作 | 0=读访问,1=写访问 |
| 2 (U/S) | 模式 | 0=内核态,1=用户态 |
| 3 (RSVD) | 保留位 | 页表项设置了保留位 |
| 4 (I/D) | 指令获取 | 取指令时触发 |
| 5 (PK) | 保护键 | 保护键权限错误 |
| 6 (SS) | 影子栈 | 影子栈访问 |
| 8 (SGX) | SGX | SGX 页错误 |
这个错误码在 do_page_fault() 中通过 read_cr2() 获取触发地址,从栈上获取错误码,进而判断故障类型。
1.3 CR2 寄存器
CR2(Control Register 2)保存触发缺页的线性地址(即导致 #PF 的虚拟地址)。内核在处理中断前必须保存 CR2,因为另一次缺页可能覆盖它。
二、缺页中断处理主路径
2.1 入口:page_fault_handler
在 x86_64 架构中,缺页处理入口定义在 arch/x86/kernel/entry_64.S 中:
idtentry_page_fault:
// 保存寄存器状态
push %rax
...
// 调用 C 处理函数
call do_page_fault
...
iretq
2.2 do_page_fault() 核心流程
do_page_fault() 定义在 arch/x86/mm/fault.c 中,是整个缺页处理的核心:
static noinline void
do_page_fault(struct pt_regs *regs, unsigned long error_code)
{
struct vm_area_struct *vma;
struct task_struct *tsk;
struct mm_struct *mm;
unsigned long address;
enum ctx_state prev_context;
// 1. 获取触发缺页的虚拟地址
address = read_cr2();
// 2. 获取当前任务的内存描述符
mm = tsk->mm;
...
// 3. 查找包含 address 的 VMA
vma = find_vma(mm, address);
if (!vma || vma->vm_start > address) {
// 没有匹配的 VMA,发送 SIGSEGV
bad_area(regs, error_code, address);
return;
}
// 4. 权限检查
if (unlikely(!(vma->vm_flags & access_mask))) {
bad_area_access_error(regs, error_code, address, vma);
return;
}
// 5. 核心处理
handle_mm_fault(vma, address, flags);
}
处理流程的决策树如下:
CR2 → find_vma() → 合法?
├── 否 → SIGSEGV(段错误)
└── 是 → 权限检查
├── 不合法 → SIGSEGV
└── 合法 → handle_mm_fault()
├── handle_pte_fault()
│ ├── 匿名页 → do_anonymous_page()
│ ├── 文件页 → do_fault()
│ │ ├── do_read_fault() (读缺页)
│ │ ├── do_cow_fault() (写时复制)
│ │ └── do_shared_fault() (共享写)
│ └── COW → do_wp_page()
└── 巨页路径 → hugetlb_fault()
三、VMA(虚拟内存区域)
3.1 VMA 数据结构
每个进程的 mm_struct 管理着一组 vm_area_struct,它们以红黑树 + 链表方式组织:
struct vm_area_struct {
unsigned long vm_start; /* VMA 起始地址 */
unsigned long vm_end; /* VMA 结束地址 */
struct mm_struct *vm_mm; /* 所属 mm_struct */
pgprot_t vm_page_prot; /* 访问权限 */
unsigned long vm_flags; /* 标志位:VM_READ/WRITE/EXEC/SHARED */
struct rb_node vm_rb; /* 红黑树节点 */
struct list_head madv_lru; /* LRU 链表(用于回收) */
const struct vm_operations_struct *vm_ops; /* 操作函数集 */
unsigned long vm_pgoff; /* 文件内偏移(页为单位) */
struct file *vm_file; /* 关联文件(如果有) */
};
3.2 VMA 查找优化
find_vma() 使用红黑树查找,时间复杂度 O(log n)。为了加速热路径上的查找,内核维护了一个缓存指针 mm->mmap_cache,记录上一次查找的 VMA。如果缺页地址落在上次命中的 VMA 内,就可以避免树查找。
3.3 VMA 标志位与权限映射
VMA 的 vm_flags 需映射到页表项的硬件权限位:
pgprot_t protection_map[16] = {
__P000, __P001, __P010, __P011, __P100, __P101, __P110, __P111,
__S000, __S001, __S010, __S011, __S100, __S101, __S110, __S111,
};
其中 __Pxxx 对应用户态可读的页面,__Sxxx 对应仅内核可访问的页面(Supervisor)。三位分别表示 Read/Write/Execute。
四、handle_mm_fault 深入
4.1 页表遍历与分配
handle_mm_fault() 需要逐级遍历页表,如果某一级页表不存在,需要动态分配:
static vm_fault_t handle_mm_fault(struct vm_area_struct *vma,
unsigned long address, unsigned long flags)
{
pgd_t *pgd;
p4d_t *p4d;
pud_t *pud;
pmd_t *pmd;
pte_t *pte;
// 获取或分配 PGD
pgd = pgd_offset(mm, address);
pgd_alloc(mm, pgd, address); // 按需分配
// P4D(4级页表固定映射到PGD)
p4d = p4d_alloc(mm, pgd, address);
// PUD(大页可能直接在此结束)
pud = pud_alloc(mm, p4d, address);
if (pud_none(*pud)) {
// 尝试 PUD 级大页(1GB)
...
}
// PMD
pmd = pmd_alloc(mm, pud, address);
if (pmd_none(*pmd)) {
// 尝试 PMD 级大页(2MB THP)
...
}
// PTE
pte = pte_alloc_map(mm, pmd, address);
return handle_pte_fault(...);
}
这个分配过程体现了 Linux 的惰性分配策略:只有到真正访问时才分配页表结构,节约内存。
4.2 大页(Huge Page)处理
Linux 支持两种巨页:
Transparent Huge Pages (THP)
- 内核自动将连续 512 个 4KB 页合并为 2MB 大页
- 通过 khugepaged 内核线程在后台扫描合并
- 参数 /sys/kernel/mm/transparent_hugepage/enabled
- always:始终尝试分配 THP
- madvise:只在 MADV_HUGEPAGE 标记的 VMA 中启用
- never:禁用
HugeTLB
- 预留方式:启动参数 hugepagesz=1G hugepages=16
- 或运行时通过 /proc/sys/vm/nr_hugepages 调整
- 需要程序显式申请(mmap /dev/shm 或 hugetlbfs)
巨页对缺页处理的影响: - PMD 级 2MB 页:只需 3 级页表遍历,减少 TLB miss - PUD 级 1GB 页:只需 2 级页表遍历,极致 TLB 覆盖
五、匿名页缺页:do_anonymous_page()
5.1 首次访问
当进程首次访问一个匿名映射区域(如 malloc 分配的堆内存)时,页表项为空的 PTE 会触发 do_anonymous_page():
vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
// 1. 写时复制检查:是否是共享匿名映射的写入
if (vmf->flags & FAULT_FLAG_WRITE) {
if (vma->vm_flags & VM_SHARED) {
return do_shared_fault(vma, vmf);
}
}
// 2. 只读首次访问:映射零页(Zero Page)
if (!(vma->vm_flags & VM_WRITE)) {
pte = mk_pte(vmf->page = ZERO_PAGE(0), vma->vm_page_prot);
vmf->pte = pte_map_lock(vma->vm_mm, vmf->pmd, ...);
return 0;
}
// 3. 可写匿名页:从 buddy allocator 分配物理页
page = alloc_pages_vma(GFP_HIGHUSER_MOVABLE, 0, vma, vmf->address);
// 4. 清零内核新分配的页
clear_user_highpage(page, vmf->address);
// 5. 设置页表项
entry = mk_pte(page, vma->vm_page_prot);
entry = pte_sw_mkyoung(entry); // 标记为可回收
if (vma->vm_flags & VM_WRITE)
entry = pte_mkwrite(pte_mkdirty(entry)); // 可写+脏
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
}
5.2 零页优化
Linux 利用了"All-zero page"的优化技巧: - 只读首次访问映射到一个全零的物理页(.zero_page) - 只有当进程真正写入时才分配独立物理页(Copy-on-Write) - 这大幅减少了内存浪费,特别是 malloc 大量内存但只使用部分的情况
5.3 COW 缺页:do_wp_page()
写时复制是 Anonymous Page 中最复杂的场景。当 fork 后父子进程共享同一物理页时,页面被标记为只读。任一方尝试写入,就会触发 COW 缺页:
vm_fault_t do_wp_page(struct vm_fault *vmf)
{
struct page *old_page = vmf->page;
// 1. 检查页面引用计数
if (page_count(old_page) == 1) {
// 唯一引用者:直接将页设为可写,无需复制
wp_page_reuse(vmf);
return 0;
}
// 2. 多个引用者:必须复制
new_page = alloc_page_vma(...);
copy_user_highpage(new_page, old_page, vmf->address, vma);
// 3. 设置新页的页表项(可写+脏)
entry = mk_pte(new_page, vma->vm_page_prot);
entry = pte_mkwrite(pte_mkdirty(entry));
set_pte_at_notify(mm, vmf->address, vmf->pte, entry);
// 4. 释放旧页引用
put_page(old_page);
}
COW 过程中最关键的是 wp_page_reuse() 优化:当引用计数为 1 时(即页面只被当前进程独占),内核直接标记 page table entry 为可写,避免了昂贵的内存拷贝。
六、文件映射缺页:do_fault()
6.1 按需读页(Demand Paging)
当进程通过 mmap 将文件映射到虚拟地址空间,首次访问触发缺页。如果只是读取,调用 do_read_fault():
vm_fault_t do_read_fault(struct vm_fault *vmf)
{
// 1. 先在 page cache 中查找
page = find_get_page(mapping, pgoff);
if (page) {
// 命中 page cache,直接映射
vmf->page = page;
} else {
// 2. 不在 cache,发起磁盘 I/O
page = page_cache_alloc(...);
error = mapping->a_ops->readpage(file, page);
wait_on_page_locked(page); // 等待 I/O 完成
}
// 3. 映射到进程页表
vmf->pte = pte_map_lock(...);
entry = mk_pte(page, vma->vm_page_prot);
set_pte_at(mm, vmf->address, vmf->pte, entry);
}
这就是经典的按需调页:程序可以 mmap 一个 10GB 文件,但只分配少量页表结构,物理内存只在真正访问时才逐步分配。
6.2 文件 COW
对只读文件映射的写操作,触发 do_cow_fault():
vm_fault_t do_cow_fault(struct vm_fault *vmf)
{
// 1. 分配新物理页
new_page = alloc_page_vma(...);
// 2. 从 page cache 复制数据
copy_user_highpage(new_page, old_page, vmf->address, vma);
// 3. 标记脏、可写
entry = mk_dirty_cow_entry(vma, new_page);
set_pte_at(mm, vmf->address, vmf->pte, entry);
}
与匿名页 COW 的区别:文件 COW 复制的是文件内容而非零页(因为文件已有数据),且过程中文件数据不会被修改(需要 fsync/msync 或 MAP_SHARED)。
6.3 共享写入
MAP_SHARED 映射的写入,调用 do_shared_fault():
vm_fault_t do_shared_fault(struct vm_fault *vmf)
{
// 直接在 page cache 中写入(页面已存在则标记脏)
// 无需复制,修改会最终通过 pdflush 回写到磁盘
ret = vma->vm_ops->page_mkwrite(vma, vmf); // 通知文件系统
entry = pte_mkwrite(pte_mkdirty(entry));
set_pte_at(...);
}
这是高性能 IPC(共享内存)和数据库共享缓存的基础。
七、Swap 与页面回收
7.1 Swap 触发条件
当系统物理内存不足时,内核通过 kswapd 后台线程将不活跃页面写出到 Swap 分区/文件。被换出的匿名页在页表项中标记为 Present=0,但 pte 中包含 swap entry 信息。
7.2 Swap 缺页(Pagein)
当进程再次访问已被换出的页面时触发缺页:
// pte 中包含 swap entry,Present=0
swapin = swp_entry(swp_type, swp_offset);
// 1. 先检查 swap cache(可能其他进程也在使用)
page = lookup_swap_cache(swapin);
if (page) {
// 命中:直接从 swap cache 获取
goto map_it;
}
// 2. 不在 swap cache,执行磁盘读
page = alloc_page(...);
swap_readpage(swapin, page); // 从 swap 分区读取
// 3. 映射到页表
set_pte_at(mm, address, ptep, pte);
7.3 交换空间选择
Linux 支持多个交换分区/文件,按优先级排列。内核在 select_victim 时使用 LRU 近似算法(二次机会法 / Clock 算法)选择要回收的页面。
7.4 NUMA 感知
在 NUMA 系统中,swap 命中后的目标物理页需要考虑节点亲和性:
- numactl --interleave 设置交错分配
- numactl --membind 绑定到指定节点
- 内核 5.x+ 引入 AutoNUMA balancing,自动迁移热页面到本地节点
八、缺页处理中的锁与同步
8.1 自旋锁保护页表访问
缺页处理中多个锁是性能瓶颈:
- mmap_lock(读写信号量):保护 VMA 树。读操作(缺页)获取读锁;修改 VMA(mmap/munmap)获取写锁。
- page_table_lock(自旋锁):保护单个 PTE。设置 PTE 时需要持有。
- mm->page_table_lock 在 4.x 中被改为 per-PMD 锁以减少争用
- i_mmap_rwsem:文件映射树的读写锁
8.2 RCU 与 speculative page fault
Linux 5.12+ 引入了 speculative page fault 优化:在 RCU 临界区内读 VMA,无需持有 mmap_lock read。如果缺页处理成功,就避免了系统调用的开销。但如果处理失败(如需要分配物理页),回退到持有 mmap_lock 的正常路径。
8.3 反向映射(Reverse Map)
当需要回收一个物理页时,需要找到所有映射它的 PTE 并清除。rmap(反向映射)机制允许从物理页反查所有关联的虚拟地址:
struct rmap_private {
enum ttu_flags flags;
};
// 遍历每个映射该页的 VMA
anon_vma_lock_read(anon_vma);
anon_vma_interval_foreach(avc, node->rb_root, 0, ULONG_MAX) {
// 清除每个 PTE
ptep_clear_flush(vma, address, ptep);
}
这是 KSM(Kernel Samepage Merging)和页面迁移的基础。
九、高级特性
9.1 KSM(Kernel Samepage Merging)
KSM 通过扫描进程内存,将内容相同的页面合并为一个物理页(标记只读 + COW),在虚拟化环境中可显著节省内存:
// 在 ksm_scan_thread() 中执行
for_each_rmap_item() {
page = follow_page(vma, addr, FOLL_GET);
if (!page) continue;
// 计算 hash,判断是否存在相同内容的页面
checksum = calc_checksum(page);
if (stable_tree_insert(page, checksum)) {
// 合并:多个 PTE 指向同一个物理页
}
}
KSM 参数:
- /sys/kernel/mm/ksm/run:0/1 停止/启动
- /sys/kernel/mm/ksm/pages_to_scan:每次扫描页数
- /sys/kernel/mm/ksm/sleep_millisecs:扫描间隔
9.2 软页迁移(Soft Offlining)
当 ECC 内存检测到不可纠正的错误时,内核通过 offlining 该页面隔离坏内存:
// 清除 Present 位,标记 HWPoison
set_pte_at(mm, addr, ptep, pte_set_flags(pte, _PAGE_PRESENT));
访问到 HWPoison 页面的进程通常会收到 SIGBUS 信号。
9.3 Userfaultfd
userfaultfd 允许用户态处理缺页中断:
// 1. 创建 userfaultfd
fd = syscall(__NR_userfaultfd, O_CLOEXEC);
// 2. 注册要处理的内存区域
struct uffdio_register reg = {
.range = { .start = addr, .len = len },
.mode = UFFDIO_REGISTER_MODE_MISSING,
};
ioctl(fd, UFFDIO_REGISTER, ®);
// 3. 监听事件,用户态自定义处理
while (1) {
n = read(fd, &msg, sizeof(msg));
// 分配内存,通知内核恢复
struct uffdio_copy copy = {
.dst = msg.arg.pagefault.address,
.src = (unsigned long)source_page,
.len = PAGE_SIZE,
};
ioctl(fd, UFFDIO_COPY, ©);
}
这是 KVM/QEMU 热迁移、In-Memory 数据库快照的基础。
十、性能分析工具
10.1 perf 分析缺页热点
# 统计进程缺页总数(minor+major)
perf stat -e page-faults ./program
# 统计 major fault(磁盘I/O 引起的缺页)
perf stat -e major-faults ./program
# 统计 minor fault(不涉及 I/O 的缺页)
perf stat -e minor-faults ./program
# 跟踪 do_page_fault 内核函数
perf probe --add do_page_fault
perf record -e probe:do_page_fault -a -g -- sleep 30
perf report --sort=dso,symbol
# 查看缺页时延分布
perf script -F time,event,ip,sym | grep page_fault
10.2 /proc 接口
# 进程级缺页统计
cat /proc/PID/status | grep -E "VmRSS|VmSwap|voluntary"
# voluntary_ctswt: 自愿上下文切换(通常因 I/O 等待)
# nonvoluntary_ctswt: 非自愿上下文切换(通常因调度时间片耗尽)
# 系统级缺页统计
grep -E "pswpin|pswpgf|pgfault|pgmajfault" /proc/vmstat
# pswpin/pswpout: swap 换入/换出页数
# pgfault: 缺页总数
# pgmajfault: major fault(磁盘 I/O)
# NUMA 统计
numastat
numastat -p PID
10.3 vmstat 实时监控
vmstat 1
# bi/bo: 块设备读写速率(I/O)
# si/so: swap 换入/换出速率(内存压力)
# us/sy/id/wa: CPU 时间分布
高 si/so + 高 wa = 内存不足导致频繁换页,严重劣化性能。
十一、生产环境调优实践
11.1 减少 Major Fault
问题:冷启动时大量文件映射触发 major fault,影响启动性能。
方案:
// 启动时使用 MAP_POPULATE 预读
ptr = mmap(NULL, size, PROT_READ, MAP_PRIVATE | MAP_POPULATE, fd, 0);
// 或使用 madvise 提示内核
madvise(ptr, size, MADV_WILLNEED); // 建议预读
madvise(ptr, size, MADV_SEQUENTIAL); // 建议顺序访问
11.2 Swap 调优
问题:app 间歇性卡顿。
诊断:
- /proc/vmstat 中 pswpin 持续增长
- sar -W 1 显示每秒有 Swap 操作
解决方案:
1. 增加物理内存(根本解决)
2. 调整 vm.swappiness:
bash
# 减少 swap 倾向(默认 60)
echo 10 > /proc/sys/vm/swappiness
# 仅紧急时使用 swap(仅适合有足够内存的系统)
echo 1 > /proc/sys/vm/vm.swappiness
3. 禁用 swap:
bash
swapoff -a
# 注意:极端内存压力时 OOM killer 会直接杀进程
11.3 THP 调优
问题:数据库等低延迟场景中 THP 的 defrag 暂停明显。
方案:
# 全局关闭 THP
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
# 或仅对关键应用启用
madvise(ptr, size, MADV_HUGEPAGE); // 局部启用
madvise(ptr, size, MADV_NOHUGEPAGE); // 局部禁用
11.4 大页预分配
问题:高并发数据库中 TLB miss 是主要开销。
方案:
# 预留 1GB 大页
echo 8 > /sys/kernel/mm/hugepages/hugepages-1048576kB/nr_hugepages
# MySQL 配置
[mysqld]
large-pages=ON # 启用大页
innodb_buffer_pool_size=16G # 与预留大页匹配
11.5 NUMA 优化
问题:NUMA 远程访问延迟是本地 2-3 倍。
方案:
# 查看 numa 拓扑
numactl --hardware
# 绑定进程到 NUMA 节点
numactl --cpunodebind=0 --membind=0 ./db_server
# 查看进程 NUMA 内存分布
numastat -p $(pidof mysqld)
十二、调试与故障排查
12.1 OOM Killer 触发分析
当系统内存严重不足,所有页面都无法回收时,OOM Killer 选择进程终止:
# 查看 oom_score(越高越优先被杀)
cat /proc/PID/oom_score
# 调整 OOM 权重(-1000 表示永不杀)
echo -1000 > /proc/PID/oom_score_adj
# 查看 OOM 日志
dmesg | grep -i "out of memory"
12.2 使用 BPFTrace 追踪缺页
# 跟踪每个进程的缺页次数
bpftrace -e 'kprobe:do_page_fault { @[comm] = count(); }'
# 统计 major vs minor fault
bpftrace -e '
kprobe:do_page_fault {
@[arg2 & 0x1 ? "minor" : "major"] = count();
}
'
# 查看缺页地址分布
bpftrace -e 'kprobe:do_page_fault { @[arg1] = hist(arg1); }'
12.3 页面泄漏排查
# 查看进程内存映射
cat /proc/PID/smaps | grep -E "^[0-9a-f]|Rss|Pss|Swap"
# 持续增长但无意义的大 VMA 可能是泄漏
cat /proc/PID/maps
# 使用 valgrind 泄漏检测
valgrind --tool=memcheck --leak-check=full ./program
总结
缺页中断是 Linux 内存管理中最核心的运行时机制。从硬件 MMU 的 #PF 异常开始,经过 VMA 查找、权限校验、页表分配,最终到 handle_pte_fault() 的具体处理,每一步都体现了操作系统的精妙设计:
- 惰性分配:页表和物理页只在真正访问时才分配,极大节省了内存
- 零页共享:所有未初始化的只读页面共享一个全零物理页
- COW:fork 时避免全量复制,只在写入时按需分裂
- 按需调页:大文件的加载是渐进式的,启动速度快
但同时,缺页也是生产环境中性能问题的常见来源:频繁的 major fault 导致 I/O 风暴、TLB thrashing 拖慢计算密集型任务、swap 抖动拖垮整个系统。掌握缺页处理的完整机制,才能科学地调优 vm.swappiness、THP、NUMA 策略、大页预留等关键内核参数,确保系统在高负载下依然稳定高效。
参考资源
- Understanding the Linux Kernel, 3rd Edition — Chapter 8: Memory Management
- Linux Kernel Source:
arch/x86/mm/fault.c,mm/memory.c,mm/hugetlb.c - Intel SDM Vol. 3A Chapter 4: Paging
Documentation/admin-guide/mm/— 内核内存管理文档- Brendan Gregg — "Systems Performance" Chapter 8: Memory

发表评论 取消回复