上一篇把 Linux VM 的主线压成“先承诺,再兑现”。这一篇先看承诺本身:一个进程说“这段地址可以读写”“这段地址来自某个文件”“这段地址不能访问”,这些信息在内核里不是按字节存成数组,而是按区间组织成一组 VMA。

用户态看见的是地址。内核看见的是地址空间、区间、权限和来源。

1
virtual address -> mm_struct -> vm_area_struct -> policy

mm_struct 描述一个用户态地址空间,vm_area_struct 描述其中一段连续虚拟地址区间。页表回答“这个虚拟页当前能不能翻译到物理页”,VMA 先回答另一个问题:“这个地址原则上是不是属于进程,应该按什么规则处理”。

/proc/self/maps 看地址空间

先从最容易观察的地方开始。运行一个普通进程,查看 /proc/<pid>/maps,会看到类似这样的行:

1
2
3
4
55f2b6f3a000-55f2b6f3b000 r--p 00000000 08:01 131104 /usr/bin/cat
55f2b6f3b000-55f2b6f40000 r-xp 00001000 08:01 131104 /usr/bin/cat
7f7f8d000000-7f7f8d021000 rw-p 00000000 00:00 0
7ffd1f1d0000-7ffd1f1f1000 rw-p 00000000 00:00 0 [stack]

每一行都是一段虚拟地址区间,而不是一个页表项。字段大致可以读成:

字段 含义
start-end 虚拟地址半开区间
rwxp 读、写、执行、私有/共享权限
offset 文件映射里的文件偏移
dev:inode 文件所在设备和 inode
pathname 文件路径,或匿名映射、栈、堆等标识

Linux man-pages 把 /proc/<pid>/maps 定义为进程当前映射内存区域及其访问权限的视图。这个文件很适合做第一层观察,因为它正好站在用户态和 VMA 之间:不是源码级结构体,但已经比“一个指针”接近内核模型。

注意这几点。

第一,maps 里的区间大小可以很大,也可以只有一页。它不表示物理页已经存在,只表示这段虚拟地址被某条规则覆盖。

第二,匿名映射的 pathname 可能为空。用户态代码里一个 mmap(... MAP_ANONYMOUS ...),通常就会生成这种没有文件路径的 VMA。

第三,相邻区间可能来自同一个文件,但权限不同。可执行文件常见的 r--pr-xprw-p 分段,本质上是不同 VMA。内核不能把权限不同的区间粗暴合成一段。

模式提炼:地址空间是区间集合

1
address_space = ordered non-overlapping ranges

地址空间不是一条从 0 到最大地址的巨大数组。更准确的模型是一组有序、不重叠的区间。每个区间挂着一组属性。

模型 适合解释 不适合解释
大数组 指针算术、连续地址错觉 稀疏映射、权限分裂、文件映射
区间集合 VMA、mmapmprotectmunmap 单个虚拟页的 PTE 状态
页表树 硬件地址翻译 为什么某段地址合法、来自哪里

读 Linux VM 时,先把“地址”还原成“落在哪个区间”。这是进入内核对象的第一步。

mm_struct 是地址空间总账

一个用户态进程的地址空间由 struct mm_struct 描述。线程共享同一个地址空间,所以同一进程里的多个线程通常引用同一个 mm_struct。内核文档也把 mm_struct 称作所有共享该虚拟地址空间任务共同引用的对象。

mm_struct 不是进程本身。调度、信号、文件描述符、凭证、namespace 都有自己的结构。mm_struct 只从内存角度回答问题:

问题 mm_struct 里的角色
页表根在哪里 指向顶层页表
有哪些 VMA 管理 VMA 的 maple tree
地址空间如何并发访问 mmap_lock 等锁
统计量如何维护 RSS、VMA 数量、页表页等计数
用户态边界在哪里 代码段、数据段、堆、栈等地址信息

可以把 mm_struct 看成一个地址空间的索引页。它自己不代表每一段内存的细节,但能带到 VMA 集合、页表和相关计数。

