引言
虚拟内存是Linux内核最核心的子系统之一。它不仅是进程隔离的基石,更是现代高性能计算中NUMA优化、大页映射、内存去重等功能的技术前提。本文将从x86-64架构的多级页表结构出发,深入解析虚拟地址到物理地址的完整转换链路,并详细拆解缺页异常(Page Fault)的全流程处理机制,最后结合COW、透明大页(THP)、KSM等高级特性,给出工程实践中的优化策略。
一、x86-64多级页表结构
在x86-64架构中,Linux采用四级(或五级)页表结构完成虚拟地址到物理地址的转换。以48位虚拟地址空间为例,地址被划分为若干索引字段,依次索引PGD→P4D→PUD→PMD→PTE各级页表。
// arch/x86/include/asm/pgtable_64.h
// 48位虚拟地址空间下的页表索引布局
// [47:39] PGD索引 | [38:30] P4D索引 | [29:21] PUD索引
// [20:12] PMD索引 | [11:0] 页内偏移
#define __PAGE_OFFSET_BASE _AC(0xffff888000000000, UL)
#define PAGE_OFFSET __PAGE_OFFSET_BASE
#define PGDIR_SHIFT 39
#define P4D_SHIFT 39
#define PUD_SHIFT 30
#define PMD_SHIFT 21
#define PAGE_SHIFT 12
Linux内核通过pgd_offset()→p4d_offset()→pud_offset()→pmd_offset()→pte_offset_map()的宏调用链逐级遍历页表。当启用5级页表(LA57)时,在PGD之上增加了P4D层级,支持57位虚拟地址空间(128PB)。实际遍历流程如下:
// 页表遍历的核心路径 (简化自 follow_page_pte)
static inline pte_t *follow_pte(struct mm_struct *mm, unsigned long addr, ...)
{
pgd_t *pgd = pgd_offset(mm, addr);
p4d_t *p4d = p4d_offset(pgd, addr);
if (p4d_none(*p4d) || p4d_bad(*p4d))
return NULL;
pud_t *pud = pud_offset(p4d, addr);
if (pud_none(*pud) || pud_bad(*pud))
return NULL;
pmd_t *pmd = pmd_offset(pud, addr);
if (pmd_none(*pmd) || pmd_bad(*pmd))
return NULL;
return pte_offset_map(pmd, addr);
}
1.1 TLB与地址转换加速
每次页表遍历需要4次内存访问,代价极高。CPU通过TLB(Translation Lookaside Buffer)缓存近期使用的页表项,硬件的Page Walk Cache则缓存中间级页表。invpcid(单PCID失效)和tlbiel(本地TLB失效)指令用于进程切换时的选择性TLB维护。
// 进程切换时的TLB管理 (context_switch)
// 若CR4.PCIDE置位,则使用PCID避免全量flush
if (next->mm != prev->mm) {
load_cr3(next->pgd); // 切换页表根
// 若支持INVPCID则选择性失效
if (cpu_feature_enabled(X86_FEATURE_INVPCID))
invpcid_flush_single_context(next->context);
}
二、缺页异常全链路处理
当CPU访问的虚拟地址在TLB中未命中,且页表中也找不到有效PTE时,触发缺页异常(Page Fault,中断向量14)。Linux内核的异常处理入口是do_page_fault(),该函数最终调用到通用的handle_mm_fault()。
// arch/x86/mm/fault.c
DEFINE_IDTENTRY_RAW_ERRORCODE(exc_page_fault)
{
unsigned long address = read_cr2(); // CR2保存触发异常的虚拟地址
do_page_fault(regs, error_code, address);
}
// mm/memory.c — 核心处理入口
vm_fault_t handle_mm_fault(struct vm_area_struct *vma, unsigned long address,
unsigned int flags, struct pt_regs *regs)
{
// 1. 查找或创建VMA
// 2. 权限检查(读/写/执行)
// 3. 分级处理: handle_pte_fault -> do_fault -> do_anonymous_page / do_read_fault
...
}
2.1 异常分类与分发
| 异常类型 | 触发条件 | 处理函数 |
|---|---|---|
| 匿名页面缺页 | 首次访问malloc/mmap(ANON)分配的内存 | do_anonymous_page() |
| 文件映射缺页 | 首次访问mmap映射的文件内容 | do_fault() → do_read_fault() |
| 写时复制(COW) | 写访问只读页(共享匿名页或文件页) | do_wp_page() |
| 交换区缺页 | 页面被swap出,重新访问时读回 | do_swap_page() |
| 大页缺页 | 透明大页或hugetlbfs中的大页未分配 | do_huge_pmd_anonymous_page() |
| 权限错误 | 用户态写只读内核页或执行不可执行页 | 发送SIGSEGV |
2.2 写时复制(COW)详解
COW是fork()高效实现的关键。子进程继承父进程的页表,但所有可写页面标记为只读。任一进程写入时触发COW缺页,内核分配新物理页,复制内容,更新PTE。
// mm/memory.c — COW处理核心
static vm_fault_t do_wp_page(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
pte_t entry = vmf->orig_pte;
// 情况1: 匿名页且只有一个引用 → 提升为可写,无需复制
if (PageAnon(vmf->page) && page_mapcount(vmf->page) == 1) {
pte_unprotect(vmf); // 提升写权限
return VM_FAULT_WRITE;
}
// 情况2: 多人引用 → 分配新页 + 复制内容
new_page = alloc_page_vma(GFP_HIGHUSER, vma, vmf->address);
copy_user_highpage(new_page, vmf->page, vmf->address, vma);
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, mk_pte(new_page, vma->vm_page_prot));
return VM_FAULT_WRITE;
}
COW在容器场景中尤为重要:容器引擎共享基础镜像层的基础页面,仅在写入新数据时才触发页面复制,节省大量内存。
三、大页(Huge Pages)工程实践
3.1 透明大页(THP)
THP自动将连续的2MB(或1GB)小页面合并为大页,减少TLB压力和页表遍历开销。内核通过khugepaged内核线程后台扫描合并。
# 查看THP状态
cat /sys/kernel/mm/transparent_hugepage/enabled # [madvise|always|never]
cat /sys/kernel/mm/transparent_hugepage/defrag # 碎片整理策略
# 编程中使用madvise提示内核使用大页
madvise(addr, length, MADV_HUGEPAGE); # 标记为适合大页
madvise(addr, length, MADV_NOHUGEPAGE); # 标记为不适合大页
// THP在缺页路径中的处理
// mm/huge_memory.c
vm_fault_t do_huge_pmd_anonymous_page(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
// 如果VMA太小不适合大页,或不满足对齐要求,回退到普通页
if (!vma->vm_ops && transhuge_vma_suitable(vma, haddr)) {
// 分配2MB透明大页
page = alloc_hugepage_vma(GFP_TRANSHUGE, vma, haddr);
set_pmd_at(vma->vm_mm, haddr, vmf->pmd,
mk_pmd(page, vma->vm_page_prot));
return VM_FAULT_DONE;
}
// 回退到普通PTE处理
}
THP使用建议:数据库(PostgreSQL、Redis)、JVM堆、DPDK等大块内存密集场景推荐启用。频繁内存分配释放的短生命周期进程可能因khugepaged的合并开销而劣化。
3.2 Hugetlbfs静态大页
对于需要确定性延迟的高性能场景(实时系统、NFV数据面),静态大页提供稳定的大页预留:
# 启动时预留1GB大页 (GRUB参数)
hugepagesz=1G hugepages=16 default_hugepagesz=1G
# 或运行时挂载
mount -t hugetlbfs hugetlbfs /dev/hugepages -o pagesize=1G
# 代码中显式使用
fd = open("/dev/hugepages/my_region", O_CREAT|O_RDWR, 0755);
ptr = mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_HUGETLB, fd, 0);
// 或使用MAP_HUGE_2MB / MAP_HUGE_1GB 指定大小
四、KSM与内存去重
Kernel Same-page Merging (KSM) 通过扫描可合并的匿名页面,将内容相同的页面合并为一个只读页面,减少内存占用——在虚拟化场景中极有价值(多个相同OS镜像的VM)。
// mm/ksm.c — KSM扫描与合并
static struct rmap_item *scan_get_next_rmap_item(struct mm_struct *mm, ...)
{
// 遍历每个VMA的稳定/不稳定红黑树节点
// 逐页计算checksum,与已注册页面比较
// 找到重复页面则合并: replace_page()
}
// 合并操作
static int replace_page(struct mm_struct *mm, ...)
{
// 1. 加锁页面,锁定两页引用计数
// 2. 用读合并页替换原PTE
// 3. 释放多余物理页
// 写入时触发COW再次分页
}
# 启用KSM (默认关闭)
echo 1 > /sys/kernel/mm/ksm/run
echo 1000 > /sys/kernel/mm/ksm/pages_to_scan # 每次扫描页数
echo 20 > /sys/kernel/mm/ksm/sleep_millisecs # 扫描间隔
五、进程地址空间管理(VMA)
每个进程的mm_struct通过红黑树和区间链表维护VMA(Virtual Memory Area)。VMA描述了一段连续虚拟地址区域的属性(权限、映射类型、文件后端等)。
// 典型进程地址空间布局 (cat /proc/PID/maps)
// 地址范围 权限 offset dev inode 路径
// 55a0e0a00000-55a0e0a01000 r--p ... 主程序代码段
// 55a0e0a01000-55a0e0a02000 r-xp ... 主程序可执行段
// 55a0e0a02000-55a0e0a03000 r--p ... 主程序只读段
// 7f1234000000-7f1234021000 rw-p ... [heap]
// 7f1234021000-7f1234022000 ---p ... 保护页
// 7f7f80000000-7f7f80028000 r--p ... libc.so
// 7ffe10000000-7ffe10020000 rw-p ... [stack]
// 7ffe10020000-7ffe10021000 r--p ... [vvar]
// 7ffe10021000-7ffe10022000 r-xp ... [vdso]
// 快速VMA查找
struct vm_area_struct *vma = find_vma(mm, address);
if (vma && address >= vma->vm_start)
return vma; // 找到包含该地址的VMA
return NULL; // 未映射地址 → 发送SIGSEGV
六、工程观测与调优工具
6.1 页表相关指标
# 查看进程内存映射
cat /proc/PID/smaps_rollup | grep -E 'Rss|Pss|Swap|Anon|File'
# 查看TLB相关perf事件
perf stat -e dTLB-load-misses,dTLB-store-misses,iTLB-load-misses ./bench
# 大页使用情况
cat /proc/meminfo | grep -i huge
# 异常统计
cat /proc/vmstat | grep -E 'pgfault|pgmajfault|thp|ksm'
6.2 eBPF追踪缺页事件
// BPF程序追踪do_page_fault
SEC("kprobe/handle_mm_fault")
int trace_mm_fault(struct pt_regs *ctx) {
u32 pid = bpf_get_current_pid_tgid() >> 32;
u64 addr = PT_REGS_PARM2(ctx); // 触发异常的地址
struct event e = {};
e.pid = pid;
e.addr = addr;
bpf_get_current_comm(&e.comm, sizeof(e.comm));
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &e, sizeof(e));
return 0;
}
6.3 常见问题排查
大量minor fault:正常行为,首次访问匿名页必触发。若频率异常高可能暗示内存碎片化或大页未生效。
major fault飙升:频繁从swap或文件读取页面,暗示物理内存不足或工作集过大。
COW风暴:fork()后父子进程同时写入大量共享页面,可尝试使用vfork()或posix_spawn()替代,或改为共享内存。
七、总结
Linux的虚拟内存管理系统通过多级页表、缺页异常分级处理、COW延迟复制、大页优化、KSM去重等机制,在通用性和性能之间取得了良好的平衡。深入理解这些机制对于系统调优和性能诊断至关重要:
- 数据库/AI训练等大内存工作集:启用THP + 静态大页
- 高并发短请求服务:评估THP收益,可能never更优
- 容器/VPS场景:开启KSM去重同构页面
- DPDK/SPDK:必须使用静态大页避免TLB miss
- 实时系统:静态大页 + mlockall锁定物理页
每一层抽象都有对应的性能代价,选择正确的策略需要对应用场景有深刻理解——而这,正是内核工程师的核心价值所在。

发表评论 取消回复