进来看看你对进程虚拟内存的了解有多深?

在 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_codeend_code: 可执行代码段的范围。
  • start_dataend_data: 已初始化数据的范围。
  • start_brkbrk: 堆的起点和当前的终点。
  • 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 查表:

  1. 这个地址合法吗?
  2. 如果不合法,报段错误。
  3. 如果合法,是因为还没分配物理内存吗?如果是,则分配一个物理页并填入页表。

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 红黑树在警告:"你越界了,那是我不曾承诺给你的世界。"


本文结束。

相关推荐
yann_qu2 小时前
Mac通过ssh远程连接wsl
linux·windows·macos·ssh·wsl
默|笙2 小时前
【Linux】库制作与原理(3)_动静态库的链接过程
linux
悟空空心2 小时前
linux创建普通只读用户
linux·运维·chrome
Trouvaille ~2 小时前
【递归、搜索与回溯】专题(八):记忆化搜索——从暴力递归到动态规划的桥梁
c++·算法·leetcode·青少年编程·面试·蓝桥杯·动态规划
曼岛_2 小时前
[AI实战]Ubuntu 下安装OpenClaw——从零搭建你的专属AI助理
linux·人工智能·ubuntu·openclaw·龙虾
试试勇气2 小时前
Linux学习笔记(十六)--进程信号
linux·笔记·学习
李昊哲小课2 小时前
Python 文件路径操作详细教程
linux·服务器·python
小小小米粒2 小时前
k8s网络通信ip申请如何层级同步进行pod网络层级网络访问请求路由流程
linux·运维·服务器
Cyber4K2 小时前
【妙招系列】在Linux中测试自己邮箱是否可接收邮件?
linux·运维·python