Linux内核虚拟内存管理与页表深度实战:从地址转换到页框回收全链路剖析

一、虚拟内存:给进程的"独立世界"

现代操作系统最伟大的抽象之一就是虚拟内存——它让每个进程都以为自己独享整个地址空间。32位系统下进程看到的是4GB平坦空间,64位系统下则是128TB(48位)甚至更浩瀚的疆域。

虚拟内存带来的三大好处:

隔离——进程A损坏自己的访问不会波及进程B;简化——编译器不用再关心物理地址布局;超额分配——承诺的虚拟内存可以远超物理RAM,按需调页按需分配物理页框。

CPU执行每条指令产生的地址都是虚拟地址(VA),硬件MMU负责将其翻译为物理地址(PA)。这个翻译的"字典"就是页表(Page Table),它由操作系统内核维护、由硬件遍历查询。

二、x86-64 多级页表:四级与五级架构

x86-64采用多级页表来压缩存储。早期的4级页表将48位虚拟地址(256TB)分为9+9+9+9+12五个字段:

层级名称位数用途
L4PML49 bitsPage Map Level 4
L3PDP9 bitsPage Directory Pointer
L2PD9 bitsPage Directory
L1PT9 bitsPage Table
Offset—12 bits页内偏移(4KB页)

启动时内核在arch/x86/mm/init.c中构建初始页表,__startup_64设置早期init_level4_pgt建立恒等映射(identity mapping)。

2017年Intel引入5级页表(PML5),将虚拟地址扩展到57位(128PB)。第5级PML5位于最顶层,CR3 → PML5 → PML4 → PDP → PD → PT → Page Offset。内核通过pgtable_l5_enabled()检测CPU支持度,5级页表使每个进程可寻址空间扩大512倍。

三、页表项(PTE)的精细控制

每个PTE(64位)除了物理页框号(PFN)外,还携带关键标志位:

标志位名称含义
_PBIT (0)Present页在物理内存中
_RW (1)Read/Write可写(否则只读)
_US (2)User/Supervisor用户态可访问
_PWT (3)Write Through写直达缓存
_PCD (4)Cache Disable禁止缓存
_A (5)Accessed页已被访问过
_D (6)Dirty页已被写入
_PSE (7)Page Size大页(2MB/1GB)
_G (8)GlobalTLB中不被刷新
_NX (63)No Execute禁止执行(DEP)

AMD的NX(No-Execute)位被Intel采纳为XD(Execute Disable),是现代缓冲区溢出防护DEP的硬件基础。内核通过pgprot_noncached()、pgprot_writecombine()等helper快速设置PTE属性。

四、TLB:页表的高速缓存及其管理

每次地址翻译都要遍历多级页表——4级页表意味着4次内存访问。为了解决这个"递归"问题,CPU内置TLB(Translation Lookaside Buffer)缓存最近使用的VA→PA映射。

TLB的组织形式:通常分L1 ITLB(指令)和L1 DTLB(数据)各64/128项,加上共享的L2 STLB 1536-2048项。现代CPU采用多队列设计(如Zen 4有独立的大页TLB)。

TLB刷新策略

  • INVLPG指令:刷新单个虚拟地址的TLB项,用于页面unmap/unprot等精细操作。
  • mov CR3:切换整个地址空间(进程上下文切换)。
  • PCID:Process-Context Identifier(Intel CR4.PCIDE=1时启用),AMD等效为ASID。每个TLB项附上PCID标签,进程切换时无需刷新TLB。
  • INVPCID指令:选择性刷新指定PCID的TLB项,kernel/fpu/xstate.c中大量使用。

高版本内核(≥5.2)默认启用PCID,CONFIG_X86_PCID=y时switch_mm_irqs_off()中通过load_new_mm_cr3()设置CR3的PCID位,大幅降低上下文切换开销。

五、缺页异常(Page Fault):按需分配的舞台

