虚拟地址空间:从"地址一样但物理内存不同"说起
我最近在学操作系统里的内存管理,本来以为"虚拟地址空间"就是一句"每个进程都有自己独立的地址空间"就完事了。结果越看越发现,这里面坑挺多:父子进程打印出来的地址一样,但物理内存其实不是同一块;malloc 了也不一定马上给你物理页;task_struct 和 mm_struct 又各自管什么......我把自己踩过的坑和终于搞懂的点记一下。
先插一句:普通命令和内建命令
看 shell 的时候发现,命令和命令之间也有差异:
- 二进制文件级别的命令:普通命令,比如
ls、grep,需要去PATH里找对应的可执行文件。 - 内建命令:shell 内部自己实现的,比如
cd、echo,属于 bash 自己内部的函数调用,不依赖第三方路径。
这跟后面的进程加载、地址空间也有点关系,不过重点还是虚拟地址空间。
CPU 眼里的地址,其实是虚拟地址
CPU 中看到的地址分布大概像这样:

一个进程的虚拟地址空间里,代码段、数据段、堆、栈、共享库等都有自己的位置。栈整体是向低地址方向增长的,堆是向高地址方向增长的,所以大家常说"堆栈相对而生"。
text
高地址
+------------------+
| 栈 stack | 向下增长
+------------------+
| ... |
+------------------+
| 堆 heap | 向上增长
+------------------+
| BSS / data / text|
+------------------+
低地址
当然,实际布局还要看 32 位还是 64 位,以及内核如何安排。
踩了个坑:地址一样,物理内存就一样吗?
我写了个 fork 的小实验,父子进程打印同一个变量的地址,结果地址居然一样。当时我第一反应是:"那它们不就访问同一块内存了吗?"
后来看到类似这样的现象:

这个现象其实说明了两件事:
- 进程具有独立性。
- 这里打印出来的是虚拟地址,物理地址不可能这么巧完全相同。背后是写时拷贝:OS 给写入的一方分配了新的物理页,再把同一个虚拟地址重新映射到新的物理页上。
所以"地址相同"只是虚拟地址相同,不代表物理内存相同。终于搞懂了这一点之后,再看页表就顺眼多了。
task_struct 和 mm_struct:进程的"内存说明书"
程序在运行时会被转化为进程,一个进程会有一个 task_struct。

而一个 task_struct 中会包含一个 mm_struct,用来定义虚拟地址空间。task_struct 含有指针 mm,指向独立的 mm_struct 结构体,用来描述进程虚拟地址空间。
c
struct mm_struct {
struct vm_area_struct *mmap; // VMA链表(同时维护红黑树,用于快速查找虚拟内存区域:堆、栈、代码段、动态库)
struct vm_area_struct *mmap_cache; // 最近访问的VMA缓存,加速查找
pgd_t *pgd; // 页全局目录,页表基址,对应CPU CR3寄存器
unsigned long start_code, end_code; // 代码段 .text 的起止虚拟地址
unsigned long start_data, end_data; // 已初始化数据段 .data
unsigned long start_brk, brk; // 堆:start_brk堆起始地址,brk堆当前边界(brk系统调用修改brk)
unsigned long start_stack; // 用户栈起始虚拟地址
// ... 引用计数、内存统计等
};
这里每个字段其实都在描述"进程的虚拟内存长什么样"。比如 mmap 管 VMA,pgd 管页表基址,start_brk 和 brk 管堆的边界,start_stack 管用户栈起点。
全局变量、static 和虚拟地址空间的意义
全局变量和 static 变量的生命周期是全局的,本质上是因为它们的生命周期跟着进程走。
虚拟地址空间不只是"给每个进程画个饼",它还有几个很实际的作用:
- 可以拦截进程的非法行为,保护物理内存和其他进程。
- 有了虚拟地址空间和页表,物理内存不需要连续、有序,也能映射成进程看到的连续虚拟地址。
- 让进程管理和内存管理解耦。
以前我总觉得页表只是"地址转换表",现在发现它更像一层权限和隔离机制。
程序启动加载:不是一股脑全塞进内存
程序启动加载这块也很容易误解。创建进程时,OS 只是构建虚拟地址空间、页表,并不会一次性把磁盘上的代码和数据全部载入物理内存。
首次访问对应虚拟地址时,会触发缺页中断;OS 再从磁盘读取页面,分配物理内存,建立虚拟地址到物理地址的映射。
new / malloc 也是类似的思路:本质上是在虚拟地址空间的堆区申请,先在页表层面创建虚拟地址,后续真正要使用时再填充物理地址。这就是空间的懒加载机制。

所以 malloc 成功,不代表物理内存已经立刻到手;真正写入的时候,才可能触发缺页,让 OS 把物理页补上。
小结
这轮学下来,我最大的收获是:虚拟地址空间不是"假的地址"这么简单,它把进程隔离、写时拷贝、缺页中断、懒加载、页表权限这些东西串在了一起。以前看到父子进程地址一样会懵,现在至少知道要去看页表和写时拷贝;以前觉得 malloc 就是直接拿内存,现在知道它更多是在虚拟地址空间里先圈地,物理页可以后面再补。
继续啃内存管理,感觉还有不少坑要踩。