本文深入解析 Linux 内核的虚拟内存区域(VMA)子系统——进程地址空间的核心抽象。从红黑树到 mmap_lock,从缺页调优到大页透明,带你看清 mm_struct 背后的完整机制。

一、VMA 的设计哲学

1.1 问题的本质

每一个用户进程都独占一个"巨大的"虚拟地址空间(64 位上 128TB)。一个典型进程的内存布局杂乱无章:代码段、数据段、堆、共享库 mmap 区、栈、vdso、vvar……它们大小不同、权限各异、生命周期交错。

内核如何用统一的数据结构描述这些"区域"?答案就是 struct vm_area_struct,简称 VMA。

1.2 核心抽象模型

用户进程的地址空间本质上是一个"区间集合"。每个 VMA 描述一个连续的虚拟地址区间:

进程地址空间(mm_struct)
┌─────────────────────────────┐
│  [0x400000) ELF .text       │  VMA: 只读+可执行
│  [0x600000) ELF .data       │  VMA: 读写
│  [0x7f0000000000) 堆        │  VMA: 读写 (向上增长)
│  [随机gap]                  │  (无映射)
│  [0x7f...8000) mmap 区域    │  VMA: 读写/可执行 (多个)
│  [栈guard page]             │  (无映射)
│  [0x7ffd00000000) 栈        │  VMA: 读写 (向下增长)
│  [0x7fff00000000) vdso      │  VMA: 只读+用户可执行
└─────────────────────────────┘

VMA 不存储实际数据——它只是"一个区间上的策略描述":这段虚拟内存的权限是什么?背后有没有物理页?如果是文件映射,对应哪个文件的哪个偏移?

二、数据结构全解析

2.1 mm_struct — 进程地址空间的总管家

struct mm_struct {
    struct {                          // VMA 管理核心
        struct rb_root mm_rb;         // VMA 红黑树根节点
        struct rw_semaphore mmap_lock; // 地址空间读写信号量
        struct vm_area_struct *mmap;  // VMA 链表头(排序链表)
    };

    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; // 环境变量区

    pgd_t *pgd;                       // 页全局目录(顶级页表)

    atomic_t mm_users;                // 活跃使用者数
    atomic_t mm_count;                // 引用计数

    // 大页相关
    atomic_long_t hugetlb_usage;

    // NUMA 策略
    struct mmu_gather *mm_mt;         // 用于延迟 TLB 刷新

    // 缓存控制
    struct file __rcu *exe_file;      // 可执行文件
};

关键字段说明:

  • mm_rb:所有 VMA 组织的红黑树,O(log n) 区间查找
  • mmap_lock:读写信号量,保护整个 VMA 树/链表,是内存操作最重要的锁
  • mmap:链表头,按地址排序的 VMA 链表,遍历很快
  • pgd:顶级页表指针,直接用于 MMU 硬件遍历

2.2 vm_area_struct — 区间的完整描述

struct vm_area_struct {
    unsigned long vm_start;           // 区间起始(包含)
    unsigned long vm_end;             // 区间结束(不包含)
    struct mm_struct *vm_mm;          // 所属地址空间

    pgprot_t vm_page_prot;            // 页保护属性(R/W/X/User)
    unsigned long vm_flags;           // 行为标志(下面详解)

    struct {                          // 红黑树/链表节点
        struct rb_node vm_rb;
        unsigned long rb_subtree_last;
    };
    struct list_head vm_next;         // 链表后继

    const struct vm_operations_struct *vm_ops; // 操作函数集

    unsigned long vm_pgoff;           // 文件映射:文件内偏移(页为单位)
    struct file *vm_file;             // 映射的文件(匿名映射为 NULL)
    struct anon_vma *anon_vma;        // 匿名映射的反向映射链

    // 大页与特殊映射
    struct vm_special_mapping *vm_private_data; // vdso/vvar 等
};

vm_flags 是理解 VMA 行为的钥匙:

