vm_area_struct:进程地址空间的合法区间表
第一篇里说过,/proc/self/maps 看到的不是页表,而是一段段虚拟地址区间。每一段区间背后,在内核里通常对应一个 vm_area_struct,简称 VMA。
这篇专门把 VMA 拆开看:
一个进程访问某个虚拟地址时,Linux 怎么先判断"这段地址是不是你的"?
这个问题不能直接交给页表回答。页表只关心一页虚拟地址当前有没有被翻译到某个物理页框;但很多合法地址在第一次访问前,页表里本来就没有 present 映射。内核需要先有一张"合法区间表",记录哪些范围可以访问、权限是什么、背后是匿名内存还是文件。
这张表的核心结构就是 vm_area_struct。
一、mm_struct 是进程地址空间的总入口
Linux 里,每个普通用户进程都有一个地址空间描述符:mm_struct。它挂在进程的 task_struct 上:
task_struct
│
└─ mm
│
▼
mm_struct
mm_struct 不是一段用户内存,也不是页表本身。它是内核描述一个用户地址空间的总控结构,里面会记录:
- VMA 集合:这个进程有哪些合法虚拟地址区间。
- 页表根:例如
mm_struct->pgd,内核用它记录这个地址空间的顶级页表。 - 代码段、数据段、堆等地址范围:例如
start_code、end_code、start_brk、brk;以及栈相关参考点start_stack。 - 内存统计和限制:例如虚拟内存大小、锁定页数量、RSS 相关计数。
抽象图如下:
markdown
task_struct
│
▼
mm_struct
├─ VMA 集合:合法虚拟地址区间
├─ pgd:软件层面的顶级页表指针
├─ start_code / end_code
├─ start_data / end_data
├─ start_brk / brk
└─ start_stack:栈相关参考点,不是完整栈边界
这里没有对称的 end_stack,不能理解成"栈下边界就是堆的 brk"。用户栈通常从较高地址向较低地址增长,但它和堆之间不是紧挨着的,中间往往有 mmap 区、动态库、匿名映射、地址空间空洞和 ASLR 留出的随机化距离。
真正描述"当前哪些栈地址合法"的,仍然是栈对应的 VMA:
arduino
stack VMA
├─ vm_start:当前已纳入栈 VMA 的低地址边界
├─ vm_end:当前栈 VMA 的高地址边界
└─ vm_flags:通常带有 growsdown 语义
栈向下增长时,缺页地址如果落在允许扩展的范围内,内核可以扩展这个 stack VMA 的 vm_start;如果越过限制或保护间隔,就会变成非法访问。start_stack 更多是内核记录初始用户栈位置的字段,不能把它当成栈 VMA 的唯一边界。
这里要把 pgd 和硬件寄存器分清楚。
pgd 是 Linux 内核的页表层级抽象。mm_struct->pgd 保存的是这个进程地址空间的顶级页表位置,内核查页表、切换地址空间时会用到它。它不是 CPU 里的寄存器。
以 x86 为例,CPU 真正做地址翻译时使用的页表根寄存器是 CR3。当内核切换到某个进程的地址空间时,会把 mm_struct->pgd 指向的顶级页表的物理基址装进 CR3,之后硬件 page walk 才能从这个根开始查页表。
关系可以这样看:
rust
mm_struct->pgd
│
│ Linux 内核保存的顶级页表位置
▼
顶级页表页
▲
│
│ x86 上运行该地址空间时,CR3 指向它的物理基址
│
CPU CR3 寄存器
换句话说:mm_struct->pgd 是内核管理地址空间时保存的"账本入口",CR3 是 x86 硬件当前实际使用的"翻译入口"。在 x86-64 硬件术语里,4 级分页的顶级常叫 PML4,5 级分页还会有 PML5;Linux 在源码层面仍用 pgd 作为跨架构抽象名。
同一个进程里的多个线程通常共享同一个 mm_struct。这意味着它们共享用户地址空间、VMA 和页表;但每个线程仍然有自己的用户栈和内核栈。线程和进程的完整布局会放到第七篇集中讲。
这一篇先记住一条主线:
markdown
当前线程
│
▼
task_struct
│
▼
mm_struct
│
├─ 找 VMA:判断地址是否合法
└─ 找页表:判断这一页是否已经有映射
二、VMA 描述一段连续虚拟地址区间
一个 vm_area_struct 表示一段连续的虚拟地址范围。它不是"一个页",也不是"一块物理内存"。它更像一条规则:
text
从 vm_start 到 vm_end 这段虚拟地址,
允许按 vm_flags 指定的方式访问,
背后可能对应某个文件,也可能是匿名内存。
简化后可以这样看:
arduino
vm_area_struct
├─ vm_start:起始虚拟地址,包含
├─ vm_end:结束虚拟地址,不包含
├─ vm_flags:读、写、执行、共享、向下增长等标志
├─ vm_file:文件映射对应的 struct file,匿名映射为空
├─ vm_pgoff:映射从文件的哪个页偏移开始
├─ vm_ops:这类映射的操作函数
└─ anon_vma:匿名页反向映射相关结构
注意 vm_end 通常是不包含的。也就是说,一个 VMA 覆盖的是:
text
[vm_start, vm_end)
这和很多 C 代码里的区间习惯一致,判断地址是否落在 VMA 内部时,大致是:
text
vm_start <= addr < vm_end
如果你看 /proc/self/maps,每一行的地址范围就对应这种半开区间:
text
55b0f7d2e000-55b0f7d33000 r-xp 00004000 08:01 12345 /usr/bin/cat
可以近似理解成:
ini
vm_start = 0x55b0f7d2e000
vm_end = 0x55b0f7d33000
flags = readable + executable + private
file = /usr/bin/cat
pgoff = 0x4000 / PAGE_SIZE
maps 里的权限列很重要:
r:允许读。w:允许写。x:允许执行。p:private,私有映射,写入时通常触发写时复制。s:shared,共享映射,修改可能对其他映射者可见。
这些信息并不等于页表项里的硬件权限位,但它们会影响内核后续如何建立页表项、如何处理缺页、是否允许某次访问继续下去。
三、VMA 不是页表
VMA 和页表最容易混在一起,因为它们都和虚拟地址有关。但它们回答的问题不同。
VMA 回答:
text
这段虚拟地址范围属于当前进程吗?
允许读、写、执行吗?
背后是匿名内存、文件,还是特殊映射?
页表回答:
text
这个虚拟页现在有没有映射到物理页框?
如果有,PFN 是多少?
硬件访问权限和状态位是什么?
所以一个 VMA 可以覆盖很大的范围,而页表只为已经需要兑现的页逐步补上映射:
rust
VMA:
0x40000000 ───────────────────────── 0x80000000
rw-p anonymous
页表:
0x40000000 -> present, PFN A
0x40001000 -> not present
0x40002000 -> not present
0x40003000 -> present, PFN B
...
这就是为什么 mmap 一大片匿名内存之后,maps 里能立刻看到一段新范围,但 RSS 不一定马上增加同样大小。VMA 先建立"合法性承诺",物理页通常等第一次访问触发缺页时再分配。
可以把关系收束成一句话:
VMA 是地址空间的合同,页表是已经履约的页级映射。
这个比喻的边界也要清楚:VMA 不是用户可见的语言层对象,而是内核内部结构;页表也不是 VMA 的缓存,而是硬件参与使用的地址翻译结构。
四、匿名映射和文件映射的 VMA 有什么不同
VMA 背后不一定有文件。按来源看,常见 VMA 大致可以分成两类:匿名映射和文件映射。
匿名映射没有普通磁盘文件作为 backing。典型来源是:
- heap,也就是
brk扩展出来的堆。 - 用户栈。
mmap(MAP_ANONYMOUS)创建的匿名映射。- 大块
malloc可能向 libc 触发的匿名mmap。
匿名 VMA 通常长这样:
ini
vm_area_struct
├─ vm_start / vm_end
├─ vm_flags:VM_READ / VM_WRITE / VM_MAYWRITE ...
├─ vm_file = NULL
└─ anon_vma:用于匿名页反向映射
匿名映射刚创建时,通常只是多了一个 VMA,页表里还没有对应的 present PTE。也就是说,虚拟地址范围已经合法,但具体到每个虚拟页,页表项可能仍然是空的或 not present。
第一次访问时要分读和写:
ini
mmap anonymous 后
│
└─ VMA 存在,普通页表项通常还没有 present 映射
第一次读
│
├─ 可能映射到内核共享的只读零页
└─ 页表项形式:present + read-only + PFN=zero page
第一次写
│
├─ 分配新的私有物理页
├─ 清零
└─ 页表项形式:present + writable + PFN=新分配页
这里的"页表项形式"说的是最低层 PTE 的核心状态。真实 PTE 里还会有 user/supervisor、accessed、dirty、NX、soft-dirty、架构保留位等标志;不同架构和内核配置会有差异。对这篇来说,抓住三种状态就够了:还没有 present 映射、映射只读共享零页、映射可写私有物理页。
如果第一次是读,内核不一定马上给这个进程分配真实私有物理页,因为读一个全零的匿名页可以用共享零页满足。等后续真的写入时,由于零页是只读的,会再次触发缺页,内核再分配新页、清零、复制或直接建立私有可写映射。
以后如果内存压力很大,匿名页因为没有原始文件可以重新读取,所以要回收已经承载进程私有数据的匿名页,通常需要先把内容写到 swap。
文件映射背后有一个文件。典型来源是:
- 可执行文件的代码段和数据段。
- 动态库。
- 用户显式
mmap某个文件。 vdso这类特殊映射可以有自己的内核提供逻辑。
文件映射 VMA 会记录 vm_file 和 vm_pgoff:
rust
vm_area_struct
├─ vm_start / vm_end
├─ vm_flags:VM_READ / VM_EXEC / VM_SHARED / VM_PRIVATE ...
├─ vm_file -> struct file
├─ vm_pgoff:文件页偏移
└─ vm_ops:文件映射相关 fault 操作
文件映射缺页时,内核会根据 VMA 里的文件和偏移,去 page cache 找对应文件页;page cache 没有时,再从存储设备读入。
抽象对比如下:
markdown
匿名 VMA
│
└─ 缺页时分配新物理页 / 使用零页 / 处理 COW
文件 VMA
│
└─ 缺页时根据 vm_file + vm_pgoff 找 page cache
MAP_PRIVATE 和 MAP_SHARED 又会继续影响写入语义:
MAP_PRIVATE:私有映射。写入时通常走写时复制,当前进程得到自己的匿名页副本。MAP_SHARED:共享映射。多个映射者可能看到同一份修改;如果背后是普通文件,脏页后续可以写回这个文件。
这也是 maps 权限列里 p 和 s 的来源。
五、VMA 集合怎么被组织起来
一个进程可能有很多 VMA:程序段、动态库、heap、stack、匿名映射、文件映射、线程栈、JIT 区域等都可能各占一段。内核必须能高效回答这个问题:
text
给定一个虚拟地址 addr,找到覆盖它的 VMA。
老资料里经常会说 mm_struct 里有 VMA 链表和红黑树。这个说法对很多旧内核是对的:链表方便顺序遍历,红黑树方便按地址查找。
但现代 Linux 已经把 VMA 的核心索引切换成了 maple tree。所以现在读资料时要注意版本差异:
markdown
较老内核:
mm_struct
├─ mmap:VMA 链表
└─ mm_rb:VMA 红黑树
现代内核:
mm_struct
└─ mm_mt:maple tree 管理 VMA
maple tree 是 Linux 内核里一种面向范围索引的数据结构,适合保存"地址区间 -> 对象"这种映射。VMA 正好是按虚拟地址范围组织的对象,所以用它可以更好地支持查找、插入、删除和遍历。
对理解内存管理来说,不必一开始就钻进 maple tree 的内部节点布局。关键是知道:
VMA 不是散落在 mm_struct 里的数组,而是按虚拟地址范围组织起来,供缺页、mmap、munmap、mprotect 等路径快速查找和修改。
查找时,内核大致要做的事情是:
markdown
addr
│
▼
在 mm_struct 的 VMA 索引里查找
│
├─ 找不到覆盖 addr 的 VMA
│ └─ 地址非法
│
└─ 找到 vma
├─ addr 在 [vm_start, vm_end) 内
└─ 继续检查访问权限
这一步在缺页路径里非常关键。页表没映射并不自动意味着错误;只有找不到合法 VMA,或者访问类型和 VMA 权限不匹配,才会变成用户态能看到的段错误。
六、mmap、brk、mprotect 会改变 VMA
VMA 是动态变化的。程序运行过程中,很多系统调用都会创建、删除、拆分或合并 VMA。
brk:调整堆边界
小块 malloc 常常先从 libc 已有 arena 里切内存;当 arena 不够时,libc 可能用 brk 扩展堆。内核侧看到的是堆对应 VMA 的结束地址变化:
yaml
原 heap VMA:
[start_brk, brk)
brk 增大后:
[start_brk, new_brk)
如果扩展区域和原堆 VMA 权限、属性连续,内核可以直接扩大原 VMA,而不是创建一堆碎片化 VMA。
mmap:创建新映射
mmap 可以创建匿名映射,也可以映射文件:
ini
mmap anonymous
│
└─ 新建一个 vm_file = NULL 的 VMA
mmap file
│
└─ 新建一个 vm_file 指向目标文件的 VMA
调用成功后,用户态马上拿到一个虚拟地址。但这不代表所有页都已经有物理页。很多情况下,内核只是把 VMA 插入当前进程的 VMA 集合里,真正的物理页等访问时再处理。
munmap:删除或切开 VMA
munmap 释放一段虚拟地址范围。如果释放范围刚好覆盖整个 VMA,内核可以删除它。如果只释放中间一段,原 VMA 可能被切成两段:
yaml
原 VMA:
[A, D)
munmap [B, C) 后:
[A, B) [C, D)
这也是为什么一个进程的 VMA 数量会随运行行为增长。
mprotect:修改权限,必要时拆分 VMA
mprotect 修改某段虚拟地址的权限。如果它只覆盖原 VMA 的一部分,内核也可能拆分 VMA:
lua
原 VMA:
[A, D) rw-
mprotect [B, C) 为 r-- 后:
[A, B) rw- [B, C) r-- [C, D) rw-
这说明 VMA 的边界不是天然等于"代码段、堆、栈"这种大概念。它的边界由地址范围和属性共同决定:一旦相邻区域权限、文件、偏移、操作等属性不同,就必须分成不同 VMA。
七、缺页时,VMA 是第一道判断
现在把 VMA 放回动态路径里。CPU 访问某个虚拟地址,如果页表无法完成翻译,就会触发缺页异常。进入内核后,内核不能立刻分配页;它要先问:
text
这个地址是不是当前进程合法拥有的地址?
这次访问是读、写还是取指?
VMA 允许这种访问吗?
简化流程如下:
markdown
CPU 访问虚拟地址 addr
│
▼
页表没有 present 映射 / 权限不满足
│
▼
缺页异常,进入内核
│
▼
在 mm_struct 中查找 VMA
│
├─ 找不到覆盖 addr 的 VMA
│ └─ SIGSEGV
│
├─ 找到了,但权限不匹配
│ └─ SIGSEGV
│
└─ 找到了,权限允许
│
▼
handle_mm_fault
│
├─ 匿名映射:分配页 / 零页 / COW
├─ 文件映射:page cache / 读文件
├─ swap entry:换入
└─ 建立或更新页表项
这里能看出 VMA 和页表的配合关系:
VMA 说:这个地址范围可以这样用。
页表说:这个具体虚拟页现在有没有物理映射。
缺页处理说:如果 VMA 允许,但页表还没兑现,我来补上。
如果访问的是空指针附近地址,通常找不到合法 VMA,于是 SIGSEGV。如果对只读代码段写入,可能找得到 VMA,但写权限不允许,也会 SIGSEGV。如果访问的是刚 mmap 出来的匿名页,VMA 合法、权限允许,只是页表还没有映射,内核就可以分配物理页并重试指令。
八、可以自己做的小实验
下面这个程序会制造几种不同 VMA:用 sbrk 明确扩展 brk/heap 区间,创建匿名 mmap 和文件 mmap,再用 mprotect 改变匿名映射中一页的权限。
c
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
int main(void)
{
size_t page = (size_t)sysconf(_SC_PAGESIZE);
void *heap = sbrk((intptr_t)page * 8);
if (heap == (void *)-1) {
perror("sbrk");
return 1;
}
memset(heap, 0x11, page);
void *anon = mmap(NULL, page * 4, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (anon == MAP_FAILED) {
perror("mmap anonymous");
return 1;
}
int fd = open("/tmp/vma-demo-file", O_CREAT | O_RDWR | O_TRUNC, 0600);
if (fd < 0) {
perror("open");
return 1;
}
if (ftruncate(fd, (off_t)page * 4) != 0) {
perror("ftruncate");
return 1;
}
void *file = mmap(NULL, page * 4, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (file == MAP_FAILED) {
perror("mmap file");
return 1;
}
if (mprotect((char *)anon + page, page, PROT_READ) != 0) {
perror("mprotect");
return 1;
}
printf("pid = %d\n", getpid());
printf("heap = %p\n", heap);
printf("anon = %p\n", anon);
printf("file = %p\n", file);
printf("press Enter to exit\n");
getchar();
return 0;
}
编译运行:
bash
gcc -O0 -g vma-demo.c -o vma-demo
./vma-demo
另开一个终端观察:
bash
cat /proc/<pid>/maps
cat /proc/<pid>/smaps
下面是一份真实输出,来自 Docker 里的 x86_64 Linux 环境:
bash
docker run --rm --platform linux/amd64 gcc:13 ...
程序打印的地址:
text
arch = x86_64
pid = 17
heap = 0x405000
anon = 0x7fffff7c6000
file = 0x7fffff5d9000
截取 /proc/17/maps 中和实验相关的几行:
text
00400000-00401000 r--p 00000000 00:4e 35982568 /tmp/vma-demo
00401000-00402000 r-xp 00001000 00:4e 35982568 /tmp/vma-demo
00402000-00403000 r--p 00002000 00:4e 35982568 /tmp/vma-demo
00403000-00404000 r--p 00002000 00:4e 35982568 /tmp/vma-demo
00404000-00405000 rw-p 00003000 00:4e 35982568 /tmp/vma-demo
00405000-0042e000 rw-p 00000000 00:00 0
7fffff5d9000-7fffff5dd000 rw-s 00000000 00:4e 35982564 /tmp/vma-demo-file
7fffff7c6000-7fffff7c7000 rw-p 00000000 00:00 0
7fffff7c7000-7fffff7c8000 r--p 00000000 00:00 0
7fffff7c8000-7fffff7ca000 rw-p 00000000 00:00 0
ffffe9a9b000-ffffe9abc000 rw-p 00000000 00:00 0 [stack]
这里可以直接对上几个点:
heap = 0x405000落在00405000-0042e000 rw-p这段匿名 VMA 里,这就是这次sbrk扩出来的 brk/heap 区间。这个容器环境没有把它标成[heap],所以不要只依赖名字。/tmp/vma-demo-file是文件共享映射,权限列是rw-s,对应MAP_SHARED。anon = 0x7fffff7c6000原本是 4 页连续匿名映射;mprotect把第二页改成只读后,它被拆成了三段:rw-p、r--p、rw-p。[stack]对应用户栈 VMA,smaps里通常能看到gd这类 growsdown 标志。
再截取 /proc/17/smaps 中几段统计:
text
7fffff5d9000-7fffff5dd000 rw-s ... /tmp/vma-demo-file
Size: 16 kB
Rss: 0 kB
Shared_Dirty: 0 kB
Private_Dirty: 0 kB
VmFlags: rd wr sh mr mw me ms
ffffe9a9b000-ffffe9abc000 rw-p ... [stack]
Size: 132 kB
Rss: 16 kB
Shared_Dirty: 0 kB
Private_Dirty: 16 kB
VmFlags: rd wr mr mw me gd ac
文件映射那段 Size 是 16KB,因为程序 mmap 了 4 个 4KB 页;但 Rss 是 0KB,因为程序还没有真正读写这段文件映射。VMA 已经存在,不代表页表已经为每一页建立了真实物理页映射。
你可以重点看几件事:
- heap/brk 区间是否因为
sbrk和触碰内存而出现或扩大;有的环境会显示[heap],有的只显示一段匿名rw-p。 - 匿名
mmap对应的无文件路径 VMA。 /tmp/vma-demo-file对应的文件映射 VMA。mprotect是否把原本连续的匿名映射拆成权限不同的多段。maps中的虚拟范围可能很大,但smaps里的 RSS 不一定等于整个范围大小。
不同 libc、内核版本、ASLR 设置会影响具体地址和 VMA 数量。实验重点不是背某个输出,而是观察 VMA 如何随着系统调用变化。
九、把这一篇收束成一张图
yaml
task_struct
│
▼
mm_struct
│
├─ VMA 集合
│ │
│ ├─ vm_area_struct: code r-xp file
│ ├─ vm_area_struct: heap rw-p anonymous
│ ├─ vm_area_struct: mmap rw-p anonymous
│ ├─ vm_area_struct: libc r-xp file
│ └─ vm_area_struct: stack rw-p anonymous / growsdown
│
└─ pgd:内核记录的顶级页表
│
└─ 已经兑现的虚拟页 -> 物理页框
这一篇要留下的核心分工是:
arduino
mm_struct:一个进程用户地址空间的总入口
vm_area_struct:一段合法虚拟地址区间及其属性
页表:已经建立的页级虚拟地址到物理页框映射
struct page:物理页框在内核里的元数据
到这里,前面几篇的链路已经能接起来了:
arduino
用户访问虚拟地址
│
▼
VMA 判断地址是否合法
│
▼
页表判断这一页是否已有映射
│
▼
需要物理页时,从 zone / buddy 拿 page frame
│
▼
struct page 描述这个物理页
下一篇就继续沿着缺页异常往下走:当 VMA 说"这个地址合法",但页表里还没有 present 映射时,Linux 如何真正分配物理页、建立页表项,并让出错的指令重新执行。