☰
一个指针解引用后,CPU 到底做了什么?从虚拟地址到页表、TLB 与物理内存
2026/9/26 9:09:55 网站建设 项目流程

1. 两个进程明明用了同一个地址,为什么不会互相干扰?

假设有两个正在运行的进程。它们都访问地址0x400000,却读出了各自的数据:

进程 A:访问 0x400000 → 读到 A 的数据 进程 B:访问 0x400000 → 读到 B 的数据

同一个数字,怎么会找到两个地方?如果 A 在这里写入数据,为什么不会把 B 的数据覆盖掉?

1.1 同一个地址,为什么可以有两个结果?

先把物理内存想象成一排有编号的小格子,每个格子保存一个字节。CPU 最终要通过这些编号找到数据。

如果程序拿到的编号就是物理内存的编号,进程 A 和进程 B 都写入同一个位置,就会互相覆盖。进程之间也难以隔离。

于是,程序平时使用另一套编号:虚拟地址。内存硬件最终使用的编号叫物理地址。每个进程都有自己的虚拟地址空间,因此地址的数字相同,不代表它们指向同一个物理位置。

可以把地址前面想象成隐含了“属于哪个进程”:

进程 A 的 0x400000 → 一处物理内存 进程 B 的 0x400000 → 另一处物理内存

1.2 CPU 怎么知道这次该去哪个地方?

CPU 需要一份地址对照关系,才能把程序使用的虚拟地址变成物理地址。这份关系记录在页表中。不同进程通常有各自的用户地址空间,也就有各自的页表。

假设进程 A 和 B 的页表分别记录:

A 的页表:虚拟地址 0x400000 附近 → 物理页框 10 B 的页表:虚拟地址 0x400000 附近 → 物理页框 37

进程 A 运行时,CPU 用 A 的页表翻译;切换到进程 B 时,改用 B 的页表。负责执行地址翻译的硬件叫 MMU(内存管理单元)。这样,同一个虚拟地址就可以找到不同的物理位置。

程序中的指针也参与这件事。比如int value = *ptr;,指针ptr在普通 Linux 用户程序中保存的通常是虚拟地址;CPU 读取它指向的数据时,仍要完成上述翻译。

不过,页表不可能给每一个字节都单独写一条记录。它是怎样把地址组织起来的?

1.3 一个地址怎样拆成“页号+页内偏移”?

如果按字节记录映射,页表会大得难以使用。所以计算机把虚拟地址空间分成一块块固定大小的页,物理内存也分成同样大小的页框。页表记录的是“虚拟页对应哪个物理页框”,页内的具体字节位置由地址自己提供。

先不管复杂的多级页表,只看这个最核心的动作。

假设页面大小是常见的 4KB:

4KB = 4096 B = 2¹² B

一页里有2¹²个字节,所以只需要地址的低 12 位,就能表示这个字节在页内的位置。剩下的高位则表示它属于哪个虚拟页。

虚拟地址 = 虚拟页号 + 页内偏移

来算一个最小的例子。假设程序访问虚拟地址0x1234:

页面大小:0x1000 虚拟页号:0x1234 / 0x1000 = 0x1 页内偏移:0x1234 % 0x1000 = 0x234

假设页表中记录:

虚拟页 0x1 → 物理页框 0xA

那么最终的物理地址就是:

0xA000 + 0x234 = 0xA234

地址翻译改变的是页号,不是页内偏移。

页内偏移表示“第几个字节”。无论这个页被放到哪个物理页框,它在页内的相对位置都不需要改变。

1.4 页表项只保存物理页号吗?

页表可以先想象成一个数组:用虚拟页号找到一条记录,再从中取出物理页框号。这条记录叫页表项(Page Table Entry,PTE),物理页框号常缩写为 PFN。

但记录位置还不够。假如进程 A 找到了一个页面,却试图改写只读数据,CPU 也必须拦住它。因此,页表项还会记录页面当前是否有有效映射、用户程序能不能访问、是否允许写入或执行等信息。

这些位让页表同时承担了两件事:

地址翻译:虚拟页在哪个物理页框? 访问检查:这次读、写或执行被允许吗?

所以页表不只是一张地址对照表,也是进程内存保护的硬件依据。

1.5 既然一张表就能查,为什么还需要多级页表?

单级页表的查找思路非常直接,但它有一个致命问题:太大。

以 32 位虚拟地址、4KB 页面为例,虚拟页数量是:

2³² / 2¹² = 2²⁰ = 1,048,576 页

如果每个页表项占 4 字节,一个进程的单级页表就需要:

1,048,576 × 4B = 4MB

