引言
虚拟内存是 Linux 内核最核心的基础设施之一。它为每个进程提供独立的、连续的地址空间抽象,将物理内存的细节完全隐藏起来。从malloc()返回的一个指针,到最终 DRAM 上的物理存储单元之间,有着一整套精密的多级页表转换机制在默默工作。本文将深入剖析 Linux 内核页表管理、虚拟内存区域(VMA)、缺页中断处理、页框回收以及内存压缩等核心子系统,从 x86-64 五级页表结构出发,一直写到 KSM 和 zswap 等高级内存优化技术。
1. x86-64 多级页表结构
现代 x86-64 架构使用 4 级或 5 级页表将 48 位(或 57 位)虚拟地址映射到物理地址。4 级页表(4-Level Paging)将 48 位虚拟地址划分为 5 个字段:
63 48 47 39 38 30 29 21 20 12 11 0+----------------+------------+------------+------------+------------+---------+| Sign Ext | PML4 | PDPT | PD | PT | Offset || (全0或全1) | (9位) | (9位) | (9位) | (9位) | (12位) |+---------------+------------+------------+------------+------------+-----------+CR3 ──→ PML4[512] ──→ PDPT[512] ──→ PD[512] ──→ PT[512] ──→ 物理页(4KB)每个页表项(Page Table Entry, PTE)是 64 位,其中约 52 位用于存储物理页框号( PFN ),剩余 12 位存放标志位:
| 标志位 | 含义 |
|---|---|
| _PRESENT (bit 0) | 页面是否在物理内存中 |
| _RW (bit 1) | 0=只读, 1=可写 |
| _USER (bit 2) | 0=仅内核态可访问, 1=用户态可访问 |
| _PWT (bit 3) | Page Write Through(写直达) |
| _PCD (bit 4) | Page Cache Disable |
| _ACCESSED (bit 5) | 页面是否被访问过(由 CPU 自动置位) |
| _DIRTY (bit 6) | 页面是否被写入(由 CPU 自动置位) |
| _PSE (bit 7) | Page Size Extension(大页标志) |
| _GLOBAL (bit 8) | 全局页(TLB 不刷新) |
| _NX (bit 63) | No-Execute(禁止执行) |
Linux 内核将这些硬件标志封装为 pte_t 类型,并通过以下宏访问:pte_present()、pte_write()、pte_dirty()、pte_young() 等。
1.1 五级页表(LA57 / 5-Level Paging)
Intel 自 Ice Lake 和 AMD 自 Zen 4 开始支持 57 位虚拟地址空间,引入 PML5 层级:
CR3 ──→ PML5[512] ──→ PML4[512] ──→ PDPT[512] ──→ PD[512] ──→ PT[512] ──→ 物理页Linux 内核在 pgtable_64.h 中通过 CONFIG_X86_5LEVEL 支持这一特性。编译时使用 CONFIG_X86_5LEVEL=y 和启动参数 no5lvl 可人为禁用五级页表。启用五级页表后,每个进程可拥有 128 PB 的虚拟地址空间(相比 4 级页表的 256 TB)。
2. 内核页表(init_mm / PGD)与用户地址空间
2.1 内核地址空间布局
x86-64 上 Linux 的内核虚拟地址空间按功能划分:
ffff800000000000 ├─ 直接映射区(Direct Mapping / physmap) │ 所有物理内存 1:1 映射到此处 │ phys_addr + PAGE_OFFSET = virt_addr │ (最多 64TB 物理内存直接映射) │ffffc00000000000 ├─ vmalloc/ioremap 区 │ 非连续物理页的虚拟连续映射 │ vmalloc() 从这里分配虚拟地址 │ffffd00000000000 ├─ 固定映射区(Fixmap) │ 启动时固定用途的虚拟地址 │ (如 early_ioremap、FIX_APIC_BASE 等) │ffffe00000000000 ├─ 局部映射区(KASAN / KMSAN影子内存等) │ffffffff80000000 ├─ 内核代码段(_stext 到 _etext) │ 内核镜像所在,RX 权限 │ffffffffa0000000 ├─ 模块映射区(vmalloc 区间的子区间) │ kernel module 加载至此用户空间使用独立的 mm_struct,每个进程都有自己的 PGD(Page Global Directory = PML4/5 表)。切换进程时(schedule),context_switch() 调用 switch_mm_irqs_off() 将新进程的顶级页表物理地址加载到 CR3 寄存器。
2.2 mm_struct 核心字段
struct mm_struct { pgd_t *pgd; // 顶级页表基址(PML4/5) struct vm_area_struct *mmap; // VMA 链表头 struct rb_root mm_rb; // VMA 红黑树根(加速查找) unsigned long mmap_base; // mmap 起始地址(向下扩展) unsigned long task_size; // 用户/内核分界 unsigned long start_code, end_code; // 代码段边界 unsigned long start_data, end_data; // 数据段边界 unsigned long start_brk, brk; // 堆区 unsigned long start_stack; // 栈起始地址 unsigned long arg_start, arg_end; // 命令行参数 unsigned long env_start, env_end; // 环境变量 atomic_t mm_users; // 用户轻量级引用计数 atomic_t mm_count; // 主引用计数 atomic_long_t rss_stat; // 常驻页面计数 spinlock_t page_table_lock; // 页表操作锁 // ...};3. 虚拟内存区域(Virtual Memory Area, VMA)
VMA 是描述进程地址空间中连续虚拟区域的数据结构,它不要求底层的物理页也连续——这正是虚拟内存的核心抽象。
3.1 vm_area_struct
struct vm_area_struct { unsigned long vm_start; // 区域起始地址(页对齐) unsigned long vm_end; // 区域结束地址(不包含) struct vm_area_struct *vm_next, *vm_prev; // 链表 struct rb_node vm_rb; // 红黑树节点 unsigned long vm_pgoff; // 映射文件中的偏移(页为单位) struct file *vm_file; // 映射的文件(匿名映射为 NULL) const struct vm_operations_struct *vm_ops; // 操作函数表 unsigned long vm_flags; // 区域标志 // 标志例:VM_READ | VM_WRITE | VM_EXEC | VM_SHARED | VM_IO | VM_DONTCOPY};VMA 组合方式:mmap() 系统调用可能在多个不相邻的语义区域(代码段、数据段、堆、栈、文件映射、匿名映射)间插上多个 VMA 节点。例如典型的进程内存布局:
0x0000000000400000 ─┐ 代码段 (text, RX)0x0000000000600000 ─┤ 只读数据段 (rodata)0x0000000000601000 ─┤ 数据段 (data, RW)0x0000000000602000 ─┤ BSS 段 (anon, RW)0x00007f1234000000 ─┤ 堆(向上增长,brk/mmap 分配) │ │ ↕ 随机 mmap 区域(PIE 共享库、mmap 文件) │0x7ffd12340000 ──┤ 栈(向下增长,带 stack_guard_gap)0x7fffffffffff ──┘3.2 红黑树 vs 链表
内核需要快速判断一个虚拟地址落在哪个 VMA 中(每次缺页、munmap()、mprotect() 都要用)。如果只用链表,复杂度 O(n)。因此 Linux 从 2.6 起引入了红黑树,复杂度 O(log n)。find_vma() 先走红黑树查,命中后更新一个 mmap_cache 做 LRU 缓存优化(连续小范围缺页大概率命中同一 VMA)。
4. 缺页中断(Page Fault)处理流程
当前代码访问一个虚拟地址,MMU 在 TLB 和页表中都无法找到有效映射,触发 #PF 异常(Vector 14),进入 do_page_fault() → handle_mm_fault():
CPU TLB Miss + Page Table Walk │ ▼ __do_page_fault() (arch/x86/mm/fault.c) │ ┌────────┼────────┐ ▼ ▼ ▼ 内核态异常 用户态 地址在 hole 中 vmalloc 写保护 do_page_fault special Cow SIGSEGV │ │ ▼ ▼ handle_mm_fault() │ ├─ 1. find_vma() 定位 VMA ├─ 2. 若 VM 无 vma → SIGSEGV ├─ 3. 若标志不匹配(写只读页) → SIGSEGV ├─ 4. 若 VMA 标志 VM_FAULT_MAJOR │ → handle_mm_fault → do_anonymous_page() │ 或 filemap_fault() ├─ 5. 若写时复制 (Copy-on-Write) → do_wp_page() ├─ 6. 若页面被交换到 swap → do_swap_page() └─ 7. 将 (virt → phys) 更新到 PTE4.1 写时复制(Copy-on-Write, CoW)
fork() 系统调用时,Linux 不立即复制物理页。而是让父子进程共享同一组物理页,并将两个进程对应 PTE 标记为只读、CoW(VM_MAYWRITE + _PAGE_DIRTY)。任一方尝试写入时触发写保护缺页 → do_wp_page() 复制物理页、更新 PTE 为可写。
著名的 Copy-on-Write 实践还体现在 vfork() 和 posix_spawn() 中——后者在 Linux 上与 clone(CLONE_VFORK | CLONE_VM) 等价,子进程出关键区域前共享父进程地址空间,避免不必要的 CoW。
4.2 Demand Paging(按需调页)
当一个文件通过 mmap(PROT_READ, MAP_PRIVATE) 映射到内存,内核并不立即把所有数据从磁盘读入内存,而是只建立 VMA 并给一个 vm_file。首读访问时触发缺页,内核调用 filemap_fault(),找到最近邻已被缓存的页(pagecache_get_page()),否则从磁盘预读若干页到 page cache(page_cache_sync_readahead())。这就是\"从磁盘加载\"的精确时机——延迟到真正需要的那一刻,即需求调度(demand paging)。
4.3 Transparent Huge Pages (THP)
内核将 2MB 大页(x86-64 下 PMD 级 _PAGE_PSE)和 1GB 巨页(PUD 级)透明地替换 4KB 页,以减少 TLB miss 和页表内存开销。khugepaged 守护线程(标识为 /sys/kernel/mm/transparent_hugepage/khugepaged)周期性扫描进程的匿名内存区域,将连续的 512 个已分配 4KB 页合并为 2MB 透明大页。
5. 页框分配器(Page Frame Allocator / Buddy System)
物理以 4KB \"页框\"(Page Frame)为粒度。内核使用伙伴系统(Buddy System)将空闲页框按阶数(2^n 个连续页框)组织:
zone → free_area[MAX_ORDER] 链表表头0 阶: [4KB] [4KB] [4KB] [4KB] ...1 阶: [8KB] [8KB] [8KB] ...2 阶: [16KB] [16KB] ......10 阶:[4MB] ...分配 n 阶页框时:首先在 free_area[n] 看是否有;没有则从 n+1 阶拆下 1 块,一半留在 n 阶,一半递推向上直到找到空闲。释放时:查看伙伴块(地址 XOR (1 << order))是否在空闲链表中,如果在则合并成更大阶。
硬件层面,x86-64 的 struct zone 包含:DMA(前 16MB)、DMA32(前 4GB)、Normal(最多 64GB,通过直接映射区访问)、Movable(可迁移,用于内存热插拔)。高端内存(highmem)在 64 位系统上不复存在。
5.1 Per-CPU 页面高速缓存(PCP)
避免每个分配都触及伙伴系统的全局 zone->lock 自旋锁,内核在每个 CPU 维护 pageset,填入冷/热页各约 128~256 个。alloc_pages() 先到 PCP 取,free_pages() 先放 PCP。接近高/低水位线时才从伙伴系统批量腾挪。
6. Slab / SLUB 分配器
伙伴系统只能按整页分配(4KB+),但内核需要各种小对象:task_struct(~6KB)、inode(~600B)、dentry(~200B)等。为此在伙伴系统之上叠一层缓存:
- SLAB:基于 Per-CPU 和 Per-Node 三级缓存。后来因高 NUMA 扩展性问题被取代。
- SLOB:嵌入式使用的极简分配器(first-fit),牺牲性能换极简。
- SLUB:Linux 默认分配器。每个 CPU 维护一个活跃 slab 页的 freelist 指针(无锁 fastpath),NUMA 友好,调试选项丰富(Red Zoning、Poisoning)。
SLUB 分配 kmalloc(100, GFP_KERNEL) 时:根据 size —> 在 kmalloc_caches[] 数组中找到最近的 power-of-2 缓存(实际是用 kmalloc-128 缓存),判断该缓存是否有空闲 slab 的 partial 链表可以取出一个空闲对象;没有则 alloc_pages() 从伙伴系统申请新的 4KB 页框,切分成 32 个 128B 对象。
7. 页面回收与 kswapd
当可用物理内存低于阈值pages_low时,kswapd 内核线程被唤醒,开始异步页面回收。
7.1 LRU 链表与反向映射
每个内存 zone 维护活跃/不活跃 LRU 列表(LRU_ACTIVE_ANON、LRU_INACTIVE_ANON、LRU_ACTIVE_FILE、LRU_INACTIVE_FILE、LRU_UNEVICTABLE),共计 5 条。CPU 硬件的 Accessed / Dirty 标志被内核周期性扫描(pagevec 批量判断),从不活跃链表尾部的页面开始回收。
回收过程中,如果一个匿名页被选择丢弃,它必须先写入 swap(swap_writepage());如果是文件页脏页,则先刷盘(writeback),再丢弃干净页。反向映射(Reverse Mapping)机制让内核能高效找到一个物理页的所有 PTE,批量将其标记为不存在,再释放该页。
7.2 Swap 机制
Linux swap 可以是分区或文件(优先分区,路径 /proc/swaps)。swap 分配器维护一个带簇的 \"槽位 → 扇区\" 位图,分配新槽时尽量簇化(4~8 个连续槽)以减少磁盘寻道。页面换出时由 swap_writepage() 经块 I/O 层(bio)写入;换入时 get_swap_bio() 触发异步读。swp_entry_t 类型编码 swap 类型和偏移,被填入 PTE bit 1~63,而保留 bit 0 为 0 表示 \"不在内存\"。
8. 内存压缩与去重
8.1 zswap
zswap 是 swap 前的压缩缓存层。kswapd 要从 LRU 尾回收匿名页时,frontswap 接口将其 zstd/lzo 压缩后写入内存池(zbud/zpool),而非磁盘。命中换入时先到 zswap 解压,跳过磁盘 I/O。/sys/kernel/debug/zswap/ 有命中率统计。
8.2 zram
zram 是一个基于内存的块设备(伪磁盘),可以作为 /dev/zram0 挂载为 swap。分配匿名页时:zswap 先将压缩数据抛到 zram 块设备,再在前台使用。优点是无需磁盘写入,适合无盘 NAS / 嵌入式 / 云虚机。
8.3 KSM(Kernel Same-page Merging)
KSM 扫描进程或虚拟机内存,寻找内容相同的页。它使用红黑树作为稳定存储,树节点为 rmap_item。两个候选页先比较 PTE 标志和内容(memcmp),若内容一致,保留一个物理页,让两 PTE 共享它、标记为只读 CoW,另一个页释放。
KSM 在 KVM 环境下极具价值:运行 10 个 CentOS VM 时,KSM 通常能合并 30%~50% 的重复内存。高密度虚拟化场景下节省 GB 级别内存。代价是 CPU 开销更高(ksmd 内核线程持续扫描),/sys/kernel/mm/ksm/ 可调节扫描间隔。
9. 进程地址空间操作的系统调用
| 系统调用 | 功能 | 内核入口 |
|---|---|---|
| brk() / sbrk() | 扩展/收缩数据段末端(堆顶) | do_brk_flags() |
| mmap() | 创建新的 VMA 映射 | vm_mmap_pgoff() |
| munmap() | 撤销映射,释放 VMA + 物理页 | do_munmap() |
| mremap() | 重新映射(可移动) | do_mremap() |
| mprotect() | 修改 VMA 权限 | do_mprotect_pkey() |
| madvise() | 给内核以提示(MADV_DONTNEED, MADV_HUGEPAGE) | madvise() |
| mlock() | 固定页面不让换出 | do_mlock() |
| mincore() | 查询页面是否常驻内存(page resident) | do_mincore() |
10. 性能调优与可观测接口
下面总结实际部署中关键的 /proc /sys 接口:
// 1) THP 策略(always / madvise / never)echo madvise > /sys/kernel/mm/transparent_hugepage/enabled// 2) swappiness(0~200),控制匿名页 vs 文件页的交换倾向echo 10 > /sys/vm/swappiness// 3) page-cluster:swapin 预读页数(2^n),nvme 上可调高到 5echo 5 > /proc/sys/vm/page_cluster// 4) min_free_kbytes:保留给关键分配的最低空闲内存echo 262144 > /proc/sys/vm/min_free_kbytes// 5) overcommit:0=启发式,1=永远允许,2=严格(不超 CommitLimit)echo 0 > /proc/sys/vm/overcommit_memory// 6) KSM 扫描间隔(毫秒)echo 200 > /sys/kernel/mm/ksm/pages_to_scan可观测工具一览:
free -h— 系统内存总览,含 buff/cache、available 项vmstat 1— si/so(swap 进/出)、page faults、inactive/active 页面pmap -x <PID>— 进程内存映射详细 RSS / 模式perf stat -e dTLB-loads,dTLB-load-misses— TLB miss 统计bpftrace -e 'software:page-faults:1 { @[comm] = count(); }'— 缺页进程分布
11. 总结
Linux 虚拟内存子系统是一个多层协作的精巧架构:硬件提供 TLB 和页表遍历的 fastpath;内核分配器(伙伴 → SLUB → PCP)负责高效供给物理页;缺页中断处理在运行时动态构建、修补映射;kswapd + LRU 在内存压力下回收;zswap / KSM / zram 做压缩与去重的高级优化。
掌握这套机制,不仅能优化应用性能(减少 TLB miss、避免 swap thrash),更能理解容器、数据库、DPDK 等重度内存方案的内核行为。比如 DPDK 大页(HugeTLBFS)绕过伙伴系统、直接 mmap 2MB/1GB 页,正是对常规页框分配器的替代方案;而 Redis 的 THP 禁用策略(echo never > ...)则是因为随机写模式下 THP merge/split 的抖动反而拖慢了 fork(官方文档建议)。
无论是在嵌入式设备上做 meminfo 分析、还是在高并发服务器上排查 RSS 虚高,理解从 PTE → VMA → LRU → kswapd → swap 这条链路,是 Linux 内核工程师的基本功。

发表评论 取消回复