标志 含义 典型场景
VM_READ 可读 所有 VMA
VM_WRITE 可写 堆/栈/mmap(文件写映射)
VM_EXEC 可执行 代码段、共享库代码段
VM_SHARED 共享 MAP_SHARED mmap、fork 后共享 VM_MAYWRITE
VM_GROWSDOWN 向下扩展 栈
VM_GROWSUP 向上扩展 某些架构的堆
VM_DONTCOPY fork 不继承 某些特殊映射
VM_DONTEXPAND 不自动扩展 vdso
VM_PFNMAP 直接映射物理页 设备 mmap
VM_MIXEDMAP 混合PFN和struct page 大页+普通页混合
VM_HUGETLB 大页映射 hugetlbfs
VM_SOFTDIRTY 软脏页跟踪 /proc/pid/clear_refs
VM_ARCH_1..N 架构特定 各平台扩展

2.3 两种查找路径

内核用红黑树和链表同时维护 VMA,各取所长:

查找场景              使用结构         时间复杂度
单次 addr→VMA        红黑树           O(log n)
遍历全部 VMA         链表(按地址排序)  O(n)
区间查询(插入/删除)  红黑树          O(log n)
fork 复制全部        链表             O(n)

红黑树节点关键值 = vm_start,但巧妙的是还维护了 rb_subtree_last(子树中最大 vm_end),使得"判断新区间是否重叠"可以提前剪枝。

三、VMA 的生命周期

3.1 创建 VMA

创建路径主要有三:

1. do_mmap()       ← mmap()/mremap()/MAP_SHARED 等用户调用
2. elf_map()       ← execve() 加载 ELF 文件时
3. stack expanding ← 栈缺页时自动向低地址扩展

do_mmap() 的完整流程:

do_mmap(file, addr, len, prot, flags, vm_flags, pgoff)
  → get_unmapped_area()       // 在地址空间找一个合适空位
  → mmap_region()             // 插入 VMA
      → kmem_cachep 分配 vm_area_struct
      → vma_link()           // 插入红黑树 + 链表
      → call_mmap(file)      // 如果有文件,调用 file->f_op->mmap()
      → 返回(不分配物理页——按需分配!)

关键点:mmap 只创建 VMA,不分配物理页。这后面的缺页中断才真正把虚拟地址和物理页帧绑定。

3.2 合并与拆分

合并(vma_merge):

当一个新 VMA 与前后相邻的 VMA 具有相同的 vm_flags、文件映射、权限等属性时,内核会自动合并为一个更大的 VMA,避免 VMA 数量膨胀。

现有:[0x1000-0x2000) RW  [0x2000-0x3000) RW  [0x3000-0x4000) RW
新映射:[0x2000-0x3000) RW → 跳过(已存在)
新映射:[0x1800-0x2800) RW → 合并为 [0x1000-0x3000) RW

拆分(split_vm):

当对一段 VMA 的子区间执行保护变更(mprotect)、权限修改、或 madvise 时,内核必须将一个 VMA 拆成多个。

现有:[0x1000-0x4000) RW
执行:mprotect(0x2000, 0x1000, PROT_READ)
结果:[0x1000-0x2000) RW  [0x2000-0x3000) R  [0x3000-0x4000) RW
(一个 VMA → 三个 VMA)

这就是为什么频繁的 mprotect 会导致 VMA 数量暴增(可达数百万),拖慢 fork/exit/mproc 等操作——每遍历一次链表都是 O(n)。

3.3 删除 VMA

do_munmap(addr, len)
  → find_vma()               // 找到覆盖目标区间的 VMA
  → split_vma()              // 如果是部分解除,先拆分
  → unmap_region()           // 遍历页表,清除映射 + 释放物理页
      → free_pgtables()      // 回收页表自身占用的内存
  → remove_vma()             // 从红黑树 + 链表移除
  → kmem_cache_free()        // 释放 VMA 结构体

注意:解除映射时,已修改的文件映射会回写磁盘,匿名映射的修改则永久丢失(交换到 swap 的也不恢复)。

四、缺页中断 — VMA 到物理页的桥梁

4.1 缺页处理的完整路径