4MB 看起来不算大,但这是每个进程的固定成本。更麻烦的是,某个小程序可能只用到很少的虚拟页,却仍然要为整个地址空间准备完整表格。

多级页表的解法很像查字典:先看目录,再找具体的页。

虚拟地址的高位 → 第一级表 虚拟地址的中间位 → 第二级表 虚拟地址的更低位 → 最后的 PTE

如果一大段虚拟地址根本没有使用,那么对应的下级页表就可以完全不创建。

多级页表的核心不是让查找步骤更少,而是让未使用的地址空间不占用下级页表。

1.6 完整的寻址流程

先以进程 A 访问0x400000为例,把过程完整走一遍:

进程 A 给出虚拟地址 0x400000 ↓ 拆出虚拟页号和页内偏移 ↓ 用虚拟页号查进程 A 的页表 ↓ 得到物理页框号 ↓ 和页内偏移拼成物理地址 ↓ 访问真正的数据

换成进程 B,地址数字仍然可以是0x400000,但这一次查的是 B 的页表,于是可能得到另一个物理页框。

这条流程里还有一个明显的麻烦:页表本身也放在内存中(内存访问的速度远远小于CPU)。为了读一次数据,难道要先读页表,再读数据吗?

1.7 x86-64 的多级页表究竟怎样寻址?

现在再来看一个更接近真实硬件的例子。

在常见的 x86-64 四级分页中,4KB 页面使用的虚拟地址部分可以这样拆分:

47 39 38 30 29 21 20 12 11 0 ┌─ PML4 索引 ─┬─ PDPT 索引 ─┬─ PD 索引 ─┬─ PT 索引 ─┬─ 页内偏移 ─┐ │ 9 bit │ 9 bit │ 9 bit │ 9 bit │ 12 bit │ └────────────┤────────────┤──────────┤──────────┤────────────┘

为什么每级刚好是 9 位?

x86-64 的一个页表项通常占 8 字节,一张页表自身刚好放在一个 4KB 页中:

4096B / 8B = 512 = 2⁹

所以一级页表有 512 个项,用 9 位索引刚好选中其中一项。

CPU 的查找过程大致如下:

  1. CR3寄存器给出当前地址空间的顶级页表根;
  2. 用虚拟地址的 PML4 索引找到第一个表项;
  3. 表项指向 PDPT,再用 PDPT 索引查找;
  4. 继续走过 PD 和 PT,最后找到 PTE;
  5. PTE 给出物理页框和权限;
  6. 物理页框基址与原来的 12 位页内偏移组合,得到物理地址。

Linux 在内核中把页表抽象为五级:

PGD → P4D → PUD → PMD → PTE

这不代表每台机器都真的要走五级。架构没有使用的层级可以被“折叠”。例如在 x86-64 四级分页下,P4D 通常就是折叠的;开启五级分页时,它才对应真实的硬件层级。

而且,遇到大页时也不一定走到最后:2MB 页可以在 PD/PMD 层结束,1GB 页可以在 PDPT/PUD 层结束。

现在寻址过程是明白了,但新问题也出现了:这不会太慢吗?

1.8 进程切换时,CPU 怎么换另一张页表?

现在再回头看开头的两个进程。CPU 运行 A 时,应该使用 A 的映射;改为运行 B 时,应该使用 B 的映射。它怎么找到当前该用的页表?

在 x86-64 中,CR3寄存器保存当前地址空间顶级页表的物理地址。页表本身放在物理内存中,CPU 按规定的格式读取它。切换到另一个地址空间时,内核会设置相应的页表根。于是同一个虚拟地址可以被翻译到另一个物理页框。

更准确地说,页表跟着地址空间走。同一个进程里的线程通常共享地址空间,也共享用户页表;不同进程通常拥有不同的用户地址空间和页表根。

不过,“不同进程使用相同虚拟地址”不保证物理页面一定不同。共享内存、共享库或fork()后尚未复制的页面,都可能让两个地址空间映射到同一物理页。能不能共享由映射和权限决定,不是由地址数字是否相同决定。

1.9 读一次数据,先查四次页表?

页表本身也放在内存中。

如果每次访问数据都完整走一遍四级页表,逻辑上就可能先读取 4 个页表项,然后才读到真正的数据。

4 次页表访问 + 1 次真正数据访问

实际处理器还有数据 Cache(数据缓存)和 page-walk cache(页表遍历缓存)等机制,所以这些读取不一定真的都到 DRAM。但如果每条加载、存储指令都重复做地址翻译,开销仍然无法接受。

于是 CPU 在 MMU 附近放了一块很小、很快的缓存:TLB(Translation Lookaside Buffer),中文常称快表。

TLB 保存的不是程序数据,而是最近使用过的地址翻译:

