Linux内核分析之内存管理-07
This language version is unavailable; showing the other language.
25.1 缺页异常入口 —— x86_64 do_page_fault
x86 把缺页叫 #PF(向量 14),是内核能收到的信息最丰富的异常:CR2 给出故障地址,错误码给出故障性质,IDTENTRY 框架给出被中断上下文。入口代码的使命是把这三份信息翻译成"一个安全的问题"再交给通用层——每一步过滤都在防止恶意构造的异常打穿内核。本节沿 7.0 的实际入口走完全程。
25.1.1 错误码:异常的性质说明书
// arch/x86/include/asm/trap_pf.h:19-28
enum x86_pf_error_code {
X86_PF_PROT = BIT(0), /* 0=页不存在 1=权限拒绝 */
X86_PF_WRITE = BIT(1), /* 写访问 */
X86_PF_USER = BIT(2), /* 用户态访问 */
X86_PF_RSVD = BIT(3), /* 保留位被置 1 (硬件表被改坏!) */
X86_PF_INSTR = BIT(4), /* 取指 */
X86_PF_PK = BIT(5), /* 保护键(pkey)拒绝 */
X86_PF_SHSTK = BIT(6), /* shadow stack 访问 */
X86_PF_SGX = BIT(15), /* SGX MMU */
X86_PF_RMP = BIT(31), /* SEV-SNP RMP 违规 */
};
这 9 位是 19.2.2 节 PTE 位格的"审计结果":CPU 发现访问与表项冲突时把冲突性质打包。X86_PF_PROT=0(页不存在)对应 22.4 节的 do_pte_missing 分支;=1(权限拒绝)对应 COW/mprotect/pkey 分支——这一位是后续所有分发的第一分岔。
25.1.2 exc_page_fault:最外层入口
// arch/x86/mm/fault.c:1483-1530(节选)
DEFINE_IDTENTRY_RAW_ERRORCODE(exc_page_fault)
{
irqentry_state_t state;
unsigned long address;
address = cpu_feature_enabled(X86_FEATURE_FRED) ? fred_event_data(regs)
: read_cr2(); /* :1487 */
/*
* KVM uses #PF vector to deliver 'page not present' events to guests
* (asynchronous page fault mechanism)...
*/
if (kvm_handle_async_pf(regs, (u32)address)) /* :1497 */
return;
...
state = irqentry_enter(regs);
instrumentation_begin();
handle_page_fault(regs, error_code, address); /* :1518 */
instrumentation_end();
irqentry_exit(regs, state);
}
三个入层动作:读 CR2(FRED 事件框架下改从事件数据取,:1487——CPU 把故障地址锁存在 CR2,处理中不可被覆盖);KVM 异步缺页拦截(:1497——来宾的"页不在"可以由宿主异步投递成 #PF,注释详述了 async pf type 与 CR2 一致性的赌注);irqentry_enter/exit(建立 RCU/锁定状态——内核态 fault 的特殊性在于处理中可能睡眠,:1512-1516 的注释解释了内核态分支为何要跳过 ct_irq_enter)。
25.1.3 handle_page_fault:内核/用户分流
// arch/x86/mm/fault.c:1462-1480
static __always_inline void
handle_page_fault(struct pt_regs *regs, unsigned long error_code,
unsigned long address)
{
trace_page_fault_entries(regs, error_code, address);
if (unlikely(kmmio_fault(regs, address)))
return;
/* Was the fault on kernel-controlled part of the address space? */
if (unlikely(fault_in_kernel_space(address))) {
do_kern_addr_fault(regs, error_code, address);
} else {
do_user_addr_fault(regs, error_code, address);
}
/*
* page fault handling might have reenabled interrupts,
* make sure to disable them again.
*/
local_irq_disable();
}
按地址(而非错误码的 USER 位)分流是刻意的安全选择:攻击者可以从内核态构造"地址像用户"的异常,地址空间归属比 USER 位更根本。内核地址 fault(20 章各区)走 vmalloc 惰性同步/坏内核指针路径;用户地址 fault 进入本节主角。
25.1.4 do_user_addr_fault:过滤序列
// arch/x86/mm/fault.c:1207(节选, 过滤序列按执行顺序)
void do_user_addr_fault(struct pt_regs *regs,
unsigned long error_code, unsigned long address)
{
...
/* [1] 内核态执行用户地址: 除 AMD erratum#93 外不可恢复 */
if (unlikely((error_code & (X86_PF_USER | X86_PF_INSTR)) == X86_PF_INSTR)) {
if (is_errata93(regs, address))
return;
page_fault_oops(regs, error_code, address); /* :1218-1229 */
return;
}
/* [2] 保留位置位: 页表被硬件/注入改坏, 立即 Oops */
if (unlikely(error_code & X86_PF_RSVD))
pgtable_bad(regs, error_code, address); /* :1238-1241 */
/* [3] SMAP: 内核态(无 AC 标志)摸用户页 → 坏指针, Oops */
if (unlikely(cpu_feature_enabled(X86_FEATURE_SMAP) &&
!(error_code & X86_PF_USER) &&
!(regs->flags & X86_EFLAGS_AC))) { /* :1244-1256 */
page_fault_oops(regs, error_code, address);
return;
}
/* [4] 中断/无 mm/显式禁 fault 上下文: 不许发生 */
if (unlikely(faulthandler_disabled() || !mm)) {
bad_area_nosemaphore(regs, error_code, address); /* :1264-1268 */
return;
}
...
/* [5] kfence 采样池故障拦截 */ /* :693 附近 */
...
/* [6] per-VMA 锁快路径找 VMA, 失败回退 mmap_lock (22.2.4 节) */
vma = lock_mm_and_find_vma(mm, address, regs); /* 后段 */
...
/* [7] 权限预检: access_error(错误码, vma->vm_flags) */
/* [8] handle_mm_fault → 按 VM_FAULT_* 结果收尾 */
}
八步过滤的每一道都对应一类真实威胁:[1] 堵"内核 ROP 跳用户页";[2] 堵"页表注入"(保留位由硬件保证为 0,置 1 即表被篡改);[3] 堵"内核解引用野指针"(SMAP 下合法访问必须先 STAC 置 AC,1.6 节);[4] 堵"中断上下文睡眠";[7] 让权限违规不等供货就拒绝——VMA 只读却来了写请求,直接 SIGSEGV,不必走完分配链再回退。
x86 入口的完整分流图:
exc_page_fault (:1483)
├─ 读 CR2 / FRED 数据
├─ KVM async pf? → KVM 处理
└─ handle_page_fault (:1462)
├─ kernel 地址 → do_kern_addr_fault
│ ├─ vmalloc 区: 惰性同步 (跨 CPU 页表复制的补页)
│ └─ 坏指针 → Oops (EXTABLE 例外: copy_from_user 等)
└─ user 地址 → do_user_addr_fault (:1207)
├─ 过滤序列 [1]-[5] → Oops/SIGSEGV/吞掉
├─ lock_mm_and_find_vma → 无 VMA: bad_area → SIGSEGV
├─ access_error → SIGSEGV (SEGV_ACCERR / pkey)
├─ handle_mm_fault (22.4 节矩阵)
└─ mm_fault_error: OOM → kill; SIGBUS → IO 错误
25.1.5 假故障:spurious_kernel_fault
// arch/x86/mm/fault.c:959-1030(注释与要点)
* Handle a spurious fault caused by a stale TLB entry.
* ... reserved bit (R = 1) faults are never spurious.
static int spurious_kernel_fault(unsigned long error_code, unsigned long address) /* :980 */
一类特殊故障必须先识别后忽略:TLB 里残留旧权限的表项(如页刚被 set_memory_ro 改只读但某核 TLB 未刷新)会让 CPU 报"权限拒绝",而页表实际是合法的。入口遍历页表逐级核对实际 PTE——权限位与页表一致则吞掉故障(刷新 TLB 语义已隐含完成),不一致才继续报错。这是 19.5 节"刷新是排雷"的镜像:偶尔雷是自己响的。X86_PF_RSVD 与写保护(R=1)类故障永不假(注释 :969),豁免清单排除了误吞真问题的可能。
25.1.6 从异常到信号
用户态非法访问的终点是信号(14 章):bad_area_nosemaphore(fault.c:827-830)按"有无 VMA"给出 SEGV_MAPERR(地址根本没映射)或 SEGV_ACCERR(映射了但权限不符)——这是调试段错误的第一个线索。mm_fault_error 把供货失败(VM_FAULT_OOM/SIGBUS)接进对应通道(22.4.6 节契约)。内核态的坏指针走 page_fault_oops/no_context,唯一生路是 EX_TABLE(extable)——copy_from_user 等函数登记的"这地址合法失败"修复点,25.3 节 COW 中 copy_from_user 失败重试链依赖同一机制。
小结
x86 #PF 入口是一串精心排序的过滤器:CR2/错误码取信息,KVM 异步缺页拦截,内核/用户按地址分流,然后 do_user_addr_fault 依次执行"内核态取指用户页→保留位→SMAP→上下文合法性→kfence→VMA 查找→权限预检"的七道闸,任何一道不过都直接终结(Oops 或 SIGSEGV),全部通过才进入 22.4 节的通用矩阵。spurious fault 复核防止陈旧 TLB 的假警报,信号路径按 MAPERR/ACCERR/SIGBUS/OOM 四态收尾。下一节深入矩阵中最大的两个供货分支——匿名页与文件页的策略全景。
25.2 匿名页与文件页的分配策略
22.4 节给出了缺页矩阵的骨架;本节把两个最大分支——do_anonymous_page() 与 do_fault()(文件/设备路径)——的策略细节补全:匿名页从零页起步的渐进承诺、分配时的 NUMA 与 movable 约束,文件页从页缓存到预读的供货链,以及两者在记账上的分野。
25.2.1 匿名页:从零页到私有的渐进承诺
匿名页供货的第一原则是不提前付费:
匿名页的三段递进 (以一段 mmap 匿名区为例):
[1] 首次读触碰:
映射共享 zero page (全零页帧, 全系统一份)
pte 只读、无 swap 后备 → 零内存成本、零 TLB 之外的副作用
[2] 首次写 (该地址):
zero page 是共享只读 → COW 语义 (25.3 节)
do_anonymous_page 真分配: vma_alloc_zeroed_movable_folio
(movable: 可被规整/热插拔迁移, 18.5 节)
[3] 该页生命周期:
进 lruvec 匿名链 (24.1 节) → 冷后被换出 swap (24.3 节)
→ 再次触碰走 do_swap_page 换回 (pte 的 swap 槽形态)
// mm/memory.c:5230(要点, 22.4.4 节引文的策略面补充)
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
...
/* NUMA 策略页: mpol_misplaced 判定后改道指定节点
(内存策略: bind/preferred/interleave, 11.7 节关联) */
...
folio = vma_alloc_zeroed_movable_folio(vmf->vma, vmf->address);
if (!folio)
return VM_FAULT_OOM;
...
/* anon_vma 挂链 (22.2.5 节): 为回收/迁移铺 rmap 路 */
...
entry = mk_pte(page, vma->vm_page_prot);
entry = pte_sw_mkyoung(entry);
if (write) {
entry = maybe_mkwrite(pte_mkdirty(entry), vmf->vma);
...
}
...
}
pte_sw_mkyoung() 在映射瞬间预置 accessed 位——新页不该在下轮回收扫描(24.1 节)中被当老页冤杀。maybe_mkwrite 的分支(写路径一次置 dirty+可写)防"分配后再触发一次软缺页补标志"。
零页的三重收益 (为什么所有系统都坚持这一步):
[1] 内存: 100 个进程各 mmap 10MB 但只读前 1KB
→ 实际消耗 = 1 个 4KB 页 (全部映射同一 zero page)
[2] 缺页: 读缺页 = 建一条 pte (无分配、无清零)
→ fork 后立即读遍地址空间的探查行为零成本
[3] COW 友好: 首写 = 25.3 节标准 COW (复制零页 =
分配清零页, 代码路径与 fork COW 完全复用)
25.2.2 文件页:页缓存中心的供货
文件映射的缺页不分配新内存,而是引用页缓存(36 章的主角):
// mm/memory.c:5812-5842(要点)
static vm_fault_t do_read_fault(...) /* 读故障 */
{ ret = __do_fault(vmf); /* → vm_ops->fault → filemap_fault */ ... }
static vm_fault_t do_cow_fault(...) /* 私有写故障 */
{ ...
ret = __do_fault(vmf); /* 先取文件页进缓存 */
... /* 再为私有副本分配匿名页 */
... /* copy: 文件页 → 私有页 (COW) */
...
}
// __do_fault (:5350, 22.4.5 节) 分发 vm_ops->fault
filemap_fault()(mm/filemap.c)的策略面:
| 策略点 | 行为 | 控制旋钮 |
|---|---|---|
| 查缓存 | XArray 按索引查 folio | — |
| 未命中 | 发起同步读(阻塞) | GFP/IO 上下文 |
| 预读 | 顺序窗口扩张(默认 128KB 起步倍增) | VM_SEQ_READ 放大 / VM_RAND_READ 关闭(mm.h:422-423) |
| 共享映射写 | 页标脏,回写落盘(36.3 节) | msync/fdatasync |
| 私有映射写 | COW 出匿名副本,文件页永只读 | 25.3 节 |
| THP 直供 | 2MB 对齐时整块供货(23.3.2 节) | thp_vma_allowable_orders |
MAP_PRIVATE 文件映射的两段式是理解库加载内存账目的钥匙:进程的"私有写的库数据段"由两部分物理页构成——未写过的页仍指向页缓存共享页帧(几百个进程共享同一份 libpthread 文本与只读数据),写过一次的页变成进程私有匿名页。RSS 的"共享/私有"拆分(/proc/pid/smaps 的 Shared_Clean/Private_Dirty)正是这个形态的报表。
MAP_SHARED 匿名并不"无中生有":内核用 tmpfs(shmem)充当后备文件——vm_file 指向 tmpfs inode,页在文件页缓存里天然跨进程共享。这也是它在 overcommit 记账里按"文件页"对待的原因。
25.2.3 设备/特殊后备的 fault
vm_ops->fault 的多态(22.3.4 节)让文件路径之外的映射共用同一缺页矩阵:
后备实现 fault 函数 供货内容
──────────────────────────────────────────────────────────
普通文件 filemap_fault 页缓存 folio
tmpfs (shmem) shmem_fault tmpfs 页 (MAP_SHARED 匿名的幕后)
设备 DAX dax_iomap_fault 直接物理映射 (无页缓存)
DMA-buf/IO 自定义 vm_ops remap 的设备内存
userfaultfd handle_userfault 挂起等用户态处理器 (27 章关联)
userfaultfd 的存在改变了缺页的语义边界:fault 可以不返回内核供货,而是挂起触发进程、通知用户态监控进程做"页"的决策(迁移延迟、去重、加密解密)——缺页异常因此成为用户态可控的同步点。
25.2.4 记账分野与 NUMA 策略
记账分野 (12.4 节 cgroup / 18 章 totalram 的中间层):
匿名页 文件页
可换出 swap/zswap 丢弃+重读 (有后备)
overcommit VM_ACCOUNT 计入 不计 (有后备=天然可回收)
memcg 记账 memory.current 匿名桶 文件桶 (与页缓存共享)
kswapd 压力下 依赖 swappiness 配比 优先丢弃 (零 IO 或一次回写)
NUMA 策略点:
do_anonymous_page 内 mpol 判定 → 页落在策略节点
文件页落在首次读的节点 (自然聚集)
NUMA balancing (11.7 节) 可在缺页路径把
"页在远端 + 进程倾向本端" 转为迁移触发
overcommit 的分野解释了一个常见现象:malloc 大块匿名内存可以"成功"到远超物理内存(VM_ACCOUNT 只是记账不预扣,overcommit 策略下放行),而同样大小的 MAP_SHARED 文件映射受文件大小硬约束——前者的兑现在缺页路径,后者在文件系统。
25.2.5 swap 槽编码与 do_swap_page:供货的另一面
匿名页"冷后被换出、触碰时换回"的闭环缺了换回一半。回收侧(24.3.3 节)把 swap 槽号编进 pte(19.1.4 节"换出槽"形态),触碰时 do_swap_page() 接手:
do_swap_page 的流程:
1. 从 pte 解出 swap 槽号 (swp_entry_t)
2. swap 缓存查页: 他人已换回? → 直接共享映射 (免 IO)
3. 未命中: 分配页, 发起 swap-in IO (可读回预读邻槽 —
swap 顺序性通常好于文件随机 IO, 预读收益更高)
4. IO 完成: pte 重建 (从 swap 槽值还原权限位)
exclusive 位决定是否恢复 PageAnonExclusive (25.3.3 节)
5. 槽释放: 引用归零后 swap 槽归还 (24 章 swap 计数)
zswap 路径: 换出时先压缩进内存池, 槽标记 zswap 后备;
do_swap_page 先查 zswap 池, 命中则解压 — 零磁盘 IO
(以 CPU 换 IO 的典型取舍, 内存尚有余压但 IO 已是瓶颈)
swap-in 预读(swapin_readahead)按"swap 槽号邻近"聚类——回收按 LRU 摘取时天然带时间局部性,换回时邻近槽大概率同批被触。/proc/vmstat 的 pswpin/pswpout 与 zswpin/zswpout 分别是两条通道的流量计。
小结
匿名页与文件页的供货策略共享"渐进承诺"哲学但兑现路径不同:匿名页从共享零页到 movable 真页到 swap 往返,每步只在必要时付费,NUMA 策略与 accessed 预置在分配点一并完成,换回侧以 swap 缓存与槽号预读把 IO 摊薄、zswap 以压缩换免盘;文件页以页缓存为中心,预读窗口受访问模式提示调控,私有/共享在 pte 预埋处分岔,特殊后备(tmpfs/DAX/设备/userfaultfd)经 vm_ops 多态接入同一矩阵。两者的记账分野(VM_ACCOUNT、memcg 桶、回收优先级)构成内存管理的政策层。下一节进入矩阵中最精细的分支——COW。
25.3 Copy-on-Write 缺页处理
COW 是内核用得最狠的延迟优化:fork 不复制任何页、mmap 只读预埋、零页起步——所有"写"都推迟到无法再推迟的那一刻。代价是写缺页路径必须精确回答"这个写到底破坏了谁的共享":答错要么泄露数据(该复制没复制)、要么浪费内存(不该复制却复制)。本节拆解 do_wp_page() 的判定链与 PageAnonExclusive 优化。
25.3.1 为什么 COW 预埋是安全的
预埋的三处只读 (谁在等 COW):
[1] fork: 父子全部可写 pte 同时降为只读 (复制的页表改 pte)
18.4.2 节: 复制时 _mapcount++ — 计数先行是安全前提
[2] MAP_PRIVATE 文件映射: 建立即只读 (25.2.2 节)
[3] 零页/共享 zero page: 天然只读 (25.2.1 节)
硬件的角色: pte RW=0 时写触发 #PF (X86_PF_WRITE|X86_PF_PROT,
19.2.2 节位语义) — "写只读页"不是错误而是信号
fork 的页表复制全景 (为什么启动零拷贝):
父进程页表 子进程页表 (fork 后)
pte A: RW=1, 页帧 X ──→ pte A: RW=0, 页帧 X (同一物理页!)
pte B: RW=1, 页帧 Y ──→ pte B: RW=0, 页帧 Y
pte C: RW=0(本来只读) ──→ pte C: RW=0 (无需改)
...
复制 N 页表项的成本 ≈ memcpy 页表页
数据页: 零拷贝 — 全部"欠着", 等写时再算账
(1GB 进程 fork: 复制 ~2MB 页表 vs 拷 1GB 数据)
安全前提只有一条:任一时刻,"共享的只读副本数"必须精确可知。fork 路径以 _mapcount 计数承担(18.4.2 节的顺序约束:先增计数后复制 pte,乱序会漏复制);PageAnonExclusive 优化(25.3.3 节)则以独占标记替代计数——两条路殊途同归于"可证明"。
25.3.2 do_wp_page:判定链
// mm/memory.c:4162(签名与分支结构)
static vm_fault_t do_wp_page(struct vm_fault *vmf)
/* 经过 handle_pte_fault (:5095) 的判定前提:
pte 存在、无写权限、FAULT_FLAG_WRITE */
分支结构 (自上而下, 每层都是"能不复制就不复制"):
[A] vm_ops->page_mkwrite 存在? (文件/设备后备)
→ 交后备自查: 页还在吗、可以写了吗 (网络文件协商)
后备放行 → pte 提权即可, 零复制
[B] 页是匿名 + 独占 (PageAnonExclusive)?
→ 无共享者! 直接 pte 提权 (wp_page_reuse, :5024 区)
覆盖场景: fork 后子进程退出, 父进程又独占了
[C] 需要复制的路径 → wp_page_copy:
1. 分配新匿名 movable 页 (25.2.1 节同款)
2. cow_user_page: copy_from_user_page 拷贝旧内容
(用户页可被并发改 → 拷贝失败则 VM_FAULT_RETRY)
3. 原页若是 zero page / 文件页:
零页不用拷 (目标页本就清零)
文件页: 这是 MAP_PRIVATE 首写, 拷文件内容
4. pte 切换: 新页可写、_mapcount 处理、TLB (19.5 节)
5. 旧页 put: _mapcount-- 后仍 >0 → 父/子还有一份
do_wp_page 三分支的判定流程图:
写缺页 (#PF: WRITE|PROT)
│
├─ 文件后备? ──是──> page_mkwrite 钩子 ──放行──> pte 提权
│ └─拒绝→ SIGBUS
├─ PageAnonExclusive? ──是──> wp_page_reuse
│ (pte RW=1, 零复制!)
└─ 否 (真共享) ──> wp_page_copy
分配新页 → 拷贝旧内容 → pte 切新页(可写)
→ 旧页 put (mapcount--)
→ TLB flush → 完成
成本阶梯: 提权(0.2μs) << 复制(3μs+) —
判定链的排序就是"最便宜的先查"
[B] 分支是 7.0 的性能关键:传统实现每个 fork 后的写都要查计数、可能复制;PageAnonExclusive 把"这页其实没有其他映射者"编码成页标志,fork/退出/迁移在各临界点维护它——独占性由不变式维护而非每次检查推导,写缺页退化成一次 pte 提权。
25.3.3 PageAnonExclusive 的维护协议
标志的转移矩阵:
事件 对 PageAnonExclusive 的操作
───────────────────────────────────────────────────────
匿名页缺页分配 (写) 置位 (新页天然独占)
fork 复制 pte 父页清位 (共享开始), 子页新页或清位
进程退出 unmap 最后一个映射 置位 (回归独占)
页被 GUP 长期 pin 必须先清位/迁移 (18.5 节 pin 阻迁移同源)
swap 换出再换回 从 pte 软位恢复 (swap 编码携带)
页迁移/规整 目标页继承判定重做
误清位的代价: 多一次无谓复制 (性能)
误置位的代价: 两进程看到彼此的写 (安全漏洞!) — 所以
清位的路径宁多勿漏, 置位必须可证明独占
一个 fork-exec 生命的标志轨迹 (shell 启动 ls):
bash fork ──> 子 exec ──> 子退出 ──> bash 后续写
页 X(代码/数据) 的 PageAnonExclusive:
置位(bash 分配时) ──> fork: 父清位, 子页清位
──> exec: 子全部映射拆掉
──> 子退出: 页的映射只剩父
──> 父再写: [B] 分支命中! 零复制提权
(旧内核此处要走计数检查+可能复制 —
fork+exec 型负载的写延迟在 7.x 显著改善)
这种"乐观独占"设计是 COW 路径对 fork+exec 型负载(shell、容器、fork server)的关键优化:fork 的瞬间子进程几乎全是共享只读,exec 拆掉自己的映射后父进程对"曾经共享"的页逐个回归独占——后续写零复制。
25.3.4 与 KSM/THP 的交互
KSM (同页合并) 写缺页:
合并页写保护 → 写触发 COW → 新私有页 (被合并副本继续共享)
wp_page_copy 复制的是"合并组内容"
THP 大页的 COW:
2MB 块项写保护 → 先分裂 (23.3.4 节) → 再按 4KB COW
一次写 = 分裂 512 项 + 复制 1 页 (fork 大内存进程首写尖刺的来源)
PageAnonExclusive 的 PMD 级对应 (PM_D_anon_exclusive 语义)
让 fork 后未真正共享的 THP 免于分裂
GUP pin 的死结:
长期 pin 的页不可迁移 (18.5 节) 也必须先解 pin 才能 COW
wp_page_copy 前 try_to_unmap 失败 → VM_FAULT_RETRY
THP+COW 的组合揭示了"fork 尖刺"的机制:大内存进程 fork 后,子进程的写把每个被写的 2MB 块先分裂成 512 个 PTE 再复制一页——观测面是 thp_split_page 计数与写延迟的毫秒级尖刺。进程退出路径的独占回归([B] 分支)正是对这一尖刺的半程缓解。
25.3.5 copy 时的并发陷阱
wp_page_copy 的拷贝步骤有一处必须直面的竞态:旧页内容可能正被别人并发修改——父进程的另一个线程在同一时刻写"同一份共享页"。内核的答案不是锁(页内容无锁可言),而是协议:
- 拷贝前
clear_soft_dirty/冻结写者:fork 路径已把所有 pte 降只读,写者必然也缺页阻塞——共享期无并发写; cow_user_page拷贝失败(目标用户地址不可访问)→VM_FAULT_RETRY重来;- pte 原子替换 +
ptep_clear_flush_notify(TLB + notifier)→ 切换瞬间无撕裂可见。
时序正确性论证:
t0: fork 完成态: 父 pte(RW=0) + 子 pte(RW=0), 页帧 X
— 任何一方写都会缺页, "共享期"实际是冻结期
t1: 父写 → do_wp_page:
(子 pte 还指着 X, 但子此刻不可能在写 X —
它写 X 也会缺页阻塞!)
t2: 父拿新页 Y, 拷贝 X→Y (此刻 X 确定无人写)
→ 父 pte = RW=1@Y; 子 pte 不变 (RW=0@X)
t3: 子写 → 子自己触发 COW → 子拿 Z...
"先全员只读、后择一复制"的全局时序
就是 COW 的正确性论证: 复制发生时旧页
已处于"事实冻结"态
25.3.6 观测与调试:COW 在哪里发生
观测面:
/proc/vmstat: page_fork (fork 复制的页数), cow 相关计数
perf trace -e page_fault_* 缺页事件流
perf record -e exceptions:page_fault 按地址聚合
bpftrace: tracepoint exceptions:page_fault_user
按 error code 过滤 PF_PROT|PF_WRITE 即 COW 流量
fork 尖刺的调试套路:
1. 延迟尖刺出现时查 smaps_rollout 的共享/私有页分布变化
2. thp_split_page 计数与尖刺对刻 (25.3.4 节机制)
3. PageAnonExclusive 回归验证: fork+exec 型负载
升级 7.x 内核后私有写缺页应显著下降
调试"写被吞"类问题(写后读不到自己的值)时,先确认是否踩进 MAP_SHARED 误用或 mprotect 天花板(22.2.1 节 VM_MAYWRITE 限制)——这两种情况的写缺页会以 SIGSEGV 而非 COW 收场,与"复制了但没生效"的表象易混淆。
小结
COW 缺页的判定链是一份"能否不复制"的排除表:文件后备交 page_mkwrite 自查、独占页(PageAnonExclusive)直接提权、其余才走 wp_page_copy 的分配-拷贝-切换-放链流程。独占标志以"fork 清位/退出置位/换出携带"的转移协议把常见路径(fork 后独占写)从计数推导降为标志检查,误置位的安全代价决定了它"清位宁多、置位须证"的不对称纪律。KSM/THP/GUP 的交互把 COW 推到大页分裂与 pin 迁移的边界,"先全员只读后择一复制"的时序协议则是全部变体的正确性根基。
25.4 ARM64 与 RISC-V 缺页异常路径对比
三大架构的缺页入口最终都汇入同一个 handle_mm_fault(),但入口翻译层的形态各不相同:信息寄存器、错误编码、内核态按需同步的机制、特殊故障(SEA/GCS/vmalloc)的处理都由硬件哲学决定。本节以 x86(25.1 节)为参照系,逐项对照 ARM64 与 RISC-V。
25.4.1 信息载体的三套编码
x86_64 ARM64 RISC-V
故障地址 CR2 (锁定寄存器) FAR_EL1 (far) stval (regs->badaddr)
异常性质 #PF 错误码 (32 位) ESR_ELx (32 位, scause (64 位码)
位掩码 X86_PF_* EC+FSC 组合编码) regs->cause
访问类型 WRITE/INSTR 位 WnR/Inst 位 + abort W/X 位编码进 scause
特权态 USER 位 EL0/EL1 判定 user_mode(regs)
ARM64 的 ESR 是三者中最结构化的:异常类别(EC,Data Abort/Instruction Abort)+ 故障状态码(FSC,esr.h:115-122 的 ESR_ELx_FSC_*——FSC_FAULT=0x04 翻译故障、FSC_ACCESS=0x08 访问标志故障、权限位段分层级信息)。RISC-V 的 scause 反其道行之:一个整数区分布精确类别,stval 补地址——极简哲学贯穿到异常层(19.4.2 节 PTE 的极简主义同源)。
25.4.2 ARM64 入口:do_page_fault
// arch/arm64/mm/fault.c:556-640(节选)
static int __kprobes do_page_fault(unsigned long far, unsigned long esr,
struct pt_regs *regs)
{
...
unsigned long addr = untagged_addr(far); /* :566 MTE TBI 剥标签 */
...
if (user_mode(regs))
mm_flags |= FAULT_FLAG_USER;
/*
* vm_flags tells us what bits we must have in vma->vm_flags
* for the fault to be benign...
*/
if (is_el0_instruction_abort(esr)) { /* :583 */
/* It was exec fault */
vm_flags = VM_EXEC;
mm_flags |= FAULT_FLAG_INSTRUCTION;
} else if (is_gcs_fault(esr)) { /* :588 */
/*
* The GCS permission on a page implies both read and
* write so always handle any GCS fault as a write fault,
* we need to trigger CoW even for GCS reads.
*/
vm_flags = VM_WRITE;
mm_flags |= FAULT_FLAG_WRITE;
} else if (is_write_abort(esr)) { /* :598 */
vm_flags = VM_WRITE;
mm_flags |= FAULT_FLAG_WRITE;
} else {
/* It was read fault */
vm_flags = VM_READ;
/* Write implies read */
vm_flags |= VM_WRITE;
/* If EPAN is absent then exec implies read */
if (!alternative_has_cap_unlikely(ARM64_HAS_EPAN))
vm_flags |= VM_EXEC;
}
if (is_ttbr0_addr(addr) && is_el1_permission_fault(addr, esr, regs)) { /* :608 */
...
}
四项 ARM64 特色:
[1] MTE 标签剥离(untagged_addr,:566):ARM64 的 TBI/MTE 允许指针携带高位标签,故障地址须剥掉标签才能查 VMA——x86 LAM 模式同样机制但默认关。
[2] GCS 故障的 COW 化(:588-597 注释全文):GCS(Guarded Control Stack,影子栈,1.6 节加固特性)的读故障按写处理——影子栈页的分配/复制走 COW 语义。这是"读故障也可能是供货信号"的罕见案例,注释解释了原因:GCS 权限同时含读写,独占性判定必须走写路径。
[3] EPAN 缺失时的 exec 隐含读(:605-607):无 EPAN 硬件时"执行即隐含读"的权限暗示必须显式并入判定——硬件能力差异上浮到入口代码。
[4] TTBR0 归属 + EL1 权限故障的组合判定(:608):x86 用"地址空间+错误码 USER 位"判归属,ARM64 用"地址落在 TTBR0 区+异常来自 EL1"交叉验证——25.1.3 节"按地址分流"原则的 ARM64 变体(其 TTBR0/TTBR1 双基址结构天然提供了地址区间的判定基础,19.3.2 节)。
翻译故障的分层报告:do_translation_fault()(fault.c:786)按 FSC 层级位区分 0-3 级翻译故障,fault_info 表(:866-869)把"level N translation fault"映射到 SEGV_MAPERR——层级信息帮助定位是哪级页表缺项(用户程序只关心 SIGSEGV,内核调试受益)。do_sea()(:814)处理 SError(外部内存错误),是 ARM64 独有的硬件故障通道。
25.4.3 RISC-V 入口:handle_page_fault
// arch/riscv/mm/fault.c:278-320(节选)
void handle_page_fault(struct pt_regs *regs)
{
...
cause = regs->cause; /* scause */
addr = regs->badaddr; /* stval */
...
if ((!IS_ENABLED(CONFIG_MMU) || !IS_ENABLED(CONFIG_64BIT)) &&
unlikely(addr >= VMALLOC_START && addr < VMALLOC_END)) {
vmalloc_fault(regs, code, addr); /* :312-317 */
return;
}
/* Enable interrupts if they were enabled in the parent context. */
if (!regs_irqs_disabled(regs))
local_irq_enable();
...
}
// arch/riscv/mm/fault.c:380-402(主干, 与 22.4/25.1 的同构点)
retry:
vma = lock_mm_and_find_vma(mm, addr, regs); /* :380 */
if (unlikely(!vma)) {
tsk->thread.bad_cause = cause;
bad_area_nosemaphore(regs, code, addr); /* :144-152 */
return;
}
...
if (unlikely(access_error(cause, vma))) { /* :396 */
tsk->thread.bad_cause = cause;
bad_area(regs, mm, SEGV_ACCERR, addr);
return;
}
fault = handle_mm_fault(vma, addr, flags, regs); /* :404 */
...
三处 RISC-V 特色:
[1] thread.bad_cause 的传递:RISC-V 把 scause 存进线程结构再进通用流程——后续信号处理(mm_fault_error,:117-136 把 VM_FAULT_SIGBUS/SIGSEGV 映射到 do_trap(SIGBUS, BUS_ADRERR) 等)需要知道故障原因,而 scause 寄存器此刻可能已被覆盖。x86/ARM64 的错误码走 pt_regs 传递,无此需求。
[2] vmalloc 按需同步的条件化(:312-317):内核区故障的惰性同步(x86 是 do_kern_addr_fault 的 vmalloc 分支)在 RISC-V 仅限非 64 位或无 MMU 配置走软件路径——64 位配置下内核页表全局共享(init_mm.pgd 直接挂 satp,19.4.3 节),不存在 x86 那种"各进程内核半区独立拷贝、需跨 CPU 补页"的问题。这是"共享 PGD vs 双基址/独立拷贝"设计差异在缺页路径的直接回声。
[3] lock_mm_and_find_vma() 的共享化(:380):三架构在 7.0 都收敛到这一个 mm/memory.c 的共享辅助(x86 同样调用)——per-VMA 锁快路径 + mmap_lock 回退的协议(22.2.4 节)只在通用层实现一次。架构差异被压到入口翻译层,主干零重复是三架构代码演进的明确方向。
25.4.4 全景对照表
| 维度 | x86_64 | ARM64 | RISC-V |
|---|---|---|---|
| 异常名 | #PF (vec 14) | Data/Instruction Abort | Load/Store/Inst page fault |
| 地址寄存器 | CR2(锁定) | FAR_ELx | stval |
| 性质编码 | 错误码位掩码 | ESR EC+FSC | scause 整数 |
| 特殊故障 | KVM async PF / FRED | SEA(SError) / GCS / MTE 剥标签 | (Svpbmt 相关较少) |
| 内核区补页 | vmalloc 惰性同步 | 同(内核半区独立) | 64 位无需(共享 PGD) |
| VMA 查找 | lock_mm_and_find_vma(共享) | 同左 | 同左 |
| 信号映射 | SEGV_MAPERR/ACCERR | fault_info 表(含层级) | bad_cause 传递 + do_trap |
| 独占优化 | PageAnonExclusive(共享) | 同左 + GCS 特例 | 同左 |
25.4.5 移植视角:新架构的缺页入口清单
把三架构对照推到极端就是一份"移植缺页入口"的检查清单——任何新架构(或虚拟化后端)实现 fault 都绕不开这些条目:
入口实现清单 (按 do_page_fault 的结构序):
[1] 故障地址/原因寄存器读入 pt_regs (stval/CR2/FAR 对应物)
[2] 用户/内核态判别 (spsr/CS/sscs)
[3] 内核区 fault 的按需同步 (共享 PGD 架构可豁免)
[4] 原子上下文拒绝 (faulthandler_disabled)
[5] 地址清洗 (MTE/TBI/LAM 标签剥离; 分页模式位)
[6] 访问类型翻译 → VM_READ/WRITE/EXEC + FAULT_FLAG_*
[7] lock_mm_and_find_vma (通用, 直接复用)
[8] access_error: 架构权限位 → VMA 权限判定
[9] handle_mm_fault (通用) + RETRY 语义
[10] mm_fault_error → SIGBUS/SIGSEGV/OOM 挂钩
[11] thread 结构的 cause 备存 (信号路径需要)
[12] 特殊故障旁路: 外部错误(SEA/总线)/影子栈/KVM 异步PF
三架构在 [7]-[10] 已完全共享(lock_mm_and_find_vma 到 mm_fault_error 的通用化是数年重构的成果),剩余工作集中在 [1]-[6] 与 [12]——架构特异性被系统性地压到入口的十行翻译代码内。这既是移植工作量的压缩,也是审计友好性的提升:任何架构的 fault 语义分歧,diff 只会出现在同一个函数头部。
小结
三架构缺页入口的差异集中在翻译层:信息载体(CR2/错误码 vs FAR/ESR vs stval/scause)反映各自的硬件哲学——x86 的位掩码精细、ARM64 的结构化编码自带层级信息、RISC-V 的极简整数配 thread.bad_cause 补传。入口的架构特例——MTE 剥标签、GCS 的 COW 化读故障、SEA 外部错误、RISC-V 免除的 vmalloc 同步——都是各自硬件能力/结构的必然推论;而 VMA 查找、通用矩阵、信号收尾全部收敛到共享代码。缺页异常至此完整闭环:从 18 章的页帧供给、19 章的翻译机制、22 章的地址空间结构,到本章的异常入口——内核内存管理的主线宣告贯通。