本篇位置 虚拟化内存的核心机制。第 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 篇是同一条隧道:

  1. MMU 查页表,发现有效位是 0 → 触发缺页异常,陷入内核。
  2. 内核看这个地址在不在合法范围内(maps 里有没有这一段)。
    • 不在 → 这是野指针,发 SIGSEGV第 10 篇那个演示就停在这里。
    • → 找一个空闲物理帧,填进页表项,有效位置 1。
  3. return-from-trap重新执行刚才那条指令。这次翻译成功。

应用程序完全不知道刚才发生过什么。 它那条 mov 指令执行了两次,中间隔了一趟内核,但从它的角度看只是"这条指令有点慢"。这是第 1 篇那个"善意的谎言"最典型的一次现身。

⚠️ 顺带解释第 9 篇那个困惑:为什么不写 memset 的话 RSS 不涨?因为 malloc 只是让 glibc 记账,甚至连页表项都不一定建。物理内存要等你真的写进去,触发缺页,内核才发给你。

这也解释了 topVIRTRES 为什么能差十倍——它们量的是两件完全不同的东西。

四、⭐ 两笔账

分页把外部碎片取消了,但它要付两笔钱,而且都很贵。

账一:一次访问变成两次

页表在内存里。所以每一次内存访问,硬件都要:

  1. 先访问内存,读页表项,拿到帧号。
  2. 再访问内存,读你真正要的数据。

内存访问次数翻倍。 一个本来 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,也解释了 topVIRTRES 为什么差十倍。
  • 两笔账:访存翻倍(→ 第 13 篇 TLB),页表朴素实现要 512 GB(→ 第 14 篇多级页表)。实测 VmPTE 只有 196 kB,因为真实地址空间极度稀疏

思考题

  1. 演示②里碰了 1000 页,VmPTE 只涨了 16 kB。1000 个页表项 × 8 字节 = 8 kB。多出来的那一半是什么?
  2. 页大小如果改成 4 MB(大页),上面两笔账各自会怎么变?内部碎片呢?什么样的程序适合用大页?
  3. 缺页处理完之后要"重新执行同一条指令"。如果那条指令是 x = *p + *q,而 pq 各在一个缺失的页上,会发生几次缺页?重新执行会不会把已经做过的副作用做第二遍?
  4. 酒店那个比方里,分页是"哪间空发哪间"。那按需分页对应比方里的什么行为?前台在什么时候才真的把钥匙给你?

延伸