第 3 章 内存管理 — 【上篇】用户态/内核态两侧的内存对象与地址映射

如果说第 2 章回答的是"用户态与内核态之间如何对话",那么第 3 章要回答的则是**“对话双方各自能看管多少内存、虚拟地址与物理地址如何搭桥、页表项与页框号如何在硬件中落地”**。这一章是 ReactOS 源码中最复杂、最庞大、也最考验读者"工程直觉"的部分。

为方便读者消化,本章按内容切分为上、中、下三篇:

  • 上篇(本章):3.1.1 用户空间的管理、3.1.2 物理页面的管理、3.1.3 虚存页面的映射 —— 着重讲"用户态/内核态两侧的内存对象"以及"虚拟地址如何绑定到物理页",是虚拟内存的骨架
  • 中篇:3.1.4 Hyperspace 临时映射、3.1.5 系统空间映射、3.1.6 NtAllocateVirtualMemory()、3.2 页面异常 —— 着重讲"系统空间的特殊用途"和"运行机制",是虚拟内存的肌肉
  • 下篇:3.3 页面换出、3.4 共享映射区、3.5 系统空间缓冲区管理 —— 着重讲"虚拟内存与物理内存的交换、文件映射、内核池",是虚拟内存的工程实现

延续前两章的传统,本章每个小节先给出一张 ASCII 框架图作为"先见森林"的导览,再展开到 ReactOS 源码层面讲解"树木"。

