第 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 描述的虚拟地址区段。
StartingVpn和EndingVpn是页号(即虚拟地址右移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 的内存管理经历过两次重大迭代:
- 早期版本("rosmm"):使用 MEMORY_AREA(file:///d:/reactos/ntoskrnl/include/internal/mm.h#L253-L268) 结构管理用户态内存区段。MEMORY_AREA 的核心是"一种类型(Type)+ 一段虚拟地址区间 + SectionData 子结构",用链表串起来。
- ARM3 重构 :向 Windows Research Kernel 靠拢,使用
_MMVAD红黑树/AVL 树管理。
两种机制如何共存?通过 MI_SET_MEMORY_AREA_VAD 和 MI_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 时,内核会:
- 创建一个 Section 对象(
SECTION内核对象,详见 3.4 节)。 - 在调用进程的 VAD 树中插入一个新 VAD 节点。
- 该 VAD 的
Subsection指针指向 Section 中的具体子段。 - 该 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)和磁盘扇区位置(StartingSector、NumberOfFullSectors)。 - 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_VAD 与 MI_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_SHIFTPAGE_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):
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 数组。
典型代码片段(简化):
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 利用这一观察做了两个优化:
-
全局零页(
MmGlobalZeroPage) :内核启动时分配一个"全 0"物理页,所有进程共享它。新分配页时,PTE 的 PFN 临时指向这个零页 (设置P=1+PFN=MmGlobalZeroPage)。当用户第一次写入该页时,#PF 触发,内核才分配真正的物理页并复制零页内容。 -
零位图(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 1 :
MiInitMachineDependent在 ARM3/mminit.c(file:///d:/reactos/ntoskrnl/mm/ARM3/mminit.c) 中调用,根据 HAL 提供的MmPhysicalMemoryBlock建立 PFN 数组的初始版本。 - Phase 2 :
MiInitializePfnDatabase完成 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.ReferenceCount 和 u4.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) 中按序完成。
- ReferenceCount 和 ShareCount 协同保证 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 管理:
- 第一层(CPU 硬件):解析 PTE 中的 PFN 和标志位,完成地址翻译。硬件不关心 PTE 如何被创建/修改,只关心"当前 PTE 是什么状态"。
- 第二层(内核软件) :负责 PTE 的分配(
MiAllocatePte)、构造(MiBuildPte)、写入(MI_WRITE_VALID_PTE)和刷新(KeFlushSingleTb)。 - 第三层(并发控制):保证多核场景下 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 实际有两种状态:
- 硬件有效态 :
P=1,且有真实的 PFN。CPU 直接走硬件映射。 - 软件无效态 :
P=0,但 PTE 内容包含"软件信息",操作系统用它做:- Demand Zero :
P=0,PFN = 0。第一次缺页时从零页分配。 - Prototype PTE 跳转 :
P=0,"PFN"字段实际是另一个 PTE(Section 的原型 PTE)的地址。 - Transition :
P=0,但 PTE 中保留了一个 PFN(过渡态------该 PFN 属于 Standby/Modified 列表,正在被换出)。 - 完全无效 :
P=0,全部为零或任意非以上模式------访问会触发 ACCESS_VIOLATION。
- Demand Zero :
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_HARDWARE 宏(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 // 过渡态
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_READONLY、PAGE_READWRITE、PAGE_WRITECOPY、PAGE_EXECUTE、PAGE_EXECUTE_READ、PAGE_EXECUTE_READWRITE、PAGE_EXECUTE_WRITECOPY、PAGE_NOACCESS、PAGE_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:为什么 MiAllocatePte 与 MiBuildPte 分成两步?
分配 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 位标志。
MmAccessFault:KiTrap0EHandler调用的 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 实际有两种状态:
- 硬件有效态 :
P=1,且有真实的 PFN。CPU 直接走硬件映射。 - 软件无效态 :
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]=1(MI_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 模式的特殊性:
- PTE 是 64 位:PFN 字段从 20 位扩展到 36 位(高 16 位)。
- CR3 是 64 位:从 32 位扩展到 64 位。
- 多一层 PDPT(Page Directory Pointer Table):4 项,每项指向一张页目录(4 GB 虚拟地址空间)。
- 页表项数减半:每张页目录/页表 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.c、mm.h:250 |
MiInsertNode / MiFindNodeOrParent |
| PFN Database(3.1.2) | 物理页的状态与归属 | ARM3/pfnlist.c、mm.h:332 |
MiAllocatePage / MiFreePage |
| PTE 数组(3.1.3) | 虚拟地址→物理页的硬件映射 | i386/page.c、ARM3/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 与缺页异常处理",是虚拟内存的运行机制。