| 本篇位置 | 虚拟化内存的核心机制。第 13、14 篇都是在给这一篇擦屁股 |
| 运行环境 | 容器里的 Linux,Python 3 |
一、破法:不许挑大小
第 10 篇的结论是:分段的死穴在于段的大小是任意的。只要任意,物理内存里就会攒出一堆凑不到一起的洞。
分页的破法简单粗暴:
所有块一律一样大。
虚拟地址空间切成固定大小的页(page),物理内存切成同样大小的帧(frame)。Linux 上都是 4096 字节。
一样大之后,任何一个空闲帧都能装下任何一个请求。挑都不用挑,拿一个就行。
⭐ 外部碎片不是被解决了,是从定义上被取消了。 这是这门课里最漂亮的一次"改问题而不是解问题"。
(还记得第 1 篇为什么坚持在容器里跑?macOS 的 Apple Silicon 页大小是 16384,这一篇之后所有的数字都会差四倍。)
打个比方
第 8 篇那家酒店,现在换了规矩。
分段是:“你们团 20 个人,我给你们 501 到 520 这一整排。” 团来团走,走廊上剩下一堆零散的空房——明明空着 22 间,却凑不出连续的 20 间。
分页是:“不管你们几个人,一律按标准间发,哪间空发哪间。” 20 个人可能被发到 3 楼、7 楼、12 楼,完全不连续。
代价是前台必须记一本详细的对照表:这个团的 1 号房是实际的 307,2 号房是实际的 1215……一条都不能少。
⭐ 分段的对照表只有三行(三个段的基址界限),分页的对照表有多少页就有多少行。这本账本从此成为整件事最大的开销来源——第 13、14 篇都在对付它。
二、翻译怎么做
虚拟地址被硬件劈成两半:
虚拟地址(48 位)
┌──────────────────────────┬───────────────┐
│ 虚拟页号 (VPN) │ 页内偏移 │
│ 高 36 位 │ 低 12 位 │
└──────────────────────────┴───────────────┘
│ │
│ 查页表 │ 原样照抄
▼ ▼
┌──────────────────────────┬───────────────┐
│ 物理帧号 (PFN) │ 页内偏移 │
└──────────────────────────┴───────────────┘
物理地址
低 12 位是页内偏移,因为 4096 = 2¹²。这 12 位原样不动——页在虚拟空间里的第几个字节,在物理帧里还是第几个字节。
高位是虚拟页号,拿它去页表里查,换成物理帧号。
所以翻译的全部内容是:换掉高位,低位照抄。 硬件只要一次查表加一次拼接。
页表项里还有什么
每一项除了帧号,还带一串标志位。这些位后面几篇会反复用到:
| 位 | 含义 | 哪一篇用它 |
|---|---|---|
| 有效位 | 这一项到底指没指向一个真的帧 | 本篇(按需分页) |
| 读/写/执行 | 权限。第 8 篇 maps 里那些 r-xp |
第 8 篇、第 30 篇 |
| 访问位 | 最近被访问过吗(硬件置位) | 第 15 篇(挑谁换出去) |
| 脏位 | 被写过吗 | 第 15 篇(没写过就不用写回磁盘) |
| 用户/内核 | 用户态能不能访问 | 第 4 篇、第 30 篇 |
⭐ 有效位是这一篇后半段的主角。 一个页表项完全可以存在,但有效位是 0——意思是"这个地址是合法的,但现在没有物理内存对应它"。
三、跑一遍看看:按需分页
有了有效位,就能干一件很赚的事:先不给物理内存,等你真碰了再给。
# demand.py
import mmap, os
def status(*keys):
d = {}
for line in open("/proc/self/status"):
k = line.split(":")[0]
if k in keys: d[k] = line.split()[1] + " kB"
return " ".join(f"{k}={d[k]}" for k in keys)
PAGE = os.sysconf("SC_PAGESIZE")
print(f"页大小 = {PAGE} 字节\n")
print("① 申请 1 GB 地址空间,一个字节都不碰:")
m = mmap.mmap(-1, 1 << 30)
print(" ", status("VmSize", "VmRSS", "VmPTE"))
print("\n② 每隔一页碰一个字节,碰 1000 页(共 4 MB):")
for i in range(1000):
m[i * PAGE] = 1
print(" ", status("VmSize", "VmRSS", "VmPTE"))
print("\n③ 再连续碰满前 64 MB:")
for i in range(16384):
m[i * PAGE] = 1
print(" ", status("VmSize", "VmRSS", "VmPTE"))
页大小 = 4096 字节
① 申请 1 GB 地址空间,一个字节都不碰:
VmSize=1062696 kB VmRSS=8116 kB VmPTE=60 kB
② 每隔一页碰一个字节,碰 1000 页(共 4 MB):
VmSize=1062696 kB VmRSS=12244 kB VmPTE=76 kB
③ 再连续碰满前 64 MB:
VmSize=1062696 kB VmRSS=73780 kB VmPTE=196 kB
三个数字,三件事:
VmSize 一直是 1 GB 不动。 这是地址空间的大小——你能合法说出多少地址。
VmRSS 从 8 MB 涨到 73 MB。 这是真正占用的物理内存。你碰了 1000 页,它涨了约 4 MB(1000 × 4 KB);你碰满 64 MB,它涨了约 62 MB。你碰哪一页,内核才给哪一页。
VmPTE 是页表本身占的内存。 它也在跟着涨——第四节算这笔账。
缺页中断走的是哪条路
你碰一个有效位为 0 的页,发生的事和第 4 篇、第 10 篇是同一条隧道:
- MMU 查页表,发现有效位是 0 → 触发缺页异常,陷入内核。
- 内核看这个地址在不在合法范围内(
maps里有没有这一段)。- 不在 → 这是野指针,发
SIGSEGV。第 10 篇那个演示就停在这里。 - 在 → 找一个空闲物理帧,填进页表项,有效位置 1。
- 不在 → 这是野指针,发
return-from-trap,重新执行刚才那条指令。这次翻译成功。
⭐ 应用程序完全不知道刚才发生过什么。 它那条 mov 指令执行了两次,中间隔了一趟内核,但从它的角度看只是"这条指令有点慢"。这是第 1 篇那个"善意的谎言"最典型的一次现身。
⚠️ 顺带解释第 9 篇那个困惑:为什么不写 memset 的话 RSS 不涨?因为 malloc 只是让 glibc 记账,甚至连页表项都不一定建。物理内存要等你真的写进去,触发缺页,内核才发给你。
这也解释了 top 里 VIRT 和 RES 为什么能差十倍——它们量的是两件完全不同的东西。
四、⭐ 两笔账
分页把外部碎片取消了,但它要付两笔钱,而且都很贵。
账一:一次访问变成两次
页表在内存里。所以每一次内存访问,硬件都要:
- 先访问内存,读页表项,拿到帧号。
- 再访问内存,读你真正要的数据。
内存访问次数翻倍。 一个本来 100 纳秒的操作变成 200 纳秒——整台机器慢一半。
这显然不能接受。解法是给页表加一个缓存,叫 TLB。那是第 13 篇,也是这门课里最能体现"中间层开销必须被专门优化掉"的一篇。
账二:页表本身有多大
算一下最朴素的做法。48 位地址空间、4 KB 页,那就有 2³⁶ 个虚拟页。每个页表项 8 字节:
2³⁶ × 8 字节 = 512 GB。
每个进程一份。 这显然是荒谬的。
而上面演示里 VmPTE 只有 196 kB——差了六个数量级。为什么?
因为真实的地址空间极度稀疏。第 8 篇那张 maps 表里,堆和栈之间是一大片什么都没有的空地。那片空地的页表项,压根不需要存在。
怎么"不存在",就是第 14 篇的多级页表。
五、代价与取舍
分页换来了什么:
- 外部碎片被取消(不是缓解,是从定义上不存在)。
- 物理内存不必连续,于是内核找地变成 O(1)——摘一个空闲帧就行。
- 按需分页:地址空间可以远大于物理内存,你只为真正碰到的部分付钱。
- 一页一套权限,共享和写时复制都变得容易——第 3 篇
fork省下的那笔账,就是靠"把页表项标成只读、谁写谁触发缺页再复制"。
它花了什么:
- 访存翻倍(第 13 篇解决)。
- 页表巨大(第 14 篇解决)。
- 内部碎片回来了。要 1 个字节给你 4096 个。不过 4 KB 的浪费比分段那种"总量够但装不下"温和得多——它是可预测、有上界的。
它放弃了什么: 数据在物理内存里的连续性。回到酒店:一律按标准间发、哪间空发哪间,代价就是同一个团的二十个人可能被拆到三个楼层去。团员自己不在意(他们看的是行程单上的号),但要往房间里搬一件超长的大行李时就麻烦了——那件行李需要几间打通的房。
你的 4 MB 数组在虚拟地址上是连着的,物理上可能散在一千个不相干的帧里。绝大多数时候这没关系,但有两种场合会疼:
- DMA:设备要的是物理地址,且往往要求连续。
- 大块顺序访问:散开之后 TLB 压力变大——这正是第 13 篇的实测内容,也是大页(huge page)存在的理由。
六、小结
- 分页的破法是不许挑大小:页和帧一律 4096 字节。外部碎片从定义上被取消。
- 翻译 = 高位(虚拟页号)查表换成物理帧号,低 12 位偏移原样照抄。
- 页表项除了帧号,还有有效位、权限位、访问位、脏位——后面几篇会一个个用到。
- ⭐ 按需分页:申请 1 GB,
VmSize是 1 GB 而VmRSS只有 8 MB;碰 1000 页涨 4 MB,碰满 64 MB 涨 62 MB。碰哪页给哪页。 - 缺页走的是第 4 篇那条隧道:陷入内核 → 合法就补一个帧、不合法就
SIGSEGV→ 回来重新执行同一条指令。应用程序毫不知情。 - ⚠️ 这解释了
malloc不写就不涨 RSS,也解释了top里VIRT和RES为什么差十倍。 - 两笔账:访存翻倍(→ 第 13 篇 TLB),页表朴素实现要 512 GB(→ 第 14 篇多级页表)。实测
VmPTE只有 196 kB,因为真实地址空间极度稀疏。
思考题
- 演示②里碰了 1000 页,
VmPTE只涨了 16 kB。1000 个页表项 × 8 字节 = 8 kB。多出来的那一半是什么? - 页大小如果改成 4 MB(大页),上面两笔账各自会怎么变?内部碎片呢?什么样的程序适合用大页?
- 缺页处理完之后要"重新执行同一条指令"。如果那条指令是
x = *p + *q,而p和q各在一个缺失的页上,会发生几次缺页?重新执行会不会把已经做过的副作用做第二遍? - 酒店那个比方里,分页是"哪间空发哪间"。那按需分页对应比方里的什么行为?前台在什么时候才真的把钥匙给你?