Day 08 · 虚拟内存与页表

Week 02 · 周一 · 2026-05-18 · 主线:操作系统核心 · 预计 1.5h 阅读 + 30min 实操
今日导读:上周我们建立了「进程 = 独立的虚拟地址空间 + 资源 + PCB」这个直觉。但留了一个大坑没填:所谓「独立的虚拟地址空间」到底是怎么变出来的?两个进程都看到 0x400000,凭什么不会打架?CPU 怎么知道把这个虚拟地址翻译到哪块物理内存?为什么我的 7B 大模型加载完后 VmRSS 显示 14GB,但机器只有 32GB,跑了 4 个进程为什么没爆?今天我们一次性把虚拟内存这套魔法揭开。会回答 7 个问题:内存到底有多慢、虚拟地址 vs 物理地址、MMU 怎么工作、多级页表为什么是 4 级、TLB 为什么决定性能、大页能不能救你、/proc/meminfo 每一行什么意思。读完今天你会突然理解为什么 LLM 推理对内存带宽这么敏感。
今日目录
  1. 内存层级与延迟数量级:你必须背下来的几个数字
  2. 物理地址 vs 虚拟地址:CPU 看到的两个世界
  3. MMU:把虚拟地址翻译成物理地址的硬件
  4. 多级页表:为什么 64 位下要分 4 级
  5. TLB:MMU 自己的缓存,决定真实性能
  6. 大页(Huge Page):减少 TLB miss 的杀手锏
  7. 读懂 /proc/meminfo:内核眼里的内存账本
  8. 与 AI / Agent 安全的连结
  9. 今日小练习(3 道)

1. 内存层级与延迟数量级:你必须背下来的几个数字

讲虚拟内存之前,先建立一个性能直觉。你做 AI 时整天听到「memory-bound」「带宽不够」,但很多人没真正算过这些数字到底是什么量级。

现代计算机的存储不是一层,而是一个金字塔,越靠近 CPU 越快越贵越小

容量 延迟 带宽 ──── ──── ──── 寄存器 ~1KB < 1 ns 极高 ← CPU 一个周期就能访问 L1 cache ~32KB ~1 ns ~1 TB/s L2 cache ~256KB ~3 ns ~500 GB/s L3 cache ~32MB ~10 ns ~300 GB/s DRAM 几十~几百 GB ~80-100 ns ~50 GB/s ← 这是「内存」,相对 L1 慢 100 倍 NVMe SSD 几 TB ~50 us ~7 GB/s HDD 几十 TB ~10 ms ~200 MB/s 网络 -- ~ms 级 ~Gbps

有个经典对比把它们放大到人类时间尺度:把 L1 访问当成「1 秒」,那么访问主存大约是「100 秒」,访问 SSD 是「14 小时」,访问网络是「几个月」。这个差距不是 2 倍 3 倍,是 100 倍 1000 倍

所以工程上要永远问一个问题:我访问的数据离 CPU 多近?

这套层级直接决定了你后面要看到的一切——从 cache line、TLB、到 LLM 推理为什么 batch=1 时 GPU 利用率不到 5%。今天的主角「虚拟内存」就是在主存(DRAM)和 SSD 之间架起一座桥,让程序"看起来"有比物理内存大得多的空间。

2. 物理地址 vs 虚拟地址:CPU 看到的两个世界

给你机器里插一根 32GB 的内存条,物理上这块 DRAM 被编址为 0x000000000x7FFFFFFF(32GB = 2³⁵ 字节)。每个字节有一个唯一的物理地址(Physical Address,PA)。这是真实的、硬件电路看到的地址。

但你的 Python 进程看到的不是这个地址。你的进程看到的是虚拟地址(Virtual Address,VA)——一段长得像 0x00007fa8b1c00000、可以从 0 一直编到 2⁴⁸ 甚至 2⁵⁷ 字节的「假地址空间」。

为什么要分两层?

这是 OS 设计史上最重要的发明之一。它一次解决了 4 个问题:

类比:城市的门牌号

想象一个国家,每个城市都用自己独立的门牌号系统:北京的「中关村大街 1 号」和上海的「中关村大街 1 号」是完全不同的两座楼。但每座楼最终都对应一块真实的土地(物理地址)。城市 = 进程,门牌号 = 虚拟地址,土地坐标 = 物理地址,而政府保存的「门牌号 → 土地坐标」对照表就是 页表(Page Table)

3. MMU:把虚拟地址翻译成物理地址的硬件

