目录

Linux-24 虚拟内存:为什么值得付出这么大代价

虚拟内存是操作系统最昂贵的抽象:每一次内存访问都要经过地址翻译。这一篇讲它为什么值得。

先看五个问题:

  1. 虚拟内存最初解决的是什么问题?为什么值得付出「每次访存都要翻译地址」的代价?
  2. 分段 vs 分页之争,为什么分页赢了
  3. 为什么必须是多级页表?单级不行吗?
  4. 为什么说 TLB 是真正的性能命门
  5. 缺页异常(page fault)是 bug 还是特性?

1. 虚拟内存要解决的问题

1.1 没有虚拟内存的世界

在早期系统里,程序直接使用物理地址。三个致命问题:

   问题①:地址冲突
     程序 A 编译时假定自己从地址 0x1000 开始,程序 B 也是。
     两个程序无法同时驻留内存。
     早期的解法是【重定位】:加载时把程序里所有地址加上一个偏移量
       -> 需要程序携带重定位表,加载慢
       -> 而且程序一旦加载就不能移动(否则所有指针失效)
       -> 内存碎片无法整理

   问题②:内存不够
     物理内存 64KB,但程序想用 1MB。
     早期解法是【覆盖(overlay)】:程序员手工把程序切成块,
     手动决定什么时候把哪一块换进内存
       -> 这是【程序员的负担】,而且极易出错

   问题③:没有隔离
     任何程序都能读写任何地址 —— 一个程序的 bug 能破坏另一个程序甚至内核。
     也没有任何安全边界。

1.2 虚拟内存的核心思路

给每个进程一套独立的、连续的、假的地址空间,由硬件(MMU)在每次访存时翻译成物理地址。

   进程 A 的虚拟地址空间          物理内存              进程 B 的虚拟地址空间
   +------------------+                              +------------------+
   | 0x400000 代码    | ---> +---------------+ <---- | 0x400000 代码    |
   +------------------+      | 物理页 帧号 5  |       +------------------+
   | 0x600000 数据    | ---> +---------------+       | 0x600000 数据    |
   +------------------+      | 物理页 帧号 9  | <---- +------------------+
   | ...              |      +---------------+       | ...              |
   +------------------+      | 物理页 帧号 3  |       +------------------+

   ✅ 两个进程可以用【相同的虚拟地址】而互不干扰(问题① 解决)
   ✅ 虚拟地址空间可以【大于物理内存】,不够时换出到磁盘(问题② 解决)
   ✅ 没有映射的地址【无法访问】,映射可以标记只读(问题③ 解决)

1.3 它顺带解决了更多问题

虚拟内存真正的价值在于:一旦有了「地址翻译」这个间接层,大量原本困难的事变得可能 —— 这是本篇的主线。

能力 靠什么实现 在本系列哪里出现过
进程隔离 各自独立的页表
共享内存 两个页表指向同一物理页 20 篇的 mmap、PSS 统计(13 篇 2.3)
COW 页表标记只读 + 缺页时复制 21 篇 3.2
mmap 文件 页表指向 page cache 的页 20 篇第 3 章
按需分页 先只建映射不分配物理页 13 篇的 VSZ ≫ RSS
swap 页表项标记「在磁盘上」 11 篇第 7 章
栈自动增长 访问栈下方触发缺页时扩展
内存保护(NX) 页表项的不可执行位
ASLR 每次加载用不同的虚拟地址 第 6 章
overcommit 承诺的虚拟内存可以超过物理内存 21 篇的 Redis fork

1.4 代价

   ① 每次访存都要翻译地址
      -> 硬件必须做(软件做不起),所以有 MMU 和 TLB
   ② 页表本身占内存
      -> 一个进程的页表可能有几 MB 到几百 MB(第 3 章算)
      -> 这就是 21 篇讲的「fork 的开销与页表规模成正比」
   ③ TLB miss 的延迟
      -> 一次四级页表遍历 = 4 次内存访问,每次约 100ns(第 4 章)
   ④ 缺页异常的开销
      -> 一次上下文切换到内核 + 可能的磁盘 I/O(第 5 章)

为什么值得? 因为问题①③根本没有替代方案(现代多任务系统必须有隔离),而问题②的替代方案(手工 overlay)会把复杂度全部推给程序员。虚拟内存是「用可控的硬件开销换取不可替代的能力」。

2. 分段 vs 分页

2.1 两种切分方式

   分段(Segmentation):按【程序的逻辑单位】切
   +----------------+
   | 代码段  40KB    |   每段有独立的基址与长度,长度【可变】
   +----------------+   优点:与程序结构对应,保护粒度自然
   | 数据段  128KB   |         (代码段只读、数据段不可执行)
   +----------------+
   | 栈段    8KB     |
   +----------------+

   分页(Paging):按【固定大小】切
   +------+------+------+------+
   | 4KB  | 4KB  | 4KB  | 4KB  |   所有页大小相同
   +------+------+------+------+   优点:管理极其简单

2.2 开篇第二问:为什么分页赢了