这也解释了为什么内核线程有时没有自己的 mm。内核线程不执行普通用户态地址空间,不需要一份属于自己的用户地址空间总账;但它运行在 CPU 上时仍然需要某个页表上下文,所以内核里还有 active_mm 这类细节。这个点后面讲页表和上下文切换时再展开。

VMA 是一段地址的策略

struct vm_area_struct 描述一段连续虚拟地址区间。内核官方文档给出的基本定义很克制:用户态内存范围由 VMA 跟踪,每个 VMA 描述一段虚拟连续且属性相同的内存范围。

这句话里的“属性相同”很重要。VMA 的边界通常不是按业务对象切出来的,而是按内核策略切出来的。只要权限、来源、偏移、操作方法不同,就可能需要不同 VMA。

一个 VMA 至少要回答这些问题:

字段类型 典型字段 回答的问题
虚拟布局 vm_startvm_end 这段区间从哪里到哪里
所属地址空间 vm_mm 它属于哪个 mm_struct
权限和标志 vm_flagsvm_page_prot 能不能读写执行,页表权限如何生成
文件来源 vm_filevm_pgoff 是否 file-backed,对应文件哪个偏移
操作方法 vm_ops fault、close、split 等行为由谁处理
反向映射关系 anon_vma 物理页回头怎样找到映射它的 VMA

VMA 不是页。一个 VMA 可以覆盖很多页,也可以暂时一个物理页都没有。它更像一条策略记录:如果未来访问落在这段区间,内核应该怎样解释这次访问。

模式提炼:VMA 是缺页处理的上下文

1
fault_address -> find_vma(mm, address) -> fault policy

page fault 发生时,CPU 只告诉内核“这个地址访问失败了”。内核要先在当前 mm_struct 里找到覆盖该地址的 VMA。找不到,通常就是非法访问;找到了,才继续判断权限、匿名页、文件页、COW 或 swap。

fault 地址落点 VMA 查找结果 后续含义
落在匿名可写 VMA 命中 可以分配匿名页
落在文件映射 VMA 命中 可以走文件 fault / page cache
落在私有只读 COW VMA 命中 写入时可能复制页面
落在两个 VMA 间隙 未命中 通常是非法地址
落在可扩展栈附近 特殊处理 可能扩展栈 VMA

VMA 的价值就在这里:它把“一个失败的地址翻译”放回进程地址空间的语义里。

Maple tree 为什么适合 VMA

很多旧材料会说 VMA 用红黑树组织。这个说法对旧内核成立,但现代 Linux 已经把 VMA 管理切到 maple tree。官方文档把 maple tree 描述为一种为非重叠范围优化的 B-tree 数据结构,VMA 跟踪是它最重要的使用场景之一。

VMA 集合有几个典型操作:

操作 例子
按地址查找区间 page fault 找覆盖 fault 地址的 VMA
插入新区间 mmap 建立新映射
删除区间 munmap 移除映射
分裂区间 mprotect 改中间一段权限
合并相邻区间 相邻 VMA 属性兼容时减少碎片
找空洞 mmap(NULL, ...) 选择可用地址

这些操作天然都是区间操作。maple tree 适合存不重叠范围,也支持查找给定大小的空洞。对 VMA 来说,这比“给每一页建一条记录”更接近真实需求。

这里有一个源码阅读上的小提示:看到 mas_* 前缀,不要以为是另一个内存子系统。mas 来自 maple state,很多 VMA 遍历、查找、插入代码都会围绕这个状态对象推进。

一个实验:让 VMA 分裂

mprotect 很适合展示 VMA 不是“一个 mmap 调用一条永久记录”。下面这个程序先映射 4 页匿名内存,然后把中间 1 页改成只读。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

static void show_maps_hint(void *base, size_t length) {
printf("mapped range: %p - %p (%zu bytes)\n",
base, (char *)base + length, length);
printf("inspect with: grep -n '' /proc/%d/maps\n", getpid());
}

