在 Linux 中,每个进程都好像是楚门,生活在一个别人为它精心构建的世界里,而它却以为自己独占了整个系统的内存空间。这正是内核通过虚拟内存机制实现的。
本文将带你穿过用户态的表象,深入 Linux 内核源码与底层硬件,分析这一套复杂的协作机制。
在我们正式进入主题之前,不妨先试着思考下面几个问题:
- 在一个父子进程中,如果你同时打印一个全局变量的地址,你会发现它们的 逻辑地址(也就是虚拟地址)完全一样。但是,当子进程修改这个变量时,父进程的值却并没有改变。既然地址一样,为什么在子进程中变了,父进程中却没变?
- 如果你定义一个全局数组
int arr[1024*1024] = {1}(已初始化),编译出的可执行文件会多出 4MB。但如果你定义int arr[1024*1024](未初始化),可执行文件的大小几乎没变。明明都是定义了 4MB 的数组,为什么差别这么大? - 在只有 8GB 物理内存的 Linux 系统上,为什么你可以成功
malloc出 100GB 的内存而不报错? - Linux 内核在管理进程的 虚拟内存区域 (VMA)时,既然已经有了一个双向链表,为什么还要大费周章地维护一棵 红黑树 ?在什么场景下,这棵树会决定你的程序响应速度?
- 2018 年,Linux 引入了 KPTI 机制,强制将用户态和内核态的页表分离。这导致了明显的性能下降,内核开发者为什么要冒着性能大跌的风险,非要在内存布局里建起这堵墙?
这些问题如果你能答出来,那你很厉害了。如果某些地方还有些疑惑,也请不要担心,下面我会带大家深入了解这背后的底层原理。
1. 进程虚拟内存的宏观布局
在 Linux 中,每个进程眼里的内存都是独占且连续的。但不同架构下,进程眼里内存的大小和边界也完全不同。
1.1 经典的32位系统
在 32 位架构(如 x86)中,地址空间共 2^32=4GB ,Linux 默认采用 3:1 分割:
- 用户空间 (0 ~ 3GB):进程自己折腾的地方。
- 内核空间 (3GB ~ 4GB):所有进程共享,用于存放内核代码、页表、物理内存映射区等。
而由于内核空间仅 1GB,当物理内存超过 1GB 时,内核无法直接映射所有物理内存,被迫引入了 高端内存机制 ,这极大地增加了内核开发的复杂性。
下面介绍一下具体的细节:
在理想状态下,内核希望通过 直接映射 来工作,也就是把这 1GB 的内核虚拟地址直接映射在物理内存的最前面 1GB 上。
如果物理内存只有 512MB,那么内核这 1GB 的虚拟空间就绰绰有余,每一寸物理内存都能在内核里找到相应的映射空间。
如果物理内存有 4GB,而内核只有 1GB 的虚拟地址。如果内核直接占用了这 1GB 虚拟地址,它就只能看到前面 1GB 的物理内存,剩下的 3GB 物理内存因为没有对应的虚拟地址映射,内核根本无法直接访问。
为了解决较小的虚拟内存映射较大的物理内存的问题,内核引入了高端内存机制:物理内存的前 896MB,这部分是直接映射的,内核随时可以访问,效率极高;而 896MB 之后的物理内存,内核不给它们分配固定的虚拟地址。
当内核需要访问这些 896MB 之后的物理内存时,它会使用没有映射物理内存的剩下 128MB 虚拟内存,临时建立一个映射指向目标物理内存,用完后再拆掉。
1.2 现代64位系统
在 x86_64 架构下,虽然地址线有 64 位,但目前 Linux 仅使用了其中的 48 位,部分新 CPU 支持 57 位。48 位的地址线提供了 256TB 的虚拟地址空间:
- 用户空间 (低 128TB):从 0x0000 0000 0000 0000 到 0x0000 7FFF FFFF F000。
- 内核空间 (高 128TB):从 0xFFFF 8000 0000 0000 到 0xFFFF FFFF FFFF FFFF。
由于只使用了 48 位地址线,导致中间有一段巨大的、地址位不合法的区域,如果程序尝试访问这里,硬件会直接抛出通用保护异常。这种设计既精简了硬件电路,也为未来扩展留下了空间。
在内核空间如此庞大的情况下,整块物理内存都会被映射到虚拟地址空间的某一个起始点,内核访问任何物理地址都只需要简单的加法偏移。但是带来了便利的同时也付出了一些代价, 由于映射范围极大,页表 本身占用的内存变多了,内核现在普遍使用 4 级甚至 5 级页表,每次翻译地址的开销比 32 位(2 级)要高。
此外,所有的指针从 4 字节变成了 8 字节,在处理大量包含指针的数据结构时,内存占用会显著增加,并且对 L1/L2 缓存的压力更大,因为缓存行能容纳的元素变少了。
2. 用户空间详细布局
我们从虚拟地址的最低端一路向上扫过,看看一个运行中的程序到底把东西都藏在哪了。
1.代码段(.text):
- 存放编译后的机器指令。
- 只读、可执行,这是为了保护程序不被意外篡改。多个进程运行同一个程序时,物理内存中实际只存一份代码。
2.常量区 (.rodata):
- 存放
const修饰的全局变量、字符串常量。 - 只读,尝试修改这里会导致段错误。
3.已初始化数据段 (.data):
- 明确赋了初值且初值不为 0 的全局变量和静态变量。
- 可读写,它们在程序启动瞬间就有了初始值。
4.未初始化数据段 (.bss):
- 未初始化或初值为 0 的全局或静态变量。
- 不占磁盘空间,在可执行文件中仅记录一个大小,当程序加载到内存时,内核会分配内存并将其全部清零。这也是为什么全局变量默认值是 0 的底层原因。
5.堆:
- 由低地址向高地址生长。
6.内存映射区 (mmap):
- 动态链接库、大块内存分配(malloc 超过 128KB 时)、文件映射。
- 现代 Linux 中,通常从高地址向低地址生长,紧贴栈底下方。
7.栈:
- 存放局部变量、函数参数、返回地址。
- 由高地址向低地址生长,底部通常设有 Guard Page 保护页,一旦触碰即触发溢出报错。
8.命令行参数与环境变量:
- 用户空间的最高端。
- 存储
main(int argc, char argv, char envp)中的参数和系统环境变量。它们由父进程(通常是 Shell)在execve时压入。
3. 内核如何管理这些区域
3.1 mm_struct
每个进程都有一个task_struct进程控制块,而每个进程控制块中,都有一个mm指针指向mm_struct,它是内存管理的核心数据结构,描述了一个进程所拥有的全部虚拟内存视图。
所有用户线程共享同一个 mm_struct;而内核线程 mm 指针为 NULL,因为内核线程没有用户空间,它们直接借用上一个进程的内核页表。
我们可以把 mm_struct 的内容分为四大块:
1.内存映射区域:
- 这是最核心的部分,进程的虚拟地址空间不是连续的,而是由许多离散的块组成的,如代码段、数据段、堆、栈、
mmap区。 mmap指向vm_area_struct的链表,按虚拟地址排序,方便遍历。mm_rb指向红黑树的根节点,红黑树用于快速查找某个地址属于哪个区域,也就是寻找 VMA。
2.内存段的起止位置:
start_code,end_code: 可执行代码段的范围。start_data,end_data: 已初始化数据的范围。start_brk,brk: 堆的起点和当前的终点。start_stack: 栈的起点。
3.页表指针:
pgd: 指向当前进程的一级页表,也就是全局页目录。- 当进程切换时,内核会把这个
pgd的物理地址加载到 CPU 的 CR3 寄存器中。 - CPU 的 MMU 就能根据这套页表将虚拟地址翻译成物理地址。
4.状态统计
total_vm: 进程总的虚拟内存大小。rss: 进程当前实际占用的物理内存大小。mm_users/mm_count: 引用计数,多个线程共享同一个mm_struct时,计数会增加。
可能有人会产生这样的疑惑:既然有了页表,为什么还要 mm_struct 下面的 VMA 链表?
其实,页表是给内存管理单元 MMU 看的,它只负责告诉 CPU:地址 A 对应物理地址 B,权限是只读。而 VMA 是给内核看的,当程序发生 缺页中断 时,内核需要通过 mm_struct 查表:
- 这个地址合法吗?
- 如果不合法,报段错误。
- 如果合法,是因为还没分配物理内存吗?如果是,则分配一个物理页并填入页表。
3.2 为什么有了链表还需要红黑树
这其实是 Linux 内核设计中的经典权衡:
1.双向链表:
- 按地址顺序排列,方便内核遍历所有的内存区域。比如当你运行
cat /proc/pid/maps时,内核就是沿着链表走一遍,把信息打印出来。
2.红黑树:
- 当 CPU 访问一个地址时,内核需要以极快的速度判断这个地址是否合法,并查看这个地址属于哪个 VMA。
- 如果只用链表,查找复杂度是
O(N),如果进程映射了成千上万个动态库,查找会非常慢。 - 而红黑树将查找复杂度降到了
O(logN),无论是 缺页异常 还是 内存保护检查,红黑树都决定了系统的响应效率。
4. 问题解析
现在我们来分析一下开头的问题。
4.1 延迟分配
当你调用 malloc 分配内存时,内核其实是非常聪明的。它并不会立刻跑到物理内存条上去给你占坑,而是在 mm_struct 里加一个 VMA 记录,然后告诉你,这块内存已经是你的了。
这就是为什么你能在 8GB 的机器上申请 100GB 的虚拟内存(前提是系统开启了 Overcommit)。只有当你真正开始 读写 这块内存时,硬件会发现对应的 页表项是空的 ,触发一个 缺页异常。这时,内核才去分配物理内存,并更新页表。
4.2 BSS段与已初始化数据段的区别
- 已初始化数据段(.data):里面存的是具体的初值。这些值必须实实在在地写进磁盘的可执行文件里,加载时原样搬进内存。
- BSS段(.bss):里面全是初始为 0 的变量。内核只需要记住这里有 4MB 的空间需要清零即可,没必要在磁盘上存 4MB 的零。程序启动时,内核直接分配一批 零页 给它,既省磁盘又省 IO。
4.3 写时复制
这是回答开头关于 父子进程地址相同但值不同 的关键。
在 Linux 下,fork() 产生子进程时,并不会复制一份物理内存。相反,它让子进程的页表直接指向父进程的物理页,并把这些页的权限全部设为 只读。
- 对于 读操作,父子进程共享同一块物理内存,相安无事。
- 而对于 写操作 ,当子进程尝试修改变量时,硬件触发异常。内核查看之后得知这是写时复制页。于是,内核会为子进程 拷贝 一份物理页,再让子进程的页表指向这个新页面,然后加上写权限。
因此,虽然虚拟地址是一样的,但背后映射的物理地址已经在你不知不觉中改变了。
5. 多级页表与 MMU
要理解现代内存管理的精髓,必须把 MMU 和 多级页表 放在一起看。
5.1 页表
5.1.1 为什么需要页表?
假设物理内存是 4GB,页面大小为 4KB,那么总共有 1M 个页框。
如果使用单级页表,为了映射这 4GB 空间,每个进程都需要一个包含 1M 个表项的数组。如果每个表项占 4B,那每个进程光页表就要占用 4MB 内存。此外,单级页表要求 物理上连续 ,即使中间大片空间没用到,你也得为这些空洞预留页表项,这太浪费了。
5.1.2 多级页表
多级页表的核心思想是,只有当某个区域真的存了数据,才为它建立下一级页表。
以 32 位系统两级页表为例:
- 一级页表 (PGD/页目录): 将 4GB 分成 1024 份,每份 4MB。
- 二级页表 (PTE/页表项): 只有当进程真的访问了某个 4MB 范围时,内核才会创建一个二级页表。
5.2 MMU
MMU 是 CPU 内部的一个硬件单元,它的唯一任务就是:把虚拟地址转换为物理地址。
以 64 位 4 级页表为例:
- CPU 从控制寄存器(如 x86 的 CR3)中读取当前进程第一级页表(PGD)的物理基地址。
- 逐级拆解地址,MMU 会把 64 位的虚拟地址拆成好几段。9bit 索引第一级页表找到第二级地址,以此类推。
- 最后一级页表取出物理页的基地址,加上虚拟地址末尾的 12bit 偏移量,得到最终的物理内存地址。
5.3 TLB快表
由于多级页表需要多次访问内存,4 级页表意味着翻译一个地址要查 4 次内存,这比 CPU 执行指令慢得多。
为了提速,MMU 内部有一个超高速缓存 TLB。
- 它缓存了最近翻译过的 虚拟页到物理页 的映射关系。
- 现代系统 TLB 命中率通常在 99% 以上,翻译几乎是瞬时的。
- 但是当进程切换(也就是切换
mm_struct)时,由于 页表变了,旧的 TLB 缓存通常必须失效,这就是进程切换开销大的原因之一。
6. 安全与性能的博弈
最后,我们聊聊第五个问题。
有些系统调用(比如 gettimeofday)频率极高。如果每次都切入内核态,上下文切换的开销很大。所以内核会在用户空间最高端映射一个 vDSO 区域,里面放的是内核提供的 只读 代码,用户态直接调用,无需切换模式。
2018 年,由于 Intel CPU 的 熔断 漏洞,黑客可以通过预测执行在用户态窃取内核内存的信息。
- 以前:用户态和内核态共用一套页表,只是权限位不同。
- 现在:内核被迫做了 隔离,用户态运行时,页表里几乎不包含内核地址。
- 因此,现在每次从用户态进入内核态,都要 切换页表,性能损耗由此而来。但为了安全,我们不得不接受这个改变。
写在最后:
进程虚拟内存不只是为了给进程提供独占空间,它是 Linux 内核在效率、性能与安全之间不断权衡的杰作,
理解了虚拟内存,才算是真正触碰到了操作系统的灵魂。当你下次看到 Segmentation Fault 时,希望你脑海中的认知将不再只是一个简单的报错,而是整个 VMA 红黑树在警告:"你越界了,那是我不曾承诺给你的世界。"
本文结束。