分段 分页
单位大小 可变 固定(4KB)
外部碎片 严重(变长块的分配必然产生空隙) 完全没有(所有页大小相同,任意页可放任意帧)
内部碎片 ✅ 几乎没有 ⚠️ 有(最后一页平均浪费 2KB)
分配算法 ❌ 复杂(首次适配/最佳适配,还要合并空闲块) 极简(一个空闲页链表/位图,随便取一个)
交换(swap)粒度 整段(换出一个 128KB 的数据段) 单页(只换出真正冷的 4KB)
硬件实现 ⚠️ 需要比较基址+长度,段表项变长 纯粹的位运算(地址高位查表、低位做偏移)
与程序结构的对应 ✅ 自然 ❌ 无关(靠 VMA 在软件层补回来)
地址翻译 段号 + 段内偏移(偏移需要越界检查 页号 + 页内偏移(页内偏移天然不会越界

决定性的三点

   ① 外部碎片
      分段的变长分配注定产生碎片:内存里剩下 30KB + 40KB 的空隙,
      但来了一个 60KB 的段就放不下 —— 除非做内存整理(移动数据,极慢)。
      分页彻底消灭了这个问题:任何虚拟页都能放进任何物理帧。

   ② 交换粒度
      「内存不够时换出一部分」是虚拟内存的核心能力。
      分段只能整段换出(一个 128MB 的数据段要么全在要么全不在),
      而实际的访问局部性是页级的 —— 分页能只保留真正在用的那几页。

   ③ 硬件实现的简洁性
      分页的地址翻译是【纯位运算】:
        虚拟地址 = [页号 | 页内偏移],页大小是 2 的幂,所以直接切位
        物理地址 = [帧号 | 同样的页内偏移]
      而分段要做加法(基址+偏移)和比较(是否越界)—— 更多晶体管、更长的关键路径。

2.3 x86 的历史:段页混合到段被废弃

   8086(1978):只有分段(因为要用 16 位寄存器寻址 20 位地址)
        物理地址 = 段寄存器 << 4 + 偏移
        
   80286:保护模式的分段(有段描述符、特权级)
   
   80386(1985):分段 + 分页【两层】
        虚拟地址 --分段--> 线性地址 --分页--> 物理地址
        -> 实际上操作系统几乎都把段设成「覆盖整个地址空间」,
           等于把分段这一层【旁路掉】,只用分页
           
   x86-64 长模式(2003):✅ 分段【基本被废弃】
        CS/DS/ES/SS 的基址被强制为 0、长度检查被禁用
        只保留 FS/GS 的基址(用来做线程本地存储 TLS!)
# 验证 FS/GS 仍在使用(TLS 的实现基础)
# Go 的 goroutine 调度、C 的 __thread 变量、glibc 的 errno 都靠它
sudo cat /proc/self/maps | head -3
# Go 程序里 runtime 用 TLS 存当前 g 的指针(GMP 模型的基础)

# 这就是「历史包袱以最小形态留存」的例子:
#   分段作为地址翻译机制被淘汰,但作为「一个额外的基址寄存器」被保留下来做 TLS

结论:分页赢了,但分段没有完全消失 —— 它退化成了 TLS 的实现手段。这是 18 篇讲的「ABI 只能新增不能删除」在硬件层面的对应。

3. 页表:从单级到多级

3.1 开篇第三问:单级页表的空间灾难

   假设用单级页表(一个大数组,虚拟页号做下标):
     x86-64 的有效虚拟地址是 48 位
     页大小 4KB = 2^12
     -> 虚拟页数 = 2^48 / 2^12 = 2^36 = 687 亿个页
     -> 每个页表项 8 字节
     -> 页表大小 = 2^36 × 8 = 512 GB  💀

   而且这是【每个进程】都要一份!
   一台跑 100 个进程的机器需要 50TB 的页表 —— 完全不可能。

3.2 多级页表的核心洞察

关键事实:地址空间是极度稀疏的。

# 一个真实进程实际用了多少地址空间
awk '{split($1,a,"-"); sum += strtonum("0x"a[2]) - strtonum("0x"a[1])} END {printf "%.2f GB\n", sum/1024/1024/1024}' /proc/self/maps
# 通常几 MB 到几 GB —— 而地址空间总量是 256TB(2^48)
# 也就是说【绝大部分地址空间是空的】

多级页表利用这个稀疏性:没有映射的区域,对应的中间层页表根本不需要存在。

   四级页表(x86-64,48 位地址)

   虚拟地址(48 位有效)
   +--------+--------+--------+--------+------------+
   | PGD 9位 | PUD 9位 | PMD 9位 | PTE 9位 | 偏移 12位  |
   +--------+--------+--------+--------+------------+
      |         |        |        |          |
      |         |        |        |          +--> 页内偏移(4KB 内)
      |         |        |        +--> 页表(Page Table Entry)
      |         |        +--> 中间目录(Page Middle Directory)
      |         +--> 上层目录(Page Upper Directory)
      +--> 全局目录(Page Global Directory)—— CR3 寄存器指向它

   每级 9 位 = 512 项,每项 8 字节 = 恰好【一个 4KB 页】✅
   这个设计非常精巧:每一级页表本身就是一个标准页

   总空间:如果进程只映射了几 MB,
     只需要 1 个 PGD + 1 个 PUD + 1 个 PMD + 几个 PTE 页 = 几十 KB
     而不是 512GB

3.3 一次地址翻译的完整过程

   访问虚拟地址 0x00007f8e_4c3d_2000

   ① CR3 寄存器 -> PGD 的物理地址
   ② 取虚拟地址的 47:39 位(9 位)作为 PGD 索引 -> 读一次内存 -> 得到 PUD 地址
   ③ 取 38:30 位作为 PUD 索引               -> 读一次内存 -> 得到 PMD 地址
   ④ 取 29:21 位作为 PMD 索引               -> 读一次内存 -> 得到 PTE 地址
   ⑤ 取 20:12 位作为 PTE 索引               -> 读一次内存 -> 得到【物理帧号】
   ⑥ 物理地址 = 帧号 | 虚拟地址的 11:0 位

   ⚠️ 一次翻译要【4 次内存访问】,而每次内存访问约 100ns
      -> 如果每条访存指令都这样做,程序会慢 5 倍以上
      -> 这就是必须有 TLB 的原因(第 4 章)

3.4 页表项里有什么

   x86-64 的 PTE(64 位)关键位:
   +----------------------------------------------------------+
   | bit 0   P     Present      ✅ 是否在物理内存中             |
   | bit 1   R/W   Read/Write   ✅ 可写?(COW 就靠清掉它!)    |
   | bit 2   U/S   User/Super   用户态能否访问                  |
   | bit 3   PWT   写通缓存                                    |
   | bit 4   PCD   禁用缓存(设备内存用)                       |
   | bit 5   A     Accessed     ✅ 被访问过(页回收算法用)      |
   | bit 6   D     Dirty        ✅ 被写过(决定换出时是否要写盘)|
   | bit 7   PS    Page Size    ✅ 是否是大页(2MB/1GB)        |
   | bit 8   G     Global       上下文切换时不刷新(内核页用)   |
   | bit 12-51     物理帧号                                    |
   | bit 63  NX    No Execute   ✅ 不可执行(防代码注入)        |
   +----------------------------------------------------------+

   P=0 时,其余位可以被内核【复用】来存别的信息:
     - swap 条目(在哪个 swap 设备的哪个偏移)—— swap 的实现基础
     - 文件映射的信息
     - 「从未分配过」(按需分页)

这张表解释了本系列多个现象

   R/W 位     -> 21 篇的 COW(fork 时清掉它,写入触发缺页)
   A 位       -> 25 篇的页回收(LRU 靠它判断冷热)
   D 位       -> 脏页回写(04 篇讲的 write() 返回不等于落盘)
   PS 位      -> 大页(第 7 章)
   NX 位      -> 为什么现代系统上栈溢出难以直接执行注入的代码
   P=0 复用   -> swap 与按需分页

3.5 页表的实际开销

# 看一个进程的页表占了多少内存
grep -E 'VmPTE|VmPMD|VmRSS|VmSize' /proc/self/status
# VmSize:   12345 kB      虚拟地址空间
# VmRSS:     4567 kB      物理内存
# VmPTE:       48 kB      ✅ 页表占用
# VmPMD:       12 kB

# 找出页表最大的进程(大内存进程的页表可能有几百 MB)
for p in /proc/[0-9]*; do
  pte=$(awk '/^VmPTE/{print $2}' $p/status 2>/dev/null)
  [[ -n $pte ]] && echo "$pte ${p##*/} $(cat $p/comm 2>/dev/null)"
done | sort -rn | head -5

# 系统整体的页表开销
grep PageTables /proc/meminfo
# PageTables:      123456 kB

# 估算:4KB 页的页表开销约为映射内存的 1/512(0.2%)
#   -> 一个 64GB 内存的进程,页表约 128MB
#   -> 21 篇讲的「Redis fork 慢」就是要复制这 128MB(而且要遍历所有 VMA)

3.6 五级页表

# Linux 4.14+ 支持五级页表(57 位虚拟地址 = 128PB)
grep -o 'la57' /proc/cpuinfo | head -1        # CPU 是否支持
dmesg | grep -i '5-level'                      # 内核是否启用

# 为什么需要?因为 48 位(256TB)在超大内存机器上开始不够了
# 代价:翻译多一级 = TLB miss 时多一次内存访问
# 所以内核默认只在需要时(内存 > 64TB)才启用

4. TLB:真正的性能命门

4.1 开篇第四问:为什么它是命门

TLB(Translation Lookaside Buffer)是 CPU 里缓存「虚拟页号 → 物理帧号」的硬件表。

   没有 TLB:每次访存 = 4 次页表内存访问 + 1 次真正的数据访问 = 5 倍开销
   有 TLB:  命中时【零额外开销】(与 CPU 流水线并行完成)

   TLB 命中率通常 > 99%,正是它让虚拟内存的代价变得可接受。

但 TLB 非常小

   典型的现代 x86 CPU(每核):
     L1 dTLB:64 项(4KB 页)
     L1 iTLB:64~128 项
     L2 TLB(统一):1024~2048 项

   -> 用 4KB 页时,L2 TLB 能覆盖 2048 × 4KB = 【8MB】内存
   -> 一个工作集 1GB 的程序,TLB 完全覆盖不住 -> 频繁 miss

4.2 TLB miss 的代价

   一次 TLB miss 的开销:
     ① 遍历四级页表 = 最多 4 次内存访问
     ② 这些页表项本身可能不在 CPU cache 里 -> 真的要访存(~100ns 每次)
     ③ 最坏情况约 300~400ns
     
   对比:
     TLB 命中:~0ns(并行完成)
     L1 cache 命中:~1ns
     内存访问:~100ns
     
   -> 在指针密集、随机访问的负载(数据库、图计算、JVM/Go 的 GC 标记阶段)
      TLB miss 可能占总时间的 10%~30%
# 实测 TLB miss(需要 perf 与硬件支持)
sudo perf stat -e dTLB-load-misses,dTLB-loads,iTLB-load-misses ./myapp
# 或者看更详细的 walk 周期
sudo perf stat -e dtlb_load_misses.walk_active,dtlb_load_misses.walk_completed ./myapp

# 一个直观的对比实验:顺序访问 vs 随机访问同一块内存
cat > /tmp/tlb.c <<'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define N (256*1024*1024/8)     // 256MB 的 long 数组
int main(int argc, char **argv) {
    long *a = malloc(N*8);
    for (long i=0;i<N;i++) a[i]=i;
    struct timespec t1,t2; long sum=0;
    clock_gettime(CLOCK_MONOTONIC,&t1);
    if (argv[1][0]=='s') {                       // 顺序
        for (long i=0;i<N;i++) sum += a[i];
    } else {                                      // 随机(每次跳一个页)
        for (long i=0;i<N;i++) sum += a[(i*4096/8) % N];
    }
    clock_gettime(CLOCK_MONOTONIC,&t2);
    printf("%s: %.3f s (sum=%ld)\n", argv[1],
           (t2.tv_sec-t1.tv_sec)+(t2.tv_nsec-t1.tv_nsec)/1e9, sum);
}
EOF
gcc -O2 -o /tmp/tlb /tmp/tlb.c
/tmp/tlb s; /tmp/tlb r          # 随机访问通常慢 5~20 倍(TLB miss + cache miss 叠加)
sudo perf stat -e dTLB-load-misses /tmp/tlb r 2>&1 | grep TLB

4.3 上下文切换与 TLB flush

   问题:进程 A 和进程 B 的虚拟地址 0x400000 指向【不同】的物理页。
        切换进程时,TLB 里 A 的条目对 B 是【错误】的。

   最简单的解法:切换时把 TLB 全部清空(flush)
     -> 但那意味着切换后的一段时间里【所有访存都 TLB miss】
     -> 上下文切换的真实代价里,TLB 重建往往比保存寄存器贵得多
        (这正是 13 篇讲「上下文切换贵在 cache/TLB 而不是保存寄存器」的依据)

   硬件的改进:给 TLB 条目打上「地址空间标识」
     - x86:PCID(Process-Context Identifier,12 位)
     - ARM:ASID
     -> 切换时不用 flush,只是让不同 PCID 的条目互不匹配
# 检查 CPU 是否支持 PCID / INVPCID
grep -o -E 'pcid|invpcid' /proc/cpuinfo | sort -u
# pcid
# invpcid

# ⚠️ Meltdown 缓解措施(KPTI)让这件事变复杂了
dmesg | grep -i 'page table isolation'
cat /sys/devices/system/cpu/vulnerabilities/meltdown
# Mitigation: PTI

# KPTI 的机制:给【内核】和【用户态】用两套独立的页表
#   -> 每次系统调用进出都要切换 CR3 = 可能的 TLB 影响
#   -> 有 PCID 时开销小得多(这就是为什么 PCID 突然变得很重要)
#   -> 在系统调用密集的负载上,KPTI 的开销可达 5%~30%
#   -> 这也是 18 篇讲「io_uring 想减少系统调用次数」的一个背景原因

4.4 大页如何拯救 TLB

   一个 TLB 条目能覆盖多少内存?
     4KB 页  -> 4KB
     2MB 页  -> 2MB      (✅ 512 倍)
     1GB 页  -> 1GB      (✅ 262144 倍)

   2048 项的 L2 TLB:
     用 4KB 页 -> 覆盖 8MB
     用 2MB 页 -> 覆盖 4GB       ← 对大内存应用是决定性的差异

这是大页存在的唯一理由(第 7 章展开它的代价)。

5. 缺页异常:不是 bug,是核心机制

5.1 开篇第五问:三种缺页

ps -o pid,min_flt,maj_flt,comm -p $$
#         ^^^^^^^ 次要缺页    ^^^^^^^ 主要缺页
类型 含义 代价 是否正常
Minor fault(次要) 页表项无效,但不需要读磁盘(页已在内存里,或只需分配一个新页) 微秒级 完全正常,数量大也没关系
Major fault(主要) 需要从磁盘读取(文件映射首次访问、swap 换入) 毫秒级(SSD 几十微秒) ⚠️ 持续增长才是问题
Invalid fault 访问的地址根本没有映射 这才是 bugSIGSEGV
# 观察缺页
ps -eo pid,min_flt,maj_flt,comm --sort=-maj_flt | head -6
sar -B 1                          # 系统级:pgpgin/s pgfault/s majflt/s
pidstat -r 1                      # 每进程的缺页速率
sudo perf stat -e page-faults,major-faults ./myapp
sudo bpftrace -e 'software:major-faults:1 { @[comm] = count(); }'

5.2 缺页处理流程

   CPU 访问虚拟地址 -> MMU 查页表发现 P=0 或权限不符
        |
        v  触发缺页异常(硬件),CPU 切到内核态,跳到 do_page_fault()
   ① 拿到出错的地址(CR2 寄存器)与错误码(是读还是写、用户态还是内核态)
        |
        v
   ② 在进程的 VMA 树(红黑树/maple tree)里查找这个地址
        |
        ├─ 找不到 VMA -> ❌ 非法访问 -> SIGSEGV(段错误)
        |               (除了一个例外:栈的自动增长,见 5.3)
        |
        └─ 找到了 VMA -> 检查权限(VMA 的 flags 允许这次访问吗?)
                 ├─ 不允许(如往只读段写)-> ❌ SIGSEGV
                 └─ 允许 -> 继续
                       |
                       ├─ 【匿名页首次访问】-> 分配物理页,清零,建映射(minor)
                       |     若是【读】-> 甚至可以直接映射到全局的零页(省内存)
                       |
                       ├─ 【文件映射】-> 查 page cache
                       |     命中 -> 建映射(minor)
                       |     未命中 -> 从磁盘读(major)✅ 这就是 20 篇第 3 章的路径
                       |
                       ├─ 【COW 页】(P=1 但 R/W=0 且 VMA 可写)
                       |     -> 分配新页,复制内容,改页表为可写(minor)
                       |        ✅ 这就是 21 篇 3.2 的实现
                       |
                       └─ 【在 swap 里】(P=0,PTE 存着 swap 条目)
                             -> 从 swap 读回(major)
        |
        v
   ③ 更新页表,刷新 TLB,从异常返回,【重新执行那条指令】

5.3 用缺页实现的七种功能

这是本篇最重要的一节:缺页不是「错误处理」,而是内核实现各种内存特性的统一机制。

   ① 按需分页(demand paging)
      malloc(1GB) 立即返回,但【一个物理页都没分配】——
      只是建了一个 VMA。访问时才逐页分配。
      -> 这就是 13 篇讲的 VSZ ≫ RSS

   ② COW(21 篇)
      fork 时把双方页表都标记只读,写入时触发缺页才真正复制

   ③ mmap 文件映射(20 篇)
      映射时只建 VMA,访问时缺页 -> 从 page cache 取页(或读磁盘)
      -> 这让「把 10GB 文件当数组用」成为可能

   ④ swap 换入(11 篇)
      PTE 里 P=0,其余位存着「在哪个 swap 设备的哪个槽位」

   ⑤ 栈自动增长
      栈向下增长,访问栈指针下方的未映射页时,
      内核【特殊处理】:如果地址接近栈顶且在 RLIMIT_STACK 内,就扩展栈 VMA
      -> 这是 SIGSEGV 判断里唯一的「合法的越界访问」

   ⑥ 透明大页提升(第 7 章)
      khugepaged 后台把连续的 4KB 页合并成 2MB 大页

   ⑦ userfaultfd —— 把缺页处理【交给用户态】
      内核把缺页事件通过一个 fd 通知用户态进程,由它决定怎么填充这一页
# 观察按需分页:malloc 之后 RSS 几乎不涨,写入后才涨
cat > /tmp/demand.c <<'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(){
    size_t sz = 512UL*1024*1024;
    char *p = malloc(sz);
    printf("malloc 完成,看 RSS(应该很小): "); fflush(stdout); getchar();
    memset(p, 1, sz);           // ✅ 现在才真正分配物理页
    printf("写入完成,RSS 应该涨到 512MB: "); fflush(stdout); getchar();
    free(p);
}
EOF
gcc -o /tmp/demand /tmp/demand.c && /tmp/demand &
watch -n1 "grep -E 'VmSize|VmRSS' /proc/$(pgrep -n demand)/status"
# userfaultfd 的实际用途(这是一个很能说明「机制的威力」的例子)
# ① 虚拟机的【后拷贝迁移】(post-copy migration):
#    先把 VM 切到目标机器运行,内存页按需从源机器拉取 —— 靠 userfaultfd 拦截缺页
# ② CRIU 的进程迁移与恢复
# ③ 分布式共享内存、内存快照
# ④ ⚠️ 也被用于漏洞利用(精确控制缺页时机来放大竞态窗口)
#    -> 所以内核加了 vm.unprivileged_userfaultfd 开关
sysctl vm.unprivileged_userfaultfd

6. 地址空间布局

6.1 x86-64 的布局

   0xFFFFFFFFFFFFFFFF  +---------------------------+
                       |  内核空间(128TB)         |  ← 所有进程共享同一份映射
   0xFFFF800000000000  +---------------------------+     (但用户态不可访问,U/S=0)
                       |                           |
                       |   非法区域("canonical    |  ← 48 位地址的中间空洞
                       |   hole",硬件规定)        |
                       |                           |
   0x00007FFFFFFFFFFF  +---------------------------+
                       |  栈(向下增长)             |  ← ASLR 随机化起始位置
                       +---------------------------+
                       |  ↓                        |
                       |  mmap 区域(向下增长)      |  ← 共享库、匿名 mmap、
                       |  (libc、堆外内存…)        |     大块 malloc
                       |  ↑                        |
                       +---------------------------+
                       |  堆(brk,向上增长)        |  ← 小块 malloc
                       +---------------------------+
                       |  BSS(未初始化数据)        |
                       |  Data(已初始化数据)       |
                       |  Text(代码,只读+可执行)   |
   0x0000000000400000  +---------------------------+
                       |  【不映射】                |  ← 空指针解引用 = SIGSEGV
   0x0000000000000000  +---------------------------+     的实现原理

6.2 读懂 /proc/pid/maps

cat /proc/self/maps
# 55d3e8c00000-55d3e8c22000 r--p 00000000 fd:01 1234567 /usr/bin/cat
# ^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^ ^^^^^^^^ ^^^^^ ^^^^^^^ ^^^^^^^^^^^^^
# 虚拟地址范围              权限  文件偏移  设备   inode   映射的文件
#                          ^^^^ r/w/x + p(私有)或 s(共享)
# 55d3e8c22000-55d3e8c4a000 r-xp 00022000 fd:01 1234567 /usr/bin/cat   <- 代码段
# 55d3e8c4a000-55d3e8c5c000 r--p 0004a000 fd:01 1234567 /usr/bin/cat   <- 只读数据
# 55d3e8c5d000-55d3e8c5e000 rw-p 0005c000 fd:01 1234567 /usr/bin/cat   <- 可写数据
# 55d3e9a00000-55d3e9a21000 rw-p 00000000 00:00 0       [heap]         <- 堆
# 7f8e4c000000-7f8e4c028000 r--p 00000000 fd:01 2345678 /usr/lib/libc.so.6
# 7ffd8c000000-7ffd8c021000 rw-p 00000000 00:00 0       [stack]        <- 栈
# 7ffd8c1fe000-7ffd8c200000 r-xp 00000000 00:00 0       [vdso]         <- 见下
# ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]

# 更详细的版本(每个区域的 RSS/PSS/脏页)
sudo grep -A 12 '\[heap\]' /proc/self/smaps

# vdso 是什么?—— 内核映射到每个进程的一小段代码
#   用途:让 gettimeofday/clock_gettime 这类调用【不用陷入内核】
#   -> 直接在用户态读取内核维护的时间数据
#   -> 这是「减少系统调用开销」的一个经典优化(Go 的 time.Now() 就走它)
ldd /bin/ls | grep vdso

6.3 ASLR 与空指针

# ASLR:每次运行时随机化各区域的起始地址
for i in 1 2 3; do awk 'NR==1{print $1}' /proc/self/maps; done
# 三次输出不同 -> ASLR 生效

sysctl kernel.randomize_va_space
# 2 = 完全随机化(代码/堆/栈/mmap)  1 = 部分  0 = 关闭
setarch $(uname -m) -R ./myapp        # 临时关闭 ASLR(调试用)

# ⚠️ 空指针为什么是段错误?
#    因为虚拟地址 0 附近【故意不映射】
sysctl vm.mmap_min_addr
# 65536       <- 低于这个地址【禁止映射】
# 为什么需要这个限制?防御一类内核漏洞利用:
#   如果内核里有「解引用 NULL」的 bug,攻击者可以在用户态 mmap 地址 0
#   放上恶意数据,让内核跳到那里执行 -> mmap_min_addr 封死了这条路

7. 大页:收益与代价

7.1 两种大页

静态大页(HugeTLB) 透明大页(THP)
分配时机 启动时预留vm.nr_hugepages 运行时自动分配与合并
应用是否要改 ✅ 要(用 hugetlbfsMAP_HUGETLB ❌ 不用,完全透明
能否被换出 ❌ 不能(永久锁在内存) ⚠️ 需要先拆分成 4KB
延迟可预测性 (预留好了,运行时无分配) (见 7.2)
典型用户 Oracle、PostgreSQL(huge_pages=on)、DPDK、KVM 默认对所有应用启用
# 静态大页
grep -i huge /proc/meminfo
# AnonHugePages:    524288 kB      <- THP 实际使用量
# HugePages_Total:       0         <- 静态大页预留数
# HugePages_Free:        0
# Hugepagesize:       2048 kB
sudo sysctl -w vm.nr_hugepages=1024        # 预留 1024 × 2MB = 2GB
# 持久化:/etc/sysctl.d/ 或内核参数 hugepages=1024

# THP 状态
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] madvise never
cat /sys/kernel/mm/transparent_hugepage/defrag
# always defer [defer+madvise] madvise never

7.2 THP 的三个代价

这解释了为什么几乎所有数据库都要求关闭它(04 篇 10.x 和 21 篇 3.3 提过):

   ① 分配延迟不可预测(最主要的问题)
      要凑出一个 2MB 的【物理连续】区域,内核可能需要做【内存规整】
      (compaction)—— 移动大量页来腾出连续空间。
      这个过程会让触发它的进程【同步阻塞几百毫秒】。
      -> 表现为「服务偶发的长尾延迟尖刺」,而且极难定位
         (因为原因藏在内存子系统里,应用侧看不出任何异常)

   ② 内存放大
      对随机访问的负载:只用到 2MB 里的几 KB,剩下的全浪费。
      Redis/MongoDB 这类稀疏访问模式尤其明显。

   ③ COW 粒度放大(21 篇 3.3)
      fork 之后,父进程写一个字节会触发【2MB】的复制,而不是 4KB。
      -> Redis BGSAVE 期间内存放大 512 倍的风险
      -> 这是 Redis 官方文档明确要求关闭 THP 的首要原因
# 关闭 THP(数据库/延迟敏感服务的标准操作)
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/defrag
# 永久:内核参数 transparent_hugepage=never
# 或者用 systemd 服务在启动时设置

# 折中方案:madvise 模式(只对显式请求的应用启用)
echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
# 应用用 madvise(ptr, len, MADV_HUGEPAGE) 主动请求 —— ✅ 机制与策略分离(18 篇)

# 观察 THP 造成的规整开销
grep -E 'compact_stall|compact_fail' /proc/vmstat
# compact_stall 持续增长 = 有进程因为内存规整被阻塞过
sudo bpftrace -e 'kprobe:try_to_compact_pages { @[comm] = count(); }'

8. Go 视角

8.1 为什么 Go 程序的 VSZ 很大

ps -o pid,vsz,rss,comm -C myapp
#          ^^^^^ 常常 1~2GB,而 RSS 只有几十 MB(13 篇 2.3 讲过这个现象)
   原因:Go runtime 在启动时【预留】大块虚拟地址空间(arena),
        用 mmap(PROT_NONE) 只占地址不占物理页。
        目的是让堆可以连续增长,简化指针运算与 GC 的元数据管理。
        
   -> 这正好印证 13 篇的结论:VSZ 几乎没有意义,要看 RSS/PSS

8.2 GC、madvise 与 RSS 不降

// Go 的 GC 释放内存后,为什么 RSS 不立刻下降?
// 因为 runtime 用 madvise 告诉内核「这些页我暂时不用了」,有两种方式:

// MADV_FREE(Go 1.12~1.15 默认,Linux 4.5+)
//   -> 内核【延迟】回收:页仍在 RSS 里,直到内存压力出现才真正释放
//   -> 优点:如果应用又用到这块内存,无需重新缺页(快)
//   -> 缺点:RSS 看起来不降,容易被误判为内存泄漏,
//            也容易在容器里撞上 memory.max(因为 cgroup 按 RSS 算)

// MADV_DONTNEED(Go 1.16+ 恢复为默认)
//   -> 立即归还,RSS 马上下降
//   -> 代价:下次访问要重新缺页(慢一点)
//   -> ✅ 换回它的主要原因就是【容器环境下的可观测性与 OOM 风险】

// 手动触发归还
debug.FreeOSMemory()

// 观察
// GODEBUG=madvdontneed=1 ./myapp     (Go 1.12~1.15 时期的临时开关)
# 与 GOMEMLIMIT 的关系(13 篇 10.2 提过)
GOMEMLIMIT=1800MiB ./myapp
# 它让 GC 感知「内存上限」,在接近时更积极地回收
# ✅ 必须设:否则 Go 只按 GOGC 的比例增长,容器里很容易被 OOMKilled
#    因为 Go 不知道 cgroup 的限制(13 篇 10.2 的 nproc 同类问题)

# 观察 Go 的内存与缺页
curl -s localhost:6060/debug/pprof/heap > heap.prof
GODEBUG=gctrace=1 ./myapp             # 每次 GC 打印堆大小
ps -o min_flt,maj_flt,rss -C myapp    # 缺页与 RSS

8.3 Go 与大页

   Go 的堆是通过 mmap 匿名映射得来的,所以【会被 THP 影响】:
     ✅ 收益:大堆(几 GB)时 TLB 命中率显著改善,GC 标记阶段(指针密集遍历)尤其明显
     ⚠️ 风险:THP 的分配延迟会造成 GC 停顿的长尾;
              以及 21 篇讲的 COW 放大(如果程序里有 fork/exec)

   实践:
     - 延迟敏感的服务:关闭 THP(用 never 或 madvise 模式)
     - 大内存吞吐型服务:可以测试开启 THP 的收益
     - 无论如何都要设 GOMEMLIMIT + 关注 gctrace 的停顿时间

9. 面试题

Q:虚拟内存解决的最初问题是什么?为什么值得付出每次访存都要翻译地址的代价?

三个原始问题:地址冲突(程序编译时假定固定地址,无法同时驻留;早期靠加载时重定位,程序一旦加载就不能移动,碎片无法整理)、内存不够(早期靠程序员手工 overlay,把「什么时候换入哪一块」的负担交给人)、没有隔离(任何程序能读写任何地址)。

值得的理由是:隔离和保护根本没有替代方案(现代多任务系统的前提),而 overlay 的替代方案是把复杂度全推给程序员。

但真正的价值在于「地址翻译」这个间接层一旦存在,大量原本困难的事变得几乎免费:共享内存(两个页表指向同一物理页)、COWmmap 文件按需分页swap栈自动增长NX 保护ASLRovercommit —— 这些全部是同一个机制的衍生品。

代价是四项:地址翻译开销(所以必须有 MMU 和 TLB)、页表本身占内存(这是 21 篇「fork 开销与页表规模成正比」的来源)、TLB miss 延迟、缺页异常开销。

Q:分段与分页之争,为什么分页赢了?

三个决定性因素:

  1. 外部碎片 —— 分段是变长分配,必然产生空隙(剩 30KB+40KB 但来了 60KB 的段就放不下,除非做内存整理)。分页彻底消灭了这个问题:所有页大小相同,任意虚拟页能放进任意物理帧
  2. 交换粒度 —— 「内存不够时换出一部分」是虚拟内存的核心能力。分段只能整段换出(128MB 的数据段要么全在要么全不在),而实际访问局部性是页级的
  3. 硬件实现简洁 —— 分页的翻译是纯位运算(页大小是 2 的幂,直接切位,页内偏移天然不越界),而分段要做加法和越界比较

代价是分页有内部碎片(最后一页平均浪费 2KB)和页表开销,但这两项都可控。

有意思的是分段没有完全消失:x86-64 长模式下 CS/DS/SS 的基址被强制为 0、长度检查禁用,但保留了 FS/GS 的基址用来做线程本地存储(TLS) —— Go 的 goroutine 调度、C 的 __thread、glibc 的 errno 都靠它。这是「历史包袱以最小形态留存」的典型。

Q:为什么必须是多级页表?单级为什么不行?

单级页表会吃掉 512GB。 x86-64 有效虚拟地址 48 位、页 4KB,虚拟页数 = 2⁴⁸/2¹² = 2³⁶ ≈ 687 亿,每项 8 字节 → 512GB,而且每个进程一份

多级页表利用的关键事实是地址空间极度稀疏:一个真实进程通常只映射几 MB 到几 GB,而地址空间总量是 256TB。没有映射的区域,对应的中间层页表根本不需要存在。

四级页表(PGD/PUD/PMD/PTE)每级 9 位 = 512 项 × 8 字节 = 恰好一个 4KB 页 —— 这个设计非常精巧,每一级页表本身就是一个标准页。只映射几 MB 的进程只需要几十 KB 页表。

代价是一次翻译要 4 次内存访问(每次约 100ns),如果每条访存指令都这样,程序会慢 5 倍以上 —— 所以必须有 TLB。

实际开销约为映射内存的 1/512(0.2%):64GB 内存的进程页表约 128MB,可以用 grep VmPTE /proc/<pid>/status 看到,也解释了 Redis fork 慢的原因。

Q:为什么说 TLB 是真正的性能命门?

因为它是让虚拟内存「代价可接受」的唯一原因:TLB 命中时零额外开销(与流水线并行完成),而 miss 时要遍历四级页表、最坏约 300~400ns。命中率通常 >99%。

但 TLB 非常小:典型现代 CPU 的 L2 TLB 只有 1024~2048 项,用 4KB 页时只能覆盖 8MB 内存。一个工作集 1GB 的程序根本覆盖不住 —— 在指针密集、随机访问的负载(数据库、图计算、GC 标记阶段)TLB miss 可占总时间的 10%~30%。

三个相关要点:

  • 上下文切换要 flush TLB,切换后一段时间内所有访存都 miss —— 这正是 13 篇讲「上下文切换贵在 cache/TLB 而不是保存寄存器」的依据。硬件的改进是 PCID/ASID(给 TLB 条目打地址空间标识,切换时不用 flush)
  • KPTI(Meltdown 缓解)让内核和用户态用两套页表,每次系统调用进出都可能影响 TLB —— 有 PCID 时开销小得多,这也是「减少系统调用次数」(io_uring)的一个背景动因
  • 大页是唯一能根本改善 TLB 覆盖率的手段:2048 项 TLB 用 4KB 页覆盖 8MB,用 2MB 页覆盖 4GB

Q:缺页异常是 bug 还是特性?

绝大多数缺页是特性,只有一种是 bug:

  • Minor fault(不需读磁盘,只需分配页或建映射)—— 微秒级,数量大也完全正常
  • Major fault(需从磁盘读:文件映射首次访问、swap 换入)—— 毫秒级,持续增长才是问题
  • Invalid fault(地址根本没映射)—— 这才是 bug,转成 SIGSEGV

缺页是内核实现各种内存特性的统一机制,七种功能全靠它:按需分页malloc(1GB) 立即返回但零物理页,这就是 VSZ ≫ RSS)、COW(fork 时标记只读,写入时才复制)、mmap 文件(让「把 10GB 文件当数组用」成为可能)、swap 换入(PTE 的 P=0 时其余位复用来存 swap 位置)、栈自动增长(SIGSEGV 判断里唯一合法的越界访问)、THP 提升userfaultfd(把缺页处理交给用户态 —— 虚拟机的后拷贝迁移、CRIU 靠它)。

Q:页表项里的哪些位解释了本系列讲过的现象?

  • R/W 位COW:fork 时清掉它,写入触发缺页才复制(21 篇)
  • A(Accessed)位页回收的 LRU 靠它判断冷热
  • D(Dirty)位 → 决定换出时是否要写盘,也是「write() 返回不等于落盘」的基础
  • PS 位 → 大页
  • NX 位 → 为什么现代系统上栈溢出难以直接执行注入的代码
  • P=0 时其余位被复用 → swap 条目、按需分页的标记

Q:THP 为什么被数据库集体要求关闭?

三个代价:

  1. 分配延迟不可预测(主因) —— 要凑出 2MB 物理连续区域,内核可能需要做内存规整(compaction),移动大量页,导致触发它的进程同步阻塞几百毫秒。表现为服务偶发的长尾延迟尖刺,而且极难定位(原因藏在内存子系统,应用侧看不出异常)
  2. 内存放大 —— 随机稀疏访问只用到 2MB 里的几 KB,其余浪费(Redis/MongoDB 尤其明显)
  3. COW 粒度放大 —— fork 后写一个字节触发 2MB 复制而非 4KB,这是 Redis BGSAVE 内存放大的直接原因,也是官方文档要求关闭它的首要理由

折中方案是 madvise 模式:只对用 madvise(MADV_HUGEPAGE) 主动请求的应用启用 —— 又一个「机制与策略分离」的例子(18 篇)。观察规整开销用 grep compact_stall /proc/vmstat

Q:Go 程序 GC 之后为什么 RSS 不下降?

因为 runtime 用 madvise 归还内存,有两种语义:

  • MADV_FREE(Go 1.12~1.15 默认):内核延迟回收,页仍计入 RSS 直到出现内存压力。优点是应用再次使用时无需重新缺页;缺点是 RSS 看着不降,容易被误判为泄漏,而且在容器里容易撞上 memory.max(cgroup 按 RSS 算)
  • MADV_DONTNEED(Go 1.16+ 恢复为默认):立即归还,RSS 马上下降,代价是下次访问要重新缺页

换回 MADV_DONTNEED 的主要原因就是容器环境下的可观测性与 OOM 风险。 手动归还可以用 debug.FreeOSMemory()

另外 Go 的 VSZ 常有 1~2GB 是因为 runtime 启动时用 mmap(PROT_NONE) 预留大块地址空间(只占地址不占物理页),让堆能连续增长 —— 再次印证「VSZ 没有意义,要看 RSS/PSS」。容器里必须设 GOMEMLIMIT,否则 GC 不知道 cgroup 上限,很容易被 OOMKilled。


小结

  • 虚拟内存的价值不只是「解决三个原始问题」,而是「地址翻译这个间接层」让 COW/mmap/swap/共享/ASLR/overcommit 几乎免费
  • 分页赢在三点:消灭外部碎片、交换粒度细、硬件实现是纯位运算;分段退化成了 TLS 的实现手段
  • 单级页表要 512GB → 多级页表利用地址空间的稀疏性;每级 9 位恰好是一个 4KB 页
  • 页表开销约为映射内存的 0.2% → 这是 fork 慢的根源
  • TLB 是性能命门:只能覆盖 8MB(4KB 页),miss 要 300~400ns;上下文切换的真实代价在 TLB 重建;PCID 与 KPTI 都围绕它
  • 缺页是机制不是错误:按需分页、COW、mmap、swap、栈增长、THP、userfaultfd 全靠它
  • 页表项的每一位都对应一个我们见过的现象:R/W→COW、A→LRU、D→脏页回写、NX→防注入
  • THP 的三个代价(规整延迟、内存放大、COW 粒度放大 512 倍)解释了数据库为什么集体关闭它
  • Go 的 VSZ 大是地址预留,RSS 不降是 MADV_FREE;容器里必须设 GOMEMLIMIT

下一篇讲 内核如何管理物理内存:buddy 分配器如何对抗外部碎片、slab/slub 为什么要在 buddy 之上再建一层、页回收的 LRU 双链表与 refault 检测、swap 的哲学、以及 OOM Killer 的评分函数为什么这么算