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 = 4MB4MB 看起来不算大,但这是每个进程的固定成本。更麻烦的是,某个小程序可能只用到很少的虚拟页,却仍然要为整个地址空间准备完整表格。
多级页表的解法很像查字典:先看目录,再找具体的页。
虚拟地址的高位 → 第一级表 虚拟地址的中间位 → 第二级表 虚拟地址的更低位 → 最后的 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 的查找过程大致如下:
CR3寄存器给出当前地址空间的顶级页表根;- 用虚拟地址的 PML4 索引找到第一个表项;
- 表项指向 PDPT,再用 PDPT 索引查找;
- 继续走过 PD 和 PT,最后找到 PTE;
- PTE 给出物理页框和权限;
- 物理页框基址与原来的 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 到底有什么区别?
两者都快,都小,都会命中或未命中,但它们缓存的东西完全不同。
| 对比 | TLB | CPU 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 的一次读取为例:
- CPU 得到虚拟地址
0x400000; - MMU 把地址拆成虚拟页号和页内偏移;
- MMU 先查询 TLB;
- TLB 命中时,直接得到物理页框和权限;
- TLB 未命中时,硬件从 A 当前使用的页表根开始逐级遍历;
- 找到合法 PTE 后,结果被填入 TLB;
- PFN 与原来的页内偏移组合成物理地址;
- 处理器再通过 Cache 层次获取真正的数据;
- 如果映射不存在或权限不允许,就触发 Page Fault,让内核决定如何处理。
换成进程 B,CPU 使用的是 B 的地址空间。同样的虚拟地址会按 B 的映射翻译,因此可以读到 B 自己的数据。页表负责指向哪里、允许怎样访问;TLB 负责加快重复的地址翻译。
所以开头真正要补全的不是地址数字,而是**“谁在使用这个地址”**。只看0x400000,我们无法判断它最终落到哪一块物理内存;知道当前地址空间及其映射之后,CPU 才能找到答案。