[内核学习]进程虚拟地址空间

/ 0评 / 0赞

每个进程都以为自己独占一整条地址线,这份错觉由 mm_struct 和它名下的一串 VMA 支撑.这篇讲地址空间怎么划分成区间,区间怎么合并怎么拆分 — 这套看似枯燥的簿记,恰恰是 mmap,mprotect,munmap 全家行为的关键.实验纯用户态就能做得非常干净:自己 mmap 自己看自己的 maps,四步变化一步一拍.

这篇回答几件事:

先摆容器.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 就在这条缝上做文章).

最后留几个容易忽略的细节:

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注