本页目录

虚拟内存

覆盖: 页表遍历 (4-level/5-level) → PTE 格式 → TLB 机制 → HugeTLB/THP → KPTI 隔离 内核版本: 2.6 ~ 6.x,重点标注 5-level paging 和 mTHP 演进

概述

虚拟内存是 Linux 内存管理的基石。它将每个进程隔离在自己的地址空间中,同时通过页表实现延迟分配 (demand paging)、写时复制 (COW)、内存共享 (mmap MAP_SHARED) 和 swap。

理解虚拟内存的关键在于理解页表遍历⁠——CPU 如何把一个虚拟地址转换成物理地址。这个过程发生在每次内存访问中(由 MMU 硬件完成),速度至关重要。


页表结构

x86-64 4-level paging (48-bit)

flowchart LR
    VA["虚拟地址 (48-bit)"]

    VA --> PGD["PGD/PML4 索引<br/>[47:39] 9 bit<br/>512 entries"]
    PGD -->|"CR3 →"| PUD["PUD 索引<br/>[38:30] 9 bit<br/>512 entries"]
    PUD --> PMD["PMD 索引<br/>[29:21] 9 bit<br/>512 entries"]
    PMD --> PTE["PTE 索引<br/>[20:12] 9 bit<br/>512 entries"]
    PTE --> PAGE["4KB 物理页<br/>offset [11:0] 12 bit"]

    classDef va fill:#e3f2fd,stroke:#1565c0
    classDef level fill:#f3e5f5,stroke:#7b1fa2
    classDef phys fill:#e8f5e9,stroke:#2e7d32
    class VA va
    class PGD,PUD,PMD,PTE level
    class PAGE phys

为什么每级正好 9 bit (512 entries)? 因为每个 PTE 是 8 bytes,512 × 8 = 4KB = 正好一个物理页。这种自相似的设计使得页表本身可以用页面分配器管理。

5-level paging (57-bit, kernel 4.14+)

flowchart LR
    VA5["虚拟地址 (57-bit)"]
    VA5 --> P5E["PML5 索引<br/>[56:48] 9 bit<br/>512 entries"]
    P5E --> P4D["P4D 索引<br/>[47:39] 9 bit<br/>512 entries"]
    P4D --> PUD5["PUD 索引<br/>[38:30] 9 bit"]
    PUD5 --> PMD5["PMD 索引<br/>[29:21] 9 bit"]
    PMD5 --> PTE5["PTE 索引<br/>[20:12] 9 bit"]
    PTE5 --> PAGE5["4KB 物理页<br/>offset [11:0]"]

    classDef va fill:#e3f2fd,stroke:#1565c0
    classDef level fill:#f3e5f5,stroke:#7b1fa2
    classDef phys fill:#e8f5e9,stroke:#2e7d32
    class VA5 va
    class P5E,P4D,PUD5,PMD5,PTE5 level
    class PAGE5 phys

需要 CPU 支持 LA57 feature (Ice Lake+) 检查: grep la57 /proc/cpuinfo

5-level 将虚拟地址空间从 256TB (128T user + 128T kernel) 扩展到 128PB。不是为今天的应用准备的——是为未来的 CXL 共享内存池和 PB 级 NVDIMM。

大页如何减少层级

flowchart LR
    subgraph HUGE["大页遍历层级"]
        direction LR
        A["PGD"] --> B["PUD"]
        B -->|"2MB: PS=1"| C["PMD → 2MB 大页 ✅"]
        B -->|"1GB: PS=1"| D["PUD → 1GB 大页 ✅"]
    end

    subgraph NORMAL["4KB 页遍历"]
        direction LR
        E["PGD"] --> F["PUD"] --> G["PMD"] --> H["PTE → 4KB"]
    end

    classDef huge fill:#e8f5e9,stroke:#2e7d32
    classDef normal fill:#e3f2fd,stroke:#1565c0
    class HUGE huge
    class NORMAL normal

在 PMD/PUD entry 中: bit 7 (PS bit) = 1 → 不再往下走,这一级就是最终物理地址

页表项 (PTE) 格式

x86-64 PTE (简化, 64 bit):
  bit 0:   Present       → 页在物理内存中 (1) 还是被换出/未映射 (0)
  bit 1:   R/W           → 可写 (1) 还是只读 (0) — COW 的关键!
  bit 2:   U/S           → User 可访问 (1) 还是 kernel only (0)
  bit 3:   PWT           → Page Write-Through caching
  bit 4:   PCD           → Page Cache Disable
  bit 5:   Accessed      → 硬件自动设 1 (页面被访问过)
                          → 回收的关键: 没访问过的页面是 swap 首选
  bit 6:   Dirty         → 硬件自动设 1 (页面被写过)
                          → 脏页 swap 时需写回,干净页直接丢弃
  bit 7:   PAT / PS      → PTE 级: PAT (Page Attribute Table)
                          → PMD/PUD 级: PS (Page Size, 指示大页)
  bit 8:   Global        → CR3 切换时不刷 (用于内核页表)
  bit 11:  NX bit 63     → No-Execute (XD), 不可执行
  bit 63:  NX            → 同 bit 11 的别名

