0x400000,凭什么不会打架?CPU 怎么知道把这个虚拟地址翻译到哪块物理内存?为什么我的 7B 大模型加载完后 VmRSS 显示 14GB,但机器只有 32GB,跑了 4 个进程为什么没爆?今天我们一次性把虚拟内存这套魔法揭开。会回答 7 个问题:内存到底有多慢、虚拟地址 vs 物理地址、MMU 怎么工作、多级页表为什么是 4 级、TLB 为什么决定性能、大页能不能救你、/proc/meminfo 每一行什么意思。读完今天你会突然理解为什么 LLM 推理对内存带宽这么敏感。
讲虚拟内存之前,先建立一个性能直觉。你做 AI 时整天听到「memory-bound」「带宽不够」,但很多人没真正算过这些数字到底是什么量级。
现代计算机的存储不是一层,而是一个金字塔,越靠近 CPU 越快越贵越小:
有个经典对比把它们放大到人类时间尺度:把 L1 访问当成「1 秒」,那么访问主存大约是「100 秒」,访问 SSD 是「14 小时」,访问网络是「几个月」。这个差距不是 2 倍 3 倍,是 100 倍 1000 倍。
所以工程上要永远问一个问题:我访问的数据离 CPU 多近?
这套层级直接决定了你后面要看到的一切——从 cache line、TLB、到 LLM 推理为什么 batch=1 时 GPU 利用率不到 5%。今天的主角「虚拟内存」就是在主存(DRAM)和 SSD 之间架起一座桥,让程序"看起来"有比物理内存大得多的空间。
给你机器里插一根 32GB 的内存条,物理上这块 DRAM 被编址为 0x00000000 到 0x7FFFFFFF(32GB = 2³⁵ 字节)。每个字节有一个唯一的物理地址(Physical Address,PA)。这是真实的、硬件电路看到的地址。
但你的 Python 进程看到的不是这个地址。你的进程看到的是虚拟地址(Virtual Address,VA)——一段长得像 0x00007fa8b1c00000、可以从 0 一直编到 2⁴⁸ 甚至 2⁵⁷ 字节的「假地址空间」。
这是 OS 设计史上最重要的发明之一。它一次解决了 4 个问题:
0x400000 和进程 B 的虚拟地址 0x400000 翻译到完全不同的物理地址。A 写到自己的 0x400000,B 的内存毫发无损。这是所有沙箱安全的基石。fork() 的 COW、mmap MAP_SHARED、共享库(libc.so 只在内存里有一份)的实现方式。想象一个国家,每个城市都用自己独立的门牌号系统:北京的「中关村大街 1 号」和上海的「中关村大街 1 号」是完全不同的两座楼。但每座楼最终都对应一块真实的土地(物理地址)。城市 = 进程,门牌号 = 虚拟地址,土地坐标 = 物理地址,而政府保存的「门牌号 → 土地坐标」对照表就是 页表(Page Table)。
每次 CPU 执行 mov rax, [0x7fa8b1c00000] 这样的指令,它实际要做的事是:
0x7fa8b1c000000x12340000)0x12340000 读 8 字节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)。
每条页表项(PTE)除了存「这个虚拟页对应哪个物理页」,还存着这些权限位:
P(Present):这一页现在在物理内存里吗?如果是 0,访问会触发缺页异常(page fault),内核接管。R/W:可读还是可写。代码段 text 是 R-only;如果写 R-only 页,CPU 抛 SIGSEGV。U/S:用户态可访问还是只能内核访问。NX(No Execute):这页可以执行指令吗?防止「数据当代码执行」类攻击的硬件基石。D(Dirty):自上次同步后,这页被写过吗?OS 据此决定是否要写回磁盘。A(Accessed):被访问过吗?OS 据此实现 LRU 替换算法。这几个位也是所有内存保护、所有 mmap 标志、所有 KASLR/NX-bit 之类安全特性的硬件基础。Day 41 讲栈溢出攻击时,我们会回头看 NX 位是怎么挡住 shellcode 执行的。
现在做一道算术。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 位:
每一级都是一个 4KB 的表(512 项 × 8 字节)。切换进程时,OS 只要改一个寄存器(CR3),整个地址空间就换了一份——这就是进程隔离在硬件层面的实现。
注意一个可怕的事实:每翻译一个虚拟地址,MMU 要顺着 4 级表走,访问 4 次内存(每次 ~100 ns),最后才能访问目标数据(第 5 次内存访问)。一次普通的 x = arr[i] 内部可能要 500 ns?
当然不会。这就引出了 TLB。
MMU 内部有一个小缓存,叫 TLB(Translation Lookaside Buffer)。它存最近翻译过的「虚拟页号 → 物理页号」对应关系。每次翻译时:
现代 CPU 的 TLB 容量很小:
| 层级 | 容量(典型) | 覆盖的虚拟内存(4KB 页) |
|---|---|---|
| L1 dTLB(数据) | 64 项 | 256 KB |
| L1 iTLB(指令) | 64 项 | 256 KB |
| L2 TLB | 1024-2048 项 | ~8 MB |
关键事实:普通 4KB 页下,TLB 全用满也只能覆盖几 MB 的"热内存"。一旦你的工作集(working set)超过这个大小,TLB miss 暴涨,每次内存访问额外付 4 次 page walk 的代价。
# 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% 就值得调优
既然 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。
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
每当你想"我机器到底用了多少内存",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 # 透明大页用了多少
① 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 进程
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
今天的虚拟内存知识不是抽象理论,它在 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;再看 VmRSS 和 VmHWM(历史峰值)确认进程到底吃了多少;最后看 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 种攻击行为,分别会被「页表的哪一位 / 哪个机制」挡住或不挡住?给每条一句话理由:
0x400000 处的内存提示:A/B/C 都对应今天讲过的某个具体的页表位或机制;D 是个例外——它不被任何"位"挡住,去查"侧信道",记住这个词,Week 05 周六我们专门讲。
malloc 或申请一个 list,glibc 的 ptmalloc 怎么管理这些字节?brk 和 mmap 各自什么时候用?为什么 jemalloc / tcmalloc 能让多线程程序快几倍?OOM killer 又是按什么规则选择"谁该死"?把今天的「虚拟地址空间是怎么变出来的」和明天的「这块空间是怎么被切分使用的」连起来,你就拿到了完整的内存图景。