int main(void) {
size_t page = (size_t)sysconf(_SC_PAGESIZE);
size_t length = page * 4;

void *base = mmap(NULL, length,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS,
-1, 0);
if (base == MAP_FAILED) {
fprintf(stderr, "mmap failed: %s\n", strerror(errno));
return 1;
}

show_maps_hint(base, length);
printf("stage 1: one writable VMA, press enter\n");
getchar();

void *middle = (char *)base + page;
if (mprotect(middle, page, PROT_READ) != 0) {
fprintf(stderr, "mprotect failed: %s\n", strerror(errno));
munmap(base, length);
return 1;
}

printf("stage 2: middle page is read-only, press enter\n");
getchar();

if (mprotect(middle, page, PROT_READ | PROT_WRITE) != 0) {
fprintf(stderr, "mprotect restore failed: %s\n", strerror(errno));
munmap(base, length);
return 1;
}

printf("stage 3: permissions restored, press enter\n");
getchar();

munmap(base, length);
return 0;
}

编译运行:

1
2
cc -Wall -Wextra -O2 vma_split.c -o vma_split
./vma_split

在另一个终端查:

1
2
cat /proc/<pid>/maps
cat /proc/<pid>/smaps

第一阶段,通常会看到一段连续的 rw-p 匿名映射。第二阶段,中间一页变成只读,这段映射就可能分裂成三段:

1
2
3
...-... rw-p 00000000 00:00 0
...-... r--p 00000000 00:00 0
...-... rw-p 00000000 00:00 0

第三阶段恢复权限后,内核如果判断相邻 VMA 属性兼容,可能把它们再合并回去。

这个实验抓住了 VMA 的本质:VMA 边界服务于“相同属性”这个约束,而不是服务于用户态的一次调用。一次 mmap 可以被后续 mprotect 拆开,多个相邻区间也可能被内核合并。

模式提炼:局部属性变化会切开区间

1
2
range[A: rw] + mprotect(subrange, r)
-> range[left: rw] + range[middle: r] + range[right: rw]

区间结构里最常见的变化就是 split 和 merge。文件系统 extent、数据库 range partition、编译器 source range、内存 VMA 都有类似模式:只要中间一段属性变了,原区间就不能继续作为一个整体存在。

操作 区间变化
mmap 插入新区间,必要时找空洞
munmap 全段 删除区间
munmap 中间 原区间分裂成左右两段
mprotect 中间 原区间按权限分裂
连续兼容映射 相邻区间可能合并

这个模式会在后续页回收和 rmap 里继续出现。区间不是静态目录,而是会随着系统调用和 fault 路径不断重写。

mapssmaps 看见的不是同一层

maps 主要展示映射区间和权限。smaps 会在每个映射下面追加更细的内存统计,例如 Size、Rss、Pss、Shared_Clean、Private_Dirty、AnonHugePages、VmFlags 等。

这两个文件经常一起看,但它们不回答同一个问题。

文件 更适合回答
/proc/<pid>/maps 进程有哪些 VMA,权限和来源是什么
/proc/<pid>/smaps 每个映射实际占了多少 RSS、脏页、共享页

这一区分很重要。VMA 是地址空间的承诺;RSS 是已经驻留在内存里的页面统计。一个 1GB 的匿名 VMA,如果只写入了 4KB,它的 VMA 很大,但 RSS 可以很小。

所以排查内存问题时不能只看 mapsmaps 能告诉区间从哪里来,smaps 才能继续看它到底兑现了多少页面。

VMA 并发:为什么锁这么复杂

VMA 不是只在系统调用里被访问。page fault 也要查 VMA,/proc 读取要遍历 VMA,回收路径可能通过反向映射找到 VMA,mmapmunmapmprotect 又会修改 VMA 集合。读多写少,而且读路径很热。

因此 Linux VM 里的 VMA 锁不是一个粗暴的大锁。官方文档把相关锁分成几层:

粒度 典型用途
mmap_lock 整个 mm_struct 稳定地址空间和 VMA 集合
VMA lock 单个 VMA 减少 page fault 读路径对 mmap_lock 的依赖
rmap lock 匿名或文件反向映射 从物理页反查 VMA 时稳定关系
page table lock 页表页 修改具体页表项

这张表先不用背。第二篇只需要记住一件事:VMA 是共享元数据,读写都必须先让它稳定。文档特别提醒,锁住 VMA 元数据不等于锁住它描述的内存,也不等于锁住页表。区间合法性、物理页状态、页表项状态是三层不同对象。

这个区别会在 page fault 文章里变得非常具体。一个 fault handler 既要稳定 VMA,又要在合适的时候拿页表锁,还可能参与匿名页、文件页、COW 等不同路径。

从源码读这一层

读 VMA 这一层,先抓几个入口:

入口 读它的目的
include/linux/mm_types.h mm_structvm_area_struct 字段
mm/mmap.c mmapmunmap、VMA 插入和合并
mm/mprotect.c 看权限变化怎样改 VMA 和页表
fs/proc/task_mmu.c /proc/<pid>/mapssmaps 怎样从 VMA 生成
mm/memory.c 看 page fault 怎样查 VMA 并进入后续路径
include/linux/maple_tree.h 看 maple tree 接口和 ma_state

不要一上来追所有调用。更好的方式是带着实验反推源码:mprotect 让一段匿名映射分裂成三段,那么源码里一定有“拆区间、改权限、必要时合并”的逻辑。先找这个结构,再看锁和错误处理。

研究生迁移表

Linux VM 概念 更一般的系统概念 迁移含义
mm_struct 全局上下文对象 不同子系统先要找到自己的“总账”
VMA 区间元数据 稀疏空间通常按范围而不是按点管理
maple tree 区间索引 范围查找、空洞查找、顺序遍历需要专门结构
mmap_lock 粗粒度一致性边界 写路径简单,读路径可能需要更细粒度优化
VMA split/merge 区间重写 局部属性变化会改变全局结构
maps / smaps 观测层分离 元数据视图和资源兑现视图不能混用

这个迁移表适用于很多系统。虚拟地址空间、文件 extent、LSM tree 的 key range、数据库分区、编译器 source map,都有“稀疏区间 + 属性 + split/merge”的影子。

常见误解

第一个误解是把 VMA 当成页表项。VMA 是区间策略,页表项是硬件翻译记录。VMA 可以存在而 PTE 还不存在,这正是 lazy allocation 和 demand paging 的基础。

第二个误解是认为一次 mmap 永远对应一个 VMA。后续 mprotectmunmapmremap 都可能拆分或合并 VMA。maps 展示的是当前结果,不是系统调用历史。

第三个误解是把 maps 里的大区间直接当成真实内存占用。真实驻留要看 RSS、PSS、dirty 等统计,通常要转向 smapsstatus 或 cgroup 视图。

第四个误解是认为地址空间查找只服务于系统调用。page fault 热路径同样要查 VMA,因此 VMA 查找结构和锁设计会直接影响性能。

练习

第一,运行本文的 vma_split.c,记录三次暂停时 /proc/<pid>/maps 的变化,标出哪几行对应同一段匿名映射。

第二,把 mprotect(middle, page, PROT_READ) 改成 mprotect(base + page, page * 2, PROT_NONE),观察 VMA 是否仍然分裂成三段。

第三,把第三阶段恢复权限的代码删掉,改成 munmap(base + page, page),观察中间打洞后 maps 的变化。

第四,给程序加一次 ((char *)base)[0] = 1,再比较 mapssmaps。注意 VMA 大小和 RSS 的差异。

第五,阅读 Documentation/mm/process_addrs.rst 的 locking 部分,把 mmap_lock、VMA lock、rmap lock、page table lock 分成“稳定元数据”和“修改映射”两类。

系列导航

  • 上一篇:重读 Linux VM:给系统研究生的虚拟内存导读
  • 本文:地址空间不是数组:mm_structvm_area_struct
  • 下一篇:页表:CPU 能读懂的翻译结构

参考资料