当PTE的Present位为0(页不在内存)或访问违反权限时,CPU触发Page Fault异常(#PF),错误码压栈(bit0=Present, bit1=WR, bit2=US)。

Linux内核的#PF处理从idtentry page_fault入口进入,最终调用do_page_fault()(x86)→ handle_mm_fault():

int handle_mm_fault(struct vm_area_struct *vma, unsigned long address,
                    unsigned int flags, struct pt_regs *regs)
{
    // 1. 检查VMA权限
    // 2. 遍历页表获取PTE
    // 3. 分类处理:
    //    a) Present=0 → do_anonymous_page() / do_fault()
    //    b) Present=1 但权限不足 → COW / 保护错误
    //    c) 大页缺页 → hugetlb_fault()
}

缺页处理的三个主要分支:

匿名页缺页 → do_anonymous_page():分配一个新的物理页框,清零后填入PTE。这就是fork后写时复制的触发点。

文件页缺页 → do_fault():映射了文件的页不在缓存中时,从磁盘读取数据到page cache再映射到进程地址空间。

写时复制(COW) → do_wp_page():fork后父子共享同一物理页(PTE只读标记),任一进程写入时触发#PF,内核复制该页并重新映射。

COW对性能至关重要:vfork()和clone(CLONE_VM)可以共享mm_struct避免立即大量复制,而fork()写时复制确保不浪费内存。kernel/fork.c中copy_page_range()在fork阶段设置父子PTE为只读。

六、VMA:进程地址空间的"地块管理"

内核不跟踪每个PTE的映射信息,而是通过vm_area_struct(VMA)描述一段连续虚拟地址区域的属性:

struct vm_area_struct {
    unsigned long vm_start;          // 区域起始地址
    unsigned long vm_end;            // 区域结束地址(不包含)
    struct mm_struct *vm_mm;         // 所属地址空间
    pgprot_t vm_page_prot;           // 访问权限(融入PTE)
    unsigned long vm_flags;          // 区域标志
    struct rb_node vm_rb;            // 红黑树节点
    struct list_head vma_link;       // 链表节点(按地址排序)
    ...
};

vm_flags标志位:VM_READ/VM_WRITE/VM_EXEC控制访问权限;VM_SHARED标记共享映射;VM_GROWSDOWN指明栈可向下扩展;VM_SEQ_READ/VM_RAND_READ给readahead hint。

VMA查找与查找优化

find_vma()通过红黑树O(log n)定位包含指定地址的VMA。内核还维护了VMA链表和最近的缓存(vmacache,per-CPU记录最近访问的VMA),加速频繁检查。

栈的动态扩展示例:栈所在VMA有vm_start=0x7fff_1234_0000,vm_end=0x7fff_1234_1000(1页)。当栈入栈超出此范围,CPU产生#PF,expand_stack_downward()检测到VM_GROWSDOWN后vm_start -= 4KB创建新的匿名页映射,实现栈的按需增长(通常上限RLIMIT_STACK,默认8MB)。

七、mmap与brk:用户态内存映射的两大入口

用户态申请内存的两条主路径:

7.1 brk/sbrk:数据段的缓慢向上生长

brk(addr)直接移动mm->brk标记,将数据段(.data/.bss)尾部延伸到指定地址。kernel_brk()处理:

SYSCALL_DEFINE1(brk, unsigned long, brk) {
    struct mm_struct *mm = current->mm;
    if (brk < mm->start_brk) return mm->brk; // 不能缩小到brk下方
    
    newbrk = PAGE_ALIGN(brk);
    oldbrk = PAGE_ALIGN(mm->brk);
    if (newbrk == oldbrk) return mm->brk;
    
    if (brk < mm->brk) do_munmap(mm, newbrk, oldbrk - newbrk); // 收缩
    else return do_brk_flags(oldbrk, newbrk - oldbrk, 0); // 扩展
}

brk分配的匿名页面位于mm->start_brk和mm->brk之间。glibc的malloc(small < 128KB)就使用brk,大malloc走mmap。

7.2 mmap:通用的内存映射接口

mmap(addr, len, prot, flags, fd, off)是Linux中功能最丰富的系统调用。内核入口:

ksys_mmap_pgoff() → vm_mmap_pgoff() → do_mmap()

根据flags分支处理:

  • MAP_ANONYMOUS | MAP_PRIVATE:纯匿名映射。不关联文件,常用于malloc大分配、execve时bss段的初始化。
  • MAP_SHARED:共享映射。多个进程对同一文件的修改相互可见,用于共享内存IPC(mmap MAP_SHARED + MAP_ANONYMOUS)、进程间数据共享。
  • MAP_FIXED:强制映射到指定地址(start参数必需),会覆盖该地址上已有映射。execve加载用户态ELF段时大量使用。
  • MAP_POPULATE:预读(预填充页表),避免后续大量#PF。
  • MAP_HUGETLB:大页映射,绕过4KB粒度。与MAP_HUGE_2MB/MAP_HUGE_1GB组合使用。

remap_file_pages()(已废弃)和mremap()可以移动或扩展映射。madvise()提供hint:MADV_DONTNEED提示内核可以丢弃这些页面(释放物理内存);MADV_HUGEPAGE建议内核合并为大页。

八、Huge Page:减少TLB压力的利器

标准4KB页粒度意味着512个TLB条目只能覆盖2MB物理内存。大页将PSE位置位,单个TLB项覆盖2MB(PD级大页)或1GB(PDP级大页)。

Linux提供两种大页机制:

Transparent HugePage(THP):在khugepaged守护线程扫描器的监控下,内核自动将符合条件的连续小页合并为2MB透明大页。/sys/kernel/mm/transparent_hugepage/enabled控制(always/madvise/never)。对内存密集型应用(数据库/KVM)提升显著,但可能因khugepaged扫描引入延迟抖动,数据库生产环境常设为madvise策略。

HugeTLBFS:通过hugetlbfs文件系统预留2MB或1GB大页池(/proc/sys/vm/nr_hugepages),运行时mmap(MAP_HUGETLB)申请。需要预分配,数量固定,但延迟确定性强。GPU直连GPUDirect RDMA场景常用。

内核通过PMD_SIZE(2MB)和PUD_SIZE(1GB)检查大页条件,transp_hugepage_enabled()和__thp_get_unmapped_area()管理透明大页的映射策略。

九、页框回收:LRU与KSM

物理内存有限,内核必须在内存紧张时回收页框。Linux采用LRU(Least Recently Used)近似算法,每个Zone(Zone_DMA, Zone_DMA32, Zone_Normal, Zone_MOVABLE)维护活跃/不活跃LRU链表。

9.1 kswapd与页面回收

当空闲页低于pages_low时,kswapd内核线程被唤醒;低于pages_min时,分配者进入直接回收(direct reclaim)同步等待——这会引发性能问题。

回收路径:

kswapd → balance_pgdat() → shrink_node() → shrink_lruvec()
        → shrink_list() → shrink_active_list() / shrink_inactive_list()
        → shrink_page_list() → pageout()
  • 文件页(file-backed):直接丢弃(page cache中干净的副本)或写回脏页到磁盘。PG_dirty标志判断。
  • 匿名页(anonymous):交换(swap)到磁盘区域。swap_writepage()完成。

扫描优先级(sc.priority)从12(激进)到0(最激进),越深入越低,对LRU链表头部探查更多页。/proc/sys/vm/swappiness(默认60)权衡匿名页vs文件页回收偏好,降低该值减少swap(如设为1)适用于不希望数据库缓存被swap出去的场景。

9.2 KSM:内存去重的秘密武器

Kernel Samepage Merging (KSM):/sys/kernel/mm/ksm/控制的后台线程ksmd定期扫描内存,内容相同的匿名页被合并成一份只读副本(COW共享),大幅降低虚拟化场景的物理内存占用。

应用场景:一台服务器运行100个相似的KVM虚拟机时,相同的库文件/重复页面只存一份。madvise(addr, len, MADV_MERGEABLE)标记区域允许KSM扫描。pages_shared/pages_sharing统计当前去重效果。代价是ksmd消耗CPU时间进行哈希比较,虚拟化密度极高时收益显著。

十、NUMA感知的内存分配

多核时代NUMA(Non-Uniform Memory Access)成为性能关键:每个CPU节点访问本地内存延迟低、带宽高,跨节点访问则慢得多。

Linux内核为每个NUMA node维护独立的zone列表pglist_data→node_zones[]。分配器alloc_pages_node()按照fallback顺序优先从请求node本地分配,依次扩展到远端node。

task_struct→mempolicy可以绑定进程的内存策略:MPOL_BIND限制只在指定node分配;MPOL_PREFERRED优先从某node分配;MPOL_INTERLEAVED使用round-robin交错分配到所有node(适合大内存带宽需求)。

numactl --membind=node1 --cpunodebind=node1 ./app是最常用的NUMA调优命令。kernel/numa.c中numa_migrate_pages()负责运行时内存迁移,auto NUMA balancing机制自动将页面迁移到访问它的线程所在的NUMA node。

十一、实战:观察虚拟内存工作状态

/proc和/sys提供了丰富的观测接口:

接口用途
/proc/[pid]/maps完整VMA列表(地址、权限、设备、inode、路径)
/proc/[pid]/smaps每个VMA的RSS/PSS/Swap/Referenced/Anonymous详细统计
/proc/meminfo全局内存概览:MemTotal/MemFree/AnonPages/SwapTotal
/proc/vmstat全局VM事件统计:pgfault/pgmajfault/thp_fault_alloc
/proc/sys/vm/dirty_ratio脏页占最大内存比例阈值(触发回写)
/proc/sys/vm/swappiness匿名页回收偏好(0~200)
/sys/kernel/mm/transparent_hugepage/enabledTHP模式(always/madvise/never)

工具链示例:

# 查看进程1234的内存映射,按rss排序
$ cat /proc/1234/smaps | grep -E "^(Rss|Pss|Size|VAnonymous)" | head -30

# 监测缺页异常速率
$ vmstat 1 5

# 查看TLB击落(TLB shootdown)中断数
$ watch -n1 'cat /proc/interrupts | grep -i tlb'

# 检查HugePage使用
$ grep -i huge /proc/meminfo

十二、总结与性能清单

Linux虚拟内存子系统是一个精密的协作链:MMU硬件翻译虚拟地址、多级页表压缩存储映射、TLB缓存加速、#PF处理按需填充、VMA描述区域、LRU+KSM回收与去重、NUMA优化访问延迟。

云计算与虚拟化场景的性能调优清单:

① 对延迟敏感型服务:启用isolcpus隔离CPU,关闭numabalancing减少抖动

② 对内存密集型DB:使用madvise(MADV_HUGEPAGE)或预分配HugePage,避免THP合并延迟

③ 密度型虚拟化:KSM合并重复页,/proc/sys/vm/swappiness=1减少不必要swap

④ 持久内存场景:mmap(MAP_SYNC) + DAX直接绕过页缓存;配合MAP_SHARED_VALIDATE

⑤ 高并发网络服务:考虑SO_MEMINFO监控socket缓冲区,必要时调整tcp_rmem,mmap大缓冲区

虚拟内存是操作系统与硬件最紧密的耦合点之一,理解页表、缺页、回收三个核心脉络,是性能调优和系统编程的必备功底。


本文代码基于 Linux 6.x 内核版本分析,主要涵盖 x86_64 架构。相关内核源码路径:arch/x86/mm/、mm/memory.c、mm/mmap.c、mm/page_alloc.c、mm/swap.c。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部