物理地址: bits [51:12] → 指向 4KB 对齐的物理页
  其余 bits [11:0] 是 offset,不参与地址转换

Accessed 和 Dirty 位是内存回收的硬件加速器。没有它们,内核需要用 page fault 来模拟"是否被用过",成本高数百倍。


TLB (Translation Lookaside Buffer)

基本概念

TLB = MMU 内部的页表 cache (硬件)
  L1 TLB: ~64 entries, 1 cycle 延迟 (指令和数据分开)
  L2 TLB: ~1500 entries, ~5 cycles

TLB miss:
  1. 硬件遍历页表 (x86: hardware page walker, 4 次内存访问)
  2. 如果遍历中间任何一级的 Present=0 → page fault → 内核处理
  3. 找到 PTE 后,加载到 TLB

TLB 覆盖率:
  4KB pages:  L2 TLB 1500 entries × 4KB = 6MB 覆盖
  2MB pages:  L2 TLB 1500 entries × 2MB = 3GB 覆盖
  1GB pages:  32 entries (L1 only) × 1GB = 32GB 覆盖

PCID / ASID

问题: 上下文切换时写 CR3 → TLB 中所有 non-global entries 失效
       → 新进程的每个内存访问都是 TLB miss → 严重性能损失

解决方案:
  x86 PCID (Process-Context Identifier, 12-bit):
    写 CR3 时附上 ASID → TLB 中不同 ASID 的 entries:
      - 不会被刷掉 (保留在 TLB 中)
      - 不会误命中 (ASID 不匹配)
    → 热进程切换回来后,TLB 仍然大部分命中

  ARM64 ASID (8-bit 或 16-bit):
    类似原理,TTBR0_EL1 写入时指定 ASID
    硬件区分不同 ASID 的 TLB entries

  内核态页表 (Global bit):
    kernel 页表的 PTE 设 Global bit → CR3 切换不刷
    → 内核态 TLB entries 在不同进程间共享

TLB Shootdown

TLB Shootdown:多核 TLB 一致性协调流程 CPU 0 · 发起核 其他 CPU · 目标核 1. 修改页表 如 munmap() / mprotect() 2. 本地立即刷 TLB INVLPG 指令 或 重写 CR3 仍执行原任务 尚未感知页表已变更 (TLB 中仍缓存旧 PTE) 3. 发送 IPI 广播 Inter-Processor Interrupt 收到 IPI 中断 暂停当前执行流程 IPI 阻塞等待 flush_tlb_mm_range() 未返回 中断上下文中刷 TLB 清除该 mm 的失效 PTE 缓存 回复完成(ACK) 通知发起核已处理完成 所有目标 CPU 回复完成 flush_tlb_mm_range() 返回,各核 TLB 状态一致 Lazy TLB 目标在内核线程(无用户态)→ 延迟刷 Batching 合并多次 flush → 一次 IPI 硬件广播 AMD INVLPGB / Intel TLB broadcast 为什么必须发 IPI:仅刷本地 TLB 不够——其他核仍缓存旧 PTE, 不广播通知就会继续用旧翻译,造成核间内存视图不一致甚至安全问题。

HugeTLB

预留大块连续物理内存,提供确定的大页映射:

// 配置
echo 128 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
// 或 boot param: hugepagesz=1G hugepages=4 hugepagesz=2M hugepages=1024

// 使用方式 1: hugetlbfs
mount -t hugetlbfs -o pagesize=2M none /dev/hugepages
mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_HUGETLB, fd, 0);

// 使用方式 2: mmap flag
mmap(NULL, size, PROT_READ|PROT_WRITE,
     MAP_PRIVATE|MAP_ANONYMOUS|MAP_HUGETLB, -1, 0);

// QEMU/libvirt 使用 HugeTLB 为 VM 映射 guest RAM
// → 减少嵌套页表 (stage-2 page table) 的 TLB miss

HugeTLB 的优点:确定可用(预留时分配)、TLB 效率高。缺点:需要连续物理内存、不可 swap(5.x 之前)、小数据浪费整页。


THP (Transparent Huge Pages)

机制

内核自动将连续的 4KB 页合并为 2MB 大页,对用户透明:

分配:
  进程 malloc(128KB) → 触发 page fault → do_anonymous_page()
    → 如果 VM 支持 THP 且对齐 → 尝试分配 2MB 零页
    → 成功: 整个 2MB 用一个大页映射 (减少后续 512 个 page faults)
    → 失败: 回退到 4KB 页

khugepaged:
  后台内核线程 → 周期性扫描进程地址空间
    → 发现可以合并的连续 4KB 页 → 迁移/合并 → 升级为 2MB THP
    → 发现 THP 内大部分页被释放 → 分裂为 4KB 页
    → 扫描通过 /sys/kernel/mm/transparent_hugepage/khugepaged/ 控制

策略控制

cat /sys/kernel/mm/transparent_hugepage/enabled
# always:  总是尝试分配 THP (默认)
# madvise: 仅 MADV_HUGEPAGE 标记的 VMA
# never:   禁用

# 应用级控制:
madvise(ptr, size, MADV_HUGEPAGE);   // 建议使用 THP

THP 的代价

优点:
  + TLB miss 率大幅降低 (512 倍覆盖率)
  + page fault 减少 (一次 allocation 512 个页)
  + 内核页表遍历减少一级

缺点:
  - 分配延迟 (需要找 2MB 连续物理内存 → 可能需要 compact/reclaim)
  - 内部碎片 (3KB 有效数据占 2MB 物理内存)
  - 某些 workload 变慢 (如某些数据库自己管理大页)

这就是为什么 DBA 经常建议: echo never > transparent_hugepage/enabled

mTHP (multi-size THP, 6.12+)

正在演进的方案: 允许 THP 的大小不是固定 2MB
  → 4KB / 16KB / 32KB / 64KB / 128KB / 256KB / 512KB / 1MB / 2MB
  → 按需选择合适的大小
  → 减少内部碎片 (不需要整个 2MB)
  → 保留 TLB 效率 (比 4KB 好很多)

这是内核社区正在活跃开发的领域

KPTI: 页表隔离

Meltdown 与修复

Meltdown (2018):
  CPU 在推测执行中不检查 U/S bit → 用户态可以推测读取内核内存
  → cache 中的残留数据可以通过 side channel 读取

KPTI (Kernel Page Table Isolation):
  x86: PTI (Page Table Isolation)
  ARM: KPTI (Kernel Page Table Isolation)

  每个进程有**两套**页表:
    内核态页表: 完整映射 (user + kernel 地址)
    用户态页表: 最小映射 (user + 仅 trampoline)

  trampoline (入口蹦床):
    只包含 entry_SYSCALL_64 / iret / 中断描述符表 (IDT)
    → 足够进入内核态 → 进入后立即切换到内核态页表
    → syscall 返回前切回用户态页表 → 然后 iret

性能影响

syscall 密集型负载: ~5%~30% 性能退化
  每次 syscall: 2 次 CR3 写 + 2 次 TLB miss (trampoline 太小)
  缓解:
    - PCID: 避免完全刷 TLB (但 CR3 写本身开销还在)
    - 非 Meltdown 影响的 CPU (AMD / newer Intel): KPTI 自动禁用

调试接口

# 完整页表遍历 (需要 root)
cat /proc/<pid>/pagemap  # 二进制,每页 8 bytes
# bit 63: page present
# bits [54:0]: PFN (物理页号)

# 检查 THP 使用情况
grep -i thp /proc/meminfo
# AnonHugePages: THP 用于匿名内存
# ShmemHugePages: THP 用于 shmem

# 检查是否启用 5-level paging
grep la57 /proc/cpuinfo

# 大页信息
cat /proc/meminfo | grep -i huge
cat /sys/kernel/mm/hugepages/hugepages-*/nr_hugepages

# 页表大小 (per-process)
cat /proc/<pid>/status | grep VmPTE

参考与延伸

  • 内核文档⁠: Documentation/x86/x86_64/mm.rst, Documentation/vm/transhuge.rst, Documentation/x86/pti.rst
  • LWN:
    • "5-level paging" (lwn.net/Articles/717293/)
    • "Transparent huge pages in 6.x" (lwn.net/Articles/971579/)
    • "The multi-size THP patch set" (lwn.net/Articles/931701/)
  • 源码文件⁠:
    • arch/x86/include/asm/pgtable.h — PTE 格式定义
    • arch/x86/mm/tlb.c — TLB flush 实现
    • mm/huge_memory.c — THP 核心
    • mm/hugetlb.c — HugeTLB
    • arch/x86/mm/pti.c — KPTI

关键词: 页表, PGD/PUD/PMD/PTE, PTE flags (A/D/RW/NX), TLB, PCID, ASID, HugeTLB, THP, khugepaged, mTHP, KPTI, Meltdown