地址空间不是数组:mm_struct 和 vm_area_struct
上一篇把 Linux VM 的主线压成“先承诺,再兑现”。这一篇先看承诺本身:一个进程说“这段地址可以读写”“这段地址来自某个文件”“这段地址不能访问”,这些信息在内核里不是按字节存成数组,而是按区间组织成一组 VMA。
用户态看见的是地址。内核看见的是地址空间、区间、权限和来源。
1 | |
mm_struct 描述一个用户态地址空间,vm_area_struct 描述其中一段连续虚拟地址区间。页表回答“这个虚拟页当前能不能翻译到物理页”,VMA 先回答另一个问题:“这个地址原则上是不是属于进程,应该按什么规则处理”。
从 /proc/self/maps 看地址空间
先从最容易观察的地方开始。运行一个普通进程,查看 /proc/<pid>/maps,会看到类似这样的行:
1 | |
每一行都是一段虚拟地址区间,而不是一个页表项。字段大致可以读成:
| 字段 | 含义 |
|---|---|
start-end |
虚拟地址半开区间 |
rwxp |
读、写、执行、私有/共享权限 |
offset |
文件映射里的文件偏移 |
dev:inode |
文件所在设备和 inode |
pathname |
文件路径,或匿名映射、栈、堆等标识 |
Linux man-pages 把 /proc/<pid>/maps 定义为进程当前映射内存区域及其访问权限的视图。这个文件很适合做第一层观察,因为它正好站在用户态和 VMA 之间:不是源码级结构体,但已经比“一个指针”接近内核模型。
注意这几点。
第一,maps 里的区间大小可以很大,也可以只有一页。它不表示物理页已经存在,只表示这段虚拟地址被某条规则覆盖。
第二,匿名映射的 pathname 可能为空。用户态代码里一个 mmap(... MAP_ANONYMOUS ...),通常就会生成这种没有文件路径的 VMA。
第三,相邻区间可能来自同一个文件,但权限不同。可执行文件常见的 r--p、r-xp、rw-p 分段,本质上是不同 VMA。内核不能把权限不同的区间粗暴合成一段。
模式提炼:地址空间是区间集合
1 | |
地址空间不是一条从 0 到最大地址的巨大数组。更准确的模型是一组有序、不重叠的区间。每个区间挂着一组属性。
| 模型 | 适合解释 | 不适合解释 |
|---|---|---|
| 大数组 | 指针算术、连续地址错觉 | 稀疏映射、权限分裂、文件映射 |
| 区间集合 | VMA、mmap、mprotect、munmap |
单个虚拟页的 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_start、vm_end |
这段区间从哪里到哪里 |
| 所属地址空间 | vm_mm |
它属于哪个 mm_struct |
| 权限和标志 | vm_flags、vm_page_prot |
能不能读写执行,页表权限如何生成 |
| 文件来源 | vm_file、vm_pgoff |
是否 file-backed,对应文件哪个偏移 |
| 操作方法 | vm_ops |
fault、close、split 等行为由谁处理 |
| 反向映射关系 | anon_vma 等 |
物理页回头怎样找到映射它的 VMA |
VMA 不是页。一个 VMA 可以覆盖很多页,也可以暂时一个物理页都没有。它更像一条策略记录:如果未来访问落在这段区间,内核应该怎样解释这次访问。
模式提炼:VMA 是缺页处理的上下文
1 | |
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 | |
编译运行:
1 | |
在另一个终端查:
1 | |
第一阶段,通常会看到一段连续的 rw-p 匿名映射。第二阶段,中间一页变成只读,这段映射就可能分裂成三段:
1 | |
第三阶段恢复权限后,内核如果判断相邻 VMA 属性兼容,可能把它们再合并回去。
这个实验抓住了 VMA 的本质:VMA 边界服务于“相同属性”这个约束,而不是服务于用户态的一次调用。一次 mmap 可以被后续 mprotect 拆开,多个相邻区间也可能被内核合并。
模式提炼:局部属性变化会切开区间
1 | |
区间结构里最常见的变化就是 split 和 merge。文件系统 extent、数据库 range partition、编译器 source range、内存 VMA 都有类似模式:只要中间一段属性变了,原区间就不能继续作为一个整体存在。
| 操作 | 区间变化 |
|---|---|
mmap |
插入新区间,必要时找空洞 |
munmap 全段 |
删除区间 |
munmap 中间 |
原区间分裂成左右两段 |
mprotect 中间 |
原区间按权限分裂 |
| 连续兼容映射 | 相邻区间可能合并 |
这个模式会在后续页回收和 rmap 里继续出现。区间不是静态目录,而是会随着系统调用和 fault 路径不断重写。
maps 和 smaps 看见的不是同一层
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 可以很小。
所以排查内存问题时不能只看 maps。maps 能告诉区间从哪里来,smaps 才能继续看它到底兑现了多少页面。
VMA 并发:为什么锁这么复杂
VMA 不是只在系统调用里被访问。page fault 也要查 VMA,/proc 读取要遍历 VMA,回收路径可能通过反向映射找到 VMA,mmap、munmap、mprotect 又会修改 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_struct 和 vm_area_struct 字段 |
mm/mmap.c |
看 mmap、munmap、VMA 插入和合并 |
mm/mprotect.c |
看权限变化怎样改 VMA 和页表 |
fs/proc/task_mmu.c |
看 /proc/<pid>/maps、smaps 怎样从 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。后续 mprotect、munmap、mremap 都可能拆分或合并 VMA。maps 展示的是当前结果,不是系统调用历史。
第三个误解是把 maps 里的大区间直接当成真实内存占用。真实驻留要看 RSS、PSS、dirty 等统计,通常要转向 smaps、status 或 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,再比较 maps 和 smaps。注意 VMA 大小和 RSS 的差异。
第五,阅读 Documentation/mm/process_addrs.rst 的 locking 部分,把 mmap_lock、VMA lock、rmap lock、page table lock 分成“稳定元数据”和“修改映射”两类。
系列导航
- 上一篇:重读 Linux VM:给系统研究生的虚拟内存导读
- 本文:地址空间不是数组:
mm_struct和vm_area_struct - 下一篇:页表:CPU 能读懂的翻译结构


