每个进程都以为自己独占一整条地址线,这份错觉由 mm_struct 和它名下的一串 VMA 支撑.这篇讲地址空间怎么划分成区间,区间怎么合并怎么拆分 — 这套看似枯燥的簿记,恰恰是 mmap,mprotect,munmap 全家行为的关键.实验纯用户态就能做得非常干净:自己 mmap 自己看自己的 maps,四步变化一步一拍.
这篇回答几件事:
- mm_struct 和 VMA 各管什么
- 什么时候 VMA 会合并,什么时候会拆分
- /proc/self/maps 每一列什么含义
- malloc 和堆的真正关系
先摆容器.mm_struct 是进程地址空间的"户口",链着页表根,地址空间大小,代码段数据段边界,还有最重要的:全部 VMA.每个 VMA(vm_area_struct)描述一段连续虚拟地址区间:起止,权限,背后是匿名内存还是文件,文件偏移.VMA 是内核的账页,不是硬件的 — 硬件只认页表,VMA 是"这段地址我答应过你什么"的合同.缺页时内核拿着故障地址找到 VMA,合同说可写就补一页进来,说没登记就 SIGSEGV,上一章讲缺页时再展开.
maps 是合同的公开副本,每行一段 VMA:
ffff88c8e000-ffff88c8f000 rw-p 00000000 00:00 0
五列:区间,权限(rwx 加 p 私有/s 共享),文件偏移,设备号:inode,最后是文件路径 — 全零无名的就是匿名内存.实验开演,第一步合并:连续三次 mmap,每次一页,参数完全相同:
三次独立 mmap: a=...8e000 b=...8f000 c=...90000
---- 三次 mmap 后 ----
ffff88c8e000-ffff88c93000 rw-p 00000000 00:00 0
maps 里只剩一行.三次独立的系统调用,三次独立的"请给我一页",在内核里被合成一个 VMA — mmap_region 入口先问 vma_merge:新区间和前后的 VMA 相邻且属性(权限,文件,pgoff 连续性)完全一致,就并进去,不建新对象.为什么这么执着:VMA 越少,查找越快,内存越省,而用户接口上根本没人分得清自己那三页是三个映射还是一个.
第二步拆分,mprotect 把中间页改成只读:
ffff88c8e000-ffff88c8f000 rw-p
ffff88c8f000-ffff88c90000 r--p <- 被改的中间页
ffff88c90000-ffff88c93000 rw-p
一行变三行.权限是 VMA 级属性,改半截只能先把 VMA 劈开(split_vma),中间那段换新权限.所以 mprotect 一页的代价可能是"多两个 VMA 对象加一次区间树调整",频繁对同一区域动权限是给自己挖坑.第三步挖洞,munmap 中间页:
ffff88c8e000-ffff88c8f000 rw-p
ffff88c90000-ffff88c93000 rw-p
洞两边的 VMA 永久分家 — 即使属性相同也不合并,因为 pgoff 不再连续,合了就把两个不相干的区间谎报成一个.合并的判定条件里"虚拟相邻且文件偏移连续"缺一不可,这是防语义错误的底线.
malloc 和堆的关系也顺手验了.glibc 的 malloc 小块从 [heap] 段拿 — 那是 brk 指针撑起来的 VMA,伸缩由内核 brk 系统调用配合;大块直接 mmap,实测 malloc(4MB) 返回的地址落在一个独立的 4MB 匿名 VMA 里,和 [heap] 毫无关系.分界线默认 128KB.这也解释了 free 的两种命运:小块还回 malloc 自己的池,大块直接 munmap 归还内核 — 用 smaps 观察常驻内存时,两种行为的曲线形状完全不同.
VMA 的组织结构值得记一笔历史:老内核用红黑树,查找 O(log n);6.1 起换成 maple tree,RCU 友好的 B 树变体,区间查询更快,配合近年的 per-VMA 锁,多线程并发缺页不再挤一把大锁.用户态看不见这些,但 /proc/PID/maps 的输出速度和大规模进程(fork 百万 VMA 的 JVM)的启动时间能感觉到.地址空间的安全边界也立在这:VMA 权限是内核检查的第一道门,尽管硬件页表位才是最终执法者,两边不一致就是漏洞(曾经的 Dirty COW 就在这条缝上做文章).
最后留几个容易忽略的细节:
- vvar 和 vdso 两行是内核塞给每个进程的礼物页,时钟数据和小系统调用的用户态实现,5.1 篇提过 vDSO,maps 里它们永远在场.
- mmap 的 MAP_FIXED 会强行替换既有映射,拿它对齐地址时要小心,一错就把自己代码段掀了,现代写法用 MAP_FIXED_NOREPLACE.
- 栈也是 VMA,rwxp 里的 x 在多数发行版默认关着(NX),maps 里栈永远没有 x,看到有就该警觉.
- mprotect 改权限只改 VMA 和之后的页表,已缓存的部分指令(ICache)不自动失效,做 JIT 生成代码要 mprotect 后跳转前清缓存,ARM 上这是真问题.
- [heap] 的 brk 和 mmap 区之间隔着巨大的地址空隙,ASLR 让每次运行布局都不同,别把上次进程的地址硬编码进这次.
- VMA 有数量上限(vm.max_map_count,默认 65530),超大进程 mmap 段过多会撞墙,报错疑似内存不足,实际是"合同份数"超限.
- munmap 半个 VMA 会拆两半,但 munmap 超出任何 VMA 的未注册区间是合法空操作,循环释放时部分成功部分静默,账要自己核.