每个 VMA 在创建时都不对应物理页。当 CPU 访问某个没有映射的虚拟地址时,触发缺页异常(Page Fault),MMU 把控制权交给内核的缺页处理程序。

// x86_64 缺页入口
do_page_fault(regs, error_code)
  → __do_page_fault()
      → find_vma(addr)              // 1. VMA 查找——这个地址合法吗?
        → 没找到 → SEGV (段错误)
      → 检查 vm_flags vs access_type // 2. 权限检查
        → 写只读页 → SEGV or COW
        → 执行不可执行页 → SEGV
      → handle_mm_fault()           // 3. 真正的缺页处理
          → hugetlb_fault()          // 大页路径
          → or:
          → handle_pte_fault()       // 普通页路径
              → do_fault()           // a. 文件映射缺页
                  → file->f_op->fault()  // 从磁盘读入
              → do_swap_page()       // b. 被换出的页
                  → swapin_readahead()
              → do_anonymous_page()  // c. 匿名页首次使用
                  → alloc_zeroed_user_highpage()

4.2 三种缺页类型

文件映射缺页(Major Fault): - 访问内容已在 page cache → 只需 PTE 指向缓存页(Minor Fault) - 内容不在 page cache → 从磁盘读入,阻塞进程(Major Fault) - 典型场景:首次执行共享库中的函数、首次mmap后访问大文件

匿名映射缺页: - 访问从未写过的新匿名页 → 分配零页(Lazy,实际是全局零页的第一个写时触发 COW) - 典型场景:malloc 大内存后首次读写

换出页缺页(Swap Fault): - 匿名页被 kswapd 换出 → 从 swap 设备读回 → 阻塞进程 - 典型场景:系统内存紧张后重新访问之前分配但长期未用的内存

4.3 写时复制(COW)

COW 是 fork() 高效的核心。fork 后父子共享同一物理页,但全部标记为只读。任一进程首次写入时触发 COW 缺页,内核复制一份新页。

fork() 前:进程A → 物理页P [RW]
fork() 后:进程A → 物理页P [R] (COW)
          进程B → 物理页P [R] (COW)
A 写 → 缺页 → 分配新页P' → A 指向P'[RW] → B 保持P[R]

性能影响:COW 是 Web 服务器(Nginx/Java 等)fork 后紧跟高内存写入场景的主要性能杀手——每次 COW 都触发缺页中断(~1-5μs),大量 fork 会导致严重延迟。使用 vfork() 或 posix_spawn() 可避免无意义的 COW。

五、mmap_lock 与并发性能

5.1 mmap_lock 的作用

mmap_lock 是一个读写信号量(rw_semaphore),保护:

  1. VMA 红黑树/链表的完整性(防止并发插入/删除导致数据结构损坏)
  2. 缺页处理中的一致性(防止并行操作改变地址空间布局)
读锁持有者可并发:多个线程同时触发缺页
写锁独占:mmap/munmap/mprotect/expand_stack

5.2 mmap_lock 的性能瓶颈

在多线程应用中,mmap_lock 是一个非常常见的争用点:

场景 1:密集 mmap/munap 操作
  → 写锁阻塞所有缺页(读锁)→ 延迟飙升

场景 2:大量线程同时首次分配大内存

  → 多个匿名页缺页竞争同一个读锁
  → 虽然读锁可并发,但 cache line bouncing 仍然致命(特别是 NUMA)

场景 3:ptrace / /proc/pid/maps 读取
  → 读锁被 mmap 写锁阻塞 → top/strace 卡顿

5.3 优化方向:lockless 与 分区锁

Linux 5.x 引入的关键优化:

  1. VMA 遍历 lockless:通过 mas_lockless() 机制,在不需要修改 VMA 树的情况下进行无锁遍历,减少读锁竞争。
  2. 页表锁(PGT):对页表修改使用更细粒度锁,替代 mmap_lock 的部分职责。
  3. mapcount 引用计数:通过 mapcount 跟踪页被多少 VMA 引用,安全释放无需持有 mmap_lock。

六、大页(Huge Pages)与透明大页

6.1 传统大页(hugetlbfs)

