背景

CPU 访问内存时,靠页表把虚拟地址翻译成物理地址。页表项里除了物理页号 PFN,还记录着一批状态位,dirty bit 就是其中之一:页面只要被写入,硬件便自动把它置上,告诉内核「这页的数据被改过了」。内核许多决策都依赖这个位,回收内存时判断数据要不要保留,文件页判断要不要回写磁盘,都先看它。dirty bit 一旦丢失,内核就会误以为这页从没被改过。

x86 的页表分多级,PTE 一级管理 4KB 的普通页,PMD 一级可以直接映射 2MB 的 THP(Transparent Huge Page),dirty bit 在 PMD 项里同样存在。

mprotect()、NUMA hinting、userfaultfd 写保护这类操作只调整权限,不改变映射本身,它们统一走 pte_modify()、pmd_modify()、pud_modify() 这一族函数。做法分两步:先把旧页表项的值与一张掩码相与,PFN 和 accessed bit、dirty bit 这类硬件维护的状态位被原样留下;再把 newprot 里的新权限位并进来。PTE 一级用 _PAGE_CHG_MASK,PMD 与 PUD 一级用 _HPAGE_CHG_MASK,两张掩码都经 _PAGE_DIRTY_BITS 涵盖了 _PAGE_DIRTY 与 _PAGE_SAVED_DIRTY。

掩码为什么必须带上 dirty bit,要从 shadow stack 说起。shadow stack 是 x86 保护返回地址的硬件安全特性,它把 Write=0、Dirty=1 的页项编码为 shadow stack 内存。普通内存一旦只读,就不允许继续用硬件 Dirty 位表达脏状态,否则会被误认为 shadow stack。内核的解法是多借一个软件位 _PAGE_SAVED_DIRTY:写保护脏页时把 Dirty 转移到 SavedDirty,恢复可写时再转移回来。转移由 pmd_mksaveddirty() 与 pmd_clear_saveddirty() 完成,而且只在旧值可写时才做 Dirty 到 SavedDirty 的转移,避免动到真正的 shadow stack 页项。

dirty 状态在两个编码之间的转移
┌───────────────────┐  写保护时转移,恢复写时转回  ┌────────────────────┐
│                   ├─────────────────────────────▶│                    │
│   可写,Dirty=1   │                              │ 只读,SavedDirty=1 │
│                   │◀─────────────────────────────┤                    │
└───────────────────┘                              └────────────────────┘

这套转移有个前提:掩码得先把脏状态留下来,否则后面无脏可转。shadow stack 支持引入 SavedDirty 转移逻辑时,唯独 pmd_modify() 的掩码被改成剔除 _PAGE_DIRTY,pte_modify() 与 pud_modify() 的掩码则始终涵盖 dirty bit,三者的对称性从此被打破。PMD 一级的脏状态从此失去保障,问题正源于此。

问题

  • pmd_modify() 的掩码剔除 _PAGE_DIRTY,可写脏 PMD 改权限后丢脏
  • mprotect() 与 NUMA hinting 都走 pmd_modify()
  • dirty bit 丢失后 lazyfree folio 被判干净,回收时数据被静默丢弃
  • PMD 映射的 file THP 丢脏后,重写的数据永远等不到回写

以 PMD 映射的 anonymous THP 为例,看看数据是怎么一步步丢掉的。MADV_FREE 的语义是:这些数据暂时用不到,内存紧张时可以回收,但只要之后又写过,就必须留住。内核把这批页面所在的 folio 标记为 lazyfree,之后正是靠 dirty bit 判断「标记之后又写过没有」。MADV_FREE 只清脏、不写保护,页面保持可写,之后的再次写入由硬件重新置上 dirty bit,这正是内核识别「又写过」的依据。

anonymous THP 经 MADV_FREE 后重写再改权限的丢失链路
┌─────────────────────────────────────┐
│ memset 写入数据,硬件置上 dirty bit │
└──────────────────┬──────────────────┘
                   ▼
┌─────────────────────────────────────┐
│ MADV_FREE 清脏,folio 标记 lazyfree │
└──────────────────┬──────────────────┘
                   ▼
┌─────────────────────────────────────┐
│ 再次 memset,硬件重新置上 dirty bit │
└──────────────────┬──────────────────┘
                   ▼
┌─────────────────────────────────────┐
│  mprotect 改权限,dirty bit 被丢弃  │
└──────────────────┬──────────────────┘
                   ▼
┌─────────────────────────────────────┐
│  回收时误判 folio 干净,数据被丢弃  │
└─────────────────────────────────────┘

问题出在第四步,mprotect() 经过 pmd_modify(),重新置上的 dirty bit 被掩码剔掉。之后内存压力到来,回收路径检查这个 lazyfree folio,PMD 上没有 dirty,folio 上也没有 dirty,__discard_anon_folio_pmd_locked() 于是直接释放它。数据明明在 MADV_FREE 之后被重写过,再次读到的却是缺页异常补上的全零页,数据就这样静默丢失。

NUMA hinting 是内核观测跨节点内存访问的机制,靠反复调整页表权限工作,连 mprotect() 都不需要:do_huge_pmd_numa_page() 恢复 PMD 时同样经过 pmd_modify(),单独即可踩中。PMD 映射的 file THP 也走同一条路,丢脏之后,重写的数据无法被回写。

方案

把 _PAGE_DIRTY 加回 pmd_modify() 的掩码,与 pte_modify()、pud_modify() 对齐,改动只有一行:

-val &= (_HPAGE_CHG_MASK & ~_PAGE_DIRTY);
+val &= _HPAGE_CHG_MASK;

用大白话说,就是让掩码重新把脏状态留下来。这一步补上后,原本就存在的转移逻辑便能正常工作:旧值可写时,pmd_mksaveddirty() 按新权限决定是否把 Dirty 搬进 SavedDirty;旧值只读时,pmd_clear_saveddirty() 决定是否搬回来。shadow stack 的编码规则不被破坏,脏状态也不再凭空消失。

掩码补回后的 pmd_modify() 流程
┌───────────────────────────────────┐
│ 旧值与掩码相与,dirty bit 被留下  │
└─────────────────┬─────────────────┘
                  ▼
┌───────────────────────────────────┐
│     从 newprot 并入新的权限位     │
└─────────────────┬─────────────────┘
                  ▼
┌───────────────────────────────────┐
│ 可写则存进 SavedDirty,只读则取回 │
└─────────────────┬─────────────────┘
                  ▼
┌───────────────────────────────────┐
│     写回 PMD,脏状态完整保留      │
└───────────────────────────────────┘

两条丢失链路由此断开:回收路径与回写路径看到的都是真实的脏状态,mprotect() 与 NUMA hinting 不会再让数据凭空消失。

收益

作者未提供性能数据,从代码逻辑推断的预期收益:

  • MADV_FREE 后重写的 THP 再经 mprotect() 或 NUMA hinting 不再丢脏位,回收路径不会丢数据
  • PMD 映射的 file THP 权限变更后脏状态保留,重写的数据正常回写