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

第 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 行附近。简化后其关键字段如下:

c 复制代码
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)。

c 复制代码
/* 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)):

c 复制代码
#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)):

c 复制代码
#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))------ 用于标记物理页"被谁占用":

c 复制代码
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))会校验调用者确实持有该锁:

c 复制代码
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) 中。简化后:

c 复制代码
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):

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

典型代码片段(简化):

c 复制代码
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)

零页机制的关键代码路径

  • MmGlobalZeroPageARM3/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 1MiInitMachineDependentARM3/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 关键字段(简化):

c 复制代码
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)):

c 复制代码
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)):

c 复制代码
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)):

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)):

c 复制代码
#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"映射的标准三步

c 复制代码
/* 第 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))做原子写入:

c 复制代码
#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

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

MI_PTE_HARDWAREmm.h(file:///d:/reactos/ntoskrnl/include/internal/mm.h)):

c 复制代码
#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)):

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)):

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 刷新函数

c 复制代码
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 与缺页异常处理",是虚拟内存的运行机制

相关推荐
caimouse1 小时前
ReactOS 架构
架构
代码的小搬运工1 小时前
【iOS】MVC架构
ios·架构·mvc
程序员佳佳1 小时前
向量引擎:AI 时代的“记忆中枢“,从原理到落地的完整认知框架
人工智能·gpt·架构·aigc·ai编程
国科安芯1 小时前
ASP7A84AS高精度抗辐照线性稳压器技术特性与应用分析
单片机·嵌入式硬件·安全·架构
阿狸猿1 小时前
论模型驱动架构设计方法及其应用
架构
金融支付架构实战指南1 小时前
微服务DDD落地规范:内部抛异常、RPC外层Result封装
微服务·rpc·架构·错误码
奋斗的小方1 小时前
Java进阶篇1-2:泛型
java·开发语言·windows
C137的本贾尼1 小时前
MySQL 整体架构与存储引擎对比
数据库·mysql·架构
caimouse1 小时前
Reactos 第 4 章 对象管理 — 4.6 对象的访问控制 / 4.7 句柄的遗传和继承
开发语言·windows·架构