显式调用 mmap(MAP_HUGETLB) 或通过 hugetlbfs 文件系统直接分配。

  • 大小:x86_64 上 2MB 或 1GB
  • 永不换出,内核启动时预分配池中预留
  • 减少 TLB miss 和页表遍历(5 级页表下,2MB 大页只需要 PMD 一级映射)
// 显式大页映射示例
void *ptr = mmap(NULL, size, PROT_READ|PROT_WRITE,
                 MAP_PRIVATE|MAP_ANONYMOUS|MAP_HUGETLB,
                 -1, 0);

6.2 透明大页(THP)

让应用"不知不觉"地享受大页的好处——内核在后台扫描匿名页,自动合并成 2MB 大页。

工作流程:
1. 应用申请匿名内存(默认 4KB 页)
2. khugepaged 内核线程异步扫描
3. 发现连续 512 个有效 4KB 页 → 合并为 2MB 大页
4. 后续分配使用 pmd_alloc_huge_pmd() → 直接分配 2MB

开关策略:

/sys/kernel/mm/transparent_hugepage/enabled = "madvise"
  always   → 所有匿名映射都尝试 THP
  madvise  → 仅 MADV_HUGEPAGE 标记的区域(默认,大多场景推荐)
  never    → 关闭

数据库场景建议关闭 THP(Oracle/Redis/Postgres 官方均推荐):THP 的碎片化延迟和 khugepaged CPU 开销在内存密集数据库场景下有负面影响。

# 数据库服务器推荐配置
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag

6.3 vm_max_map_count 与 VMA 上限

/proc/sys/vm/max_map_count 控制单个进程最多可拥有的 VMA 数量:

默认值:65530(老版本)→ 65536(现代)
典型超标场景:
  - Java JIT 编译器频繁 mmap 可执行代码区
  - Redis 使用 THP 且开启 AOF rewrite 时大量 mmap
  - Go runtime 的内存分配器大量小 VMA

调整:
  sysctl -w vm.max_map_count=262144

VMA 过多的后果:

  • fork() 变慢(复制 VMA 树 + 页表)
  • 缺页路径 find_vma() 耗时增加(虽然红黑树 O(log n),但常数变大)
  • /proc/pid/maps 输出膨胀 → top/strace 卡顿

七、反向映射(Reverse Map)机制

7.1 为什么需要反向映射

当内核需要回收一个物理页时,必须知道"哪些进程的哪些 VMA 引用了这个页"。如果没有反向映射,回收一个共享库代码页需要扫描所有进程的页表——这是不可接受的。

7.2 anon_vma — 匿名页的反向映射

匿名页(堆、栈、MAP_ANONYMOUS)通过 anon_vma 实现反向映射:

内存页 (page)
  ↔
anon_vma 结构(描述一组共享匿名映射的 VMA)
  ↔
链表中的多个 VMA

COW 时,fork 后子进程继承父的 anon_vma(通过 anon_vma_chain 链接),并在自己首次写入时脱离原 anon_vma。

7.3 优先搜索树(Prio Tree)

文件映射使用基于区间红黑树的优先搜索树,高效回答"给定一个页帧,哪些页表项指向它?"。

八、实战诊断

8.1 VMA 映射查看

# 进程完整 VMA 布局
cat /proc/<pid>/maps

# 统计 VMA 数量 (wc -l 行数)
grep -c '.' /proc/<pid>/maps

# 查看大页使用情况
grep -i huge /proc/<pid>/smaps
grep -E 'AnonHugePages|ShmemPmdMapped' /proc/meminfo

8.2 缺页统计

# 单次进程缺页统计
ps -o min_flt,maj_flt,cmd <pid>

# 系统级别缺页速率
vmstat 1
  → 关注 si/so (swap in/out) 和 re (页回收)

# 详细统计
grep -E 'pswpin|pswpgfault|pgmajfault' /proc/vmstat

8.3 mmap_lock 争用分析

# 使用 perf 检测 mmap_lock 等待
perf trace -e 'rwsem:*' -p <pid>

