Linux内核分析之内存管理-01
19.1 多级页表结构 —— PGD 到 PTE
要管理页表,内核必须先回答一个抽象问题:页表长什么样? 每个架构的答案不同——x86_64 四或五级、ARM64 三到五级可配、RISC-V 三到五级运行时冻结——但内核的核心子系统(内存管理、缺页处理、页表复制)不能为每个架构写一套。于是 Linux 用五级固定命名的层级抽象(pgd/p4d/pud/pmd/pte)把"树多深"包装成编译期可折叠的宏系统:没有 p4d/pud 的架构会把对应宏退化为直通透传。本节结合 Linux 7.0.10 源码分析这套抽象的类型系统、遍历表达式与共享页表语义。
19.1.1 表项类型:一维包装的语义屏障
每个页表项在物理上都是一个 64 位(或 32 位)整数,但内核坚持用单字段结构体包装:
// arch/x86/include/asm/pgtable_types.h:293-368(节选)
typedef struct pgprot { pgprotval_t pgprot; } pgprot_t; /* :293 权限位集合 */
typedef struct { pgdval_t pgd; } pgd_t; /* :295 顶级表项 */
typedef struct { p4dval_t p4d; } p4d_t; /* :342 第四级 */
typedef struct { pudval_t pud; } pud_t; /* :368 第三级 */
/* pmd_t/pte_t 同理 */
单字段结构体不是脱裤子放屁——它在类型系统层面建立了语义屏障:
pmd_t不能隐式赋给pte_t:两者位布局不同(PMD 表项是"指向下一级表"或"块映射",PTE 才是页帧+权限),混用即 bug;- 赋值必须经
__pmd(value)/pmd_val(pmd)显式进出:#define __pgd(x) native_make_pgd(x)(arch/x86/include/asm/pgtable.h:100)、#define pgd_val(x) native_pgd_val(x)(pgtable.h:99),grep 点使代码审计可以精确找到所有"裸值进出页表"的位置; pgprot_t把权限位与地址位分离:mk_pte(page, prot)组装时才合并,权限策略(如PAGE_KERNEL变体)可以独立于物理地址演进。
19.1.2 五级抽象与宏折叠
内核固定命名五级:PGD → P4D → PUD → PMD → PTE。每级一张物理页大小的表(x86_64/ARM64/RISC-V 均为 4KB/512 项),表项数由 PTRS_PER_* 描述。架构不支持的层级通过 include/asm-generic/pgtable-nop*.h 折叠成"单元素表"——pud_offset() 直接返回 p4d 指针、pud_val() 直接透传,遍历代码无需感知。三级索引链构成遍历表达式:
// include/linux/pgtable.h:71
#define pgd_index(a) (((a) >> PGDIR_SHIFT) & (PTRS_PER_PGD - 1))
// include/linux/pgtable.h:140-156
static inline pgd_t *pgd_offset_pgd(pgd_t *pgd, unsigned long address)
{ ... return pgd + pgd_index(address); }
#define pgd_offset(mm, address) pgd_offset_pgd((mm)->pgd, (address)) /* :149 */
#define pgd_offset_k(address) pgd_offset(&init_mm, (address)) /* :156 */
// include/linux/pgtable.h:95-100
static inline pte_t *pte_offset_kernel(pmd_t *pmd, unsigned long address)
{ return (pte_t *)pmd_page_vaddr(*pmd) + pte_index(address); }
pgd_offset(mm, addr) 从 mm->pgd(进程顶级表基址,task_struct->mm)出发,按 PGDIR_SHIFT 取高位索引定位顶级表项;沿链下探由各级 _offset 宏接力,每级从当前表项中解出下一级表的物理页地址(x86 的 pmd_page_vaddr(),arch/x86/include/asm/pgtable.h:1022:(pmd_val(pmd) & PTE_PFN_MASK) >> PAGE_SHIFT 再 __va() 转虚拟地址)。无锁路径(GUP 快速遍历)使用 pud_offset_lockless()/pmd_offset_lockless()(include/linux/pgtable.h:2237-2240)——先取表项值再解引用,配合 RCU 与原子读保证不会访问已被释放的表页。
完整的软件遍历链 (以内核映射某地址为例)
pgd = pgd_offset_k(addr) ← init_mm.pgd 出发(内核地址)
└─ p4d = p4d_offset(pgd, addr) ← x86 四级时 p4d==pgd(折叠)
└─ pud = pud_offset(p4d, addr)
└─ pmd = pmd_offset(pud, addr)
├─ pmd_large(*pmd) → 2MB 块映射终点
└─ pte = pte_offset_kernel(pmd, addr)
├─ pte_none(*pte) → 空表项(未映射)
└─ pte_page(*pte) → struct page / 物理帧
为什么用多级而不是一级大表? 48 位虚拟地址、4KB 页的一级表需要 512GB 连续内存存放 2^36 个表项——比大多数机器的总内存还多。多级树把空间开销变成按需分配:只有实际映射的地址范围才分配下级表,一个只映射了 8MB 的进程只需 PGD+2×PMD+4×PTE 表(约 7 页)。代价是遍历时间(最多 5 次访存),由 TLB 缓存最热结果(19.5 节)。
19.1.3 内核表与用户表的关系
pgd_offset_k()(pgtable.h:156)揭示了一个关键事实:内核地址与用户地址共享同一张 PGD。init_mm.pgd(swapper_pg_dir)是内核自己的顶级表;普通进程的 mm->pgd 在创建时从 init_mm 拷贝内核半区(22 章展开 mm_alloc() 的细节)。这一共享带来两个推论:
- 内核映射零成本共享:所有进程可见同一内核直接映射区,无需逐进程建立;
- 切换进程只需换 PGD 一个指针:x86 写 CR3、ARM64 写 TTBR0、RISC-V 写 satp——下半区(内核半区)保持不变,因此内核页(全局页)的 TLB 项可以在切换后幸存(19.5 节 PG_GLOBAL 的意义)。
PGD 的上下两半 (x86_64 为例)
用户半区 (低地址) 内核半区 (高地址)
┌─────────────────────────┬─────────────────────────┐
│ 进程私有: 代码/堆/栈/mmap │ 所有进程共享: 直接映射/ │
│ 进程切换时随 CR3 更换 │ vmalloc/vmemmap/内核映像 │
└─────────────────────────┴─────────────────────────┘
mm->pgd (每进程) 从 init_mm 拷贝, 生命周期
pte 权限含 _PAGE_USER 与进程无关; 修改经 vmalloc/
set_memory 等全局接口
19.1.4 表项的三种形态
每个中间级表项有两种形态,叶子级还有一种特殊形态,这套判别谓词是所有页表代码的基础:
| 形态 | 判别谓词(x86) | 含义 |
|---|---|---|
| 空项 | pud_none()/pmd_none()/pte_none() |
未建立映射,下级表不存在 |
| 链接项 | pud_present() && !pud_large() |
指向下一级表(PFN 字段) |
| 块/叶项 | pud_large()/pmd_large()(PS 位) |
直接映射 1GB/2MB 块,跳过下级 |
| 换出槽 | pte_none() 且 swap 值非零 |
页在 swap(swp_entry_t 编码在 pte 值内,24 章) |
块映射让"一棵树只需两级"成为可能:映射一段物理连续的 1GB 区域时,PUD 级直接写块项,省掉 PMD/PTE 两级表及其遍历开销。这也解释了第 23 章大页的意义——不仅是 TLB 效率,还有页表本身的空间与访问成本。
19.1.5 遍历代码的两种范式
全内核的页表遍历收敛为两种范式,理解它们能速读任何 MM 代码:
范式一:持锁显式遍历(写侧/精确路径)。调用者持 mmap_lock(读或写)+ 页表锁(ptl),逐级 _offset 定位。pte_offset_kernel() 的返回值直接解引用——锁保证表页不会被释放。缺页处理(22.4 节)、mprotect、页表填充(20.3 节 vmalloc)都是此范式:
/* 典型形状 (伪码, 22.4 节 handle_pte_fault 的骨架) */
pud = pud_offset(p4d, addr); /* 逐级 _offset */
pmd = pmd_offset(pud, addr);
ptl = pte_lockptr(mm, pmd); /* 表页锁 */
spin_lock(ptl);
pte = pte_offset_kernel(pmd, addr); /* 定位表项 */
...判读与修改 *pte...
spin_unlock(ptl);
范式二:无锁快照遍历(读侧热路径)。GUP(get_user_pages)快速路径、per-VMA 锁下的缺页(22.2.4 节)不持页表锁:先用 pmd_offset_lockless()/pud_offset_lockless()(include/linux/pgtable.h:2237-2240)按值读表项,再验证"值指向的表页仍活着"(RCU 宽限期保证表页释放延迟),成功后原子地处理。19.1.2 节"先值后链"的纪律即指此——先读表项值、按值解引用,而不是先拿指针再解引用(指针可能在两步之间失效)。
两范式的分野:
持锁显式 无锁快照
锁 mmap_lock + ptl RCU + 原子读 + seq 比对
表页生存 锁保护 RCU 宽限期延迟释放
性能 每次锁开销 零锁, 但需回退协议
失败处理 不失败(锁保证) 值过期 → 慢路径重试
典型 do_wp_page, zap_* GUP-fast, per-VMA 缺页
19.1.6 表页自身的分配与释放
多级树的每一级表页都是一张普通 4KB 页(18 章伙伴系统供货),但其生命周期有一层 RCU 包裹:pte_alloc_one() 分配、pte_free_defer()(RCU 延迟版)释放——释放必须延迟到无锁读者全部退场(范式二的前提)。zap 路径(22.3.5 节 mmu_gather 的 tlb_remove_table())把表页挂批次、刷 TLB 后经 RCU 释放,与页帧的"先失效后释放"铁律同构。表页记账计入 mm_pgtables_bytes()——OOM 评分(24.4.2 节 oom_badness 的第三正项)把"页表膨胀"也计入嫌疑,防的是恶意进程用 mmap 造海量空表耗尽内存的攻击。
小结
本节建立了页表的通用抽象:单字段结构体(pgd_t…pte_t)用类型系统隔离各级位布局语义;五级命名(PGD/P4D/PUD/PMD/PTE)配合宏折叠让"树深"成为架构私有细节;pgd_offset → pte_offset_kernel 的遍历表达式是全内核共享的公共路径,无锁变体以"先值后链"的纪律保证 RCU 安全。内核与用户地址共享 PGD,使进程切换退化为一个指针的替换;表项的空/链接/块三形态由统一谓词判别。下一节进入 x86_64 的具体实现:PTE 位布局、四级/五级布局与 CR3。
19.2 x86_64 四级/五级页表
19.1 节的抽象落到 x86_64 上:四级布局(48 位虚拟地址)是默认,五级布局(LA57,57 位)在支持并可配置时启用。本节逐位解析 x86_64 的 PTE 格式——PRESENT/RW/USER/ACCESSED/DIRTY/PSE/GLOBAL/NX 这些位如何支撑缺页处理、COW、回收与安全机制——并用一个具体地址走一遍完整翻译,最后解释 CR3/PCID 与内核页表的全局属性。
19.2.1 布局参数:四级与五级
// arch/x86/include/asm/pgtable_64_types.h:48-86(节选)
#define PGDIR_SHIFT pgdir_shift /* :50 四级=39, 五级=48 (运行期变量) */
#define PTRS_PER_PGD 512 /* :51 */
#define P4D_SHIFT 39 /* :56 */
#define MAX_PTRS_PER_P4D 512
#define PTRS_PER_P4D ptrs_per_p4d /* :58 四级=1(折叠), 五级=512 */
#define PUD_SHIFT 30 /* :67 1GB 块映射阶 */
#define PTRS_PER_PUD 512
#define PMD_SHIFT 21 /* :74 2MB 块映射阶 */
#define PTRS_PER_PMD 512
#define PTRS_PER_PTE 512 /* :80 */
四级布局的索引切分:
48 位虚拟地址 (4KB 页, 四级):
47 39 38 30 29 21 20 12 11 0
┌───────────────┬─────────────┬─────────────┬─────────────┬────────────┐
│ PGD 索引(9) │ PUD 索引(9) │ PMD 索引(9) │ PTE 索引(9) │ 页内偏移(12)│
└───────────────┴─────────────┴─────────────┴─────────────┴────────────┘
512 512 512 512 4096B
PGDIR_SHIFT=39 PUD_SHIFT=30 PMD_SHIFT=21 PAGE_SHIFT=12
PGD 一项覆盖 512GB (PGDIR_SIZE)
用户空间: 47 位以下 128TB ; 内核空间: 47 位以上 128TB
五级 (LA57): PGD 索引 9 位提到 56-48 位, PGDIR_SHIFT=48,
单项覆盖 256TB, 虚拟地址 57 位 (MAXMEM 同步放宽)
pgdir_shift/ptrs_per_p4d 是运行期变量(在 2.9 节的 early_cpu_init() 前由 probe_page_size_mask() 确定),五级机器上未启用 LA57 时保持四级布局——同一内核映像在两种机器上都能运行,代价是顶级索引计算多一次内存读。
19.2.2 一次完整翻译:具体地址走读
用一个真实感地址 0x00007f3a_1234_5678(典型 mmap 区地址)走四级翻译:
翻译 0x00007f3a12345678 (48 位):
二进制切分:
000000000111 11110011 10100010 01000110 10001010110 011001011000
└─ PGD idx ─┘└─ PUD ─┘ (十进制值:)
0x7 = 7 0xF3=243 PMD=0xA2=162 PTE=0x46B=1131 off=0x678
每级表项的内容 (假设映射 4KB 页):
CR3 ──┐
▼
PGD[7] = 0x1AB001063 → 指向 PUD 表 (物理页 0x1AB001)
▼ (+0x1AB001000)
PUD[243] = 0x1AC011067 → 指向 PMD 表 (物理页 0x1AC011)
▼
PMD[162] = 0x80000001AD5087
││ │└──── PTE 表物理帧 = 0x1AD5 (低位是权限位)
││ └─ bit7 (PS)=0 → 是 PTE 表, 不是 2MB 大页
│└── bit1 RW=1, bit0 P=1
▼
PTE[1131] = 0x800000012345887
│ └─ 页帧号 = 0x12345 → 物理地址 0x12345678
└─ (A 位可能已被访问置 1, D 位=1 写过)
最终: 物理 = 0x12345000 + 0x678 = 0x12345678 (假定恒等映射的页帧)
访存代价: 4 次页表读 + 1 次数据读
(TLB 命中时: 1 次数据读 — 19.5 节缓存的全部意义)
若 PMD[162] 的 PS 位=1,则是 2MB 大页(块映射):PMD 表项直接编码 2MB 对齐的物理帧,PTE 级整个消失——翻译少一跳,这就是 23 章大页的硬件基础(19.2.4 节)。
19.2.3 PTE 位布局:一个 64 位字的全部智慧
// arch/x86/include/asm/pgtable_types.h:10-30(节选)
#define _PAGE_BIT_PRESENT 0 /* is present */
#define _PAGE_BIT_RW 1 /* writeable */
#define _PAGE_BIT_USER 2 /* userspace addressable */
#define _PAGE_BIT_PWT 3 /* page write through */
#define _PAGE_BIT_PCD 4 /* page cache disabled */
#define _PAGE_BIT_ACCESSED 5 /* was accessed (raised by CPU) */
#define _PAGE_BIT_DIRTY 6 /* was written to (raised by CPU) */
#define _PAGE_BIT_PSE 7 /* 4 MB (or 2MB) page */
#define _PAGE_BIT_PAT 7 /* on 4KB pages */
#define _PAGE_BIT_GLOBAL 8 /* Global TLB entry PPro+ */
#define _PAGE_BIT_SOFTW1 9 /* available for programmer */
...
#define _PAGE_BIT_PAT_LARGE 12 /* On 2MB or 1GB pages */
#define _PAGE_BIT_SOFTW4 57 /* available for programmer */
...
#define _PAGE_BIT_NX 63 /* No execute: only valid after cpuid check */
63 62 61 ... 59 58 57 56 ... 13 12 11 10 9 8 7 6 5 4 3 2 1 0
┌──┬──┬──┬───┬──┬──┬──┬────┬──┬──┬──┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
│NX│ K│ │AVL│SW│SW│ │PFN [51:12] │PAT│ G│PS│A│D│PC│PW│ U│RW│P│
│ │E │ │ │5 │4 │ │ │ │ │ │ │ │D │T │SR│ │ │
└──┴──┴──┴───┴──┴──┴──┴────┴──┴──┴──┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
位12: PAT 位9-11/58-62: 软件可用 位7: PSE(中间级)=块映射
位8: GLOBAL(切换 CR3 不刷) 位6: DIRTY(CPU 硬件置位)
位5: ACCESSED(CPU 硬件置位) 位1: RW 位2: USER
位0: PRESENT 位63: NX (EFER.NXE 启用)
各位的内核语义与消费点:
| 位 | 硬件行为 | 内核消费点 |
|---|---|---|
| P (bit 0) | 0 则触发 #PF | 缺页入口区分"未映射"与"权限拒绝"(25.1 节) |
| RW (bit 1) | 违反写触发 #PF | COW 检测:pte 禁写但 VMA 可写 → 复制页(25.3 节) |
| USER (bit 2) | Ring3 访问触发 #PF | 用户/内核隔离;SMEP 在此之上再加执行侧限制 |
| A (bit 5) | CPU 硬件置位 | 回收扫描器的年龄判断(24.1 节),软件定期清零重新计龄 |
| D (bit 6) | CPU 硬件置位 | 回写决策:未脏页可直接丢弃、脏页必须先写回(24/36 章) |
| PS (bit 7) | 中间级表项启用块映射 | 2MB/1GB 大页(23 章) |
| G (bit 8) | CR3 切换不失效该 TLB | 内核直接映射区避免逐进程刷新(19.5 节) |
| NX (bit 63) | 取指违反触发 #PF | W^X 强制、vmalloc 模块区不可执行;依赖 EFER.NXE(2.6 节引导设置) |
| SOFTW1-5 | 硬件不碰 | 软件私有:如 _PAGE_BIT_SOFTW1=special、soft-dirty(用 SOFTW2,脏位跟踪) |
A/D 位由硬件置位是 x86 的珍贵特性:回收器无需访问计数即可知道页是否被用过。ARM64 的对应机制(AF 位 + DBM)与 RISC-V(A/D 位)语义一致但异常处理不同——ARM64 老硬件上 A 位缺失会触发 Access Flag fault 由软件补记(19.3 节)。
A/D 位驱动的"软缺页 vs 硬缺页" (22.4.3 节矩阵的硬件基础):
场景: 应用写一页已映射、可写、但 CPU 未置 D 位的页
硬件行为: 自动置 D=1, 正常写执行 — 无异常!
(内核唯一的知情途径: 下次扫描页表/回收时看到 D=1)
→ "零成本脏位跟踪"
场景: 写一页 RW=0 的页 (COW 预埋)
硬件行为: 拒绝 + 触发 #PF (错误码 WRITE|PROT)
→ 内核介入判定 COW (25.3 节)
19.2.4 CR3:翻译树的根指针与 PCID
// arch/x86/include/asm/special_insns.h:41-47
static __always_inline void native_write_cr3(unsigned long val)
{
asm volatile("mov %0,%%cr3": : "r" (val) : "memory");
}
CR3 寄存器布局 (4KB 对齐的 PGD + PCID 标志):
63 12 11 0
┌────────────────────────┬─────────────┐
│ PGD 物理地址 (高位) │ PCID/标志 │
└────────────────────────┴─────────────┘
bit 0-11: 启用 PCIDE 时为 PCID (12 位, 4096 个进程标签)
未启用时: bit3 PWT / bit4 PCD 等
写 CR3 的副作用 (19.5 节 TLB 的核心):
非 G 标志的 TLB 项 → 全部失效
启用 PCID: 失效范围收窄到"旧 PCID 的项"
→ 新进程的 TLB 项得以幸存 (切回零成本)
CR3 低 12 位是标志(其中大半是 PCID,进程上下文标识,19.5 节展开),高位是 PGD 物理地址(4KB 对齐天然低 12 位为零)。native_write_cr3() 是进程地址空间切换的最底层动作——switch_mm() → load_new_mm_cr3()(arch/x86/mm/tlb.c)最终都落到它。PGD 表项里存放的物理页来自 mm->pgd——它由 pgd_alloc() 在进程创建时分配(18.3 节伙伴系统一页),并把内核半区从 init_mm 拷入。
19.2.5 块映射与大页位
PS 位让中间级表项直接变成叶项:
PUD 级 PS=1: 1GB 页 (PUD 表项直接编码 1GB 对齐物理帧)
PMD 级 PS=1: 2MB 页 (PMD 表项直接编码 2MB 对齐物理帧)
遍历路径对比 (映射 2MB 区域):
四级+4KB 页: PGD → PUD → PMD → PTE 表(512 项×8B=1页) → 512 个 4KB 项
四级+2MB 页: PGD → PUD → PMD(PS=1) ← 一项搞定, 无 PTE 表
收益: TLB 项覆盖 2MB; 页表内存省 1 页; 遍历访存少 1 级
页表内存账 (映射 1TB 内存的 PTE 元数据):
全 4KB: PTE 表共 2GB (1TB/2MB×4KB)
全 2MB: PMD 表共 16MB ← 500 倍差距
全 1GB: PUD 直达, 元数据仅数 MB
内核在哪些地方用块映射:内核映像本身(init_mem_mapping() 优先 1GB/2MB 映射)、直接映射区(大块映射减少 TLB 压力)、以及 23 章的 THP/hugetlb 对用户进程的透明大页。page_size_mask(2.9 节 init_mem_mapping 参数)正是 PS 能力的探测结果。
19.2.6 内核页表的全局属性
PAGE_KERNEL 等常量组装了内核映射的标准权限:
PAGE_KERNEL = P | RW | N (可写不可执行, NX 必开)
PAGE_KERNEL_RO = P | N (只读不可执行: 内核只读段)
PAGE_KERNEL_EXEC= P | RW (可执行: 模块加载后部分区域)
PAGE_KERNEL_IO = ... | PWT/PCD 变体 (MMIO 映射)
7.0 内核已全面 W^X:可执行映射必须显式声明且运行期经 set_memory_ro/set_memory_x 严格切换(安全加固机制见 1.6 节与 20.5 节 KASLR 的配合)。_PAGE_BIT_SOFTW4(bit 57)等高位软件位因 52 位物理地址扩展而仍留有余量。
小结
x86_64 用 9+9+9+9+12 的五段切分实现四级翻译(一次具体地址走读验证了每级索引与 PFN 拼接),LA57 启用时顶级索引上移;PTE 的 64 位字里,硬件消费 P/RW/USER/A/D/PS/G/NX 八位,其余留给软件——A/D 由 CPU 自动置位支撑回收年龄跟踪与零成本脏位跟踪,RW/USER/NX 支撑 COW、特权隔离与 W^X。CR3 写入完成地址空间切换并依 G 位与 PCID 决定 TLB 存活范围;页表内存账说明大页在元数据上的 500 倍收益。下一节看 ARM64 如何用对偶 TTBR 与 Block Mapping 达成同样的翻译目标。
19.3 ARM64 页表与 Block Mapping
ARM64 的翻译硬件与 x86 有三点根本差异:用户与内核各用一根独立的基址寄存器(TTBR0/TTBR1)而非共享 PGD;页表层级数量是编译期可配的(2-5 级,随 VA_BITS 变化);表项格式由两个类型位决定(块映射/表映射),并额外提供 contiguous hint 让连续表项共享一个 TLB 项。5.2 节从体系结构层面介绍过 TTBR 分割,本节结合 Linux 7.0.10 源码进入内核实现细节。
19.3.1 层级计算:从 VA_BITS 推出树深
ARM64 不像 x86 那样固定层级,而是由虚拟地址位宽反推:
// arch/arm64/include/asm/pgtable-hwdef.h:20-31(公式注释与定义)
* levels = DIV_ROUND_UP((va_bits - PAGE_SHIFT), PTDESC_TABLE_SHIFT)
#define ARM64_HW_PGTABLE_LEVELS(va_bits) \
DIV_ROUND_UP(((va_bits) - PAGE_SHIFT), PTDESC_TABLE_SHIFT)
PTDESC_TABLE_SHIFT = 9(4KB 页表 512 项)。于是 39 位 VA → 3 级、48 位 → 4 级、52 位(LPA2)→ 5 级:
// arch/arm64/include/asm/pgtable-hwdef.h:49-96(节选)
#define PTRS_PER_PTE (1 << PTDESC_TABLE_SHIFT) /* :49 = 512 */
#define PMD_SHIFT ARM64_HW_PGTABLE_LEVEL_SHIFT(2) /* :55 */
#define PUD_SHIFT ARM64_HW_PGTABLE_LEVEL_SHIFT(1) /* :65 */
#define P4D_SHIFT ARM64_HW_PGTABLE_LEVEL_SHIFT(0) /* :72 */
#define PGDIR_SHIFT ARM64_HW_PGTABLE_LEVEL_SHIFT(4 - CONFIG_PGTABLE_LEVELS) /* :82 */
#define PTRS_PER_PGD (1 << (VA_BITS - PGDIR_SHIFT)) /* :85 */
PTRS_PER_PGD 不再固定 512:39 位 VA、3 级布局时 PGD 只需 8 项(1 << (39-36)),页表本身不满一页——这正是 5.2 节"39 位模式省内存"的实现层来源。VA_BITS(arch/arm64/include/asm/memory.h:43,取 CONFIG_ARM64_VA_BITS)与 PAGE_OFFSET(memory.h:45,_PAGE_OFFSET(VA_BITS))决定了用户/内核半区的切分点。
ARM64 三档布局对照 (4KB 页):
VA_BITS=39 (3 级): VA_BITS=48 (4 级): VA_BITS=52 (5 级, LPA2):
[38:30] PGD (8 项!) [47:39] PGD (512 项) [51:42] PGD
[29:21] PUD (512) [38:30] PUD [41:30] PUD
[20:12] PTE [29:21] PMD [29:21] PMD
[11:0] off [20:12] PTE [20:12] PTE
[11:0] off [11:0] off
PGDIR_SHIFT=36 PGDIR_SHIFT=39 PGDIR_SHIFT=42
每进程顶级表仅 1 项可用空间 512 项满表 内核半区更大
内核选择: VA_BITS=48 + 52 位能力共存时用
"双 T0SZ" 技巧 (5.2 节): 用户 52 位, 内核 48 位
(内核半区不用那么大 — 省一级遍历)
19.3.2 TTBR0/TTBR1:对偶基址寄存器
ARM64 双寄存器翻译 (48 位 VA):
用户地址 < 2^48 内核地址 >= 2^64-2^48
│ │
TTBR0_EL1 ── 用户页表树 TTBR1_EL1 ── 内核页表树
(每进程切换) (init_mm.pgd, 常驻)
ASID 放在 TTBR0_EL1 低 16 位 → 上下文感知 TLB
每进程切换: 写 TTBR0 = pgd | (ASID << 48), 随后 TLBI 必要时
对比 x86"一张 PGD 上下两半"的做法,ARM64 用两根寄存器 + 硬件按地址位选择实现同样的分割。后果有二:其一,内核页表(TTBR1 侧)与用户页表(TTBR0 侧)物理上独立成树,内核映射变更无需触碰进程页表;其二,进程切换时 TTBR0 换指针即完成(cpu_switch_mm() → __cpu_set_tcr_t0sz() 调整 T0SZ 以适配 5.2 节的 52/48 位切换,arch/arm64/include/asm/mmu_context.h:67-98),reserved_pg_dir(mmu_context.h:41)用于"暂不希望任何用户翻译生效"的窗口(如上下文切换间隙、KPTI 收窄时)。
TTBR0_EL1 寄存器布局:
63 48 47 0
┌──────────┬──────────────────────────┐
│ ASID(16) │ PGD 物理基址 (按 TCR 对齐)│
└──────────┴──────────────────────────┘
TCR_EL1 还同时配置: T0SZ/T1SZ (两个半区的位宽)
/TG0/TG1 (页粒度 4K/16K/64K) / 各级 cache 属性
— x86 用 MSR 族散装配置的东西, ARM64 收敛在
TTBR0/1 + TCR 三根寄存器里
ASID 的 generation 管理 (19.5.3 节对照):
65536 个 ASID 用尽 → 全局换代 → 所有 mm 标
"需重建" → 下次调度时懒分配新 ASID
(mm->context.id 存 ASID+代际)
ASID 是 ARM64 版的 PCID:cpu_set_asid() 写入 TTBR0 低 16 位,TLB 项携带 ASID 标签,进程间互不干扰——避免了 x86 无 PCID 时代"切进程必刷全 TLB"的灾难(19.5 节对照)。
19.3.3 描述符格式:两个类型位决定一切
// arch/arm64/include/asm/pgtable-hwdef.h:127-138
#define PUD_TYPE_SECT (_AT(pudval_t, 1) << 0) /* 1GB 块 */
#define PMD_TYPE_TABLE (_AT(pmdval_t, 3) << 0) /* 指向下一级 */
#define PMD_TYPE_SECT (_AT(pmdval_t, 1) << 0) /* 2MB 块 */
PMD/PUD 级表项的 bit[1:0] 是类型标签:0b01 = Block(块映射叶项)、0b11 = Table(指向下一级)、0b0x = 无效。PTE 级另有细节更丰富的位布局:
// arch/arm64/include/asm/pgtable-hwdef.h:164-196(节选)
#define PTE_VALID (_AT(pteval_t, 1) << 0) /* 有效位 */
#define PTE_TYPE_MASK (_AT(pteval_t, 3) << 0)
#define PTE_TYPE_PAGE (_AT(pteval_t, 3) << 0) /* 4KB 叶项 */
#define PTE_RDONLY (_AT(pteval_t, 1) << 7) /* AP[2]: 只读 */
#define PTE_SHARED (_AT(pteval_t, 3) << 8) /* SH[1:0] 内部共享 */
#define PTE_AF (_AT(pteval_t, 1) << 10) /* Access Flag */
#define PTE_DBM (_AT(pteval_t, 1) << 51) /* Dirty Bit Modifier */
#define PTE_PXN (_AT(pteval_t, 1) << 53) /* Privileged XN */
#define PTE_UXN (_AT(pteval_t, 1) << 54) /* User XN */
#define PTE_ATTRINDX(t) (_AT(pteval_t, (t)) << 2) /* MAIR 索引 */
ARM64 PTE (4KB 页) 位图:
63 62 ... 54 53 52 51 50... 11 10 9 8-7 6-2 1-0
┌──┬────┬───┬───┬───┬───────────┬───┬──┬─┬───┬────┬────┐
│NS│... │UXN│PXN│ │ DBM │ PFN [47:12] │nG│AF│SH│ATTR│TYPE│V│
└──┴────┴───┴───┴───┴─────┴───────────┴───┴──┴─┴───┴────┴───┘
bit0 V: Valid (对应 x86 PRESENT)
bit6 AF: Access Flag —— 无它则访问触发 Access Flag fault
bit51 DBM: Dirty Bit Modifier —— AF+DBM 时硬件写置脏
bit53 PXN: 内核态禁执行 bit54: UXN 用户态禁执行 (W^X 基础)
bit2-4 ATTR: MAIR 寄存器索引 (内存属性: Normal/Device/...)
bit7 RDONLY (AP[2]) ; bit9 nG: 非 global (对应 x86 G 取反!)
与 x86 最大的语义错位有三处,读 ARM64 页表代码时必须警觉:
- AF 位是"访问许可"而非"已访问统计":AF=0 时任何访问触发 Access Flag fault,由软件(
do_page_fault→set_pte补 AF)处理——老内核用它实现"首次访问陷阱",如今主要用于大页/contiguous 的惰性权限; - 脏位跟踪用 AF+DBM 组合模拟:硬件在 AF=1 且 DBM=1 的页上写操作自动置脏等效状态,软件用 PTE_RDONLY+DBM 组合实现"硬件管理脏位"(
pte_dirty判别依赖 DBM 位,__clean_pte族配合); - nG 语义与 x86 的 G 相反:ARM64 bit9=1 表示非全局(切换 ASID 即失效),x86 bit8=1 表示全局——两边内核常量
PTE_NG/_PAGE_GLOBAL的组装方向相反。
19.3.4 Block Mapping 与 contiguous hint
Block Mapping 让 2MB(PMD_TYPE_SECT)与 1GB(PUD_TYPE_SECT)区域跳过下级表,语义与 x86 PS 位等价。ARM64 额外的 contiguous hint 是 TLB 效率的独门武器:
// arch/arm64/include/asm/pgtable-hwdef.h:90-96
#define CONT_PTE_SHIFT (CONFIG_ARM64_CONT_PTE_SHIFT + PAGE_SHIFT)
#define CONT_PTES (1 << (CONT_PTE_SHIFT - PAGE_SHIFT))
#define CONT_PMD_SHIFT (CONFIG_ARM64_CONT_PMD_SHIFT + PMD_SHIFT)
#define CONT_PMDS (1 << (CONT_PMD_SHIFT - PMD_SHIFT))
默认配置下 CONT_PTES = 16:PTE 表中 16 个连续表项(对齐 64KB)标注 cont 位后,硬件允许用单个 TLB 项覆盖这 64KB——TLB miss 成本摊薄 16 倍。THP/大块连续映射路径(23 章)会尽力按 cont 对齐设置表项;contpte_try_fold/unfold(arch/arm64/mm/contpte.c)在运行期合并/拆分。
contiguous hint 合并示意 (16×4KB = 64KB):
PTE 表 (512 项):
┌──┬──┬──┬──┬───────────────┬──────────────────────┐
│..│P0│P1│..│P15(cont=1 ×16)│ 未对齐区: 逐项映射 │
└──┴──┴──┴──┴───────────────┴──────────────────────┘
└────── 1 个 TLB 项覆盖 ──────┘
约束: 16 项必须同权限/同属性/物理连续/64KB 对齐 —— 违反则拆散
与 2MB 大页的对比:
2MB 块映射: 1 项/2MB, 需物理连续+对齐 (碎片敏感)
cont 4KB: 1 项/64KB, 无物理连续要求 (碎片免疫)
→ 细粒度页保留灵活性的同时拿 16 倍 TLB 收益
19.3.5 与 x86 的实现层对照
| 维度 | x86_64 | ARM64 |
|---|---|---|
| 基址寄存器 | CR3 一根(上下半共享 PGD) | TTBR0/TTBR1 两根(独立树) |
| 层级 | 4/5 级,运行期探 LA57 | 2-5 级,编译期按 VA_BITS |
| 块映射 | PS 位(PUD/PMD) | TYPE 位(PUD/PMD TYPE_SECT) |
| TLB 标签 | PCID(CR3[11:0],软件切换) | ASID(TTBR0[63:48],硬件比对) |
| 访问/脏位 | A/D 硬件置位 | AF 许可 + DBM 组合模拟 |
| 全局页 | G=1 跨 CR3 幸存 | nG=0 项跨 ASID 幸存(内核侧) |
| 特殊能力 | LA57 运行期切换 | cont hint / LPA2 / MTE 标签位 |
5.8 节的架构对比表在这里获得了页表层的具体支撑:两套硬件哲学(单根切换 vs 对偶基址)最终收敛到同一套 pgd/pud/pmd/pte 抽象,19.1 节的宏折叠功不可没。
小结
ARM64 用 VA_BITS 反推层级数(3-5 级),TTBR0/TTBR1 对偶寄存器分割用户/内核两棵独立树并以 ASID 标记上下文(TTBR0 内嵌 ASID + TCR 双 T0SZ);表项由两个类型位区分块映射与表链接,PTE 的 AF/DBM 组合模拟 x86 的 A/D 硬件位,nG/PXN/UXN 承担全局页与 W^X 职责。contiguous hint 以 16 项一组共享 TLB 项,是 ARM64 在大内存场景的独有优化。下一节看 RISC-V 如何以极简位布局实现同一目标,并在引导期冻结层级选择。
19.4 RISC-V Sv39/Sv48/Sv57 页表
RISC-V 的翻译规格不是一个,而是一族:Sv39(39 位)、Sv48、Sv57 由不同位宽的 MMU 模式实现。内核面临的独特问题是同一内核映像要兼容三种硬件——层级数不能像 ARM64 那样编译期定死,也不能像 x86 那样每级独立探测,而必须引导期一次性探测并永久冻结(页表结构中途改型等于重建所有进程的地址空间)。5.2 节与 6.4 节分别从体系结构与引导流程介绍过 SATP 与 set_satp_mode(),本节聚焦页表本体:三档布局、PTE 位布局与 satp 的运行期语义。
19.4.1 三档布局与运行期冻结
// arch/riscv/include/asm/pgtable-64.h:16-20
#define PGDIR_SHIFT_L3 30 /* Sv39: 顶级索引 9 位, 1GB/PUD */
#define PGDIR_SHIFT_L4 39 /* Sv48: 顶级索引 9 位, 512GB */
#define PGDIR_SHIFT_L5 48 /* Sv57: 顶级索引 9 位, 256TB */
#define PGDIR_SHIFT (pgtable_l5_enabled ? PGDIR_SHIFT_L5 : \
(pgtable_l4_enabled ? PGDIR_SHIFT_L4 : PGDIR_SHIFT_L3))
三档共享同一个规律:每级 9 位索引、512 项表,档位只差"最顶级占几段":
Sv39 (39 位): [38:30] PGD │ [29:21] PMD │ [20:12] PTE │ [11:0] off 3 级
Sv48 (48 位): [47:39] PGD │ [38:30] PUD │ [29:21] PMD │ [20:12] PTE │ off 4 级
Sv57 (57 位): [56:48] PGD │ [47:39] P4D │ [38:30] PUD │ [29:21] PMD │ PTE │ off 5 级
PMD_SHIFT = 21 (2MB) 对三档恒定 pgtable-64.h:39
PTRS_PER_PGD/PTE = 4096/8 = 512 pgtable.h:33/35
差异只在 PGD 索引位宽 → pgtable_l4/l5_enabled 引导期冻结
冻结动作在 set_satp_mode()(arch/riscv/mm/init.c:858):先用一个临时两级页表(只映射内核映像)在 57 位模式试探启动 MMU,失败(非法指令或功能探测不通过)则退回 48 位、再退 39 位(init.c:820/827 的 satp_mode = SATP_MODE_48/39 回退序列)。一旦 MMU 以某档位成功点亮,pgtable_l4_enabled/pgtable_l5_enabled 就永远成立,所有进程页表按该档位建立。satp_mode 全局变量(init.c:49-53)记录最终档位,__pi_set_satp_mode_from_cmdline/fdt(init.c:813-814)允许设备树/命令行降档。
探测与冻结流程:
setup_vm() 建临时页表(两级, 覆盖内核+DTB)
│
v
set_satp_mode(dtb_pa) arch/riscv/mm/init.c:858
│ 写 satp = (SATP_MODE_57 << 60) | 临时表基址
│ sfence.vma + 验证 MMU 生效
├─ 57 位成功 → satp_mode = SATP_MODE_57
├─ 失败 → satp_mode = SATP_MODE_48 init.c:820
└─ 再失败 → satp_mode = SATP_MODE_39 init.c:827
v
pgtable_l4_enabled / pgtable_l5_enabled 冻结
此后 PGDIR_SHIFT/PTRS_PER_PGD 恒定, 正式页表按此建立
19.4.2 PTE 位布局:RISC-V 的极简主义
// arch/riscv/include/asm/pgtable-bits.h:11-76(节选)
#define _PAGE_PRESENT (1 << 0) /* V: Valid —— 与 ARM64 的 V 同名同位 */
#define _PAGE_READ (1 << 1) /* R: Readable */
#define _PAGE_WRITE (1 << 2) /* W: Writable */
#define _PAGE_EXEC (1 << 3) /* X: Executable */
#define _PAGE_USER (1 << 4) /* U: User */
#define _PAGE_GLOBAL (1 << 5) /* G: Global —— 与 x86 同语义 */
#define _PAGE_ACCESSED (1 << 6) /* A: Set by hardware on any access */
#define _PAGE_DIRTY (1 << 7) /* D: Set by hardware on any write */
#define _PAGE_SOFT (3 << 8) /* RSW: 保留给软件 */
#define _PAGE_SPECIAL (1 << 8) /* RSW: 0x1 */
...
#define _PAGE_TABLE _PAGE_PRESENT /* :59 中间级表项只需 V */
#define _PAGE_PROT_NONE _PAGE_GLOBAL /* :65 PTE 无效但作权限-none 用 */
#define _PAGE_LEAF (_PAGE_READ | _PAGE_WRITE | _PAGE_EXEC) /* :76 */
RISC-V PTE (64 位):
63 10 9 8 7 6 5 4 3 2 1 0
┌─────────────────────────┬──┬──┬─┬─┬─┬─┬─┬─┬─┬─┐
│ PFN [55:10] │RS│RS│D│A│G│U│X│W│R│V│
│ (56 位物理地址支持) │W2│W1│ │ │ │ │ │ │ │ │
└─────────────────────────┴──┴──┴─┴─┴─┴─┴─┴─┴─┴─┘
RSW[9:8] 留给软件: SPECIAL/soft-dirty/UFFD-WP 各占其一
叶项判别: R|W|X 任一为 1 (_PAGE_LEAF), 与 x86 PS 位哲学不同 ——
中间级表项若意外带 RWX 位会被硬件视为叶项(规范规定组合行为)
与 x86 的对照颇能说明架构设计权衡:
| 功能 | x86 位 | RISC-V 位 | 语义差异 |
|---|---|---|---|
| 存在 | P (bit 0) | V (bit 0) | RISC-V 的中间级只需 V=1 |
| 可读 | 无(P 即可读) | R (bit 1) 显式 | RISC-V 有显式不可读页 |
| 写 | RW (bit 1) | W (bit 2) | 相同 |
| 执行 | NX=1 反向 | X (bit 3) 正向 | RISC-V 默认不可执行(Sv 上无 X 即拒) |
| 用户 | USER (bit 2) | U (bit 4) | 相同 |
| 访问 | A 硬件置位 | A 硬件置位 | 相同(回收器福音) |
| 脏 | D 硬件置位 | D 硬件置位 | 相同 |
| 全局 | G (bit 8) | G (bit 5) | 相同语义 |
_PAGE_PROT_NONE = _PAGE_GLOBAL(:65)是个巧妙的复用:V=0、但保留其他位的表项表示"页存在但暂不可访问"(Sv 提案中的 NAPOT/权限-none 用法),内核用它实现 mprotect(PROT_NONE)——不触发常规缺页路径的"不存在",而进入权限拒绝处理。_PAGE_SWP_EXCLUSIVE = _PAGE_ACCESSED(:68)则在 swap 槽位 PTE 里借 A 位做迁移位标记(24 章 swap 编码展开)。
19.4.3 satp 与进程切换
satp(Supervisor Address Translation and Protection)寄存器同时承担"模式 + ASID + 根指针":
satp 寄存器 (64 位):
┌────────┬──────────────┬────────────────────────────┐
│ MODE[63:60] │ ASID[59:44] │ PPN[43:0] 根表物理页 │
│ 8=Sv57 │ 16 位标签 │ 4KB 对齐 │
│ 9=Sv48 │ │ │
│10=Sv39 │ │ │
└────────┴──────────────┴────────────────────────────┘
进程切换: csr_write(satp, mode|asid|pgd>>12) + sfence.vma
(与 x86 写 CR3 / ARM64 写 TTBR0 对应)
MODE 字段是三档共存的硬件基础:同一份内核在探测期可以反复改写 MODE 重启 MMU(19.4.1 节),运行期则只读。ASID 16 位与 ARM64 同位宽,内核以 generation 算法回收 ASID(arch/riscv/mm/context.c,19.5 节对照 PCID/ASID 管理)。
19.4.4 用户地址空间大小与合法位模式
// arch/riscv/include/asm/pgtable.h:1236-1247(注释与定义)
* - 0x4000000000 ( 256GB) for RV64 using SV39 mmu
* - 0x800000000000 ( 128TB) for RV64 using SV48 mmu
* - 0x100000000000000 ( 64PB) for RV64 using SV57 mmu
#define TASK_SIZE_64 (PGDIR_SIZE * PTRS_PER_PGD / 2)
Sv 规范要求高位必须等于 bit 63(位模式合法性检查,canonical address):非法位模式(如 Sv39 下 bit 62 ≠ bit 63)触发非法地址异常而非翻译。TASK_SIZE_64 取半空间正是对这条规范的实现——用户半区从 0 增长到 2^(VA_BITS-1),内核半区从 2^63 向下。MMAP_VA_BITS_64(pgtable.h:127-128)进一步限制 mmap 区按 48 位以下布局(除非显式请求),保证旧 39 位程序兼容。
小结
RISC-V 以"每级 9 位、512 项"的统一结构实现 Sv39/Sv48/Sv57 三档翻译,档位由引导期 set_satp_mode() 用临时页表探测后一次性冻结(pgtable_l4/l5_enabled),satp 的 MODE/ASID/PPN 三字段承担模式、标签与根指针。PTE 位布局比 x86 更显式——独立的 R 与 X 位、硬件自动置位的 A/D、RSW 软件区——并以 _PAGE_PROT_NONE、_PAGE_LEAF 等组合语义补齐权限模型。至此三大架构的翻译机制全部展开;它们修改页表之后面临的共同问题是 TLB 缓存一致性,这是下一节的主题。
19.5 TLB 刷新与延迟策略
页表是内存里的数据结构,CPU 却只为它维护一份每核私有的缓存——TLB。于是每次修改页表都埋下一颗雷:其他(或本)CPU 可能还在用旧翻译访问新页表已改变的地址。TLB 刷新就是排雷动作,而刷新的代价(IPI 打断所有相关 CPU)让它成为内存子系统最贵的同步操作之一。本节结合 Linux 7.0.10 源码,分析三大架构的刷新指令、x86 的 tlb_gen/PCID 延迟刷新协议、ARM64/RISC-V 的 ASID 代际管理,以及 mmu_gather 的批量收集协议。
19.5.1 为什么必须刷新:缓存的私有性与标签
无标签 TLB (无 PCID/ASID) 有标签 TLB (PCID/ASID)
TLB: {VA → PA} TLB: {VA, tag → PA}
换进程 = 全量失效! 换进程 = 换 tag, 旧项幸存
每次切换后: 冷 TLB 遍历 切回旧进程: 项仍在, 零成本
刷页表 = 无差别 IPI 刷页表 = 按 tag 定点失效
x86: CR3 低 12 位 PCID (CR4.PCIDE=1 时) arch/x86 tlbstate 每核跟踪
ARM64: TTBR0 ASID 字段 generation 回收复用
RISC-V: satp ASID 字段 同上
标签 TLB 的出现没有消除刷新的必要性——同一进程内修改页表(munmap、mprotect、页迁移)仍需失效旧项——但它把"跨进程"的失效从每切换必刷降为按代际批刷。理解刷新代码的关键常是理解它在为哪一层延迟买单。
19.5.2 x86:tlb_gen 与 flush_tlb_mm_range
x86 用单调递增的 mm->context.tlb_gen 计数器给每次页表修改编号,各 CPU 记住"我见过第几代",刷新即"把计数器推到新值并让落后者失效":
// arch/x86/mm/tlb.c:1449-1489(节选)
void flush_tlb_mm_range(struct mm_struct *mm, unsigned long start,
unsigned long end, unsigned int stride_shift,
bool freed_tables)
{
struct flush_tlb_info *info;
int cpu = get_cpu();
u64 new_tlb_gen;
/* This is also a barrier that synchronizes with switch_mm(). */
new_tlb_gen = inc_mm_tlb_gen(mm); /* :1457 代际推进 */
info = get_flush_tlb_info(mm, start, end, stride_shift, freed_tables,
new_tlb_gen);
if (mm_global_asid(mm)) { /* :1471 全局 ASID 分支 */
broadcast_tlb_flush(info); /* 硬件广播 INVLPGB */
} else if (cpumask_any_but(mm_cpumask(mm), cpu) < nr_cpu_ids) {
info->trim_cpumask = should_trim_cpumask(mm);
flush_tlb_multi(mm_cpumask(mm), info); /* IPI 广播 */
consider_global_asid(mm);
} else if (mm == this_cpu_read(cpu_tlbstate.loaded_mm)) {
lockdep_assert_irqs_enabled();
local_irq_disable();
flush_tlb_func(info); /* 仅本核: 直接失效 */
local_irq_enable();
}
...
mmu_notifier_arch_invalidate_secondary_tlbs(mm, start, end);
}
三条路径的择优体现了延迟思想:
- 仅本核持有 mm 时,连 IPI 都省了,本地
invlpg循环完事; - IPI 广播
flush_tlb_multi()(tlb.c:1379)给mm_cpumask里每核发函数调用,目标核比对info->new_tlb_gen与本核记录,落后才刷——若某核已因调度切换走(见到更新的 gen),无事可做; mm_global_asid是 7.0 新路径:AMD INVLPGB 指令允许不发起 IPI 的硬件广播失效(invlpgb_flush_addr_nosync+__tlbsync,tlb.c:1505-1521 的invlpgb_kernel_range_flush()展示了分块上限处理),对宽机器把刷新成本从"逐核中断"降为"一次总线广播"。
范围参数 stride_shift 决定刷新粒度:PMD_SHIFT 表示"大页级刷"(一次 invlpg 覆盖 2MB 对齐区),PAGE_SHIFT 逐页刷,TLB_FLUSH_ALL(tlbflush.h:23,值 -1UL)整表刷。freed_tables=true(页目录被释放)时必须全刷,因为 invlpg 逐项失效无法覆盖"中间级表项本身失效"的语义。
PCID 与 CR3 切换的配合:load_new_mm_cr3() 写 CR3 时把当前 mm 的 tlb_gen 打包进 PCID/低 12 位标志,使"本核正在运行的 mm"自动被视为最新代——刚切进来的进程无需任何补刷。flush_tlb_one_user()(tlb.c:1649)是单地址失效的最终执行者(要求仅用户地址——内核地址失效走 flush_tlb_one_kernel(),语义严格分开),flush_tlb_local()(tlb.c:1702)刷本核全部非全局项。
19.5.3 ARM64:TLBI 与 RANGE 批量失效
ARM64 的失效指令 TLBI <op> 按目标域组合:VAE1(按 VA,EL1)、VAAE1(按 VA,忽略 ASID 即所有 ASID)、ALLE1(全失效)、VMALLE1(本 VMID 全失效)等;IS 后缀表示 Inner Shareable(广播到共享域):
// arch/arm64/include/asm/tlbflush.h:42
#define __tlbi(op, ...) __TLBI_N(op, ##__VA_ARGS__, 1, 0)
范围失效走 FEAT_TLB 指令 TLBI RANGE——一条指令携带 num/scale/tlb_level 编码,一次失效最多 2^24 页:
// arch/arm64/include/asm/tlbflush.h:430-465(节选)
#define __flush_tlb_range_op(op, start, pages, stride, \
asid, tlb_level, tlbi_user, lpa2) \
do { \
...
while (__flush_pages > 0) { \
if (!system_supports_tlb_range() || \
__flush_pages == 1 || \
(lpa2 && __flush_start != ALIGN(__flush_start, SZ_64K))) { \
addr = __TLBI_VADDR(__flush_start, asid); \
__tlbi_level(op, addr, tlb_level); /* 逐项 */ \
... \
continue; \
} \
\
num = __TLBI_RANGE_NUM(__flush_pages, scale); \
if (num >= 0) { \
addr = __TLBI_VADDR_RANGE(__flush_start >> shift, asid, \
scale, num, tlb_level); /* 范围 */ \
__tlbi(r##op, addr); \
... \
} \
scale--; /* 大粒度失败降一级重试 */ \
} \
} while (0)
循环从 scale=3(最大范围)逐级降档,单页兜底回退到普通 TLBI <op>;tlb_level 参数利用页表层级信息只失效对应层级的 TLB 项(如仅 PMD 级块映射项),比全级失效更省。全量刷新用 __tlbi(vmalle1)(tlbflush.h:293,本核)或 vmalle1is(:301,共享域广播)。
ARM64 无 tlb_gen 计数器,延迟刷新由 ASID generation 实现:mm->context.id 存 ASID+代际,asids 用尽(65536 项)时全局换代、所有 mm 标记失效、下次切换时懒重建——代价被摊到"低频的代际翻转"上。
19.5.4 RISC-V:sfence.vma 的单指令语义
// arch/riscv/include/asm/tlbflush.h:18-43(节选)
static inline void local_flush_tlb_all(void)
{
__asm__ __volatile__ ("sfence.vma" : : : "memory"); /* 全失效(当前 ASID) */
}
static inline void local_flush_tlb_all_asid(unsigned long asid) /* :23 */
{ ... /* sfence.vma zero/ASID */ ... }
static inline void local_flush_tlb_page(unsigned long addr) /* :32 */
static inline void local_flush_tlb_page_asid(unsigned long addr, ...) /* :37 */
sfence.vma 是 RISC-V 唯一的 TLB 管理指令,rs1(地址)/rs2(ASID)的取舍形成四种组合——rs1=x0 则全地址、rs2=x0 则全 ASID。语义上它同时是内存屏障(保证之前的页表写全局可见后才失效),因此 RISC-V 的页表修改天然比 x86/ARM64 少一道显式屏障。多核广播由 flush_tlb_range() 等函数经 on_each_cpu IPI 完成与 x86 同构;内核同样用 ASID generation 懒失效(tlbflush.h:23-43 的 asid 变体即为此设计)。
19.5.5 mmu_gather:批量收集与延迟释放
页取消映射(munmap/mremap/进程退出)是 TLB 刷新最密集的场景,mmu_gather 把"刷 TLB"与"还页/还表"解耦成先全刷、后释放的两阶段协议:
// include/asm-generic/tlb.h:321-351(节选)
struct mmu_gather {
struct mm_struct *mm;
#ifdef CONFIG_MMU_GATHER_TABLE_FREE
struct mmu_table_batch *batch; /* 待释放页目录批次 */
#endif
unsigned long start;
unsigned long end;
unsigned int fullmm : 1; /* 正在整个 mm 上操作 */
unsigned int need_flush_all : 1; /* 需要全量刷 */
unsigned int freed_tables : 1;/* 已解除页目录 */
unsigned int delayed_rmap : 1;/* 挂起的延迟 rmap 移除 */
...
};
munmap 的两阶段协议 (tlb.h:49-94 注释归纳):
tlb_gather_mmu(&tlb, mm, start, end) 收集器就位
│ unmap 路径逐 VMA:
│ zap_pte_range(): pte 清零 + __tlb_remove_page() 挂入收集器
│ 页不立即释放! 引用由 tlb 暂持
│ clear_pmd/pud 时 tlb_remove_table() 挂入表批次
v
tlb_finish_mmu(&tlb, start, end)
├─ flush_tlb_range() → 架构刷新 (此时所有旧翻译已失效)
└─ tlb_remove_table_free() / 页交还伙伴系统 ← 刷新之后才动手
顺序保证: 若先还页再刷 TLB, 另一 CPU 可能在旧 TLB 命中后
访问已被伙伴系统重新分配的页 → 数据损坏
为什么不能边解映射边释放:另一 CPU 可能持有旧 TLB 项继续访问该页。两阶段协议以"暂持整批引用"换"只刷一次 TLB",batch 上限(MMU_GATHER_BUNDLE)控制在合理内存占用。fullmm(进程退出)时直接 TLB_FLUSH_ALL 一把刷完。
19.5.6 刷新成本的三层延迟策略总览
| 层 | 手段 | 效果 | 代价 |
|---|---|---|---|
| 不刷 | PCID/ASID 标签 + 代际 | 跨进程切换零失效 | ASID 空间耗尽时代际翻转 |
| 少刷 | mmu_gather 批量、tlb_level/stride 粒度 | N 次 unmap 合并为 1 次刷新 | 暂持引用的内存 |
| 便宜地刷 | INVLPGB 硬件广播(x86)、TLBI RANGE(ARM64)、sfence 语义屏障(RISC-V) | 单指令多地址/免 IPI | 需硬件新特性,逐级降档 |
19.5.7 mm_cpumask 的维护与失效边界
x86 协议的隐含前提是 mm_cpumask 准确——"哪些 CPU 可能缓存了本 mm 的翻译"。它由两处维护:调度器在 switch_mm() 时置位本 CPU(cpumask_set_cpu),缺页 IPI 命中后对不再运行本 mm 的 CPU 清位(should_trim_cpumask 的收缩逻辑)。误收窄的风险:某 CPU 实际缓存了翻译却被 mask 掉——防护同样是"代际比对":目标核发现 info->new_tlb_gen 大于自己见过的代才行动,错过一次刷新的 CPU 会在下次 switch_mm 的 lazy 检查中补课。这三个机制(mask 收缩、代际比对、lazy 补课)层层互备,是"乐观广播 + 确定性兜底"的又一实例。
一条指令的完整旅程 (munmap 触发的单页刷新):
CPU 3 munmap(addr)
├─ pte 清除 + inc_mm_tlb_gen → gen=105
├─ flush_tlb_multi(mm_cpumask, info{gen=105})
│ ├─ CPU 0: 正运行本 mm, gen 落后 → invlpg addr ✓
│ ├─ CPU 1: 已切走 (lazy, gen=104<105) → 补课+无事 ✓
│ └─ CPU 2: 从未运行 → mask 外, 不打扰 ✓
└─ 完成 (无锁, IPI 即同步屏障)
小结
TLB 刷新是页表修改的必然后手,三架构给出了同一问题的三种答案:x86 以 tlb_gen 代际 + mm_cpumask 落后者检测把 IPI 压到最少,7.0 又加入 INVLPGB 硬件广播路径;ARM64 以 TLBI RANGE 单指令批量失效并按页表层级定点清理;RISC-V 用 sfence.vma 四种参数组合承担全部职责。PCID/ASID 标签层把跨进程失效延迟到代际翻转,mmu_gather 两阶段协议把密集取消映射合并为单次刷新——所有策略共同服从"先失效后释放"的铁律。至此页表机制(建表、翻译、缓存一致性)完整闭环,下一章进入内核自己的虚拟地址空间布局。