虚拟地址不是内存:Linux 如何切开一个进程的地址空间
我们平时写 C 程序时,指针看起来像是"内存地址":
c
int *p = malloc(4096);
printf("%p\n", p);
但这个地址并不是内存条上的物理位置。它首先是进程看到的虚拟地址 。CPU 真正访问内存前,还要通过页表把虚拟地址翻译成物理页框;翻译不到,就会触发缺页异常,让内核决定是补上映射,还是给进程发 SIGSEGV。
所以理解 Linux 内存管理的第一步,不是去看物理内存有多少,也不是去看 buddy 怎么分配页,而是先把这个问题拆开:
一个进程看到的地址空间,到底被 Linux 切成了哪些区域?这些区域和真实物理内存是什么关系?
一、先分清三种地址
内存管理里最容易混的是这三种东西:
- 虚拟地址:进程指令里使用的地址,也是指针打印出来的值。
- 物理地址:内存控制器、总线和物理页框层面的地址。
- 内核虚拟地址:内核代码访问内存时使用的虚拟地址,其中一部分会线性映射到物理内存。
用户程序平时只直接接触第一种。比如 malloc 返回的地址、局部变量的地址、mmap 返回的地址,全都是虚拟地址。它们是不是已经有物理页 backing,取决于页表里有没有 present 的映射。
markdown
用户程序
│
│ 指针值:0x7f3a_1234_5000
▼
CPU / MMU
│
│ 查 TLB / 页表
▼
物理页框:PFN n
这意味着一句很重要的话:
虚拟地址空间可以很大,但物理内存只会在真正需要时逐页兑现。
你 malloc(1GB),进程的虚拟地址空间可能多出一大片合法范围;但如果只写了第一个字节,内核通常只需要给被触碰的那一页分配物理页。剩下那些地址在"法律上"属于你,在"物理上"还没有落地。
二、每个进程都有自己的用户地址空间
Linux 里,每个普通进程都有一套自己的用户态虚拟地址空间。两个进程即使打印出相同的指针值,也不表示它们在访问同一块物理内存。
css
进程 A 的虚拟地址 0x5555_4000
│
└─ A 的页表 ──► 物理页框 X
进程 B 的虚拟地址 0x5555_4000
│
└─ B 的页表 ──► 物理页框 Y
虚拟地址相同,只说明两个进程的地址空间布局相似。真正翻译到哪里,要看当前进程的页表。进程切换时,内核会切换页表根,让同一个虚拟地址在不同进程里得到不同解释。
这就是虚拟内存最基本的隔离能力:一个进程不能因为猜到了另一个进程的指针值,就直接读写对方的内存。
三、用户地址和内核地址怎么分
在 64 位 Linux 上,一个进程的虚拟地址空间通常被切成两大半:低地址给用户态,高地址给内核态。具体边界和地址形式与架构、内核配置有关,不能把某个机器上的数值当成所有 Linux 的固定结论。
抽象图更重要:
arduino
低地址
0x0000_0000_0000_0000
│
│ 用户态虚拟地址空间(user space)
│
│ 程序代码 / 只读数据
│ data / bss
│ heap
│ mmap 区:动态库、文件映射、匿名映射
│ user stack
│
─────┼──────────────── user / kernel 分界
│
│ 内核态虚拟地址空间(kernel space)
│
│ direct map
│ vmalloc
│ modules
│ fixmap
│ kernel text / data
│
高地址
用户态代码只能访问用户地址范围。它如果直接去碰内核地址,会因为权限不允许而触发异常。内核态代码则能访问内核地址范围,也能在受控路径上访问当前进程的用户地址。
一个容易误解的点是:内核地址空间通常映射在每个进程页表的高地址部分。这不是说每个进程都有一份完整的内核拷贝,而是为了让 CPU 陷入内核后,内核代码、内核数据和 direct map 等区域仍然能被同一套地址翻译机制访问。
现代内核还会受到 KPTI、地址随机化、不同架构页表格式等影响,细节会变。但对这个系列来说,先记住职责边界就够了:
- 用户地址空间描述"这个进程自己能看到什么"。
- 内核地址空间描述"内核运行时能看到什么"。
- 页表把这些虚拟地址翻译到具体物理页框,或者标记为当前还没有映射。
四、/proc/self/maps 看到的是 VMA,不是页表
观察一个进程地址空间,最直接的入口是:
bash
cat /proc/self/maps
你会看到类似这样的行:
text
55b0f7d2a000-55b0f7d2e000 r--p 00000000 08:01 12345 /usr/bin/cat
55b0f7d2e000-55b0f7d33000 r-xp 00004000 08:01 12345 /usr/bin/cat
55b0f8c80000-55b0f8ca1000 rw-p 00000000 00:00 0 [heap]
7f36c5200000-7f36c53a0000 r-xp 00000000 08:01 67890 /usr/lib/libc.so.6
7ffc8a9d0000-7ffc8a9f1000 rw-p 00000000 00:00 0 [stack]
每一行表示一段连续的虚拟地址区间,内核里对应一个 vm_area_struct,通常简称 VMA。
一行里最关键的信息有这些:
- 起止地址:这段虚拟地址从哪里到哪里。
- 权限:
r可读,w可写,x可执行。 - 私有/共享:
p是 private,s是 shared。 - 文件来源:如果是文件映射,会显示路径;匿名映射通常没有路径。
- 特殊区域:例如
[heap]、[stack]、[vdso]。
但一定要注意:maps 不是页表转储。
它告诉你"这些虚拟地址区间是否合法、权限是什么、背后是什么对象",不告诉你"每一页现在是否已经有物理页"。一个 1GB 的匿名 VMA 可能只有少数几页真的分配了物理内存;maps 仍然只显示一行大区间。
rust
/proc/self/maps 看到的是:
0x40000000 - 0x80000000 rw-p anonymous
│
│ 这段地址合法,允许读写
▼
页表可能是:
0x40000000 -> present, PFN A
0x40001000 -> not present
0x40002000 -> not present
...
这就是后面反复要用的区分:
VMA 记录"这段地址是否合法";页表记录"这一页是否已经兑现成物理映射"。
五、堆、栈、动态库和 mmap 区分别在哪里
从用户态看,一个普通进程常见区域大致是这样:
arduino
低地址
│
│ 程序代码段:r-x
│ 只读数据:r--
│ data / bss:rw-
│
│ heap:brk 扩展出来的堆
│
│ mmap 区:
│ - 动态库
│ - mmap 文件
│ - 大块匿名映射
│ - 线程栈
│
│ stack:主线程用户栈
│
│ vdso
│
高地址(仍在用户态范围内)
几个区域的来源不同:
- 程序代码段来自可执行文件,通常可读可执行,不可写。
- data / bss保存全局变量、静态变量,通常可读写。
- heap 通常由
brk/sbrk调整边界,小块malloc常从这里切。 - mmap 区 由
mmap系统调用创建,大块malloc、动态库、文件映射都可能出现在这里。 - stack是用户栈,函数调用、局部变量、返回地址等都在这里增长。
- vdso是内核映射给用户态的一小段辅助代码,用来加速某些系统调用相关操作。
这里不要把 malloc 简化成"等于系统调用"。用户态 malloc 首先是 libc 的分配器行为:小分配可能只在已有 arena 里切一块,不进内核;需要扩展地址空间时才会用 brk 或 mmap。即使进了内核,也通常只是创建或扩大 VMA,不代表立刻分配所有物理页。
六、CPU 访问一个虚拟地址时发生了什么
把静态布局和动态访问连起来,一次内存访问大致走这条路:
bash
CPU 执行 load/store/fetch,拿到虚拟地址
│
├─ TLB 命中
│ └─ 直接得到物理页框,继续执行
│
└─ TLB 未命中
│
▼
page walk 查页表
│
├─ PTE present
│ └─ 建 TLB,继续执行
│
└─ PTE not present / 权限不符
└─ 缺页异常,陷入内核
缺页异常不是天然等于错误。它只是说明硬件当前没法完成这次翻译,必须让内核判断原因:
- 地址根本不在任何 VMA 里:非法访问,
SIGSEGV。 - 地址在 VMA 里,但操作权限不匹配:保护错误,
SIGSEGV。 - 地址合法,只是页还没分配:分配物理页,填页表,重试指令。
- 文件映射页不在内存:从 page cache 或磁盘读入。
- 写时复制页被写:复制一页新的。
- 页被换出到 swap:从交换区换回。
这篇先不展开缺页处理,只保留最关键的分工:
VMA:判断地址是否合法、权限是否允许、背后是什么
页表:判断这一页当前有没有真实映射
物理页:真正存放数据的页框
七、可以自己看的几个入口
想把这篇的概念落到本机上,可以从这些命令开始:
bash
cat /proc/self/maps
cat /proc/self/smaps
pmap $$
maps 看 VMA 的地址区间和权限;smaps 会进一步显示每个 VMA 的内存统计;pmap $$ 用更紧凑的方式看当前 shell 进程的地址空间。
这里的几个词先简单解释一下:
- 每段映射 :就是
maps里的每一行,也就是一个 VMA。比如代码段、heap、某个动态库映射、stack,都会分别统计。 - RSS:resident set size,表示这段 VMA 当前有多少页真的在物理内存里。
- PSS:proportional set size,表示按比例分摊后的 RSS。共享库这类页面可能被多个进程共享,PSS 会把共享页按进程数分摊,而不是每个进程都算一整份。
- 匿名页 :没有文件背书的页,典型来源是 heap、stack、匿名
mmap。这类页如果要被回收,脏数据通常只能写到 swap。 - page cache :内核用物理内存缓存磁盘文件内容的机制。比如读文件、执行程序、加载动态库、访问文件
mmap时,文件内容进入内存后通常就由 page cache 管着;下次再读同一段文件,可以直接从内存拿,不必重新读磁盘。 - 文件页 :背后对应磁盘文件的页,典型来源是程序代码段、动态库、文件
mmap。文件页在内存里通常属于 page cache。干净文件页可以直接丢掉,需要时再从文件读回来。
如果你在不同机器上看到地址差异,不必惊讶。ASLR、架构、页大小、内核配置、动态链接器版本都会影响具体数值。真正稳定的是结构关系:
markdown
进程看到虚拟地址
│
▼
VMA 判断这段地址是否合法
│
▼
页表记录已兑现的虚拟页 -> 物理页映射
│
▼
物理页框真正存放数据
下一篇就从这条链路继续往下走:虚拟地址最终翻译到物理页框以后,Linux 如何组织那一大片物理内存?为什么不是简单维护一个"空闲 RAM 列表",而是要有 node、zone、PFN 和 struct page。