# 更精确的锁争用检测(需要 lock_stat)
 echo 1 > /proc/sys/kernel/lock_stat
 # ...运行工作负载...
 cat /proc/lock_stat | grep mmap_read
 echo 0 > /proc/sys/kernel/lock_stat

九、常见问题与调试

9.1 OOM Killer 的选择逻辑

当内存耗尽时,OOM Killer 选择受害进程的公式:

oom_score = (占用页面数 × 1000 / 总内存) × 10^(oom_score_adj / 1000)

影响因素:
- RSS 大小(越大越高)
- oom_score_adj(-1000 到 +1000,-1000 永不 kill)
- 特权进程轻微减分
- 运行时间越长,分数越低(避免 kill 重要进程)

VMA 数量本身不直接影响 OOM 评分,但 VMA 越多意味着更多间接开销(页表、anon_vma 等),加剧内存压力。

9.2 栈溢出 vs MAP_GROWSDOWN

经典问题:当栈扩展到刚好碰到相邻 mmap 区域时,Linux 默认允许栈向下扩展(guard page 机制),但如果程序跳过 guard page 一次性超限,将触发 SIGSEGV 而非栈溢出合并。

# 设置栈大小限制
ulimit -s unlimited  # 允许无限栈(危险!慎用)
ulimit -s 8192       # 8MB 默认线

9.3 mmap 与文件截断的竞争(Truncate Race)

一个经典的 TOCTOU 漏洞:

线程 A:mmap(fd) → 开始映射文件
线程 B:truncate(fd, 0) → 文件变空
线程 A:访问映射区域 → 访问已释放页帧 → BUS ERROR (SIGBUS)

解决方案:文件映射时使用 MAP_PRIVATE(写时复制,截断不受影响)或持有文件锁。

十、演进趋势

10.1 Maple Tree(Linux 6.1+)

用新的 Maple Tree(MA树)替代 VMA 红黑树 + 双重链表:

优势:
- 区间查询更高效(原本是 O(log n)+O(k),现在是直接定位)
- 减少锁争用(支持 RC )
- 简化代码路径(一种结构替代 rb+list)

Maple Tree 是一种适用于区间(range-based)数据的 B 树变体,节点可存多个区间端点。

10.2 DAMON — 数据访问监控

Linux 5.15+ 引入的框架,可以非侵入地监控应用的内存访问模式,指导 THP 合并决策:

工作流:
1. 划定监控区域(一组 VMA)
2. 周期性采样 PTE access bit
3. 输出热力图 → 自动调整 NUMA 页面迁移 / THP 合并范围

10.3 CXL 内存与 VMA 扩展

Compute Express Link(CXL)引入持久内存容量层级,未来 VMA 可能新增:

  • VM_CXL_TIER:标识 CXL 连接的内存层级
  • 跨 NUMA 节点的 VMA 迁移策略
  • 由 DAMON 驱动的自动冷热分层

总结

VMA 是 Linux 内存管理的"骨架"——每个进程的地址空间就是一棵精心组织的 VMA 树。理解 VMA 的结构、查找算法、生命周期以及它们如何通过缺页中断与物理页绑定,是理解整个虚拟内存子系统的关键。

关键数字速查:

  • VMA 查找:红黑树 O(log n),单次约 50-200 CPU cycles
  • mmap_lock 等待:多线程超过 1000+ threads 时常 >10% wall time
  • 缺页开销:Minor ~1-5μs,Major 取决于磁盘延迟(ms 级),Swap 介于两者之间
  • VMA 上限:默认 65536,可通过 vm.max_map_count 调高
  • COW 触发:fork 后首次写时,~10-50μs(需要复制整个 4KB 页)

从 Slab 分配到 VMA,Linux 内存管理的每个子系统都是一块精妙的拼图——它们共同支撑起了从 kB 到 PB 规模的内存管理。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
/* 跳过导航链接 (无障碍) */ position: absolute; top: -100px; left: 15px; z-index: 99999; padding: 8px 16px; background: #007bff; color: #fff; font-size: 14px; border-radius: 0 0 4px 4px; text-decoration: none; transition: top 0.2s; } top: 0; outline: 3px solid #0056b3; }