第一次正经读Linux内核内存管理源码的人,十个里有八个会被struct vm_area_struct劝退:名字长、字段多、注释里还全是老前辈留下的"此地危险"警告。但等你真正在线上排查过几次"内存怎么又涨了几十GB"、或者被偶发的缺页异常性能尖刺折磨过之后,就会明白VMA(Virtual Memory Area,虚拟内存区)是绕不开的一课。VMA是内核为每个用户进程维护的"虚拟地址空间地契",它把从0到TASK_SIZE这一大段虚拟地址划分成一段段具有相同属性和映射对象的区间;缺页异常按它裁决生死,mmap按它登记地块,甚至gdb看病态进程也得靠它编译出maps视图。这篇就围绕用户内存空间里的VMA,把它的结构、生命周期、合并分裂、以及真实排查案例一次讲透,适合正在啃mmap和缺页异常源码的人,也适合天天对着/proc/pid/maps发呆的运维。
1. 先搞定概念:VMA到底圈住了地址空间里的哪块地
1.1 虚拟地址空间不是一块整地,而是一排贴着地契的小地块
每个用户进程都拥有一片虚拟地址空间,在x86-64 4级页表下大约128TB,也就是从0到0x7ffffffff000这个范围。内核当然不可能给这128TB全部建立页表,实际上初始状态几乎什么都没有:页表只有极小一部分,剩下的全凭"登记信息"来决定能不能碰。这份登记信息就是VMA。
VMA描述一段连续的、属性相同的虚拟地址区间。区间是半开区间[vm_start, vm_end),start在低地址、end在高地址,大小必须是页的整数倍。每一段都记录了权限(可读/可写/可执行)、映射对象(是哪个文件、偏移多少)、行为特征(是否共享、能不能向下增长、是不是IO映射)等元信息。
打个比方:进程的地址空间像一张城市用地规划图,VMA就是上面一块块标注了用途的地块;两块地之间没被登记的空地是未映射区域,谁访问了那里,内核直接送一个SIGSEGV。当你执行malloc一大块内存、mmap一个文件、加载一个动态库、创建一条线程栈时,内核做的事情本质上都是"在规划图上登记新地块"。
1.2 /proc/pid/maps就是一份现成的VMA花名册
Linux内核很多源码看不进去没关系,但/proc/pid/maps一定要会读,因为它的每一行就是一个VMA。比如:
00400000-0040b000 r-xp 00000000 fd:01 1234567 /usr/bin/simple 0060a000-0060b000 r--p 0000a000 fd:01 1234567 /usr/bin/simple 0060b000-0060d000 rw-p 0000b000 fd:01 1234567 /usr/bin/simple 7f4c2c000000-7f4c2c1e0000 r-xp 00000000 fd:01 2345678 /usr/lib/libxyz.so.1 7f4c2c3e0000-7f4c2c3e3000 rw-p 001e0000 fd:01 2345678 /usr/lib/libxyz.so.1 7f4c2c5e0000-7f4c2c608000 rw-p 00000000 00:00 0 [heap] 7ffd3a200000-7ffd3a223000 rw-p 00000000 00:00 0 [stack]每列从左到右依次是:虚拟地址区间、权限、文件内偏移、设备号、inode、映射路径。权限部分rwx之后跟的p代表私有映射,s代表共享映射。内核在生成这个伪文件时,做的事情就是遍历当前mm_struct里的所有VMA,把vm_start、vm_end、vm_flags、vm_file这些字段翻译成一行文本。换句话说,你看到的就是VMA结构体的"人话版本"。
比maps更细的是/proc/pid/smaps,它在每个VMA底下额外列出RSS、PSS、Swap等统计。这里的RSS是按VMA拆开的,不是ps里那个笼统数字。遇到"内存不知道被谁吃了"的问题,我一般先看smaps里哪个VMA的RSS最大,再顺着映射路径和偏移去代码里找元凶。
提示:/proc/pid/smaps_rollup 把整个进程所有VMA的统计汇总成一份,省得自己逐段相加,排查大内存问题时特别省事。
2. vm_area_struct逐字段拆解:从边界到回调,每个成员都有用
当年为了讲清楚这个结构,我把它简化成下面这样——真正的内核头文件include/linux/mm_types.h里还有更多条件编译字段,但核心骨架不变:
struct vm_area_struct { unsigned long vm_start; /* 区间起始地址,含 */ unsigned long vm_end; /* 区间结束地址,不含 */ struct mm_struct *vm_mm; /* 所属进程的内存描述符 */ unsigned long vm_flags; /* 权限与行为标志位 */ pgprot_t vm_page_prot; /* 页表项要用的保护位 */ struct file *vm_file; /* 映射的文件,匿名映射为NULL */ unsigned long vm_pgoff; /* 在文件中的页偏移 */ const struct vm_operations_struct *vm_ops; /* 缺页等回调表 */ struct anon_vma *anon_vma; /* 匿名页反向映射锚点 */ void *vm_private_data; /* 设备驱动私有数据 */ };2.1 vm_start/vm_end:半开区间与"按页对齐"的规矩
这两个字段定义了VMA的管辖范围。用半开区间[start, end)而不是闭区间,好处非常多:计算区间大小时直接end - start就完事,不需要加一减一;判断地址是否在某段内时,可以统一用"vm_end大于addr"来比较,这也是find_vma()能够高效工作的前提。
另一个容易被新手忽略的点:VMA的边界必须按页对齐。虚拟内存的管理粒度是页,不可能出现一个VMA从地址0x1000的第3个字节开始。vm_pgoff记录的是文件内的页偏移,配合vm_start/vm_end就能算出"这段虚拟地址对应的文件数据范围":假设vm_pgoff是N,那么这段VMA映射的就是文件从第N页开始、长度和虚拟区间一样的数据。
我在看maps时经常有一个疑惑:同一个可执行文件为什么会出现三段VMA,一段r-xp、一段r--p、一段rw-p?答案是权限不一样,内核必须把它们拆成独立地块。三段VMA也许物理上对应同一个inode的同一段文件,但虚拟属性和保护位不同,就不能合并成一段。
2.2 vm_flags:权限位如何决定缺页异常和mprotect的走向
vm_flags是VMA的"身份证",它同时影响两个层面:一是硬件页表保护位的生成,二是缺页异常时的软件裁决。基础权限位就四个:VM_READ(0x01)、VM_WRITE(0x02)、VM_EXEC(0x04)、VM_SHARED(0x08)。
除了这四位,还有一组"预算位":VM_MAYREAD、VM_MAYWRITE、VM_MAYEXEC。这组位的含义是"将来通过mprotect最多能扩展到什么权限"。比如你用mmap(PROT_NONE)创建了一段不可访问区域,但带上VM_MAYREAD,之后就可以用mprotect把它变成可读;如果没带MAY位,mprotect想加权限都会被拒绝。这就是为什么很多安全敏感场景会故意用PROT_NONE + 白名单授权的方式圈地。
其他高频行为位里,VM_GROWSDOWN和VM_GROWSUP专门用于栈区;VM_DONTCOPY表示fork时不需要复制这段VMA;VM_IO、VM_PFNMAP通常出现在设备驱动把物理内存直接暴露给用户态的映射里;VM_ANON表示匿名映射;VM_LOCKED对应mlock锁页。缺页异常处理时,内核会先检查"地址落在VMA里吗",再检查"这次访问的读写执行类型跟vm_flags匹配吗",不匹配就是经典的Segmentation fault。
2.3 vm_file/vm_pgoff/vm_ops:从虚拟区间回到文件与驱动
文件映射的VMA里,vm_file保存的是打开文件描述对应的struct file指针,它持有对文件的引用计数,防止文件被提前释放。vm_pgoff是文件内页偏移,有了它,缺页异常就能算出"虚拟地址A对应的文件位置是pgoff + (A - vm_start) / PAGE_SIZE"。
vm_ops是一组回调函数,常见的有fault、page_mkwrite、huge_fault、access等。普通文件映射的fault处理会落到通用逻辑,而从设备驱动mmap出来的VMA,驱动通常会自定义fault回调,把设备内存的物理页映射进来。你在/proc/pid/maps里看到一些没有路径、权限又是rw-s的段,多半就是驱动映射。
anon_vma这一项很多人看不明白,它是为"反向映射"服务的。正向映射是"虚拟地址 -> 物理页",靠页表;反向映射是"物理页 -> 哪些进程的哪些虚拟地址在用",这就要靠anon_vma把所有引用同一匿名页的VMA串起来。它同时决定了匿名VMA能不能合并,后面的章节会再碰到。
2.4 链表+红黑树到maple tree:VMA查找机制的一次代际更替
我早年间读的代码里,每个VMA还带着vm_next/vm_prev双向链表指针,同时vm_mm里挂着一棵红黑树。链表负责遍历/proc/pid/maps这种顺序输出,红黑树负责快速查找find_vma()。两套结构要维护一致,节点还有rb_subtree_gap这样的字段记录区间间隙,代码很绕。
Linux 6.1合入的maple tree彻底改变了这个局面:VMA不再需要同时维护链表和红黑树,而是统一存在mm->mm_mt这棵maple tree里,遍历和查找都通过"VMA迭代器"完成。内核开发者为此写了大量mas_find、vma_find这类代码。对普通读者来说,你只需要记住:
- 旧内核里查VMA是红黑树O(log n),遍历是链表O(n);
- 新内核(6.1起)统一用maple tree,查找效率依然是对数级,但内存局部性更好,维护也简化了。
find_vma()是缺页异常路径上的高频函数,它的性能直接影响每一笔内存访问的缺页开销,这一版数据结构升级对高并发场景是实打实的收益。
3. 从mmap()到缺页异常:VMA是怎么出生、又怎么参与裁决的
3.1 mmap的幕后:找空地、查限额、尝试合并、挂入树
调用mmap()之后,内核走的是vm_mmap_pgoff() -> do_mmap()路径。第一步是给新映射找一块空闲的虚拟地址区间,这个动作在get_unmapped_area()里完成。它会参考你传入的hint地址,但不会保证一定用那个地址,因为还要做对齐检查、避开已经占用的VMA、考虑mmap_base与brk分界等限制。
找到地址后,内核马上做两道检查:一是看VMA数量是否已经超过vm.max_map_count,二是看这次映射是否会导致虚拟内存总量超过RLIMIT_AS。这两道坎决定了mmap会不会返回ENOMEM。
接下来的动作非常有趣:内核不会无脑创建一个新VMA,而是先调vma_merge()试试能不能跟相邻的已有VMA合并。能并掉就直接扩展旧VMA,省掉一个新节点;并不掉才真正分配vm_area_struct、初始化字段、用vma_iter_store()把它插入maple tree。
这也是为什么"连续多次mmap同一文件"在很多情况下并不会让VMA数量暴涨——内核天生就有合并倾向。只有那些无法合并的情况(比如权限不同、anon_vma不同、偏移不连续)才会让VMA数量线性增长。
3.2 缺页异常入口的find_vma:一次查询决定生死
虚拟地址第一次被访问时,CPU会发现页表里没有对应项,于是陷入缺页异常。在x86架构的处理流程里,内核拿到出错地址后干的第一件事就是调用find_vma()去VMA集合里查询:是否存在一个vm_end > addr的VMA?
如果找到了,还要确认addr是否落在[vm_start, vm_end)内。如果addr比该VMA的vm_start还小,内核会看这个VMA有没有VM_GROWSDOWN标志,有的话尝试向下扩展栈,没有的话照样判定非法访问。整个过程在do_page_fault到handle_mm_fault之间完成,任何一步不满足都是SIGSEGV。
缺页异常是内存子系统里最热的路径,所以内核把find_vma实现得非常快:maple tree的查找在cache里就能完成,加上现代内核的"fault-around"机制一次缺页会顺手填充附近多个页表项,进一步减少缺页次数。理解这条路径后,你就能明白为什么"程序刚启动时一堆缺页"是正常的——所有匿名页和文件页都是懒加载的,第一次真正读写才分配物理页。
3.3 文件页与匿名页缺页:VMA让内核在岔路口分流
进入handle_mm_fault()之后,VMA开始真正"分流"。内核根据vm_file和vm_flags判断该走哪条路:
- 匿名映射:vm_file为NULL,走
do_anonymous_page(),需要在物理内存里分配一个新页(读场景可能用全局零页优化),建立PTE映射; - 文件映射:走
filemap_fault(),先查页缓存,没缓存就调底层readpage/readahead把数据从磁盘读上来; - 写时复制:如果PTE被标记为写保护、而VMA本身是可写的,那说明这是COW页,内核在
wp_page_copy()里判断VMA是否带VM_SHARED——共享映射就直接改写PTE,私有映射则复制物理页再更新PTE。
我见过不少同学在调试"为什么修改了共享内存没生效"时一头雾水,其实根源就在这个分流逻辑里:两个进程用MAP_SHARED映射同一文件,VMA带VM_SHARED,写入会直接改页缓存;如果误用了MAP_PRIVATE,写入变成COW,改动只留在本进程的私有页里,对面进程当然看不到。
3.4 栈的"偷偷长大":VM_GROWSDOWN的按需扩展
用户态栈在maps里通常就是一行rw-p [stack],但它的VMA一直在变。当函数调用层数加深、栈需求超过当前VMA大小时,CPU访问到vm_start下沿附近的地址,缺页异常发现"addr在VMA下方一点点",且VMA带VM_GROWSDOWN标志,就会尝试调用expand_stack()向下扩展这个VMA。
扩展不是无限制的,内核要先检查下面有没有别的VMA或guard gap,再检查扩展后的总大小是否还在RLIMIT_STACK限制内。这套"按需增长"设计让每个线程的栈初始时都很小,只有用到了才扩张,否则几万个线程光栈就吃掉海量虚拟空间。
栈溢出的经典表现也和VMA有关:扩展碰到guard page或者超过rlimit时,内核拒绝扩展,后续访问直接段错误。所以调试栈溢出别只盯着"为什么会段错误",多想想"为什么这次扩展失败了"。
4. VMA合并与分裂:内核的"内存洁癖"是一把双刃剑
4.1 合并条件比想象中苛刻:映射对象、anon_vma、pgoff都要对得上
VMA合并的核心函数是vma_merge(),它判断"新区间是否可以直接并进前一个或后一个VMA"。判断条件包括但不限于:
- 映射对象相同:vm_file要么都是同一个文件指针,要么都是NULL;
- 权限和标志位一致:vm_flags、vm_page_prot不能有差异;
- 文件偏移连续:新区间的vm_pgoff要正好等于相邻VMA的vm_pgoff加上它的页数;
- anon_vma匹配:匿名映射合并要求anon_vma相同;
- 其他特殊属性一致:比如NUMA策略、userfaultfd设置等。
这条规则解释了为什么"顺序mmap同一个文件"可以合并,而"每个匿名mmap各走各的anon_vma"往往合并不了。合并成功后,VMA数量不变,但区间变大;这也让后续缺页异常能在更大范围内做fault-around,提升局部性。
4.2 分裂是常态:mprotect/MAP_FIXED/munmap怎么切碎一块地
有合并就有分裂。mprotect()想改一段地址的权限时,如果这段地址只覆盖某个大VMA的中间部分,内核必须先调用split_vma()把这个VMA切成左、中、右三段,中间的改成新权限,两边保持原样。MAP_FIXED指定地址覆盖已有映射时,同样要先拆分旧VMA。munmap()只解除中间一段时也会分裂出两个残留VMA。
所以你会发现,一个长期运行的进程,如果频繁做"部分mprotect、部分unmap、随机MAP_FIXED",VMA会被切得越来越碎。每次mmap都在尝试合并,但因为碎片边界权限不匹配,合并往往失败,VMA数量只增不减。
我曾在一个态势感知系统里见过某个模块,每秒钟对同一个缓冲区做几十次局部mprotect再还原,结果进程的VMA数量从几百涨到五万多。代码逻辑上没错,但内核在为它持续制造分裂节点。
4.3 VMA碎片化的代价:ENOMEM、fork变慢、缺页变卡
VMA数量过多不是无害的,代价是实打实的:
- 首先是
vm.max_map_count默认65530这个硬顶,超过之后mmap直接返回ENOMEM,即使物理内存还有大量空闲; - 其次是fork成本:
dup_mmap()要复制整个VMA集合,VMA越多fork越慢,高频fork的服务会明显感受到; - 再就是缺页路径:maple tree的查找虽然是对数级,但VMA数量大、cache miss概率高,find_vma和后续的权限判断都会变慢;
- 每个VMA在内核里还占着一两百字节的节点内存,几十万个VMA就是几十MB的内核内存。
内核的"内存洁癖"其实是一种自我保护:尽量合并、减少节点,让查找和复制都保持轻盈。我们写用户态代码时也应当顺应它,少做无谓的"小mmap + immediate munmap",多用内存池复用,能明显减少VMA碎片。
5. 用VMA知识排过的三个现场:从max_map_count到RSS谜案
5.1 场景一:内存明明还有空,malloc却报"cannot allocate memory"
某网关服务突然开始报"cannot allocate memory",线上free一看物理内存还剩几十GB,Swap也没满。第一反应是overcommit问题,但/proc/sys/vm/overcommit_memory一直是默认值。后来用cat /proc/<pid>/maps | wc -l一数,VMA数量卡在65530附近,dmesg里也出现了max_map_count相关提示。
根因是模块每次处理一批数据都用mmap申请一块几十KB的匿名内存,用完再munmap。按道理一map一unmap不会涨VMA,但它中间穿插了mprotect改权限、部分unmap等操作,把合并条件破坏了,VMA只增不减。临时把vm.max_map_count从65530调到200000后服务恢复,但这不是治本,最后还是改了代码,把所有小块映射改成进程启动时一次性mmap一个大池子,按偏移分配。
经验是:遇到ENOMEM先别急着加max_map_count,先数VMA、看smaps,确认是虚拟地址空间碎片还是物理内存不足。后者加参数没用,真正要修的是分配模式。
5.2 场景二:频繁mmap/munmap引起服务响应抖动
另一个服务表现是高峰期响应时间出现周期性毛刺,用perf record一看,热点全在do_page_fault、handle_mm_fault和vma_merge上,/proc/pid/status里的voluntary_ctxt_switches倒正常。进一步用perf stat -e page-faults对比后发现,毛刺时段缺页异常数量暴涨。
这个服务的自研内存池有一个"动态缩容"功能:空闲超过阈值就把整块内存munmap,再次需要时再mmap回来。典型的高频抖动模式:mmap新区域后首次访问全是缺页,每个页都要走一遍do_anonymous_page分配物理页;munmap又要拆VMA、释放页表、做TLB无效化。这套动作在高并发下互相放大。
修法是引入MADV_DONTNEED代替munmap:告诉内核这块区域暂时用不上、物理页可以回收,但VMA和虚拟地址保留。下次访问只是缺页重新分配页,省掉了建VMA、拆VMA的完整链路。毛刺立竿见影地消失了。
5.3 场景三:RSS居高不下,靠smaps锁定一段"来历不明"的匿名区
某个数据处理进程RSS一直涨,Top里看得到,但业务代码里找不到明显的泄漏点。我先用cat /proc/<pid>/smaps_rollup看总量,然后逐个VMA看smaps的RSS排序,很快就发现一个大约2GB的匿名VMA,权限rw-p,没有路径,附近区域是[heap]的延伸。
顺着vm_start的分布规律,发现它在堆区之上、mmap区之下,典型的brk分配特征。再对比代码里的数据结构,找出了罪魁祸首:一个全局容器在收到消息后不断追加元素,但某条异常路径只清空了容器内容没有释放底层内存,导致brk堆只涨不缩。虽然这不是传统意义的"内存泄漏",但在VMA视角下,它表现为一个持续扩大、永不缩小的匿名地块。
那次排查之后我养成了习惯:内存问题一律先看VMA再看业务代码。因为VMA是内核视角的"登记簿",它不会骗你;而malloc库和业务代码都可能把问题藏起来。
最后分享一个我自己的小技巧:排查任何进程内存问题,第一件事永远是打开/proc/pid/maps和smaps_rollup,把VMA按RSS排序,先搞清"哪块地最大、谁圈的地、是什么权限",再顺着映射路径和地址分布去追业务代码。VMA这个东西平时看不见摸不着,但mmap、缺页、fork、mprotect、coredump全都围着它转。把它的字段含义、生命周期和合并分裂机制想明白了,比背一百个内核函数名都管用。