虚拟页号 VPN → 物理页框 PFN + 权限

它可以理解成页表中少量热门结果的硬件快照。

1.10 TLB 命中和未命中分别会发生什么?

现在让进程 A 再次访问0x400000。CPU 已经知道该使用 A 的地址空间,接下来 MMU 会先拿虚拟页号查 TLB。

1.10.1 TLB 命中

TLB 已经有对应翻译,MMU 可以直接拿到 PFN,检查权限,再与页内偏移组合成物理地址。

如果翻译虽然命中,但缓存的权限不允许这次读、写或取指,访问仍然会触发异常;“命中”并不能绕过页级保护。

虚拟地址 → TLB 命中 → 物理地址

这是最常见、也是最快的路径。

1.10.2 TLB 未命中

TLB 中暂时没有结果,硬件需要遍历页表,这个过程叫页表遍历(page walk)。

如果页表中的映射存在且权限允许,CPU 会把翻译结果填入 TLB,然后继续执行原来的访存。

虚拟地址 → TLB miss → page walk → 填充 TLB → 物理地址

这条路更慢,但它仍然是正常的硬件行为。

1.10.3 页表也无法完成翻译

如果页表遍历时发现当前没有可用映射,或者这次访问违反了权限,CPU 会触发 Page Fault,进入内核。即使 TLB 中命中了翻译,只要权限不允许这次访问,也会触发异常。

虚拟地址 → TLB miss → page walk 失败 → Page Fault


这里有一个特别容易混淆的结论:

TLB miss 不是 Page Fault。

TLB miss 后,只要页表中有有效映射且权限允许,地址翻译就能继续。映射缺失或权限冲突时,才需要让内核介入。此时也不能直接断定程序出了错:内核可能按需分配页面,也可能最终拒绝这次访问。

1.11 TLB 和 CPU Cache 到底有什么区别?

两者都快,都小,都会命中或未命中,但它们缓存的东西完全不同。

对比TLBCPU Cache
缓存什么地址翻译和页权限指令和数据的 Cache Line
常见关系VPN → PFN地址 → 数据
解决什么查页表太慢访问主存太慢
未命中后遍历页表查下级 Cache 或内存

简单来说:

TLB 帮 CPU 找地址,Cache 尽量直接交出数据。

1.12 进程切换后,TLB 里的映射不就乱了吗?

页表是地址空间的一部分。进程切换时,CPU 需要切换当前使用的页表根;在 x86-64 中,这与CR3寄存器有关。

如果 TLB 里只记虚拟页号,不记这条翻译属于哪个地址空间,那么进程 A 的映射确实可能被进程 B 误用。

最直接的做法是在切换时使相关 TLB 项失效。现代处理器也可以使用 ASID 或 x86 的 PCID 这类标识,给 TLB 项带上地址空间标签,从而减少不必要的清空。

1.13 大页为什么能减少 TLB 压力?

TLB 的容量有限。如果每条 TLB 项映射一个 4KB 页,那么 1000 个页也只能覆盖大约 4MB 内存。

换成 2MB 大页后,一条映射覆盖的范围是原来的 512 倍。这种“TLB 能同时覆盖多大内存范围”的能力,常被称为 TLB reach。

大页还能让 page walk 在更高层级提前结束,节省下级页表空间。但它也不是免费的:大页更容易造成内存浪费,分配和回收也会更复杂。

所以大页解决的不是“页越大越好”,而是特定工作负载下页表开销与 TLB 覆盖范围的问题。

1.14 回到开头:为什么同一个地址不会弄混?

现在回到两个进程都访问0x400000的场景。以进程 A 的一次读取为例:

  1. CPU 得到虚拟地址0x400000;
  2. MMU 把地址拆成虚拟页号和页内偏移;
  3. MMU 先查询 TLB;
  4. TLB 命中时,直接得到物理页框和权限;
  5. TLB 未命中时,硬件从 A 当前使用的页表根开始逐级遍历;
  6. 找到合法 PTE 后,结果被填入 TLB;
  7. PFN 与原来的页内偏移组合成物理地址;
  8. 处理器再通过 Cache 层次获取真正的数据;
  9. 如果映射不存在或权限不允许,就触发 Page Fault,让内核决定如何处理。

换成进程 B,CPU 使用的是 B 的地址空间。同样的虚拟地址会按 B 的映射翻译,因此可以读到 B 自己的数据。页表负责指向哪里、允许怎样访问;TLB 负责加快重复的地址翻译。

所以开头真正要补全的不是地址数字,而是**“谁在使用这个地址”**。只看0x400000,我们无法判断它最终落到哪一块物理内存;知道当前地址空间及其映射之后,CPU 才能找到答案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询