每次 CPU 执行 mov rax, [0x7fa8b1c00000] 这样的指令,它实际要做的事是:

  1. 取到虚拟地址 0x7fa8b1c00000
  2. 把它翻译成物理地址(比如 0x12340000
  3. 去 DRAM 的 0x12340000 读 8 字节
  4. 放进 rax 寄存器

第 2 步——「翻译」——由 CPU 内部一个叫 MMU(Memory Management Unit)的硬件单元完成。不是 OS,不是软件,是硬件。OS 只负责填好那张对照表(页表),运行时翻译由 MMU 用电路在纳秒级完成。

页:翻译的最小单位

如果对照表精确到字节,那 32GB 内存需要 32G 条记录,谁也存不下。所以硬件规定:翻译以「页(page)」为单位。x86_64 默认页大小是 4KB(4096 字节)

这意味着:虚拟地址的低 12 位(4KB = 2¹²)作为页内偏移(offset)不参与翻译,直接照搬到物理地址;高位作为虚拟页号(VPN),通过页表翻译成物理页号(PPN,又叫 page frame number)

虚拟地址 (48 位) ┌──────────────────────────────────────┬──────────────────┐ │ 虚拟页号 VPN (36 位) │ 页内偏移 (12 位) │ └──────────────────────────────────────┴──────────────────┘ │ │ ↓ 通过页表翻译 ↓ 不变直接拷贝 ┌──────────────────────────────────────┬──────────────────┐ │ 物理页号 PPN (例如 24 位) │ 页内偏移 (12 位) │ └──────────────────────────────────────┴──────────────────┘ 物理地址 (例如 36 位,对应 64GB 物理内存)

每条页表项(PTE)除了存「这个虚拟页对应哪个物理页」,还存着这些权限位

这几个位也是所有内存保护、所有 mmap 标志、所有 KASLR/NX-bit 之类安全特性的硬件基础。Day 41 讲栈溢出攻击时,我们会回头看 NX 位是怎么挡住 shellcode 执行的。

4. 多级页表:为什么 64 位下要分 4 级

现在做一道算术。x86_64 用 48 位虚拟地址(实际可访问空间 256 TB)。页大小 4KB(12 位 offset),所以虚拟页号有 36 位,意味着一个进程理论上有 2³⁶ = 680 亿个虚拟页

如果你用一张扁平表存这 680 亿个映射(每条 PTE 8 字节),光这张表就要 512 GB。每个进程都要这么大一张表。荒谬。

层次化是答案

注意一个事实:实际程序只用了虚拟地址空间的很小一部分——可能只摸到几 MB 到几 GB。绝大部分页表项是空的。所以可以用「树」结构:只为真正用到的部分创建子表。

x86_64 用 4 级页表。把 36 位 VPN 切成 4 段,每段 9 位:

虚拟地址 (48 位): ┌────────┬────────┬────────┬────────┬──────────────┐ │ PML4 │ PDPT │ PD │ PT │ Page Offset │ │ 9 bits │ 9 bits │ 9 bits │ 9 bits │ 12 bits │ └────────┴────────┴────────┴────────┴──────────────┘ │ │ │ │ │ │ │ └──► 在 PT 里第 N 项 → 找到 PPN │ │ └──────────► 在 PD 里第 N 项 → 找到下一级 PT 的物理地址 │ └───────────────────► 在 PDPT 里第 N 项 → 找到 PD └────────────────────────────► 在 PML4 里第 N 项 → 找到 PDPT 根:CR3 寄存器指向当前进程的 PML4 表的物理地址

每一级都是一个 4KB 的表(512 项 × 8 字节)。切换进程时,OS 只要改一个寄存器(CR3),整个地址空间就换了一份——这就是进程隔离在硬件层面的实现。

翻译一次要 4 次内存访问

注意一个可怕的事实:每翻译一个虚拟地址,MMU 要顺着 4 级表走,访问 4 次内存(每次 ~100 ns),最后才能访问目标数据(第 5 次内存访问)。一次普通的 x = arr[i] 内部可能要 500 ns?

当然不会。这就引出了 TLB。

5. TLB:MMU 自己的缓存,决定真实性能

MMU 内部有一个小缓存,叫 TLB(Translation Lookaside Buffer)。它存最近翻译过的「虚拟页号 → 物理页号」对应关系。每次翻译时:

现代 CPU 的 TLB 容量很小:

层级容量(典型)覆盖的虚拟内存(4KB 页)
L1 dTLB(数据)64 项256 KB
L1 iTLB(指令)64 项256 KB
L2 TLB1024-2048 项~8 MB

关键事实:普通 4KB 页下,TLB 全用满也只能覆盖几 MB 的"热内存"。一旦你的工作集(working set)超过这个大小,TLB miss 暴涨,每次内存访问额外付 4 次 page walk 的代价。

什么场景会 TLB 抖动

怎么看你的 TLB miss

# Linux perf 可以直接量化 TLB miss
perf stat -e dTLB-loads,dTLB-load-misses,iTLB-loads,iTLB-load-misses \
    python3 your_inference_script.py

# 关注 miss / loads 的比例,超过 1% 就值得调优

6. 大页(Huge Page):减少 TLB miss 的杀手锏

既然 4KB 页让 TLB 太捉襟见肘,最直接的办法是:把页变大。x86_64 支持 3 种页大小:

页大小需要几级页表单个 TLB 项覆盖1024 项 TLB 总覆盖
4 KB(标准页)4 级4 KB~4 MB
2 MB(大页 / huge page)3 级(跳过 PT)2 MB~2 GB
1 GB(巨页 / gigantic page)2 级(跳过 PT、PD)1 GB~1 TB

同样 1024 项的 TLB,用 2MB 页覆盖范围是 4KB 页的 512 倍;用 1GB 页是 26 万倍。这就是为什么大模型推理引擎(vLLM、TensorRT-LLM)都建议开 huge page。

Linux 上两种 huge page

1. 静态预留 HugeTLB:开机时预留一批 2MB 或 1GB 页,应用通过 mmap(MAP_HUGETLB)hugetlbfs 申请。最稳,性能可控,但用不掉就浪费。

# 预留 1024 个 2MB 大页(共 2GB)
echo 1024 | sudo tee /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages

# 看预留情况
grep Huge /proc/meminfo
# HugePages_Total:    1024
# HugePages_Free:     1024
# Hugepagesize:       2048 kB

2. 透明大页 THP(Transparent Huge Pages):内核自动把连续的 4KB 页合并成 2MB 页。无需应用改动,但有抖动风险——遇到大块写入时,内核要扫描合并,可能突发卡顿。Redis、PG 文档都建议线上关闭 THP。LLM 推理场景一般开 madvise 模式(只对显式申请的区域生效)。

# 看 THP 状态
cat /sys/kernel/mm/transparent_hugepage/enabled
# 输出 [always] madvise never 三选一,方括号是当前选项

# 改成 madvise 模式
echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
实践经验:在用 vLLM 跑 Llama-2 70B 时,开 2MB huge page 大约能让 TLB miss 降一个数量级,端到端推理吞吐提升 5-10%。这种"几行配置就能拿到的免费午餐"在工程上极其值得。

7. 读懂 /proc/meminfo:内核眼里的内存账本

每当你想"我机器到底用了多少内存",free -h 给你的是表面信息,真相在 /proc/meminfo 里。这个虚拟文件是 Linux 内核每次读取时实时计算出来的内存账本。挑最重要的 12 行解释:

$ cat /proc/meminfo
MemTotal:        32867432 kB   # 物理内存总量
MemFree:          2104560 kB   # 完全空闲(注意:不是"可用"!)
MemAvailable:    18432104 kB   # 真正可用 = Free + 可回收的 cache
Buffers:           512000 kB   # 文件系统元数据缓存
Cached:          15234560 kB   # 页缓存(page cache,读过的文件内容)
SwapTotal:        8388604 kB   # swap 区总量
SwapFree:         8388604 kB   # swap 空闲
Active:          12345678 kB   # 最近活跃的页
Inactive:         5432100 kB   # 不活跃,候选被回收
Slab:             1234567 kB   # 内核对象池(dentry/inode cache 等)
PageTables:        102400 kB   # 所有进程的页表自己占的内存
HugePages_Total:        0      # 静态大页数量
AnonHugePages:    2097152 kB   # 透明大页用了多少

3 个最容易踩坑的概念

① MemFree ≠ 可用内存。Linux 倾向于把空闲内存全部用作 page cache 加速文件读写。看到 MemFree 只有几百 MB 别紧张,MemAvailable 才是真相。

② Cached 是可以瞬间回收的。当应用要内存,内核可以丢弃干净的 page cache 立刻腾位置。所以「Cached 占了 15GB」不是问题。但dirty cache(被写过还没刷盘)不能直接丢,写入压力大时会卡顿。

③ PageTables 自己也占内存。一个 4KB 页对应一条 8 字节 PTE。你跑 10 个进程,每个映射了 4GB 内存,光页表大约要 10 × 4GB / 4KB × 8B = 80MB。这就是为什么海量小进程场景下页表本身能涨到 GB 级——这也是大页的另一个好处:大页让页表项数减少 512 倍

实操:观察一个 Python 进程的内存细节

# 跑一个 Python 进程
python3 -c "x = [0] * 100_000_000; import time; time.sleep(1000)" &
PID=$!

# 1. 总览
cat /proc/$PID/status | grep -E '^Vm|^Rss'
# VmPeak: 峰值虚拟内存
# VmSize: 当前虚拟内存(地址空间总量)
# VmRSS:  常驻物理内存(最关键的数字)
# VmData: 数据段大小
# VmStk:  栈大小

# 2. 详细每段
cat /proc/$PID/smaps | head -40
# 每个 segment 一段:地址范围、权限、Rss、Pss、Anonymous、AnonHugePages...
# Pss = 按"共享比例"分摊的内存,最公平的"该进程实际占多少"

# 3. 一行汇总每个进程内存
ps aux --sort=-rss | head -10

🔗 与 AI / Agent 安全的连结

今天的虚拟内存知识不是抽象理论,它在 LLM 工程和 Agent 安全里到处出现:

1. vLLM 的 PagedAttention 直接借用 OS 分页思想。传统推理引擎给每个请求分配一大块连续 KV cache 内存,剩余部分浪费(内部碎片可达 60-80%)。PagedAttention 把 KV cache 切成 16-token 的小 block,用一张"block table"做虚拟到物理映射——这跟今天讲的虚拟内存 → 页表 → 物理内存几乎是 1:1 类比。Week 06 周日会专门讲这套设计,今天的页表概念是前置。

2. NX 位是缓冲区溢出攻击的硬件防线。攻击者经典做法是把 shellcode 塞到栈或堆上,然后控制 EIP 跳过去执行。NX 位让"非代码段不可执行",这种攻击立刻失败——只能改用 ROP 等更高级技术。你今天看到的页表权限位,每一位背后都是几代攻防的产物。Day 41 内存安全攻防会展开。

3. Agent 沙箱用 mmap + 权限位做强隔离。可靠的代码沙箱(gVisor、Firecracker、Modal 的执行环境)核心机制之一就是用独立页表把 guest 内存和 host 内存完全隔开。理解页表权限位,你才能理解为什么 --privileged Docker 容器、/proc 挂载到容器里这类配置是高危——它们都在削弱页表层的隔离

4. LLM 推理 OOM 诊断必看 /proc/[pid]/status。生产里"模型加载失败/中途崩"的事故有大半是 OOM Killer 出手。诊断流程:先看 dmesg | grep -i oom 确认是不是被 kill;再看 VmRSSVmHWM(历史峰值)确认进程到底吃了多少;最后看 smaps 找出"哪段"内存占大头(通常是模型权重 mmap、KV cache、PyTorch 内部 allocator)。这套调试链条全部建立在今天的概念上。

5. 多租户 GPU 服务的内存侧信道。Day 48 会讲 GPU 多租户安全。GPU 显存的虚拟化机制和 CPU 页表非常类似,显存隔离做得不好就会出现跨用户的内存残留——前一个用户的 prompt 或权重残留在物理显存里,被下一个用户读到。理解 CPU 虚拟内存的隔离原理,是看懂 GPU 隔离漏洞的基础。

📝 今日小练习

115 分钟 · 动手:看穿你机器的内存账本

Linux 上跑(macOS 上没有 /proc,可以用 vm_stat 替代,下面给两种):

# Linux
cat /proc/meminfo | head -25
free -h

# 然后做个对照表,回答:
# (a) MemFree 和 MemAvailable 差多少?解释这两者的区别。
# (b) Buffers + Cached 占总内存多少比例?这部分是"可回收"还是"已占用"?
# (c) PageTables 占了多少 MB?想想这值的物理含义。

# macOS 替代命令
vm_stat
# Pages free / active / inactive / wired down 各是什么

问题:把上面 (a)(b)(c) 的答案用自己的话写出来,每条不超过两句。

220 分钟 · 观察:进程视角的虚拟地址空间

在 Linux 上跑下面这段(macOS 可用 vmmap [PID] 替代 /proc/[pid]/maps):

python3 -c "
import numpy as np
x = np.zeros(100_000_000, dtype=np.float32)  # ~400 MB
import time; time.sleep(1000)
" &
PID=$!

# 1. 总览
cat /proc/$PID/status | grep -E '^Vm|^Rss'

# 2. 看完整段表
cat /proc/$PID/maps | wc -l                  # 一共有多少段
cat /proc/$PID/maps | grep heap              # 堆在哪
cat /proc/$PID/maps | grep stack             # 栈在哪
cat /proc/$PID/maps | grep -c '\.so'         # 加载了多少动态库

# 3. 找出最大的几段
sort -k1 /proc/$PID/maps | head -5

问题:VmSize 和 VmRSS 谁大?大多少?解释为什么(提示:demand paging、动态库共享)。

310 分钟 · 思考题:哪种攻击会绕过哪一层

下面 4 种攻击行为,分别会被「页表的哪一位 / 哪个机制」挡住或不挡住?给每条一句话理由:

  1. 在栈上写一段 shellcode,让程序跳过去执行
  2. 修改 text 段的代码,把某个函数的开头几字节改成 jmp
  3. 读取另一个进程的虚拟地址 0x400000 处的内存
  4. 通过测量自己内存访问的时间,间接推断同物理机别的 VM 的内存访问模式

提示:A/B/C 都对应今天讲过的某个具体的页表位或机制;D 是个例外——它不被任何"位"挡住,去查"侧信道",记住这个词,Week 05 周六我们专门讲。