阅读本章前,建议先回顾 [第 1 章 §1.2 用户空间和系统空间](file:///d:/reactos/doc/第1章_概述.md) 和 [第 2 章 §2.6 从内核中发起系统调用](file:///d:/reactos/doc/第2章_系统调用.md) 中关于内核对象与 PreviousMode 的概念,本章会反复用到。


3.1 内存区间的动态分配

3.1.1 内核对用户空间的管理

3.1.1.0 框架图(先见森林)

在展开"VAD 树"细节之前,先用一张图勾勒"用户态 2 GB 虚拟地址空间"在 ReactOS 内部的整体表示。读者可把它当作本节的导航图。

┌────────────────────────────────────────────────────────────────────┐
│       进程 A 的 4 GB 虚拟地址空间(用户态 0~2 GB 部分)              │
│                                                                    │
│  ┌────────────┐ ┌──────────────┐ ┌─────────────┐ ┌────────────┐  │
│  │ EXE 主映像  │ │ .dll 集合     │ │ 堆(ntdll)  │ │ 栈(默认   │  │
│  │ 0x00400000 │ │ 0x10000000   │ │ 0x00100000  │ │ 1 MB)     │  │
│  │ (64 KB 对齐)│ │ (64 KB 对齐) │ │             │ │ 0x00200000 │  │
│  └────────────┘ └──────────────┘ └─────────────┘ └────────────┘  │
│                                                                    │
│  ────────────  自由区段(由 VAD 树管理)────────                    │
│  0x00300000 ~ 0x7FFE0000 = "可分配"区段(VAD 树跟踪)              │
│  0x7FFE0000 = KUSER_SHARED_DATA(用户态可读的内核数据)            │
│                                                                    │
├────────────────────────────────────────────────────────────────────┤
│       VAD 树(AVL 红黑树,按起始地址排序)                          │
│                                                                    │
│  VadRoot ──→ 0x10000000 (DLL 段)                                   │
│              /                          \                          │
│             /                            \                         │
│     0x00400000 (EXE)                0x7FFE0000 (KUSER_SHARED)     │
│             \                            /                        │
│              \                          /                         │
│              0x00100000 (堆)    0x00200000 (栈)                    │
│                                                                    │
│  说明:每个节点是一个 _MMVAD;树的根在 EPROCESS->VadRoot           │
└────────────────────────────────────────────────────────────────────┘

本图核心要点:VAD(Virtual Address Descriptor)是描述"用户态虚拟地址空间使用情况"的核心数据结构。树中每个节点是一个 _MMVAD 结构,对应一段用户态已分配的虚拟地址。树的根挂在每个进程的 EPROCESS->VadRoot 成员上,进程退出时整棵树随 EPROCESS 一起释放。

3.1.1.0.1 设计意图

核心问题:本节要回答"用户态进程如何在 4 GB 虚拟地址空间内分配和管理地址区段,以及操作系统如何跟踪这些分配"。

设计哲学:Windows NT/ReactOS 的内存管理遵循"先记录、后映射"的两段式策略。VAD 树是"记录层"——它记录"哪段地址被谁用、用途是什么"。这是一个纯软件结构,CPU 硬件完全不知道 VAD 树的存在。只有当用户态实际访问某个地址时,VAD 树的信息才被用来填写 PTE(页表项),让硬件 MMU 生效。这是"懒映射(lazy mapping)"的核心思想:先记账、后落地。记账永远比落地更快——记账只是在 VAD 树中插入一个节点(O(log N)),落地需要分配物理页、填写 PTE、刷新 TLB(涉及硬件操作,慢得多)。

本节定位:3.1.1 节是第 3 章的开篇。读者在阅读本节后,应能理解"用户态虚拟地址空间被谁、如何、以什么粒度在跟踪"。后续 3.1.2 节讨论物理页(PFN Database),3.1.3 节讨论虚拟页如何绑定到物理页(PTE)——VAD 树是整个虚拟内存管理的"上层骨架"。

3.1.1.1 为什么要管理用户空间

Windows NT 的每个用户态进程都拥有独立的 4 GB 虚拟地址空间(在 x86 32 位下),其中用户态可见的低 2 GB(0x00000000~0x7FFFFFFF)由进程自己掌控。一个进程通常会做以下事情:

  • 加载一个 EXE 主映像(典型地址 0x00400000
  • 加载若干 DLL(kernel32、user32、gdi32、ntdll 等,加载到 64 KB 对齐的随机地址)
  • 申请若干私有堆(malloc、new、C++ 运行时)
  • 创建线程并维护默认 1 MB 的栈
  • 内存映射文件(CreateFileMapping + MapViewOfFile)
  • 直接调用 VirtualAlloc 申请大段虚拟地址

这一系列操作都会让用户态虚拟地址空间"被占用"。操作系统必须用某种数据结构记录"哪段地址被谁用了、用途是什么、保护位是什么",否则缺页异常处理、内存释放、跨进程共享都会变成"大海捞针"。

这就引出了VAD(Virtual Address Descriptor,虚拟地址描述符)

3.1.1.2 VAD 的关键属性

ReactOS 中的 VAD 由 _MMVAD 结构描述,定义在 [ntoskrnl/include/internal/mm.h](file:///d:/reactos/ntoskrnl/include/internal/mm.h) 第 250 行附近。简化后其关键字段如下:

typedef struct _MMVAD {
    MMADDRESS_NODE VadNode;          // 树节点 (StartingVpn / EndingVpn)
    ULONG u1.VadFlags;               // VAD 标志 (Private/Image/Mapped/...)
    PVOID StartingAddress;           // 起始地址(用户态)
    PVOID EndingAddress;             // 结束地址
    union {
        struct {
            ULONG_PTR CommitCharge;  // 该 VAD 的物理页承诺数
            PMM_SECTION_SEGMENT Segment;  // 若是 Section 映射,指向 Segment
        } ...
    } ...;
} MMVAD, *PMMVAD;
  • 起始/结束 VPN(Virtual Page Number):VAD 描述的虚拟地址区段。StartingVpnEndingVpn 是页号(即虚拟地址右移 PAGE_SHIFT),用页号而不是字节地址可以避免 32/64 位长度差异。
  • VadFlags:包括
    • Private:私有内存(malloc、VirtualAlloc),不共享。
    • ImageSection:EXE/DLL 加载形成的映像 section。
    • MappedDataFile:内存映射文件数据。
    • WriteWatch:使用 VirtualAlloc(... MEM_WRITE_WATCH) 标志,需要写监控。
    • CopyOnWrite:写时复制标志。
  • Subsection/PrototypePte:当该 VAD 是 Section 映射时,指向具体哪段 Section 子段和原型 PTE。
  • ControlArea 指针:VAD 所挂载的 Section 对象的控制区。
3.1.1.3 VAD 树(AVL 红黑树)

为什么是树而不是链表? 一个进程通常有 100~10000 个 VAD 节点。VAD 的"查找"操作(MiFindVadByAddress)会在缺页异常处理中被频繁调用,每次都需要回答:“给我一个虚拟地址,它所在 VAD 的属性是什么?”——这要求查找是 O(log N) 而不是 O(N)。

ReactOS 中的 VAD 树直接复用了 RTL 通用库 RtlAvlTree,树的根由 MM_AVL_TABLE 描述(见 [ntoskrnl/mm/ARM3/vadnode.c](file:///d:/reactos/ntoskrnl/mm/ARM3/vadnode.c) 第 20~21 行的 #include <sdk/lib/rtl/avlsupp.c>)。它本质上是一棵自平衡的 AVL 树(注意是 AVL 而不是 RB,红黑树在 NT 早期版本用过,Windows Research Kernel 已切到 AVL)。

/* ARM3/vadnode.c 顶部 */
#include <sdk/lib/rtl/avlsupp.c>

AVL 树的关键 API(在 [ARM3/vadnode.c](file:///d:/reactos/ntoskrnl/mm/ARM3/vadnode.c) 中以 MiInsertNode / MiRemoveNode / MiFindEmptyAddressRangeInTree 等名字出现):

  • MiInsertNode(Table, Node):在 AVL 树中插入新节点。返回时已完成平衡调整。
  • MiRemoveNode(Table, Node):从 AVL 树中删除节点。
  • MiFindNodeOrParent(Table, Address):查找指定地址所属的 VAD 节点(或其父节点)。
  • MiFindEmptyAddressRangeInTree(...):在空闲虚拟地址空间中找一段大小合适的"洞"。

所有这些函数都要求调用者先持有 AddressCreationLock 写锁(参见 [vadnode.c](file:///d:/reactos/ntoskrnl/mm/ARM3/vadnode.c) 第 95~100 行的 MiDbgAssertIsLockedForWrite 断言)。

3.1.1.4 MEMORY_AREA 与 VAD 的过渡

ReactOS 的内存管理经历过两次重大迭代:

  1. 早期版本(“rosmm”):使用 [MEMORY_AREA](file:///d:/reactos/ntoskrnl/include/internal/mm.h#L253-L268) 结构管理用户态内存区段。MEMORY_AREA 的核心是"一种类型(Type)+ 一段虚拟地址区间 + SectionData 子结构",用链表串起来。
  2. ARM3 重构:向 Windows Research Kernel 靠拢,使用 _MMVAD 红黑树/AVL 树管理。

两种机制如何共存?通过 MI_SET_MEMORY_AREA_VADMI_IS_MEMORY_AREA_VAD 这两个宏实现互转(见 [mm.h:270-273](file:///d:/reactos/ntoskrnl/include/internal/mm.h#L270-L273)):

#define MI_SET_MEMORY_AREA_VAD(Vad) do { (Vad)->u.VadFlags.Spare |= 1; } while (0)
#define MI_IS_MEMORY_AREA_VAD(Vad) (((Vad)->u.VadFlags.Spare & 1) != 0)
#define MI_SET_ROSMM_VAD(Vad) do { (Vad)->u.VadFlags.Spare |= 2; } while (0)
#define MI_IS_ROSMM_VAD(Vad) (((Vad)->u.VadFlags.Spare & 2) != 0)

这两个宏利用了 MMVAD_FLAGS::Spare 字段的几个保留位来在 MMVAD 与 MEMORY_AREA 之间打标记。ReactOS 当前同时维护两套:

  • MI_SET_MEMORY_AREA_VAD:把一个 MMVAD 标记为"同时也作为 MEMORY_AREA 暴露给旧代码"。
  • MI_IS_ROSMM_VAD:判断该 VAD 节点是否由 rosmm(ReactOS 老 MM 框架)创建。

对读者的实际意义:当你看到 EPROCESS->VadRoot 树上的节点时,要先判断它属于哪一类。如果是 MI_IS_MEMORY_AREA_VAD 为真,则该节点同时挂在 rosmm 的内存区链表上,需要通过 MmLookupMemoryArea 等老 API 访问;否则就是纯 ARM3 管理的 VAD。

3.1.1.5 VAD 与 Section 的链接

VAD 与 Section 是强耦合的关系:当用户态调用 CreateFileMapping + MapViewOfFile 时,内核会:

  1. 创建一个 Section 对象(SECTION 内核对象,详见 3.4 节)。
  2. 在调用进程的 VAD 树中插入一个新 VAD 节点。
  3. 该 VAD 的 Subsection 指针指向 Section 中的具体子段。
  4. 该 VAD 的 PTE 设为 “prototype PTE 跳转”——即 VAD 关联的 PTE 不直接指向物理页,而是指向 Section 的原型 PTE。

这样多个进程映射同一文件时,它们各自的 VAD 节点最终都指向同一组原型 PTE,从而实现"共享内存"。

3.1.1.6 代码片段

VAD 树节点的定义片段([mm.h:250 附近](file:///d:/reactos/ntoskrnl/include/internal/mm.h#L250)):

#define MA_GetStartingAddress(_MemoryArea) \
    ((_MemoryArea)->VadNode.StartingVpn << PAGE_SHIFT)
#define MA_GetEndingAddress(_MemoryArea) \
    (((_MemoryArea)->VadNode.EndingVpn + 1) << PAGE_SHIFT)

MI_USAGE_* 枚举([mm.h:332-355](file:///d:/reactos/ntoskrnl/include/internal/mm.h#L332-L355))—— 用于标记物理页"被谁占用":

typedef enum _MI_PFN_USAGES {
    MI_USAGE_NOT_SET = 0,
    MI_USAGE_PAGED_POOL,
    MI_USAGE_NONPAGED_POOL,
    MI_USAGE_NONPAGED_POOL_EXPANSION,
    MI_USAGE_KERNEL_STACK,
    MI_USAGE_KERNEL_STACK_EXPANSION,
    MI_USAGE_SYSTEM_PTE,
    MI_USAGE_VAD,                  // VAD 占用的物理页
    MI_USAGE_PEB_TEB,
    MI_USAGE_SECTION,              // Section 占用的物理页
    MI_USAGE_PAGE_TABLE,
    MI_USAGE_PAGE_DIRECTORY,
    MI_USAGE_LEGACY_PAGE_DIRECTORY,
    MI_USAGE_DRIVER_PAGE,
    MI_USAGE_CONTINOUS_ALLOCATION,
    MI_USAGE_MDL,
    ...
} MI_PFN_USAGES;

这些枚举值会被设置到对应 PFN 结构的 u5.Usage 字段中,方便内存诊断工具(!vm、!poolfind)反查"该物理页属于谁"。

3.1.1.7 概念解释
  • VAD(Virtual Address Descriptor,虚拟地址描述符):一个 _MMVAD 结构,记录"用户态某段虚拟地址被谁使用、用途是什么、保护位是什么"。每个用户态已分配区段对应一个 VAD 节点。
  • VAD 树(AVL 树):VAD 不是链表而是按起始地址排序的 AVL 树。树的根在 EPROCESS->VadRoot。查找/插入/删除的复杂度是 O(log N)。ReactOS 当前使用 AVL 而非红黑树,但本质上都是平衡二叉搜索树。
  • Prototype PTE(原型 PTE):当一段虚拟地址是 Section 映射时,进程页表中的 PTE 并不直接指向物理页,而是指向 Section 的"原型 PTE"。这是"文件 → 多进程共享内存"的关键。3.4 节会深入讲解。
  • MEMORY_AREA:ReactOS 早期版本的"内存区"结构(在 ARM3 引入 VAD 之前使用)。它表示"一种类型 + 一段虚拟地址区间",通过 MmCreateMemoryArea 创建。本节解释了它与 VAD 的过渡关系(通过 Spare 标志位互转)。
  • AllocationGranularity(64 KB):用户态分配虚拟地址时的"段对齐"单位。所有 VirtualAlloc 返回的地址都是 64 KB 对齐的。这是为了在 VAD 树中以"粗粒度"表示连续区段。
  • Subsection:Section 内部的一个"子段"——一个 Section 文件太大时按 64 KB 切分,每段一个 Subsection(与 VAD 树中的区间粒度匹配)。Subsection 内部有 SubsectionBase(指向一组 PTE)和磁盘扇区位置(StartingSectorNumberOfFullSectors)。
  • VAD Flag(MMVAD_FLAGS:VAD 节点的属性位集合,含 Private(私有内存)、Image(EXE/DLL 映像)、MappedDataFile(内存映射文件)、WriteWatch(写监控)、CopyOnWrite(写时复制)等。这些标志决定了 PTE 被填写时应该如何设置硬件保护位。
  • AddressCreationLock:进程级写锁,保护 VAD 树的所有修改。任何对 VAD 树的插入、删除、修改都必须持有这把锁。它是 VAD 树并发安全的基石。
  • VadRoot(EPROCESS->VadRoot:每个进程独立的 VAD 树根节点。不同进程的 VAD 树互不干扰——这是"每个进程有独立虚拟地址空间"的数据结构表达。
3.1.1.8 为什么要这样设计

问题 1:为什么用 VAD 而不是线性表?
4 GB 虚拟地址空间上已分配区段数 100~10000 个,链表查找 O(N) 在缺页异常中不可接受。AVL 树的 O(log N) 是工业级系统的标准选择——一次缺页异常的 VAD 查找只需 ~13 次比较(log₂ 10000 ≈ 13)。

问题 2:为什么用 AVL/红黑树而不是 hash 表?
虚拟地址是稀疏的、动态插入删除的:用户随时 VirtualAlloc / VirtualFree,树结构在插入删除的均衡性上有理论保证。hash 表在"动态范围查询"(“给我落在 [a, b] 之间的所有 VAD”)上无能为力——而 Section 解除映射、COW 复制等场景恰好需要范围查询。

问题 3:为什么 ReactOS 同时维护 MEMORY_AREA 与 VAD?
历史包袱。MEMORY_AREA 是早期设计;ARM3 引入 VAD 后向 NT 模型靠拢。两者通过 MI_SET_MEMORY_AREA_VADMI_IS_MEMORY_AREA_VAD 宏互转(见 [mm.h:270-273](file:///d:/reactos/ntoskrnl/include/internal/mm.h#L270-L273))。新代码应使用 VAD;老的 rosmm 代码通过宏兼容,逐步迁移。

问题 4:为什么 64 KB 段对齐?
x86 PTE 是 4 KB,但 Section 在磁盘上是 512 字节扇区;折中选 64 KB(16 页)作为"段",让一个 Subsection 正好可以容纳 16 个 PTE。这是 VAD 树与磁盘 I/O 的"最小区间单位"。额外好处:用户态堆、栈、内存映射文件等"天然按 64 KB 边界对齐",VAD 树中的区间可以直接用"起始 64 KB 对齐"表示,避免"页级粒度"导致的 VAD 节点爆炸。

问题 5:为什么 VAD 树用进程私有树而不是全局共享树?
每个进程的虚拟地址空间是独立的——进程 A 的 0x10000000 与进程 B 的 0x10000000 完全无关。全局树无法表达"每个进程有自己的地址映射"这一语义。进程私有树同时简化了并发控制:同一时刻只有该进程的线程会修改自己的 VAD 树,不需要与其他进程竞争。

问题 6:为什么 VAD 树的锁是"工作集锁 + 地址创建锁"双重锁?
工作集锁(Working Set Lock)保护"工作集大小(MmWorkingSetSize)“和"工作集列表”——这些数据在缺页异常处理和页面换出中频繁访问。地址创建锁(AddressCreationLock)保护"VAD 树结构本身"——插入、删除 VAD 的操作。两者职责不同,分离后可以让"只改工作集不改 VAD 结构"的操作(如缺页异常仅分配新页但不建新 VAD)只持有工作集锁,减少竞争。注意:ReactOS 约定持有这两把锁时必须按"先工作集锁、后地址创建锁"的顺序获取,否则会导致死锁。

3.1.1.9 VAD 树与进程地址空间的生命周期

VAD 树的生命周期与进程完全绑定,这是 Windows NT 内存管理的重要设计:

  • 进程创建:EPROCESS 初始化时,VadRoot 为空树。
  • EXE 加载:PE 加载器为 EXE 的 code/data 段创建 VAD 节点,标记为 ImageSection。
  • DLL 加载:每个 DLL 映射时创建一个 VAD 节点,标记为 MappedDataFile 或 ImageSection。
  • VirtualAlloc:用户态申请内存时创建 Private 类型的 VAD 节点。
  • 栈/堆:ntdll 创建栈和堆时也会通过 VirtualAlloc 创建 VAD 节点。
  • 进程退出:所有 VAD 节点随 EPROCESS 一起被释放,无需单独遍历。

这一设计的核心洞察:VAD 树不是一个"全局的分配表",而是每个进程的"记账本"。进程的生死就是 VAD 树的生死,这让"释放整个进程的地址空间"变得非常高效——不需要逐节点释放,只需释放 EPROCESS 结构本身。

3.1.1.10 VAD 操作的并发控制

VAD 树的所有操作都必须先持有进程的 AddressCreationLock 写锁。这把锁是 ReactOS 内存管理器并发安全的基石。vadnode.c 中的 MiDbgAssertIsLockedForWrite 断言(见 [vadnode.c:79-105](file:///d:/reactos/ntoskrnl/mm/ARM3/vadnode.c#L79-L105))会校验调用者确实持有该锁:

static
VOID
MiDbgAssertIsLockedForWrite(_In_ PMM_AVL_TABLE Table)
{
    if (Table == &MmSectionBasedRoot) {
        ASSERT(MmSectionBasedMutex.Owner == KeGetCurrentThread());
    } else if (Table == &MiRosKernelVadRoot) {
        ASSERT(PsGetCurrentThread()->OwnsSystemWorkingSetExclusive);
        ASSERT(PsIdleProcess->AddressCreationLock.Owner == KeGetCurrentThread());
    } else {
        PEPROCESS Process = CONTAINING_RECORD(Table, EPROCESS, VadRoot);
        ASSERT(Process == PsGetCurrentProcess());
        ASSERT(PsGetCurrentThread()->OwnsProcessWorkingSetExclusive);
        ASSERT(Process->AddressCreationLock.Owner == KeGetCurrentThread());
    }
}

三种 VAD 表对应三种锁

  • MmSectionBasedRoot(全局 Section 树):用 MmSectionBasedMutex 保护。
  • MiRosKernelVadRoot(系统 VAD 树):用系统工作集锁 + 空闲进程的 AddressCreationLock 双重保护。
  • 进程的 EPROCESS->VadRoot:用进程工作集锁 + 进程自身的 AddressCreationLock 保护。

这一设计的关键点:内存管理器对 VAD 树的修改是"大动作"——插入/删除 VAD 会改变 MmWorkingSetSize(进程工作集大小)、可能触发 PTE 调整(如果有物理页已映射)。这些动作需要事务性:要么全部完成,要么全部回滚。锁的范围设计保证了这种事务性。

3.1.1.10.1 设计意图

核心问题:当多个线程(或内核态/用户态同时)修改同一个进程的 VAD 树时,如何保证树结构的完整性?AVL 树的旋转操作涉及多个指针修改——任何中间状态被其他线程看到都会导致崩溃。

设计哲学:用"一把锁保护整个数据结构"而不是"每个节点一把锁"。后者在理论上并发度更高,但实现极其复杂(需要处理"遍历时节点被删除"等问题)。粗粒度锁在工程实践中更可靠——VAD 树的修改操作本身是"低频的"(每次 VirtualAlloc/VirtualFree 才触发一次),锁的持有时间通常在微秒级。

3.1.1.10.2 概念解释
  • AddressCreationLock(地址创建锁):保护 VAD 树结构本身的进程级写锁。所有 VAD 插入/删除都必须持有。
  • Process Working Set Lock(工作集锁):保护进程工作集大小和工作集列表。与 AddressCreationLock 配合使用。
  • MmSectionBasedMutex(全局 Section 锁):保护全局的 Section 树(MmSectionBasedRoot),跨进程共享的 Section 修改需要这把锁。
  • 死锁(Deadlock):当线程 A 持有锁 X、等待锁 Y,而线程 B 持有锁 Y、等待锁 X 时发生。锁的获取顺序是预防死锁的标准方法。
  • 事务性(Atomicity):一组操作要么全部完成、要么全部不发生。VAD 操作需要事务性因为"插入 VAD 但未成功分配 PTE"会导致半初始化状态。
3.1.1.10.3 为什么要这样设计

问题 1:为什么 AddressCreationLock 是写锁而不是读写锁?
VAD 树的"读"操作(查找地址属于哪个 VAD)与"写"操作(插入/删除)同等频繁——缺页异常每触发一次就需要一次查找。读写锁在理论上允许多个读者并发,但增加了实现复杂度。在实践中,缺页异常中的 VAD 查找不需要持锁(因为 VAD 节点一旦插入就不会在进程生命周期内被修改,除非用户主动调用 VirtualFree),所以"写锁 + 无锁读"的组合更简洁、更快。

问题 2:为什么锁的顺序是"先工作集锁、后地址创建锁"而不是相反?
这是一个全局约定。Windows NT 内核中有数百个锁,但每个锁都有一个"层级(Hierarchy Level)"——低层级锁必须在高层级锁之前获取。工作集锁的层级低于地址创建锁,因此必须先获取。违反这一约定会导致死锁。ReactOS 继承了这一设计。

问题 3:为什么 MiDbgAssertIsLockedForWrite 是断言而不是运行时检查?
断言(ASSERT)在 Debug 版本中生效,Release 版本中被优化掉。它用于"捕捉开发中的错误"而不是"处理运行时错误"。如果一个线程在未持锁的情况下修改 VAD 树,Release 版本中会直接崩溃——断言让开发人员在测试阶段就能发现这种错误。

3.1.1.11 一次完整的 VAD 插入流程

下面以 VirtualAlloc 调用为例,看 VAD 插入的完整流程:

用户态 VirtualAlloc(NULL, 1MB, MEM_COMMIT, PAGE_READWRITE)
    ↓
ntdll!NtAllocateVirtualMemory (用户态 stub)
    ↓ sysenter
ntoskrnl!NtAllocateVirtualMemory
    ↓
MiAllocateVirtualMemory (内核态)
    │
    │  1. 参数检查(Length 是否对齐、Protect 是否合法)
    │  2. 获取当前进程的 EPROCESS
    │  3. 加锁:AddressCreationLock (写) + Process Working Set Lock (写)
    │  4. 查找空闲区段:MiFindEmptyAddressRangeInTree
    │  5. 创建 VAD 节点:分配 NonPagedPool 内存,初始化 MMVAD 字段
    │  6. 插入 AVL 树:MiInsertVad → MiInsertNode
    │  7. 若 MEM_COMMIT:对每个 PTE 调用 MiAllocatePte 并设置 PTE
    │  8. 解锁
    ↓
返回 STATUS_SUCCESS
    ↓
ntdll stub 读 eax → 返回用户态

关键点

  • 第 3 步的"双重加锁":先拿工作集锁、再拿地址创建锁。顺序很重要——颠倒会导致死锁。ReactOS 内部约定所有 VAD 操作都按"先工作集、后地址创建"的顺序。
  • 第 4 步的"找空闲区段":在 MiFindEmptyAddressRangeInTree 中,遍历 AVL 树找一个"足够大"的空洞。这是一个带范围约束的二叉搜索——比线性扫描快 O(log N)。
  • 第 7 步的"批量 PTE 设置":对 1 MB = 256 个 PTE 的设置,ReactOS 内部会做批量化——使用 InterlockedExchange / InterlockedCompareExchange 等原子操作一次性设置多个 PTE。
3.1.1.11.1 设计意图

核心问题:VAD 插入是"虚拟内存分配"的核心路径。用户态每次调用 VirtualAlloc 都会触发这一流程。它必须在保证正确性(不破坏 AVL 树、不与现有区段重叠)的前提下尽可能快。

设计哲学:“先检查、后修改”。所有可能失败的操作(如找空闲区段、参数检查)都放在持锁之前执行。持锁后只做"最小必要操作"(创建 VAD 节点、插入树)。这将锁的持有时间降到最低。

3.1.1.11.2 概念解释
  • VirtualAlloc:Win32 API,用于在用户态虚拟地址空间中分配、提交、释放页面。它最终会调用 ntdll 的 NtAllocateVirtualMemory
  • MEM_COMMIT / MEM_RESERVE:VirtualAlloc 的分配类型。MEM_RESERVE 只是"保留地址区间"(在 VAD 树中记账),MEM_COMMIT 同时"分配物理页并填写 PTE"。
  • InterlockedExchange / InterlockedCompareExchange:多核原子操作指令。用于不持锁的情况下安全修改共享变量。
  • 带范围约束的二叉搜索:在 AVL 树中查找"满足 ‘大于等于 X 且长度大于等于 Y’"的节点。比普通查找多一个"长度检查"约束。
3.1.1.11.3 为什么要这样设计

问题 1:为什么参数检查在持锁之前?
参数检查是"纯计算"——不访问任何共享数据。将它放在持锁前,可以提前发现错误(如地址越界、长度为 0)并返回错误码,不占用锁。这是性能优化的常见模式:能在锁外做的事情绝不放到锁内

问题 2:为什么空闲区段查找使用 AVL 树而不是空闲链表?
AVL 树可以在 O(log N) 内找到"第一个满足长度要求的空洞"。空闲链表需要 O(N) 线性扫描。当进程有大量已分配区段时,空闲区段数也很多(因为每个已分配区段之间至少有一个空洞),O(log N) 的优势非常显著。

问题 3:为什么批量化 PTE 设置使用原子操作?
当一次性修改 256 个 PTE 时,如果中途有其他 CPU 同时修改同一页表(如缺页异常),普通赋值可能导致"中间状态"。原子操作保证每个 PTE 的修改是不可分割的——要么完整写入新值,要么保持旧值。

3.1.1.12 小结
  • 用户态进程在 4 GB 虚拟地址空间内的"已分配区段"由 VAD 描述。
  • VAD 节点是 _MMVAD 结构,按起始地址排成 AVL 树,根在 EPROCESS->VadRoot
  • 查找、插入、删除 VAD 都是 O(log N)——满足缺页异常处理的性能要求。
  • ReactOS 同时维护 VAD 与 MEMORY_AREA,通过 Spare 标志位互转;新代码用 VAD。
  • VAD 节点的 Subsection 指针连接到 Section 对象,实现"文件 → 多进程共享"。
  • 64 KB 段对齐是 VAD 树与磁盘 I/O 之间的"最小区间单位"折中。
  • VAD 操作的并发安全由"工作集锁 + 地址创建锁"双重锁保护。

3.1.2 内核对于物理页面的管理

3.1.2.0 框架图(先见森林)

如果说 3.1.1 的 VAD 树是"用户态虚拟地址空间的使用清单",那 PFN Database 就是"物理内存的账本"。在展开细节前,先用一张图勾勒其全貌。

┌────────────────────────────────────────────────────────────────────┐
│                  物理内存(4 GB 物理空间,x86 下)                  │
│                                                                    │
│  Page 0   Page 1   ...  Page 0x1000  ... Page 0x100000             │
│  ┌─────┐ ┌─────┐       ┌─────┐         ┌─────┐                    │
│  │     │ │     │  ...  │     │   ...   │     │                    │
│  └─────┘ └─────┘       └─────┘         └─────┘                    │
│    ↓       ↓             ↓              ↓                          │
│  每一个 Page 在 PFN Database 中对应一个 _MMPFN 入口                  │
│                                                                    │
│  PFN Database = 全局数组(_MMPFN MmPfnDatabase[TotalPages])        │
│                                                                    │
│  ┌────────────────┐  ┌────────────────┐  ┌────────────────┐       │
│  │ _MMPFN[0]       │  │ _MMPFN[1]       │  │ _MMPFN[1000]   │       │
│  │ - u1.Flink     │  │ - u1.Flink     │  │ - u1.Flink     │       │
│  │ - u2.Blink     │  │ - u2.Blink     │  │ - u2.Blink     │       │
│  │ - PteAddress   │  │ - PteAddress   │  │ - PteAddress   │       │
│  │ - u3.WsIndex   │  │ - u3.WsIndex   │  │ - u3.WsIndex   │       │
│  │ - u4.InPage…   │  │ - u4.InPage…   │  │ - u4.InPage…   │       │
│  │ - u5.Usage     │  │ - u5.Usage     │  │ - u5.Usage     │       │
│  └────────────────┘  └────────────────┘  └────────────────┘       │
│          ↓                  ↓                  ↓                   │
│  链表按页状态分类:Active/Standby/Modified/Free/Zeroed/...         │
└────────────────────────────────────────────────────────────────────┘

本图核心要点:PFN Database 是一个全局数组(不是树、不是 hash),下标就是物理页号 PFN。每个表项 _MMPFN 描述该物理页的"身份信息"(属于哪个进程、属于哪个工作集、当前状态是什么)。所有 6 种页状态对应 6 条双向链表,PFN 通过 u1.Flink/u2.Blink 字段串到对应链表中。

3.1.2.0.1 设计意图

核心问题:CPU 硬件页表只知道"虚拟地址映射到哪个物理页",但不知道"这个物理页是否还有效、是否被多个进程共享、是否已被写过需要写回"。操作系统需要一个"反向映射"数据结构来跟踪这些信息。

设计哲学:PFN Database 是虚拟内存管理的"核心账本"。它的设计遵循两个原则:1. 每个物理页有且只有一条记录——避免同一物理页被重复记录;2. 访问必须是 O(1)——PFN 是连续整数,直接用数组下标定位。这与 VAD 树的 O(log N) 查找形成鲜明对比——VAD 树负责"软件层面的区段管理",PFN Database 负责"硬件层面的页面管理"。

本节定位:3.1.2 节是第 3 章的"中间层"。读者理解了 VAD 树(上层)和 PFN Database(中层)后,才能理解 3.1.3 节的 PTE(下层)如何将两者连接起来。

3.1.2.1 PFN 的概念

PFN(Page Frame Number) 是物理页的编号。在 32 位 x86 下,物理地址 0~4 GB 对应 PFN 0~0xFFFFF(共 1,048,576 个 PFN)。

  • PFN = 物理页基地址 >> PAGE_SHIFT
  • PAGE_SHIFT = 12(4 KB 页面下)

PFN Database 是内核态对所有物理页的"目录表"。它是一个 _MMPFN 数组,下标就是 PFN。MmPfnDatabase 是这个数组的全局指针(在 ntoskrnl/mm/ARM3/mminit.c 中初始化)。

3.1.2.2 _MMPFN 结构

_MMPFN 是 PFN Database 的表项,定义在 [ntoskrnl/include/internal/mm.h](file:///d:/reactos/ntoskrnl/include/internal/mm.h) 以及 [ntoskrnl/mm/ARM3/miarm.h](file:///d:/reactos/ntoskrnl/mm/ARM3/miarm.h) 中。简化后:

typedef struct _MMPFN {
    union {
        PFN_NUMBER Flink;           // 前向链(前一个 PFN)
        ULONG PageState : 8;        // 6 种状态之一
        ...
    } u1;
    union {
        PFN_NUMBER Blink;           // 后向链
        ...
    } u2;
    union {
        PVOID PteAddress;           // 该 PFN 对应的 PTE 虚拟地址
        ULONG WsIndex;              // 工作集索引
        ...
    } u3;
    union {
        struct {
            ULONG ReferenceCount;   // 引用计数
            ...
        } e1;
        ...
    } u4;
    union {
        MI_PFN_USAGES Usage;        // 该页用途(VAD/Section/Pool/...)
        ...
    } u5;
} MMPFN, *PMMPFN;

关键字段解读

  • u1.PageState / Flink:6 种页状态之一(见 3.1.2.3);当页处于某个状态链表中时,u1.Flink 指向链表中的下一个 PFN。
  • u2.Blink:链表前向指针。
  • u3.PteAddress / WsIndex:当页处于 Active(被某进程的 PTE 映射)时,PteAddress 指向 PTE 的虚拟地址;当页处于 Standby/Modified 时,WsIndex 记录它"原本属于"哪个工作集。
  • u4.ReferenceCount:引用计数,0 表示该页空闲。
  • u5.Usage:PFN 用途(VAD、Section、Pool、PageTable 等),用于 !vm 诊断。

总大小_MMPFN 约 28~32 字节。4 GB 物理内存 / 4 KB = 1 M 个 PFN,对应 PFN Database 大小约 28~32 MB。这是内核态"常驻"的数据结构。

3.1.2.3 PFN 状态机

ReactOS/Windows NT 维护 6 种页状态(外加 2 个过渡态):

状态 含义 在哪个进程工作集中? 物理页中内容 何时离开该状态?
Active 被某个进程的 PTE 映射 有效 进程主动弃页 / 工作集 trim
Standby 不在任一工作集但仍映射到某进程 PTE 有效(可被"偷走") 被偷走 / 重新激活
Modified 不在任一工作集,已被写过但未写回 已修改 写回 pagefile 后进入 Standby
ModifiedNoWrite 不在任一工作集,已被写过但无需写回(如 code page) 已修改 直接进入 Standby
Free 完全空闲,内容无意义 不可读 被分配后进入 Zeroed 或 Active
Zeroed 完全空闲,内容全 0 全 0 被分配后进入 Active
Transition 在 Active 与 Standby 之间的"过渡" 有效 完成迁移后进入目标状态
Bad 已损坏,不可使用 永不离开

状态转换图

                          ┌─────────┐
       PTE 解除映射/trim  │ Active  │ ←─────┐
              ↓           └────┬────┘       │
       ┌──────┴───────┐         │             │
       ↓              ↓         │             │
 ┌───────────┐  ┌────────────┐  │             │
 │ Standby   │  │ Modified   │  │             │
 │ (可被偷)  │  │ (待写回)   │  │             │
 └─────┬─────┘  └──────┬─────┘  │             │
       │               │        │             │
       │     ┌─────────┘        │             │
       │     │ 写回 pagefile    │             │
       │     ↓                  │             │
       │  ┌────────────┐        │             │
       │  │ Standby    │        │             │
       │  └──────┬─────┘        │             │
       │         │ 被偷走       │             │
       ↓         ↓              │             │
 ┌───────────────────┐          │             │
 │  Zeroed / Free    │          │             │
 │  (空闲,待分配)   │ ─────────┘ 被分配      │
 └───────────────────┘          PTE 重新映射  │
                                                    │
  进程新分配 ┌─────────┐                            │
   ────────→│  Active  │ ─────────────────────────┘
            └─────────┘
3.1.2.4 关键函数

ReactOS ARM3 中的 PFN 操作集中在 [ntoskrnl/mm/ARM3/pfnlist.c](file:///d:/reactos/ntoskrnl/mm/ARM3/pfnlist.c):

  • MiInsertPageInFreeList([pfnlist.c:611](file:///d:/reactos/ntoskrnl/mm/ARM3/pfnlist.c#L611)):把一个 PFN 插入空闲链表(Free / Zeroed)。
  • MiRemovePageFromFreeList:从空闲链表中取出 PFN。
  • MiAllocatePage:从 Free / Zeroed 链表中分配一页,优先取 Zeroed。
  • MiFreePage:释放一个 PFN,标记为 Free 或 Zeroed。
  • MiInitializePfnDatabase([mminit.c](file:///d:/reactos/ntoskrnl/mm/ARM3/mminit.c)):初始化 PFN 数组。

典型代码片段(简化):

VOID MiInsertPageInFreeList(PMMPFN Entry) {
    ULONG State = Entry->u1.PageState;
    ASSERT(State == StandbyPageList || State == ModifiedPageList);

    // 把 Entry 插入到 State 链表头
    InsertTailList(&MmPageListHead[State], &Entry->u1.ListEntry);
    MmNumberOfPhysicalPages++;
}
3.1.2.5 零页(Zero Page)的妙用

操作系统观察到一种常见模式:80% 的新分配页实际上从未被写过(sparse array、栈空间、堆分配等)。ReactOS 利用这一观察做了两个优化:

  1. 全局零页(MmGlobalZeroPage:内核启动时分配一个"全 0"物理页,所有进程共享它。新分配页时,PTE 的 PFN 临时指向这个零页(设置 P=1 + PFN=MmGlobalZeroPage)。当用户第一次写入该页时,#PF 触发,内核才分配真正的物理页并复制零页内容。

  2. 零位图(Zero Bitmap):内核维护的"哪些 Free 页是全 0"位图。优先从零位图取页,可省去 memset(4 KB)

零页机制的关键代码路径

  • MmGlobalZeroPage 在 [ARM3/zeropage.c](file:///d:/reactos/ntoskrnl/mm/ARM3/zeropage.c) 中定义。
  • 缺页异常处理([ARM3/pagfault.c](file:///d:/reactos/ntoskrnl/mm/ARM3/pagfault.c) 第 1338 行起的 MiDispatchFault)中,demand-zero 分配会用到零页机制。
3.1.2.6 PFN 初始化时序

PFN Database 不是 ReactOS 一启动就完整建好的。ARM3 的内存管理初始化分多个阶段(Phase):

  • Phase 0:在内核镜像加载前,由 PE Loader 建立一个最小化的页表,让 NT 内核能运行。
  • Phase 1MiInitMachineDependent 在 [ARM3/mminit.c](file:///d:/reactos/ntoskrnl/mm/ARM3/mminit.c) 中调用,根据 HAL 提供的 MmPhysicalMemoryBlock 建立 PFN 数组的初始版本。
  • Phase 2MiInitializePfnDatabase 完成 PFN Database 的完整建立。
  • Phase 3:在系统启动完毕后,进一步完善(如建立 Zero 页、建立 PfnDatabase 中每个 PFN 的初始状态)。

每个阶段的具体工作见 [ARM3/mminit.c](file:///d:/reactos/ntoskrnl/mm/ARM3/mminit.c) 中的 MiInitMachineDependent 函数。

3.1.2.7 代码片段

_MMPFN 关键字段(简化):

typedef struct _MMPFN {
    union { struct { ULONG Flink : 24; ...; } e1; PFN_NUMBER Flink; } u1;
    union { struct { ULONG Blink : 24; ...; } e1; PFN_NUMBER Blink; } u2;
    union { PVOID PteAddress; LONG WsIndex; } u3;
    union { struct { USHORT ReferenceCount; USHORT ...; } e1; ULONG ShareCount; } u4;
    union { MI_PFN_USAGES Usage; ...; } u5;
} MMPFN, *PMMPFN;

MiInsertPageInFreeList 入口([pfnlist.c:611](file:///d:/reactos/ntoskrnl/mm/ARM3/pfnlist.c#L611)):

VOID
NTAPI
MiInsertPageInFreeList(PMMPFN Entry)
{
    /* ... */
    InsertTailList(&MmFreePageListHead, &Entry->u1.ListEntry);
    /* ... */
}
3.1.2.8 概念解释
  • PFN(Page Frame Number):物理页号。在 32 位下范围 0…(4 GB / 4 KB - 1) = 0…0xFFFFF。PFN Database 是按 PFN 索引的 _MMPFN 数组。
  • PFN Database(物理页数据库):内核态对所有物理页的"目录表"。数组大小 = 物理页总数,索引 = PFN。每个表项记录"该页属于谁、状态是什么"。
  • 页状态(Page State):Active、Standby、Modified、ModifiedNoWrite、Free、Zeroed、Transition、Bad。ReactOS 在 _MMPFN::u1.e1 中编码。
  • 零页(Zero Page):全局共享的一页"全 0"物理页。MmGlobalZeroPage 指向它。分配新页时,PFN 初始指向零页;按需 COW 时才分配真正的物理页。
  • _MMPFN(PFN Database 表项):每个表项约 28 字节(含联合体 u1/u2/u3/u4/u5)。含 PteAddress、WsIndex、WorkingSetIndex、ReferenceCount、Usage 等。
  • 零位图(Zero Bitmap):ReactOS 在某些路径中维护的"哪些页是全 0"位图;分配时优先从零位图取页,可省去 memset
  • MmPfnDatabase:PFN Database 的全局数组指针。ARM3 在 mminit.c 中初始化它。
  • MI_PFN_USAGES:PFN 用途枚举,标记该页是被 VAD/Section/Pool/PageTable/PageDirectory/Stack/etc. 占用。用于 !vm 诊断。
  • Transition 状态:Active 与 Standby 之间的"过渡"状态。P=0 但 PFN 仍有效,表示"页正在被换出/换入的中间状态"。
  • MmStandbyPageListHead / MmModifiedPageListHead / MmFreePageListHead / MmZeroedPageListHead:按页状态组织的全局双向链表头。
3.1.2.9 为什么要这样设计

问题 1:为什么需要 PFN Database 而不是用页表自身?
CPU 硬件页表只有"虚拟→物理"映射信息;它无法表达"该物理页当前在工作集还是已换出"、“该页属于哪个进程的工作集”、“该页是否已被写过"等。换句话说,CPU 硬件页表是"映射层”,PFN Database 是"管理层"。两者职责不同,必须分开。

问题 2:为什么用数组而不是 hash 表?
PFN 是连续整数 0…N;数组下标访问是 O(1) 且 L1 cache 友好(连续访问,硬件预取器可工作)。hash 表在连续整数下没有任何收益——这是"用最朴素的数据结构做最快的访问"的经典案例。

问题 3:为什么 6 种页状态而不是更简单?
精细的状态机让换出/换入/写回等操作可以分阶段执行。比如:

  • “Modified” 状态的页必须写回 pagefile 后才能进入 Free(否则修改会丢失)。
  • “Standby” 的页可被任何进程"偷走"作为自己的新页(page reclaim 优化)。
  • “Zeroed” 的页内容已全 0,分配时无需清零。
  • “Active” 状态的页是"被进程 PTE 映射"的,引用计数保护下不能被偷。

合并状态会损失这些优化,性能下降 10%~30%。

问题 4:为什么 zero page 是全局共享的?
80% 的新分配页实际上从未被写过(sparse array 常见模式)。共享零页省去 memset(64 KB) 的开销,是"按需物理页分配"的关键技巧。额外的好处:零页还可以被多个进程同时"映射"——它们的 PTE 都指向同一个 PFN,但都标记为 read-only。任何写入会触发 #PF,然后内核才分配真正的物理页并复制零页内容(COW 机制)。

问题 5:为什么 PFN Database 不是稀疏的?
即使 4 GB 物理内存只用了 1 GB,PFN Database 也会为所有 4 GB 保留 _MMPFN 数组。原因:PFN 必须连续——CPU 硬件对 PFN 的访问是"通过页表项中嵌入的 PFN"找到对应的 _MMPFN不能跳过未分配的 PFN。如果用稀疏数组,硬件 MMU 找不到该 PFN 对应的元数据,整个虚拟内存机制就会崩溃。

问题 6:为什么 Modified 状态不可被偷走?
Modified 页包含用户态修改过的内容。如果在写回 pagefile 之前被其他进程偷走并重新覆盖,原进程的数据会丢失。这是虚拟内存"正确性"的底线——脏页必须先写回才能被复用

问题 7:为什么 PFN Database 是全局的而不是每进程的?
物理页是全局资源——同一物理页可以被多个进程同时映射(如 Section 共享)。如果 PFN Database 是每进程的,就无法表达"物理页 42 被进程 A、B、C 同时映射"这一关系。全局的 PFN Database 是"共享内存"语义的基础。

3.1.2.10 PFN Database 的并发控制

PFN Database 是全局共享数据结构,所有 CPU 都可能并发访问。ReactOS 用以下机制保证安全:

  • MmPfnLock:全局自旋锁,保护 PFN 状态转换和链表操作。所有 PFN 的状态变更(Active → Standby → Free 等)都必须持有这把锁。
  • 引用计数的原子增减InterlockedIncrement / InterlockedDecrement,不需要持锁即可安全修改 ReferenceCount。
  • 链表操作的原子性InsertTailList / RemoveHeadList 在持锁后执行,保证链表指针不会被并发修改。

核心洞察:PFN Database 的访问频率远高于 VAD 树——每次缺页异常、每次物理页分配、每次工作集修剪都会访问它。因此它的锁设计必须"尽可能短"——仅在修改状态和指针时持锁,其他计算(如清零页面)在锁外执行

3.1.2.11 PFN 状态机的细节:每个状态的生命周期

下面展开讨论 6 种状态的生命周期,让读者对"PFN 怎么流转"有更具体的认识。

Active 状态

  • 进入:从 Zeroed / Standby 列表取页,写入 PTE 指向该 PFN 后,PFN 状态变 Active。
  • 离开:进程主动弃页(VirtualFree)、工作集 trim(MmTrimWorkingSet)、内存压力(MiPageOutProcessBulk)。
  • 链表:每个进程都有自己的 Active 列表(实际上是通过工作集实现的,不是显式链表)。

Standby 状态

  • 进入:从 Active 移除,但 PTE 仍保留指向该 PFN(Transition 状态过渡),然后 PTE 变 “transition PTE”(含 PFN 但 P=0)。
  • 离开:
    • 被偷走(page reclaim):当有进程请求新页时,优先从 Standby 列表取一个 PFN,把它的内容直接填到新 PTE。这避免了"清零"和"读盘"两步。
    • 重新激活:如果原进程再访问该虚拟地址,#PF 触发,PTE 从 transition 变回 Active。
  • 链表:MmStandbyPageListHead 全局双向链表。

Modified 状态

  • 进入:从 Standby 列表中某 PFN 被修改后(脏位被设置)。
  • 离开:写回 pagefile 后变 Standby。
  • 链表:MmModifiedPageListHead 全局双向链表。
  • 关键约束:Modified 状态不可被偷走——必须先写回(page reclaim 不会偷 Modified 页)。

ModifiedNoWrite 状态

  • 进入:通常用于只读 image 页(code 段)的 reloc 处理。reloc 不需要写回原文件。
  • 离开:直接进入 Standby。
  • 链表:与 Modified 共用,但有特殊处理标志。

Free 状态

  • 进入:从 Standby 列表中的页不再被任何进程 PTE 引用时(引用计数为 0)。
  • 离开:被分配后变 Zeroed 或 Active。
  • 链表:MmFreePageListHead 全局双向链表。

Zeroed 状态

  • 进入:从 Free 列表中取出后做 memset(0),或者从零位图中取页。
  • 离开:被分配后变 Active。
  • 链表:MmZeroedPageListHead 全局双向链表。
3.1.2.11.1 设计意图

核心问题:为什么需要 6 种状态而不是简单的"在用/空闲"两状态?两状态模型无法表达"内容有效但未被映射"(Standby)、“内容已修改需要写回”(Modified)等细粒度状态。

设计哲学状态机越精细,优化空间越大。Standby 状态让"刚刚释放的页可以被原进程快速重新激活"(page reclaim)。Modified 状态让"写回可以异步进行"(不阻塞用户态执行)。Zeroed 状态让"新分配页不需要清零"。这些细粒度状态共同构成了虚拟内存系统的"性能层次"。

3.1.2.11.2 概念解释
  • Page Reclaim(页回收):从 Standby 列表"偷走"页面直接分配给新请求。省去清零和读盘。
  • Dirty Bit(脏位):CPU 硬件在 PTE 中维护的位——页面被写入时自动置 1。Modified 状态的 PFN 对应 PTE 的 Dirty 位为 1。
  • LRU(Least Recently Used):理论上的页面置换算法。Standby 列表按"最近使用"顺序排列——链表头是"最近最少用的页",优先被偷走。
3.1.2.11.3 为什么要这样设计

问题 1:为什么 Standby 状态的页保留原始内容而不是立即清零?
清零操作有成本(memset(4KB) 约需 1000 个 CPU 周期)。如果原进程很快重新访问该页(局部性原理),保留内容让重新激活只需修改 PTE,无需读盘。80% 的缺页异常实际上是"重新激活 Standby 页",不是真的需要读盘。

问题 2:为什么 Modified 状态不直接写回磁盘而是先放入列表?
磁盘 I/O 比内存访问慢 10⁵ 倍。如果每次释放 Modified 页都同步写回,用户态进程会被长时间阻塞。放入 Modified 列表让"写回"由后台线程(MiModifiedPageWriter)异步执行——用户态进程只需要把 PTE 改为 transition 就可以继续运行。

问题 3:为什么 Transition 状态是必要的?
当 PFN 从 Active 迁移到 Standby 时,PTE 需要一个"P=0 但仍保留 PFN"的中间状态。如果直接清零 PTE,原进程重新访问时会触发"从 disk 读入"的完整缺页流程——耗时是 transition 激活的 100 倍。Transition 状态让"快速重新激活"成为可能。

3.1.2.12 引用计数与共享计数

_MMPFN 中还有两个关键字段:u4.ReferenceCountu4.ShareCount(在不同上下文中含义略有不同)。它们协同工作以保证 PFN 的"安全释放":

  • ReferenceCount:当前引用该 PFN 的 PTE 数量 + 等待中的 I/O 数量。ReferenceCount = 0 时才能安全释放。
  • ShareCount:当 PFN 被多个 PTE 共享时(如 Section 共享映射),这个数字表示"有多少 PTE 共享了该页"。

典型流程

PFN 42: ReferenceCount=3, ShareCount=2
表示:3 个 PTE 引用了 PFN 42,其中 2 个是"共享映射"的 PTE,1 个是"私有 copy"或"被偷走后留下的"。

当 ReferenceCount 降到 0 时:PFN 进入 Free 列表(如果内容无关紧要)或 Zeroed 列表(如果内容全 0)。

为什么需要 ReferenceCount 而不是只看状态? 因为状态转换是异步的——PTE 状态可能还停留在"transition PTE"(含 PFN 但 P=0),但内核已经把 PFN 移到 Standby 列表上。ReferenceCount 让释放路径能区分"还有 PTE 引用" vs “已无 PTE 引用”。

3.1.2.12.1 设计意图

核心问题:当一个物理页被多个进程的 PTE 同时映射时(如 Section 共享),如何保证"所有进程都解除映射后,该物理页才能被释放"?

设计哲学引用计数是"多对一"关系的标准解决方案。ReferenceCount 记录"有多少个 PTE/I/O 在引用该页",只有计数降到 0 时才能安全释放。ShareCount 在此基础上进一步区分"多少个是共享映射"——这在 fork/COW 场景下很关键。

3.1.2.12.2 概念解释
  • ReferenceCount(引用计数)_MMPFN.u4.ReferenceCount,记录当前引用该 PFN 的 PTE 数量 + 等待中的 I/O 数量。
  • ShareCount(共享计数)_MMPFN.u4.ShareCount,记录有多少个 PTE 是"共享映射"的(相对于私有 copy)。
  • InterlockedIncrement / InterlockedDecrement:多核原子操作指令,保证引用计数的增减是原子的——不需要持锁。
  • ABA 问题:引用计数的经典并发问题。如果引用计数减到 0 后又被加回,中间可能有其他线程看到计数为 0 而释放该页。
3.1.2.12.3 为什么要这样设计

问题 1:为什么 ReferenceCount 用原子操作而不是锁保护?
引用计数的增减频率极高——每次 PTE 变更都涉及。如果用锁保护,锁的持有时间虽然很短但频率很高,会导致多核上的锁竞争(cache line bouncing)。原子操作在硬件层面解决这个问题——lock add 指令让 CPU 直接对内存做原子增减,不需要软件锁。

问题 2:为什么需要 ShareCount 而不是只用 ReferenceCount?
在 COW 场景中,当父进程 fork 出子进程时,所有页被标记为只读 + 共享。子进程写入某页时,内核需要知道"有多少个进程共享该页"——如果 ShareCount > 1,说明需要分配新页并复制(COW);如果 ShareCount = 1,说明可以直接改写保护位而不需要复制。这是一个关键的性能优化。

问题 3:为什么 I/O 计数也要算入 ReferenceCount?
当一个页正在被异步写入 disk(pagefile 写回)时,物理页的内容正在被 DMA 控制器读取。如果此时内核释放该页并重新分配给其他进程,DMA 可能会把旧内容覆盖到新进程的数据上。将"等待中的 I/O 数量"计入 ReferenceCount,保证写回完成前不会被释放。

3.1.2.13 工作集(Working Set)机制

工作集(Working Set, WS) 是每个进程的一个数据结构,记录"该进程最近用过的物理页"。它是 PFN 与进程 PTE 之间的"中介"。

关键结构_MMSUPPORT(在 [mm.h:30 附近](file:///d:/reactos/ntoskrnl/include/internal/mm.h#L30)):

typedef struct _MMSUPPORT {
    LIST_ENTRY WorkingSetExpansionLinks;  // 全局 WS 扩展链表
    ULONG WorkingSetSize;                 // 当前 WS 大小(页数)
    ULONG WorkingSetMinimum;              // WS 最低保证
    ULONG WorkingSetMaximum;              // WS 硬上限
    ULONG PeakWorkingSetSize;             // 历史峰值
    ...
} MMSUPPORT;

工作集与 PFN 的关系

  • 当一个进程 PTE 指向一个 Active PFN 时,该 PFN 同时记录"自己属于哪个进程的工作集"(通过 u3.WsIndex 字段)。这就是 “Working Set Index”——它在进程工作集数组中的下标。
  • 工作集修剪(MmTrimWorkingSet)时:把 WorkingSetSize 之外的页"老化"(age 递减)并最终从工作集移除,进入 Standby 列表。

工作集管理是 ReactOS 内存管理的"动态层面"——它让"进程实际用到的页"留在内存,让"长期未用的页"让出物理空间。3.3 节会详细讨论工作集修剪与页面换出。

3.1.2.13.1 设计意图

核心问题:物理内存是有限资源。当系统中有数百个进程同时运行时,如何决定"哪些页留在内存、哪些页被换出"?

设计哲学工作集模型是"局部性原理"的工程实现。程序倾向于在一段时间内只访问一小部分页面(时间局部性)。将"最近使用的页"留在内存中,将"长期未用的页"换出——这是虚拟内存系统的核心算法。每个进程有独立的工作集,避免"一个进程的内存压力影响其他进程"。

3.1.2.13.2 概念解释
  • Working Set Size(工作集大小):进程当前在内存中的页数。由 _MMSUPPORT.WorkingSetSize 记录。
  • WorkingSetMaximum(工作集硬上限):进程最多能占用的物理页数。超过后必须 trim。
  • Working Set Trim(工作集修剪):当工作集超过上限时,将"最久未用的页"从工作集移除,进入 Standby 列表。
  • WorkingSetIndex(WsIndex)_MMPFN.u3.WsIndex,记录该 PFN 在进程工作集中的数组下标。用于快速反向查找。
  • Age(老化计数):每个工作集页有一个老化计数。定期递减,降到 0 时被移出工作集。
3.1.2.13.3 为什么要这样设计

问题 1:为什么每个进程有独立的工作集而不是全局一个?
全局工作集会导致"内存饥饿"——某进程大量分配内存会把其他进程的页全部挤出。每进程独立工作集保证了公平性:每个进程有自己的 WorkingSetMinimum(最低保证)和 WorkingSetMaximum(上限)。系统管理员可以通过 SetProcessWorkingSetSize 调整这些参数。

问题 2:为什么 WorkingSetIndex 存储在 PFN 中而不是进程的数据结构中?
反向查找效率:当需要"从一个 PFN 找到它属于哪个进程的工作集"时(如 page reclaim 时判断是否可以偷),直接从 _MMPFN.u3.WsIndex 即可定位,不需要遍历所有进程的工作集。这是典型的"空间换时间"优化。

问题 3:为什么工作集修剪使用"老化计数"而不是精确的时间戳?
精确时间戳需要每次访问都更新,代价太高。老化计数是一种近似——每次时钟中断(约 10-15ms 一次)对当前执行进程的工作集页做一次扫描。这是"性能与精度"的经典折中。

3.1.2.14 代码片段:MiAllocatePage

下面是 MiAllocatePage 的简化实现(取自 [ARM3/pfnlist.c](file:///d:/reactos/ntoskrnl/mm/ARM3/pfnlist.c)):

PFN_NUMBER
NTAPI
MiAllocatePage(VOID)
{
    PMMPFN Pfn;

    /* 1. 优先从 Zeroed 列表取页 */
    Pfn = RemoveHeadList(&MmZeroedPageListHead);
    if (Pfn != &MmZeroedPageListHead) {
        MmZeroedPageListHead.TotalPages--;
        return Pfn - MmPfnDatabase;
    }

    /* 2. 从 Free 列表取页并清零 */
    Pfn = RemoveHeadList(&MmFreePageListHead);
    if (Pfn != &MmFreePageListHead) {
        MmFreePageListHead.TotalPages--;
        RtlZeroMemory(MiPfnToAddress(Pfn), PAGE_SIZE);
        return Pfn - MmPfnDatabase;
    }

    /* 3. 从 Standby 列表偷一页(page reclaim) */
    Pfn = RemoveHeadList(&MmStandbyPageListHead);
    if (Pfn != &MmStandbyPageListHead) {
        /* 偷走该页,需要把它的 PTE 状态同步调整 */
        MiUnlinkPageFromList(Pfn);
        /* 注意:不需要清零,调用者会重新填充 */
        return Pfn - MmPfnDatabase;
    }

    /* 4. 没有可用页,返回 0 表示分配失败 */
    return 0;
}

三个关键点

  • 优先级:Zeroed > Free > Standby。Zeroed 最快(无需清零),Free 次之(需清零),Standby 最慢(需调整 PTE)。
  • Standby 的"偷":从 Standby 取页后,原 PTE 的 “transition” 状态被破坏——但这没关系,因为页已经被抢走,原 PTE 状态会被改写为"该页已不在内存"。
  • 失败返回 0:PFN 0 是"无效页号",用作错误码。
3.1.2.14.1 设计意图

核心问题:物理页分配是虚拟内存系统中最高频的操作之一。缺页异常每次都需要分配新页。如何让分配尽可能快?

设计哲学分层分配——从"最快的列表"开始尝试,逐层降级。Zeroed 列表最快(O(1) 取下一个),Free 列表次之(需要清零),Standby 列表最慢(需要调整其他进程的 PTE)。这种分层让"平均分配时间"最短。

3.1.2.14.2 概念解释
  • MiAllocatePage:PFN 分配的核心函数。按 Zeroed → Free → Standby 优先级尝试分配。
  • RemoveHeadList:从双向链表头部取节点。Standby/Free/Zeroed 列表都是先进先出(FIFO)——链表头是"最久未用的页",优先分配。
  • MiPfnToAddress:将 PFN 转换为内核虚拟地址。用于清零页内容。
  • MiUnlinkPageFromList:从 Standby 列表"偷走"一页时,需要将原 PTE 的 transition 状态清除——因为该 PFN 已被新进程占用。
3.1.2.14.3 为什么要这样设计

问题 1:为什么优先从 Zeroed 列表而不是 Free 列表取页?
Zeroed 页已经清零,分配后直接可用,省去 memset(4KB) 的 ~1000 CPU 周期。Free 列表需要调用 RtlZeroMemory 清零。对于对安全性敏感的操作系统(用户态分配的页不能包含其他进程的数据),清零是必须的。Zeroed 列表让"必须清零"的操作在空闲时预执行,而不是在缺页异常的关键路径上。

问题 2:为什么 Standby 列表是最后尝试的而不是第一?
Standby 列表中的页"可能还会被原进程重新访问"。如果一个页被偷走后原进程很快重新访问它,原进程需要触发缺页异常并从 disk 重新读入——这比保留该页在 Standby 中慢 10⁵ 倍。因此只有当 Zeroed 和 Free 都为空时才偷 Standby 的页。这是"性能 vs 公平性"的平衡。

问题 3:为什么失败返回 0 而不是一个特殊的错误码?
PFN 0 是物理地址 0 对应的页——这个地址在 x86 上通常是"系统 BIOS ROM"或"不可用"区域,不会被分配给用户态。用 0 作为失败码简化了调用端的判断:if (Pfn == 0) { handle failure; },不需要额外的 STATUS_ 枚举。

3.1.2.15 小结
  • PFN Database 是内核态对所有物理页的"目录表"——一个全局数组,下标就是 PFN。
  • 每个 _MMPFN 表项记录该物理页的状态、所属进程/工作集、PTE 反向指针、引用计数、用途。
  • 6 种页状态 + 2 个过渡态构成 PFN 状态机:Active / Standby / Modified / ModifiedNoWrite / Free / Zeroed / Transition / Bad。
  • **零页(MmGlobalZeroPage)**是 80% 未被写页面的优化核心——PTE → 零页 直到第一次写入才分配真正的物理页。
  • PFN 初始化分 Phase 0/1/2/3 多个阶段,在 [ARM3/mminit.c](file:///d:/reactos/ntoskrnl/mm/ARM3/mminit.c) 中按序完成。
  • ReferenceCountShareCount 协同保证 PFN 的安全释放。
  • 工作集(Working Set) 是 PFN 与进程 PTE 之间的"中介"——记录"该进程最近用过的物理页"。
  • PFN Database 的存在是"硬件 MMU 看不到的状态信息"统一在软件层管理——这是操作系统内存管理器的核心数据源。

3.1.3 虚存页面的映射

3.1.3.0 框架图(先见森林)

VAD 树描述"用户态虚拟地址空间的使用情况";PFN Database 描述"每个物理页的状态"。本节讨论如何把 VAD 关联到 PFN——这是虚拟内存的"动作"层。

┌────────────────────────────────────────────────────────────────────┐
│   进程虚拟地址空间                PTE 数组           物理页           │
│   ┌──────────────────┐         ┌─────────┐         ┌─────────┐    │
│   │ 0x00400000 (EXE) │ ──────→ │ PTE[0]  │ ──────→ │ Page 42 │    │
│   │                  │         │ Present │         │         │    │
│   │                  │         │ R/W     │         └─────────┘    │
│   │                  │         │ User    │                          │
│   │                  │         │ PFN=42  │                          │
│   │                  │         └─────────┘                          │
│   ├──────────────────┤         ┌─────────┐         ┌─────────┐    │
│   │ 0x10000000 (DLL) │ ──────→ │ PTE[?]  │ ──────→ │ Page 17 │    │
│   └──────────────────┘         └─────────┘         └─────────┘    │
│                                                                    │
│   MiBuildPte(Pte, Protection, Pfn, ...) 修改 PTE 状态               │
│   MiMapPageInHyperSpace(Process, Pfn) 通过 HyperSpace 临时映射      │
│   KeFlushSingleTb / invlpg — PTE 修改后必须刷新 TLB                │
└────────────────────────────────────────────────────────────────────┘

本图核心要点:每个 PTE 编码"1 个虚拟页 → 1 个物理页"的映射关系。ReactOS 通过 MiBuildPte 构造 PTE、通过 MI_WRITE_VALID_PTE 原子写入、通过 KeFlushSingleTb 同步 TLB——这是"虚拟内存生效"的标准三步。

3.1.3.0.1 设计意图

核心问题:VAD 树是"软件记账",PFN Database 是"物理账"。两者之间需要一座桥梁将虚拟地址与物理页绑定。这座桥梁就是 PTE 数组——它是 CPU 硬件能理解的语言。本节讨论这座桥梁如何建立、如何维护、如何刷新。

设计哲学三层设计——操作系统有三层参与 PTE 管理:

  1. 第一层(CPU 硬件):解析 PTE 中的 PFN 和标志位,完成地址翻译。硬件不关心 PTE 如何被创建/修改,只关心"当前 PTE 是什么状态"。
  2. 第二层(内核软件):负责 PTE 的分配(MiAllocatePte)、构造(MiBuildPte)、写入(MI_WRITE_VALID_PTE)和刷新(KeFlushSingleTb)。
  3. 第三层(并发控制):保证多核场景下 PTE 状态的一致性——原子写、TLB shootdown、自旋锁等。

这三层的分工是"硬件负责翻译,软件负责管理"。

本节定位:3.1.3 节是第 3 章"骨架"的最后一块。读者理解 PTE 如何工作后,就能完整理解"虚拟地址 → VAD 区段属性 → PTE 硬件映射 → 物理页 → PFN 状态"这一整条链。中篇和下篇将在此基础上讨论 Hyperspace、缺页异常、页面换出等更高级功能。

3.1.3.1 x86 PTE 位编码

x86 上的 PTE 是 32 位(非 PAE 模式)或 64 位(PAE 模式)结构。位编码表(非 PAE 32 位 PTE):

名称 含义
0 Present § 1 = 该页在内存;0 = 该页被换出或未分配
1 Read/Write (R/W) 1 = 可写;0 = 只读
2 User/Supervisor (U/S) 1 = 用户态可访问;0 = 仅内核态
3 Page-Level Write-Through (PWT) 写直达缓存策略
4 Page-Level Cache Disable (PCD) 缓存禁用
5 Accessed (A) CPU 自动设置:被访问过
6 Dirty (D) CPU 自动设置:被写过
7 Page Size (PS) 仅 PDE 有:1 = 4 MB 大页
8 Global (G) 1 = TLB 刷新时不失效(系统页常用)
9-11 Available (AVL) 操作系统自定义
12-31 Page Frame Number (PFN) 物理页号(20 位)

在 ReactOS 中,PTE 的位编码通过宏 MI_PTE_HARDWARE 完成(定义在 [ntoskrnl/include/internal/mm.h](file:///d:/reactos/ntoskrnl/include/internal/mm.h)):

#define MI_PTE_HARDWARE       0x80000000  // 最高位的 Present 位
#define MI_PTE_PROTOTYPE      0x40000000  // Prototype PTE 跳转
#define MI_PTE_TRANSITION     0x80000000  // 过渡态(P=0 但有 PFN)

MI_PTE_HARDWARE 是"硬件 PTE"标志位——操作系统要修改 PTE 时,会先清除这个标志再写入;CPU 检查到该位为 0 时知道"这是软件 PTE 状态(如 demand-zero、prototype)"。

3.1.3.2 PTE 的"软件状态"

PTE 实际有两种状态:

  1. 硬件有效态P=1,且有真实的 PFN。CPU 直接走硬件映射。
  2. 软件无效态P=0,但 PTE 内容包含"软件信息",操作系统用它做:
    • Demand ZeroP=0,PFN = 0。第一次缺页时从零页分配。
    • Prototype PTE 跳转P=0,"PFN"字段实际是另一个 PTE(Section 的原型 PTE)的地址。
    • TransitionP=0,但 PTE 中保留了一个 PFN(过渡态——该 PFN 属于 Standby/Modified 列表,正在被换出)。
    • 完全无效P=0,全部为零或任意非以上模式——访问会触发 ACCESS_VIOLATION。

ReactOS 的缺页异常处理([ARM3/pagfault.c](file:///d:/reactos/ntoskrnl/mm/ARM3/pagfault.c) 的 MiDispatchFault)会根据 PTE 的"软件状态"决定是分配新页、读取 prototype、还是返回 ACCESS_VIOLATION。

3.1.3.3 建立映射的步骤

建立"PTE → PFN"映射的标准三步

/* 第 1 步:获取 PTE 槽位 */
PTE = MiAllocatePte(Address, ...);   // 找页表项的虚拟地址

/* 第 2 步:构造 PTE 内容 */
OldPte = *Pte;
PteValue = MiBuildPte(Pfn, Protection, ...);  // 按保护位、PTE 状态编码
MI_WRITE_VALID_PTE(Pte, PteValue);  // 原子写入

/* 第 3 步:刷新 TLB */
KeFlushSingleTb(Address, TRUE);     // 让 CPU 重新加载 PTE
3.1.3.4 MiBuildPte 与 MI_WRITE_VALID_PTE

MiBuildPte(在 [ARM3/special.c](file:///d:/reactos/ntoskrnl/mm/ARM3/special.c))是构造 PTE 的核心函数。它接受:

  • Pfn:要绑定的物理页号
  • Protection:用户态保护位(PAGE_READWRITE 等)
  • OldPte:旧的 PTE(用于增量修改)
  • Process:目标 EPROCESS

返回编码好的 PTE 值。

MI_WRITE_VALID_PTE 宏(在 [ntoskrnl/include/internal/mm.h](file:///d:/reactos/ntoskrnl/include/internal/mm.h))做原子写入:

#define MI_WRITE_VALID_PTE(Pte, Value) \
    do { *(volatile ULONG *)(Pte) = (Value); } while (0)

注意:这是单指令 32 位原子写。在多核 CPU 上,保证"PFN 字段 + 标志位"一次性可见。

3.1.3.5 保护位的种类

PAGE_* 系列常量(Win32 API 中的保护位)到 PTE 硬件位的转换由 MiMakePteProtection 完成([ARM3/special.c](file:///d:/reactos/ntoskrnl/mm/ARM3/special.c))。共有 9 种保护位:

保护位 PTE.R/W PTE.U/S 含义
PAGE_NOACCESS 0 0 完全不可访问
PAGE_READONLY 0 1 用户态只读
PAGE_READWRITE 1 1 用户态可读写
PAGE_WRITECOPY 1 1 用户态写时复制
PAGE_EXECUTE 0 0 内核态可执行
PAGE_EXECUTE_READ 0 1 用户态可读可执行
PAGE_EXECUTE_READWRITE 1 1 用户态可读写可执行
PAGE_EXECUTE_WRITECOPY 1 1 用户态可执行+写时复制
PAGE_GUARD - - Guard Page(栈自动扩展)

WRITECOPY 是 Windows 的特殊优化:让 PTE 标记为"只读+U/S=1"——任何写入触发 #PF,然后内核做 COW 复制。这是 3.1.1 中提到的 Section 共享映射的硬件基础。

3.1.3.6 PAE 模式的特殊性

PAE(Physical Address Extension) 模式是为了支持 36 位物理地址(最多 64 GB 物理内存)。在 PAE 模式下:

  • PDE/PTE 变 64 位:其中 PFN 字段从 20 位扩展到 36 位(高 16 位)。
  • 多一层 PDPT(Page Directory Pointer Table):4 项,每项指向一张页目录。
  • CR3 寄存器变宽:从 32 位变 64 位。

ReactOS 的 PAE 实现集中在 [ntoskrnl/mm/i386/pagepae.c](file:///d:/reactos/ntoskrnl/mm/i386/pagepae.c) 中。MiBuildPte 在 PAE 模式下使用 MI_PTE_HARDWARE_PAE 宏构建 64 位 PTE。

3.1.3.7 TLB 刷新

TLB(Translation Lookaside Buffer) 是 CPU 内部的"虚拟地址→物理地址"硬件缓存。修改 PTE 后必须刷新 TLB:

  • invlpg 指令(汇编):刷新单个虚拟地址对应的 TLB 项。
  • mov cr3, new_cr3:刷新整个 TLB(让所有虚拟地址的 TLB 项失效)。
  • 进程切换:内核在上下文切换时会刷 TLB,因为不同进程的虚拟地址空间不同。

ReactOS 在 [ntoskrnl/mm/i386/page.c](file:///d:/reactos/ntoskrnl/mm/i386/page.c) 中定义了 MiFlushTlb

VOID MiFlushTlb(PVOID Address, BOOLEAN AllProcessors) {
    if (AllProcessors) {
        // IPI 让所有 CPU 刷 TLB
        KeIpiGenericCall(MiFlushTlbWorker, ...);
    } else {
        // 当前 CPU 刷 TLB
        __invlpg(Address);
    }
}
3.1.3.8 代码片段

MI_PTE_HARDWARE([mm.h](file:///d:/reactos/ntoskrnl/include/internal/mm.h)):

#define MI_PTE_HARDWARE       0x80000000  // 最高位的 Present 位
#define MI_PTE_PROTOTYPE      0x40000000  // Prototype PTE 跳转
#define MI_PTE_TRANSITION     0x80000000  // 过渡态

MiAllocatePte 的关键路径([ARM3/special.c](file:///d:/reactos/ntoskrnl/mm/ARM3/special.c)):

PMMPTE
NTAPI
MiAllocatePte(PEPROCESS Process, PVOID Address, ...)
{
    /* ... */
    /* 根据地址计算 PDE/PTE 表项位置 */
    Pte = MiPteToAddress(...);
    /* ... */
    return Pte;
}

MiFlushTlb 实现片段([i386/page.c](file:///d:/reactos/ntoskrnl/mm/i386/page.c)):

VOID __forceinline __invlpg(PVOID Address) {
    __asm {
        invlpg [Address]
    }
}

VOID MiFlushTlb(PVOID Address) {
    __invlpg(Address);
}
3.1.3.9 概念解释
  • PTE(Page Table Entry):x86 上的页表项。32 位 PTE 编码 1 页(4 KB)的硬件映射(PFN、保护位、状态位)。PAE 模式下 PTE 是 64 位。
  • PDE(Page Directory Entry):x86 上的页目录项。指向一张页表的物理页号;或(当 PS=1 时)指向一个 4 MB 大页。
  • PDPT/PDPTE(PAE 专属):PAE 模式下比常规页表多一层——4 项的"页目录指针表"(每项指向 1 张页目录)。
  • PTE 位编码:Present(0)、R/W(1)、U/S(2)、PWT(3)、PCD(4)、Accessed(5)、Dirty(6)、PS(7)、Global(8)、Available(9-11)、PFN(12-31)。32 位 PTE 共 12 位标志位 + 20 位 PFN(详见 MI_PTE_HARDWARE 宏)。
  • TLB(Translation Lookaside Buffer):CPU 内部的"虚拟地址→物理地址"硬件缓存。修改 PTE 后必须 invlpg 指令或 mov cr3 让 TLB 同步。
  • 保护位编码PAGE_READONLYPAGE_READWRITEPAGE_WRITECOPYPAGE_EXECUTEPAGE_EXECUTE_READPAGE_EXECUTE_READWRITEPAGE_EXECUTE_WRITECOPYPAGE_NOACCESSPAGE_GUARD 共 9 种。
  • MI_PTE_HARDWARE 标志位:最高位(bit 31),由操作系统设置。CPU 看到该位为 0 时知道 PTE 是"软件自定义状态",触发 #PF。
  • MI_WRITE_VALID_PTE:单指令 32/64 位原子写 PTE。由编译器生成 volatile 写,保证不被优化掉。
  • mfence / sfence 指令:内存屏障指令。在 TLB 刷新前调用,保证所有 CPU 都能看到 PTE 写入。
  • KeFlushSingleTb:刷新单个虚拟地址的 TLB 函数,内部调用 invlpg
3.1.3.10 为什么要这样设计

问题 1:为什么 PTE 修改后必须 invlpg 显式 TLB 刷新?
CPU 硬件不会自动发现 PTE 变化;TLB 缓存的旧映射会导致"读 PTE 已经是新的但 CPU 用的是旧 TLB 映射"的诡异 bug。例如:用户释放了一页物理内存,CPU 仍以为该页有效——这会导致"读到了已经被另一个进程复用的内存"的灾难性安全漏洞。MiFlushTlb 是 ReactOS 在每次 PTE 修改后调用的"安全网"。

问题 2:为什么 MI_WRITE_VALID_PTE 宏要做原子写?
PTE 写入是 32/64 位操作。多核 CPU 上两个 CPU 同时写 PTE 可能导致"中间状态"被其他 CPU 看到(如 P=1 但 PFN 错误)。原子写保证 P 位和 PFN 同步出现。多核上的内存屏障:内核在 MI_WRITE_VALID_PTE 之后还会调用 KeFlushSingleTb,它内部用 mfence / sfence 等指令保证 TLB 刷新前的内存写入对其他 CPU 可见。

问题 3:为什么 PAE 模式下 PTE 变 64 位?
PAE 模式是为了支持 36 位物理地址(最多 64 GB 物理内存)。32 位 PTE 只能编码 4 GB 物理地址;64 位 PTE 才能容纳 36+ 位 PFN。这是 x86 在 32 位下扩展物理内存的关键——但代价是 PTE 占用空间翻倍(8 字节 vs 4 字节),每个页表的 PTE 数从 1024 减到 512。

问题 4:为什么 MiAllocatePteMiBuildPte 分成两步?
分配 PTE 槽位(获取虚拟地址)与"构造" PTE(写内容)是独立的:

  • 分配可能失败(页表已满、虚拟地址已被映射)。
  • 构造 PTE 后可能还要再修改(如 COW 标志)。
  • 失败时需要回滚,分两步让代码可以更精确地回滚。

问题 5:为什么 WRITECOPY 是硬件支持的?
WRITECOPY 复用 PTE 的 R/W 位 + U/S 位 = “用户态可读+触发 #PF”。它不需要 CPU 特殊指令支持——内核在 #PF 处理中根据 PTE 的"是否 P=0 + R/W=1" 判定为 COW 需求。这是软件 + 硬件协同设计的经典案例:CPU 提供"读权限触发 #PF"的能力,内核在 #PF 中实现 COW 语义。

问题 6:为什么 PTE 的软件无效态用 P=0 作为统一入口?
P=0 是 CPU 硬件定义的"页不在内存"标志。所有"软件管理的 PTE 状态"(demand-zero、prototype、transition、完全无效)都必须走 P=0 这条路径。这是一个聪明的设计——硬件只提供一个触发器(#PF on P=0),软件在 #PF 处理中区分具体情况。如果硬件提供多个触发器,操作系统与 CPU 的接口会变得更复杂。

问题 7:为什么 MI_PTE_HARDWARE 是最高位(bit 31)而不是其他位?
bit 31 在 x86 上是"最高位",对大多数 CPU 来说检测这一位只需要一条 test 指令(test eax, 0x80000000),不影响其他位。同时这一位不会与 PFN 字段(bits 12-31)的低位冲突,保证 PFN 编码空间不受影响。

3.1.3.11 PTE 管理的关键数据结构关系

PTE 管理涉及多个数据结构的协作。以下关系图说明它们如何配合:

用户态虚拟地址 (VA)
    ↓ MiAddressToPte(VA)
PTE 虚拟地址 (PTE VA)
    ↓ 读 *PTE
PTE 值 (32/64 位)
    ├── P=1, PFN=xxx → 物理页 PFN xxx
    │                      ↓ MmPfnDatabase[xxx]
    │                      _MMPFN 结构(状态、引用计数等)
    │
    ├── P=0, prototype → 原型 PTE(Section 共享映射)
    │                      ↓ 递归解析原型 PTE
    │                      最终指向物理页或需要从 disk 读入
    │
    ├── P=0, transition → PFN 有效但不在 Active 状态
    │                      ↓ 从 Standby/Modified 列表恢复
    │
    └── P=0, demand-zero/其他 → 需要分配新页
                            ↓ MiAllocatePage()
                            PFN Database 分配新页

核心关系:PTE 是"虚拟→物理"的连接点。它的一侧是用户态虚拟地址(由 VAD 树管理区间属性),另一侧是物理页 PFN(由 PFN Database 管理状态)。PTE 的修改必须同时更新两侧:

  • 写入 PTE 时:要更新对应 PFN 的引用计数(_MMPFN.u4.ReferenceCount
  • 释放 PTE 时:要将 PFN 归还到 Free/Zeroed 列表(MiInsertPageInFreeList
  • 修改保护位时:要同步更新 VAD 树中的保护位标记
3.1.3.12 一次完整的 PTE 写入流程

下面以"用户态访问 demand-zero 页"为例,看一次完整的 PTE 写入流程:

用户进程首次访问 0x12345000(PTE 是 demand-zero)
    ↓
CPU 查 TLB → miss
    ↓
CPU 查页表 → PTE.P = 0
    ↓
CPU 触发 #PF(错误码 = 0x04:用户态读 + 页不在内存)
    ↓
IDT[0x0E] → _KiTrap0E(汇编入口)
    ↓
_KiTrap0E 保存现场(KTRAP_FRAME)
    ↓
KiTrap0EHandler → MmAccessFault(FaultCode, FaultAddress, TrapFrame)
    ↓
MiDispatchFault(ARM3/pagfault.c:1338)
    │
    │ 1. 错误码解析:P=0, R/W=0, U/S=1 → "用户态读 + 页未映射"
    │ 2. VAD 树查找:FaultAddress 落在哪个 VAD?
    │    → MiFindNodeOrParent(EPROCESS->VadRoot, FaultAddress)
    │ 3. 找到 VAD:判断 VAD 标志 → 私有、commit、未映射
    │ 4. 从 PFN 数据库取一个新页:
    │    Pfn = MiAllocatePage()  → 返回新 PFN
    │ 5. 把 PFN 与 FaultAddress 对应的 PTE 绑定:
    │    Pte = MiAddressToPte(FaultAddress)
    │    PteValue = MiBuildPte(Pfn, PAGE_READONLY, ...)
    │    MI_WRITE_VALID_PTE(Pte, PteValue)  ← 原子写
    │ 6. 刷新 TLB:
    │    KeFlushSingleTb(FaultAddress, FALSE)
    │ 7. 更新进程的 WorkingSetSize++
    ↓
返回 → _KiTrap0E 恢复现场
    ↓
CPU 重试访问 0x12345000 → 成功

关键点

  • 错误码解析(第 1 步):x86 错误码含 4 位标志(bit 0 = P、bit 1 = R/W、bit 2 = U/S、bit 3 = RSVD)。内核根据这 4 位快速判断"是缺页还是保护违规"。
  • VAD 树查找(第 2 步):如果 VAD 树找不到该地址对应的节点,说明是非法访问——返回 ACCESS_VIOLATION
  • 原子写(第 5 步):MI_WRITE_VALID_PTE 是 32 位原子写。多核 CPU 上保证 P 位和 PFN 同步出现。
  • TLB 刷新(第 6 步):KeFlushSingleTb 内部调用 invlpg 指令,只刷新当前 CPU 的该地址 TLB。
3.1.3.12.1 设计意图

核心问题:缺页异常是操作系统中最高频的内核事件之一(每秒可能触发数千次)。它必须在不影响用户态性能的前提下快速完成"分配物理页+建立映射"这一核心操作。

设计哲学快速路径 vs 慢速路径。大多数缺页异常是简单情况(demand-zero 或 transition 恢复),走快速路径——仅分配 PFN+写 PTE+刷 TLB。少数情况是复杂场景(需要从 disk 读入、需要 COW、需要修改 VAD 树),走慢速路径。这种分层让"平均缺页时间"保持在微秒级。

3.1.3.12.2 概念解释
  • _KiTrap0E:x86 中断向量 0xE(#PF)的汇编入口。保存现场后跳到 C 函数 KiTrap0EHandler
  • KTRAP_FRAME:中断/异常发生时保存的用户态寄存器状态。包括 EAX、EBX、ECX、EDX、ESI、EDI、EBP、ESP、EIP、EFLAGS 等。
  • 错误码(Fault Code):#PF 发生时 CPU 自动压栈的 32 位值。包含 P/RW/US/RSVD 4 位标志。
  • MmAccessFaultKiTrap0EHandler 调用的 C 函数。负责判断是内核态还是用户态访问失败,进而调用 MiDispatchFault
3.1.3.12.3 为什么要这样设计

问题 1:为什么缺页异常在汇编层保存现场而不是直接进入 C 处理?
C 函数调用约定(cdecl/stdcall)会修改寄存器(EAX/ECX/EDX 是 caller-saved)。在进入 C 处理之前,必须用汇编保存所有寄存器到 KTRAP_FRAME 中——否则用户态程序的寄存器会被内核代码覆盖,返回时程序状态错乱。

问题 2:为什么需要先检查 VAD 树再分配物理页?
VAD 树是"该虚拟地址是否被进程合法拥有"的最终权威。如果地址不在任何 VAD 区间内,说明是非法访问——直接返回 ACCESS_VIOLATION,不需要分配物理页。这一步检查同时决定了页的保护位(从 VAD 标志读取)。

问题 3:为什么分配 PFN 和写入 PTE 是两个独立步骤?
PFN 分配可能失败(内存耗尽时返回 0)。如果 PFN 分配失败,整个缺页异常应该失败并让用户态程序处理(通常触发 OOM 或崩溃)。将"分配"与"写入"分开让失败路径更清晰——PFN 分配失败时不需要回滚任何 PTE 状态。

3.1.3.13 PTE 写入的并发问题

多核场景下的 PTE 竞争

  • 假设 CPU A 在写 PTE,CPU B 同时访问同一虚拟地址。
  • 如果不原子写,CPU B 可能看到"P=1 但 PFN 未变"或"P=0 但 PFN 已变"等中间状态。
  • 原子写保证 CPU B 只能看到"P=0(未映射)“或"P=1(已完全映射)”。

典型问题:两个 CPU 同时 demand-zero 同一地址。

  • CPU A 走到第 4 步,分配了 PFN 100,正要写 PTE。
  • CPU B 触发 #PF,分配了 PFN 200,正要写 PTE。
  • 如果不保护:两个 PTE 写入会"竞争",最终只剩一个 PFN 有效,另一个 PTE 指向的页"被泄漏"。
  • ReactOS 解决:在 PFN 与 PTE 绑定之前会做PTE 的"transition"状态标记——让其他 CPU 看到"P=0 但有 PFN"时就认为"已经有人在处理"。第二个 CPU 会等第一个 CPU 完成(自旋锁)。

并发场景的另一个典型问题:COW(写时复制)。

  • 父进程和子进程共享同一物理页(PFN=42)。
  • 子进程写入该页触发 #PF,CPU 走 MiDispatchFault。
  • MiDispatchFault 分配新页 PFN=100,复制 PFN=42 的内容到 PFN=100,修改子进程的 PTE 指向 PFN=100。
  • 此时 PFN=42 还被父进程引用,PFN=100 只被子进程引用。
3.1.3.13.1 设计意图

核心问题:在多核 CPU 上,多个核可能同时访问同一个虚拟地址。如果两个核都触发 #PF 并尝试分配物理页,谁应该"赢"?如何保证不会分配两个物理页给同一个虚拟地址?

设计哲学乐观并发控制——每个 CPU 都假设自己是唯一的竞争者,先分配 PFN。在写入 PTE 之前用**原子比较交换(compare-and-swap)**检测是否已有其他 CPU 完成了同一地址的映射。如果检测到竞争,放弃自己分配的 PFN(减少内存泄漏),并"假装"自己从未分配过。

3.1.3.13.2 概念解释
  • Compare-and-Swap(CAS):原子指令 lock cmpxchg。如果内存值等于预期值,则写入新值;否则返回当前值。是实现无锁数据结构的基石。
  • Transition 状态标记:在 PTE 中临时写入 “P=0 + PFN=xxx” 表示"这个 PFN 正在被处理中"。其他 CPU 看到这个状态就知道不需要再分配。
  • PFN 泄漏(PFN Leak):如果两个 CPU 都分配了 PFN,只有一个能写入 PTE,另一个 PFN 就"丢失"了——必须主动释放,否则可用内存不断减少。
3.1.3.13.3 为什么要这样设计

问题 1:为什么不使用全局锁保护缺页异常?
全局锁会导致"多核扩展性为零"——10 个 CPU 同时触发缺页异常时,9 个必须等待锁。这会让多核系统的缺页性能跟单核一样。Transition 状态标记是一种更精细的锁:锁的范围是"单个 PTE",不是"整个 PTE 数组"。

问题 2:为什么 CAS 比简单的"先检查后写入"更安全?
"先检查 PTE 是否为 0 → 再写入 PTE"是 TOCTOU(Time-Of-Check-To-Time-Of-Use)问题——检查之后到写入之前,另一个 CPU 可能已经写入了。CAS 让"检查+写入"成为一条原子指令,消除了这个窗口。

问题 3:为什么竞争的 CPU 需要主动释放自己分配的 PFN?
如果竞争的 CPU 不释放自己分配的 PFN,那个 PFN 就会"漂浮"——它不在任何链表中(Free/Zeroed/Standby/Modified),也没有 PTE 指向它。最终会导致系统内存缓慢耗尽但无法回收——这是早期 Windows 版本中出现过的真实 bug。

3.1.3.14 PTE 的"软件状态"详解

PTE 实际有两种状态:

  1. 硬件有效态P=1,且有真实的 PFN。CPU 直接走硬件映射。
  2. 软件无效态P=0,但 PTE 内容包含"软件信息"。

软件无效态的 4 种类型

类型 PTE 内容 触发 #PF 时的处理
Demand Zero P=0, PFN=0 分配新页(从零页或 demand-zero 页),设置 PTE.P=1, PFN=new
Prototype 跳转 P=0, “PFN”=原型 PTE 地址 找到原型 PTE,递归处理原型 PTE 的状态
Transition P=0, PFN=有效 PFN 把 PFN 从 Standby/Modified 列表恢复到 Active,设置 PTE.P=1
完全无效 P=0, 任意值 ACCESS_VIOLATION(非法访问)

关键点

  • 软件无效态的 PTE 不是"空"的——它编码了"该虚拟地址是 demand-zero 还是 prototype 还是 transition"。
  • CPU 触发 #PF 时不会区分这些状态;内核在 MiDispatchFault 中通过 PTE 内容判断。
  • P=0 + PTE.PF=0(即 PTE 全 0)是"完全无效"——表示该地址未被任何 VAD 覆盖。
  • P=0 + PTE.PF!=0 + 位[11]=1MI_PTE_PROTOTYPE)是 prototype 跳转。
3.1.3.14.1 设计意图

核心问题:P=0 是 CPU 硬件定义的"页不在内存"状态。操作系统需要在这一状态下表达多种"为什么不在"的信息,而 CPU 只知道"它不在"。如何在 PTE 的 32/64 位空间中编码这些额外信息?

设计哲学在 P=0 的"未使用"空间中塞进自定义信息。当 P=1 时,PFN 字段(bits 12-31)指向物理页。当 P=0 时,PFN 字段"空闲"——操作系统把这段空闲字段当作用户自定义的"软件编码区"。这是复用硬件定义的空闲位来表达软件状态的经典设计模式。

3.1.3.14.2 概念解释
  • PTE 位空间(Bit Space):32 位 PTE 由标志位(bits 0-11)和 PFN(bits 12-31)组成。P=1 时 PFN 是物理页号;P=0 时 PFN 可被操作系统复用。
  • Prototype PTE 跳转:P=0 + 自定义标志位 + PFN 字段存储"另一个 PTE 的虚拟地址"。缺页异常时内核跳转到那个 PTE 继续处理。
  • Transition PTE:P=0 + PFN 字段存储"物理页号"。表示该页虽然不在工作集中但仍在内存中(Standby/Modified 状态)。
  • Bit [11] 标志位:操作系统自定义的标志位,区分 prototype/transition/demand-zero 等不同子状态。
3.1.3.14.3 为什么要这样设计

问题 1:为什么 Prototype PTE 需要间接跳转而不是直接写 PFN?
Section 可能被多个进程同时映射。如果直接写 PFN 到 PTE,当 Section 被换出时需要更新所有映射了该 Section 的进程的 PTE——这是 O(N) 操作。使用 prototype PTE 后,只有一个地方(prototype PTE)需要更新,其他进程的 PTE 只需指向这个 prototype PTE。这是"间接层解决一切问题"的经典例子。

问题 2:为什么 Transition 状态需要保留 PFN?
如果 transition PTE 不保留 PFN,那么当原进程重新访问该页时,内核需要从 PFN Database 反向查找"哪个 PFN 对应这个 VA"——这是 O(N) 操作。将 PFN 保存在 PTE 中让恢复路径变成 O(1)——直接从 PTE 读 PFN,然后更新状态即可。

问题 3:为什么软件无效态的区分是在 #PF 处理中判断而不是在 PTE 写回时?
#PF 处理是"必经之路"——任何 P=0 的 PTE 被访问时都会触发 #PF。在 #PF 中统一判断让"写入时"变得简单:不需要在每次写 PTE 时设置不同的标志位,只需写入正确的 PTE 值即可。这是**推延决策(lazy decision)**的设计模式——把复杂判断延迟到真正需要的时候。

3.1.3.15 PAE 模式下的 PTE 写入

PAE 模式的特殊性

  1. PTE 是 64 位:PFN 字段从 20 位扩展到 36 位(高 16 位)。
  2. CR3 是 64 位:从 32 位扩展到 64 位。
  3. 多一层 PDPT(Page Directory Pointer Table):4 项,每项指向一张页目录(4 GB 虚拟地址空间)。
  4. 页表项数减半:每张页目录/页表 512 项(vs 非 PAE 的 1024 项)。

ReactOS 中的 PAE 实现

  • [ntoskrnl/mm/i386/pagepae.c](file:///d:/reactos/ntoskrnl/mm/i386/pagepae.c) 提供 PAE 版本的页表操作。
  • MI_PTE_HARDWARE_PAE 宏构建 64 位 PTE。
  • MiBuildPte 在 PAE 模式下使用 64 位 PTE 编码。

启用 PAE 的条件

  • 物理内存超过 4 GB(典型)
  • 内核启动参数 /PAE
  • CPU 支持 PAE(几乎所有现代 x86 CPU 都支持)
3.1.3.15.1 设计意图

核心问题:32 位 x86 CPU 的地址空间只有 4 GB,但物理内存可能超过 4 GB。如何在 32 位虚拟地址空间限制下管理超过 4 GB 的物理内存?

设计哲学:PAE 是 Intel 为解决"32 位地址空间不够用"而推出的硬件扩展。操作系统选择 PAE 后,虚拟地址空间仍为 32 位(4 GB),但物理地址空间扩展到 36 位(64 GB)。代价是 PTE 翻倍(从 4 字节变为 8 字节),每个页表的项数减半。这是"时间换空间"的经典权衡——牺牲部分性能和虚拟地址空间换取更大的物理内存访问能力。

3.1.3.15.2 概念解释
  • PDPT(Page Directory Pointer Table):PAE 引入的新层级。4 项的小表,每项指向一张页目录。由 CR3 指向。
  • PAE PTE(64 位):标志位(bits 0-11)、PFN(bits 12-35)、保留位(bits 36-63)。相比 32 位 PTE 扩展了 16 位 PFN 空间。
  • PAE 启动参数:Windows 启动选项 /PAE。不设置此参数时即使物理内存超过 4 GB 也只使用前 4 GB。
  • NX 位(No eXecute):PAE 模式开启后可用的 CPU 特性。PTE 的 bit 63 用作"不可执行"标志,防止数据页被执行。
3.1.3.15.3 为什么要这样设计

问题 1:为什么不直接用 64 位操作系统来解决 4 GB 限制?
PAE 是 32 位时代的"过渡方案"。在 64 位系统普及之前,它允许服务器使用 4 GB 以上的内存。64 位系统虽然更彻底,但需要所有驱动和应用重新编译。PAE 是二进制兼容的方案——大多数 32 位驱动不需要修改就能在 PAE 模式下运行。

问题 2:为什么 PDPT 只有 4 项而不是更大?
在 PAE 模式下,虚拟地址仍为 32 位。4 项 PDPT × 512 项 PDE × 512 项 PTE × 4 KB 页 = 4 GB,正好覆盖整个虚拟地址空间。更大的 PDPT 会浪费空间(虚拟地址只有 32 位)。

问题 3:为什么 ReactOS 默认不启用 PAE?
PAE 会带来一些兼容性问题:某些旧驱动假设 PTE 是 32 位,直接读写 PTE 内容会导致崩溃。同时 PTE 翻倍意味着内核需要管理的 PTE 数量变大(占用更多非页池)。ReactOS 作为实验性系统,默认选择"最兼容"的配置,由高级用户按需启用。

3.1.3.16 TLB 刷新的细节

TLB 刷新的三种方式

方式 影响范围 性能 用途
invlpg Address 单个虚拟地址 最快 修改 PTE 后
invpcid(x86 现代特性) 单个虚拟地址,更精细控制 现代 Windows 使用
mov cr3, new_cr3 整个 TLB 进程切换

ReactOS 的 TLB 刷新函数

VOID MiFlushTlb(PVOID Address, BOOLEAN AllProcessors) {
    if (AllProcessors) {
        // IPI 让所有 CPU 刷 TLB
        KeIpiGenericCall(MiFlushTlbWorker, (ULONG_PTR)Address);
    } else {
        // 当前 CPU 刷 TLB
        __invlpg(Address);
    }
}

多核同步问题

  • 如果只有"当前 CPU"在改 PTE(其他 CPU 看不到这次修改),单 invlpg 即可。
  • 如果多个 CPU 都可能改 PTE(共享 PTE 场景),需要**IPI(Inter-Processor Interrupt)**让所有 CPU 都刷 TLB。
  • 实际 ReactOS 中几乎所有 PTE 修改都是"当前 CPU 单边修改"——多核同步通过 PTE 的原子写 + 单 invlpg 已足够。
3.1.3.16.1 设计意图

核心问题:TLB 是 CPU 硬件管理的缓存,操作系统不能直接修改 TLB 的内容,只能通过 “invalidate”(让 TLB 项失效)来暗示 CPU “下次访问时请重新查页表”。如何在这种限制下保持多 CPU 的一致性?

设计哲学广播失效(Broadcast Invalidation)。当一个 CPU 修改了 PTE,它只需要让自己的 TLB 失效(invlpg)。但如果其他 CPU 可能缓存了旧的 TLB 映射,需要通过 IPI(跨处理器中断)让每个 CPU 自己执行 invlpg。这是"最终一致性(Eventual Consistency)"在硬件层面的应用——不是立即一致,而是在每个 CPU 收到 IPI 后达到一致。

3.1.3.16.2 概念解释
  • IPI(Inter-Processor Interrupt):多核 CPU 之间发送的中断。用于通知其他 CPU 执行特定操作(如刷新 TLB)。
  • invlpg 指令:INValidates Page TLB entry。让 CPU 丢弃指定虚拟地址的 TLB 缓存项。下次访问时重新查页表。
  • mov cr3, value:写入 CR3 寄存器让整个 TLB 失效。代价高(刷新所有 TLB 项),但进程切换时必须执行(因为虚拟地址映射完全改变)。
  • TLB Shootdown:通过 IPI 让其他 CPU 刷新 TLB 的过程。是多核系统中的常见操作。
3.1.3.16.3 为什么要这样设计

问题 1:为什么不使用 mov cr3 刷新单个地址的 TLB?
mov cr3 会让整个 TLB 失效,代价是 O(100) 个 CPU 周期(因为所有缓存项都失效了,后续访问都需要重新查页表)。invlpg 只针对单个虚拟地址,代价约 O(10) 个周期。在修改单个 PTE 的高频场景下,invlpg 的性能优势是 10 倍以上。

问题 2:为什么 TLB Shootdown 需要 IPI 而不是由硬件自动同步?
CPU 硬件不直接管理"其他 CPU 的 TLB"。多核一致性协议(MESI)只保证 cache 一致性,不保证 TLB 一致性。操作系统必须显式发送 IPI。这是一个有意识的设计选择:让硬件简单(不需要跨 CPU TLB 同步逻辑),让软件处理复杂性

问题 3:为什么大多数时候只需要当前 CPU 的 invlpg?
在单 CPU 系统中,所有内存访问都经过同一个 TLB——修改 PTE 后执行 invlpg 就足够了。在多核系统中,只有当"其他 CPU 可能缓存了该地址的 TLB 项"时才需要 IPI。大多数缺页异常是"当前 CPU 访问自己进程的私有内存"——其他 CPU 的 TLB 中不会有对应项(因为不同进程的页表不同),因此不需要 IPI。

3.1.3.17 小结
  • x86 PTE 是 32 位(非 PAE)/ 64 位(PAE)结构,编码"1 个虚拟页 → 1 个物理页"的映射。
  • PTE 有两种状态:硬件有效P=1)和软件无效P=0 含软件信息)。后者包括 Demand Zero、Prototype 跳转、Transition 三种。
  • 建立映射的标准三步MiAllocatePte(取槽位)→ MiBuildPte(构造)→ MI_WRITE_VALID_PTE(原子写)→ KeFlushSingleTb(TLB 刷新)。
  • 9 种保护位(PAGE_NOACCESS、PAGE_READONLY … PAGE_GUARD)通过 MiMakePteProtection 转为 PTE 硬件位。
  • WRITECOPY 是 Windows 的硬件级优化:让 PTE 标记为"只读+U/S=1",任何写入触发 #PF,再做 COW 复制。
  • PAE 模式支持 36 位物理地址(最多 64 GB),代价是 PTE 变 64 位。
  • TLB 刷新是 PTE 修改后的"安全网"——CPU 不会自动发现 PTE 变化。
  • 多核 PTE 竞争通过"PTE transition 状态"避免——其他 CPU 看到"已有 PFN 在 transition"时自旋等待。

本篇小结

本篇是第 3 章"内存管理"的开篇,聚焦虚拟内存的骨架——用户态与内核态两侧的内存对象,以及虚拟地址到物理地址的桥接。

本篇涉及的三个核心数据结构

数据结构 描述对象 关键文件 关键 API
VAD 树(3.1.1) 用户态虚拟地址空间的使用 ARM3/vadnode.cmm.h:250 MiInsertNode / MiFindNodeOrParent
PFN Database(3.1.2) 物理页的状态与归属 ARM3/pfnlist.cmm.h:332 MiAllocatePage / MiFreePage
PTE 数组(3.1.3) 虚拟地址→物理页的硬件映射 i386/page.cARM3/special.c MiBuildPte / MI_WRITE_VALID_PTE

三者的协作关系

用户态虚拟地址 ─VAD─→ 区段属性 ─PTE─→ 物理页 ─PFN Database─→ 状态/归属
   (VAD 树)            (VAD)         (PTE)            (PFN 数组)
                                              ↓
                                          6 种状态

**本篇的"概念解释"**回答了读者最常见的 18 个术语(VAD、VAD 树、Prototype PTE、MEMORY_AREA、AllocationGranularity、Subsection、PFN、PFN Database、页状态、零页、_MMPFN、零位图、PTE、PDE、PDPT、PTE 位编码、TLB、保护位编码)。

**本篇的"设计哲学"**集中回答了 13 个"为什么要这样设计"的核心问题:

  • 为什么用 VAD 而不是线性表?(O(log N) 性能)
  • 为什么用 AVL/红黑树而不是 hash?(动态范围查询)
  • 为什么 ReactOS 同时维护 MEMORY_AREA 与 VAD?(历史包袱+Spare 互转)
  • 为什么 64 KB 段对齐?(VAD 树与磁盘 I/O 的"最小区间单位")
  • 为什么需要 PFN Database 而不是用页表自身?(管理层 vs 映射层)
  • 为什么用数组而不是 hash 表?(PFN 连续整数+硬件预取器友好)
  • 为什么 6 种页状态而不是更简单?(精细状态机的工程价值)
  • 为什么 zero page 是全局共享的?(sparse array 模式 + 按需 COW)
  • 为什么 PFN Database 不是稀疏的?(硬件 MMU 反向引用要求)
  • 为什么 PTE 修改后必须 invlpg?(CPU 不会自动发现 PTE 变化)
  • 为什么 MI_WRITE_VALID_PTE 要原子写?(多核可见性)
  • 为什么 PAE 模式下 PTE 变 64 位?(支持 36 位物理地址)
  • 为什么 MiAllocatePte 与 MiBuildPte 分成两步?(分配失败回滚+后续修改)

中篇预告:[3.1.4 Hyperspace 临时映射](file:///d:/reactos/doc/第3章_内存管理_中.md)、[3.1.5 系统空间的映射](file:///d:/reactos/doc/第3章_内存管理_中.md)、[3.1.6 系统调用 NtAllocateVirtualMemory()](file:///d:/reactos/doc/第3章_内存管理_中.md)、[3.2 页面异常](file:///d:/reactos/doc/第3章_内存管理_中.md)——讨论"系统空间的特殊用途、Hyperspace 临时映射、内存分配 API 与缺页异常处理",是虚拟内存的运行机制

更多推荐