第 3 章 内存管理 --- 【中篇】Hyperspace、系统空间、API 与异常
上篇(file:///d:/reactos/doc/第3章_内存管理_上.md) 讲述了 ReactOS 内存管理器的骨架 ------VAD 树(用户态虚拟地址空间的使用清单)、PFN Database(物理页的状态账本)、PTE 数组(虚拟→物理的硬件映射)。本篇要讨论的是肌肉 :当内核需要"零时访问一块物理页"时怎么办(Hyperspace 临时映射 );当内核需要"申请一组 PTE 槽位做一次性映射"时怎么办(系统空间映射 );当用户调用 VirtualAlloc 时从用户态到内核态经历了什么(NtAllocateVirtualMemory 全路径 );当用户访问了一个未映射的页面时,缺页异常是如何被处理的(页面异常)。
这四个小节是虚拟内存机制中最常被调用的代码路径 ------任何一个进程的每秒钟都会触发成千上万次缺页异常;每一个 I/O 操作都会用到 Hyperspace 临时映射;每一个驱动加载都会申请系统 PTE;每一个用户 malloc 都会走到 NtAllocateVirtualMemory。
3.1 内存区间的动态分配(续)
3.1.4 Hyperspace 的临时映射
3.1.4.0 框架图(先见森林)
┌────────────────────────────────────────────────────────────────────┐
│ 系统空间(2 GB~4 GB) │
│ │
│ 0x80000000 ┌──────────────────────────────────────┐ │
│ │ 内核代码(NT/HAL/驱动) │ │
│ ├──────────────────────────────────────┤ │
│ │ 内核栈、系统 PTE 区域 │ │
│ ├──────────────────────────────────────┤ │
│ 0xC0000000 │ HyperSpace(临时映射区) │ │
│ │ ┌──────┐ ┌──────┐ ┌──────┐ │ │
│ │ │ HS_0 │ │ HS_1 │ │ HS_2 │ ← 每页一项 │ │
│ │ └──────┘ └──────┘ └──────┘ │ │
│ ├──────────────────────────────────────┤ │
│ │ 其他内核保留区(PCR/PCI/...) │ │
│ 0xFFFFFFFF └──────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────┘
│
│ MiMapPageInHyperSpace(Process, Pfn) → 返回 0xC0001000
│ 临时把 Pfn 编号的物理页映射到 HyperSpace 槽位
│ MiUnmapPageInHyperSpace 立即解除映射
▼
使用例:写入换页文件前,临时映射源页 → 拷贝 → 解除
本图核心要点 :HyperSpace 是系统空间中保留的一组"临时 PTE 槽位" 。每页对应一个槽位,槽位可被临时占用------内核通过 MiMapPageInHyperSpace 把任意物理页临时"挂"到这些槽位上,访问完立即 MiUnmapPageInHyperSpace 解除。
3.1.4.0.1 设计意图
核心问题:操作系统在管理物理页时,经常需要"临时访问某个物理页的内容"------比如修改该页对应的 PTE、写入换页文件、DMA 准备。但 CPU 只能通过虚拟地址访问内存,不能直接操作 PFN。x86 架构没有 MIPS/ARM 那种「KSEG 固定映射」的硬件机制------必须靠软件在页表中留出一组槽位,按需映射物理页。这就是 HyperSpace 的本质。
设计哲学 :临时占用、用完即释、每进程独立。HyperSpace 不是一种"分配方式"------它只是一组"预留的 PTE 槽位"。每次映射只改变一个 PTE 的 PFN 字段,开销是 O(1)(填 PTE + 刷 TLB)。每进程独立的槽位池避免了跨进程锁竞争。这是"虚拟内存管理的瑞士军刀"------工具本身不占内存,但需要时能随时"把任意物理页挂到已知虚拟地址"。
本节定位:3.1.4 节是理解后续内容的基础。读者理解 HyperSpace 后,才能理解 3.1.5 的系统 PTE(也是一种"一次性映射工具")、3.2 的 COW 页复制(通过 HyperSpace 访问源页和目标页)、3.3 的页写入 pagefile(通过 HyperSpace 读取物理页内容)。
3.1.4.1 为什么需要 Hyperspace
内核在以下场景需要"零时访问一块物理页":
-
写入换页文件 :当一个 Modified 状态的 PFN 需要写回 pagefile 时,内核需要"读源页内容 → 写到 pagefile"。但 pagefile 是普通磁盘 I/O 路径,不能直接操作 PTE。解决:把源页临时映射到 HyperSpace,内核从这个虚拟地址读出内容。
-
修改 PTE 自身 :当需要修改某 PTE 的内容时(如 COW 时把 PTE 从 "R/W=1" 改为 "R/W=0"),内核需要先找到该 PTE 的虚拟地址。但如果该 PTE 描述的是"用户态虚拟地址对应的 PTE",它的虚拟地址在内核中没有直接对应------PTE 在内核中通过"页表基址 + 索引"计算得到。HyperSpace 提供"已知虚拟地址 → 直接读 PTE 内容"的桥。
-
DMA 准备:DMA 控制器需要物理地址,但内核代码处理的是虚拟地址。内核通过 HyperSpace 把"目标页"临时映射,然后取该虚拟地址对应的物理地址(PFN)交给 DMA 控制器。
-
修改页表项(如 Protection 改变) :当
VirtualProtect改变用户态页的保护位时,内核需要找到该页的 PTE 写新值。HyperSpace 提供"页表基址 + 索引"的统一访问点。
总结 :Hyperspace 是内核"短时访问任意物理页"的标准工具。x86 没有 KSEG("固定虚拟地址对应物理页")的特殊段------临时映射是 x86 唯一支持"短时访问物理页"的标准做法。
3.1.4.2 HyperSpace 的地址范围
在 ReactOS 的实现中,HyperSpace 的虚拟地址范围是:
- 起始地址 :
MmFirstReservedMappingPte(PTE 虚拟地址),对应虚拟地址 ~0xC0000000。 - 结束地址 :
MmLastReservedMappingPte,对应虚拟地址 ~0xC0000000 +MI_HYPERSPACE_PTES * PAGE_SIZE。 - 槽位数 :
MI_HYPERSPACE_PTES(通常 64~256)。
每个进程都有自己独立 的 HyperSpace 槽位集合。Process->HyperSpaceLock 自旋锁保护该进程的 HyperSpace 分配。
为什么每个进程独立? 因为 Hyperspace 锁是"自旋锁"------如果多个进程共享一个 Hyperspace 池,则任何映射操作都会让其他进程在该锁上自旋等待。每个进程独立的 HyperSpace 避免了这种跨进程锁竞争。
3.1.4.3 MiMapPageInHyperSpace 的实现
`MiMapPageInHyperSpace`(file:///d:/reactos/ntoskrnl/mm/ARM3/hypermap.c#L26-L87) 接受三个参数:Process、Page(PFN)、OldIrql(保存旧 IRQL)。其核心步骤:
c
PVOID
NTAPI
MiMapPageInHyperSpace(IN PEPROCESS Process,
IN PFN_NUMBER Page,
IN PKIRQL OldIrql)
{
MMPTE TempPte;
PMMPTE PointerPte;
PFN_NUMBER Offset;
ASSERT(Page != 0);
ASSERT(MiGetPfnEntry(Page) != NULL);
// 1. 构造 PTE(ValidKernelPteLocal + 目标 PFN)
TempPte = ValidKernelPteLocal;
TempPte.u.Hard.PageFrameNumber = Page;
// 2. 取当前可用槽位
PointerPte = MmFirstReservedMappingPte;
Offset = PFN_FROM_PTE(PointerPte);
// 3. 加锁(提升 IRQL 到 DISPATCH_LEVEL)
KeAcquireSpinLock(&Process->HyperSpaceLock, OldIrql);
// 4. 检查是否需要"翻页"(用完所有槽位后从头开始)
if (!Offset) {
Offset = MI_HYPERSPACE_PTES;
KeFlushProcessTb();
}
// 5. 写 PTE(原子操作)
PointerPte->u.Hard.PageFrameNumber = Offset - 1;
PointerPte += Offset;
MI_WRITE_VALID_PTE(PointerPte, TempPte);
// 6. 返回该虚拟地址
return MiPteToAddress(PointerPte);
}
关键设计点:
- "翻页"机制 :
PointerPte->u.Hard.PageFrameNumber实际上不是"目标 PFN",而是"当前可用槽位索引"。当所有槽位用完(Offset = 0)时,"翻页"------KeFlushProcessTb刷新 TLB,所有槽位重新可用。 - 保存旧 IRQL :
OldIrql是KeAcquireSpinLock提升 IRQL 之前的值。MiUnmapPageInHyperSpace会用这个值KeReleaseSpinLock恢复 IRQL。 - 原子性保证 :
MI_WRITE_VALID_PTE原子写入 PTE,CPU 立即看到新映射(无需立即 invlpg,因为没有"旧映射"------HyperSpace 槽位原本就是 invalid 的)。
3.1.4.4 MiUnmapPageInHyperSpace 的实现
`MiUnmapPageInHyperSpace`(file:///d:/reactos/ntoskrnl/mm/ARM3/hypermap.c#L89-L107) 是"解除映射",与 map 配对:
c
VOID
NTAPI
MiUnmapPageInHyperSpace(IN PEPROCESS Process,
IN PVOID Address,
IN KIRQL OldIrql)
{
ASSERT(Process == PsGetCurrentProcess());
// 1. 把该 PTE 清零(P=0, PFN=0)
MiAddressToPte(Address)->u.Long = 0;
// 2. 释放锁(恢复 IRQL)
ASSERT(KeGetCurrentIrql() == DISPATCH_LEVEL);
KeReleaseSpinLock(&Process->HyperSpaceLock, OldIrql);
}
关键点:
- PTE 清零:而不是标记 invalid 或保留 PFN。清零最安全------下次 HyperSpace 压力下的"翻页"不会错误地处理这个槽位。
- IRQL 恢复 :在
MiMapPageInHyperSpace之前调用方的 IRQL 通常是PASSIVE_LEVEL或APC_LEVEL,map 之后提升到DISPATCH_LEVEL;unmap 时恢复到原 IRQL。
3.1.4.5 使用场景详解
场景 1:写入换页文件
c
// 1. 把源 PFN 临时映射到 HyperSpace
Src = MiMapPageInHyperSpace(Process, SourcePfn, &OldIrql);
// 2. 拷贝到 I/O 缓冲
RtlCopyMemory(IoBuffer, Src, PAGE_SIZE);
// 3. 发起 I/O(异步)
IoStatus = NtWriteFile(SwapFileHandle, ..., IoBuffer, ...);
// 4. 立即解除 HyperSpace 映射
MiUnmapPageInHyperSpace(Process, Src, OldIrql);
关键点 :源 PFN 的内容已经在 IoBuffer 指向的物理页中(通常是 NonPagedPool 中的页)。HyperSpace 映射只是"读源"。
场景 2:修改 PTE 自身
c
// 假设 Pte 是一个 PMMPTE 类型的 PTE 指针
// 现在要把 PTE 标记为只读(COW 准备)
PteValue = *Pte; // 读旧值
PteValue.u.Hard.Write = 0; // 清 R/W 位
MI_WRITE_VALID_PTE(Pte, PteValue); // 写回
KeFlushSingleTb(VirtualAddress, FALSE); // 刷 TLB
这个场景中 Pte 是已经计算好的虚拟地址------Pte = MiAddressToPte(VirtualAddress)。关键 :这个 Pte 指向的是页表中的 PTE 项,它的物理页号(即"页表本身的物理页")需要被访问才能修改------而页表本身的物理页在 HyperSpace 槽位上映射。
3.1.4.6 概念解释
- Hyperspace :系统空间中保留的一组"临时 PTE 槽位"。每页对应一个槽位,槽位可被临时占用。ReactOS 中每进程有
MI_HYPERSPACE_PTES个槽位。 - 临时映射(Temporary Mapping):把物理页临时关联到 Hyperspace 中的某个虚拟地址,用完即解除。生命周期是"几行 C 代码的执行时间"。
MiMapPageInHyperSpace:把 Pfn 临时映射到 Hyperspace,返回虚拟地址。要求在PASSIVE_LEVEL或APC_LEVEL调用(map 后变为DISPATCH_LEVEL)。MiUnmapPageInHyperSpace:解除刚才的临时映射,恢复到原 IRQL。- DMA(Direct Memory Access):外设直接读写物理内存的机制。内核在准备 DMA 缓冲区时常用 Hyperspace 临时访问物理页,以获取该页的物理地址。
- Recursive Mapping:x86 上用 PTE 自身的"虚拟地址"作为"页表项内容"------典型地用于修改 PTE 自身(页目录项的最后一项指向页目录本身)。
3.1.4.7 为什么要这样设计
问题 1:为什么需要 Hyperspace 临时映射而不是用 KSEG、段重映射?
x86 没有 KSEG 这种"固定虚拟地址对应物理页"的特殊段。临时映射是 x86 唯一支持"短时访问物理页"的标准做法。MIPS 架构有 KSEG0/KSEG1("固定虚拟地址直接映射物理页"),但 x86 没有这种硬件支持------必须靠软件实现"短时 PTE 槽位"。
问题 2:为什么 HyperSpace 槽位是有限的?
槽位 = 槽位数 × 4 KB 内存。无限槽位会浪费虚拟地址;槽位过多又增加管理复杂度。常见做法是"每进程几个槽位"(MI_HYPERSPACE_PTES = 64~256)。在 32 位下系统空间只有 2 GB,分给 HyperSpace 通常 256 KB(64 槽位)。
问题 3:为什么映射时设 Kernel + RW 而非 User + RW?
Hyperspace 用于内核访问,禁止用户态访问 (避免 user 进程借此访问任意物理页)。PTE 的 User 位强制 ring 0 才能访问。安全意义:如果 Hyperspace PTE 是 User + RW,用户态进程就可以"借用"HyperSpace 访问任意物理页------这是巨大的安全漏洞。
问题 4:为什么 unmap 时要把 PTE 清零而非标记 invalid?
如果标记 invalid 而保留 PFN,下次 HyperSpace 压力下的"翻页"可能错误地处理这个槽位。例如:标记 invalid 但 PFN=100,下次"翻页"时可能误以为这是"已映射的槽位",跳过它。清零是最安全的状态------表示"完全空"。
问题 5:为什么每进程独立 HyperSpace 槽位?
HyperSpace 锁是"自旋锁"------如果多个进程共享一个 HyperSpace 池,则任何映射操作都会让其他进程在该锁上自旋等待。每个进程独立的 HyperSpace 避免了这种跨进程锁竞争。额外好处:HyperSpace 操作是"短时"的,独立的池能减少锁持有时间。
3.1.4.9 HyperSpace 与零页机制的配合
当 HyperSpace 与 3.1.2 节的「零页(Zero Page)」机制配合使用时,能显著降低 COW 复制的开销。
核心流程:
- COW 触发时需要分配一个新物理页 ------ 优先从 Zeroed 列表取页(内容已全 0)。
- 如果 Zeroed 列表为空,则从 Free 列表取页并通过 HyperSpace 临时映射做
memset(0)。 - 复制源页内容到新页时,源页也通过 HyperSpace 映射(因为源页可能只有 PFN 没有虚拟地址)。
为什么这是关键优化 :Zeroed 列表的页"内容已经是全 0"------跳过清零步骤。新分配的 80% 的页是纯 demand-zero 分配(不需要复制),直接使用零页就能避免 memset(4 KB) 的 ~1000 CPU 周期。
3.1.4.9.1 设计意图
核心问题:COW 复制中"分配新页 + 清零 + 复制"三步的开销不可忽视。如何将开销降到最低?
设计哲学 :将"清零"这一操作从缺页异常的关键路径中剥离出来。零页机制让"分配新页"能直接拿到已清零的页(Zeroed 列表或全局零页)。HyperSpace 在 COW 中只负责"访问源页内容"------两者组合让 COW 的关键路径只做"复制 4 KB"这一步。
3.1.4.9.2 概念解释
- Zeroed 列表 :PFN Database 中"内容已全 0"的页组成的列表。
MiAllocatePage优先从这里取页。 - Free 列表 :内容无意义的空闲页。需要
memset(0)才能交付给用户态。 - HyperSpace 清零 :通过
MiMapPageInZeroSpace(HyperSpace 的变体)将 Free 页临时映射并清零后放入 Zeroed 列表。 - COW(写时复制):3.2 节详解。此处涉及"分配新页 + 复制源页内容"。
3.1.4.9.3 为什么要这样设计
问题 1:为什么不直接在缺页异常中做 memset? 缺页异常是最高频的内核路径之一。每次 #PF 做 memset(4 KB) 约需 1000 CPU 周期------在缺页密集的应用(如 malloc-heavy、数据库)中,这会占 10%~30% 的 CPU。将清零延迟到"系统空闲时"(后台线程维护 Zeroed 列表)能将缺页异常的开销降到近乎零。
问题 2:为什么 HyperSpace 能做清零? HyperSpace 能"将任意 PFN 映射到已知虚拟地址"。只要有了虚拟地址,memset(0, PAGE_SIZE) 就是标准操作。本质是利用 HyperSpace 获得"对物理页的写访问权"。
问题 3:为什么 Free 列表不直接做 Zeroed? Free 列表的页可能刚从 Standby 列表回收------内容不一定是 0。如果 Free 列表直接作为 Zeroed,用户态进程可能读到前一个进程残留的数据(安全漏洞)。因此 Free → Zeroed 的转换必须显式清零。
3.1.4.10 HyperSpace 的并发控制
HyperSpace 的槽位数量有限(通常 64~256 个),且每进程独立。在多线程高并发场景下,多个线程可能同时申请 HyperSpace 槽位------需要严格的并发控制。
锁模型:
Process->HyperSpaceLock:自旋锁,保护该进程的 HyperSpace 槽位分配。- 持有时机 :
MiMapPageInHyperSpace进入时获取,MiUnmapPageInHyperSpace退出时释放。 - IRQL 提升 :获取自旋锁时 IRQL 从
PASSIVE_LEVEL提升到DISPATCH_LEVEL。
槽位耗尽后的"翻页"机制:
- 当所有槽位被占用时(
Offset == 0),执行KeFlushProcessTb()刷新整个进程的 TLB。 - 刷新后所有槽位被视为"未占用"(因为 HyperSpace 映射的虚拟地址只有在持有锁期间有效,释放锁后没有代码会访问这些地址)。
- 这是一种"时间戳复用"------靠 TLB 刷新保证旧映射的 TLB 项被清除。
3.1.4.10.1 设计意图
核心问题:有限的槽位 + 多线程并发访问 = 如何安全、高效地分配和释放?
设计哲学 :粗粒度锁 + 懒复用。用一把自旋锁保护整个槽位池(粗粒度)------简单可靠,锁持有时间极短(只做 PTE 写入)。槽位耗尽时不做"扫描回收"而是直接刷 TLB 全部重置(懒复用)------TLB 刷新的开销(~100 CPU 周期)远小于扫描 O(N) 回收的开销。
3.1.4.10.2 概念解释
- 自旋锁(Spin Lock):内核中最简单的锁类型。线程在获取锁之前原地自旋(不进入睡眠)。适用于"持有时间极短"的场景。
- IRQL(中断请求级别) :Windows 的中断优先级机制。提升到
DISPATCH_LEVEL能防止线程调度(保证锁持有期间不被切换走)。 - TLB 刷新(Flush TLB) :通过
mov cr3, eax或invlpg让 CPU 的 TLB 缓存失效。HyperSpace 翻页时需要刷整个进程的 TLB,因为所有槽位的旧映射都可能被缓存。 - 时间戳复用(Timestamp-based Reuse):不记录"哪个槽位空闲",而是靠 TLB 刷新后的全局重置实现复用。
3.1.4.10.3 为什么要这样设计
问题 1:为什么不使用细粒度的"每槽位一把锁"? 64~256 个槽位如果每槽位一把锁,锁本身的内存开销(每个 KSPIN_LOCK ~4 字节 + 对齐)就有 1~2 KB。更重要的是"每槽位一把锁"意味着代码需要先扫描"哪把锁可用"------这本身就需要一把"全局锁保护扫描"。粗粒度锁反而更简单、更快。
问题 2:为什么 IRQL 要提升到 DISPATCH_LEVEL? HyperSpace 操作期间不能被中断/调度------如果线程在持有 HyperSpace 锁的情况下被切换走,另一个线程获取同一把锁会导致死锁(因为锁持有者已被切换,无法释放)。提升 IRQL 到 DISPATCH_LEVEL 禁止了线程调度(DPC 以上),保证锁持有期间的原子性。
问题 3:为什么槽位耗尽后直接刷 TLB 而不是逐个 invalidate? 64 个槽位逐个 invlpg 需要 64 次操作(每次 ~10 CPU 周期)。一次 mov cr3 全刷只需 ~100 CPU 周期。在 256 槽位的情况下全刷反而更快。此外刷整个 TLB 能保证"所有 HyperSpace 映射的 TLB 项都被清除"------正确性更简单。
3.1.4.11 HyperSpace 与 PTE 修改的协同
HyperSpace 最常见的用途之一是"修改 PTE 自身"。当内核需要改变某个用户态虚拟地址对应的 PTE 时,它需要先获得该 PTE 的虚拟地址(以便写操作)------这正好是 HyperSpace 的能力。
典型流程:
- 内核拿到用户态地址
0x12345000的 PTE 物理地址:PTE_PA = PageTableBase + (0x12345000 >> 12) * 8。 - 将 PTE 所在的物理页映射到 HyperSpace:
PteVA = MiMapPageInHyperSpace(Process, PFN_from_PTE_PA, &OldIrql)。 - 读取 PTE 值:
OldValue = *PteVA;。 - 修改字段:
NewValue = OldValue; NewValue.u.Hard.Write = 1;(比如提升写权限)。 - 原子写入:
MI_WRITE_VALID_PTE(PteVA, NewValue);。 - 刷 TLB:
KeFlushSingleTb(0x12345000, FALSE);。 - 解除 HyperSpace 映射:
MiUnmapPageInHyperSpace(Process, PteVA, OldIrql);。
关键观察 :步骤 2-7 期间进程不能被调度走(IRQL 保持 DISPATCH_LEVEL),保证 HyperSpace 槽位的独占性。
3.1.4.11.1 设计意图
核心问题:PTE 是"虚拟→物理"的桥,但 PTE 自身的地址也是虚拟地址。当内核需要修改 PTE 时,它如何获得 PTE 的虚拟地址?
设计哲学 :PTE 的虚拟地址 = 页目录基地址 + PTE 索引 × PTE 大小。这个计算出的虚拟地址本身可能不在内核空间------需要一种"动态映射"的方式访问它。HyperSpace 提供了"临时获得任意物理页的虚拟地址"的能力,从而解决了 PTE 自引用的问题。
3.1.4.11.2 概念解释
- 页目录(Page Directory / PDE):x86 页表的顶层。每进程一份,包含 1024 个 PDE(非 PAE 模式)。
- PTE 虚拟地址 :对于地址 VA,其 PTE 的虚拟地址是
0xC0000000 + (VA >> 12) * sizeof(MMPTE)(ReactOS 的约定)。 - PDE 虚拟地址 :
0xC0300000 + (VA >> 22) * sizeof(MMPTE)。 - 自引用页目录(Self-referencing Page Directory):通过将页目录的最后一个 PDE 指向页目录自身,让页目录同时也是"自己的 PTE 数组"------从而能用虚拟地址访问 PTE 和 PDE。
3.1.4.11.3 为什么要这样设计
问题 1:为什么需要自引用页目录? 如果没有自引用,内核访问 PTE 需要"先查页目录找到 PTE 所在的物理页,再用 HyperSpace 映射该物理页"两步。自引用让 PTE 的虚拟地址可以直接计算得出,省去了第一步。开销从 O(2) 降到 O(1)。
问题 2:为什么 HyperSpace 还需要? 自引用页目录处理了"PTE 的虚拟地址可直接计算"的情况。但还有一种场景------内核要修改"另一个进程的页表"(比如跨进程复制页表项)------此时目标进程的页表不在当前进程的虚拟地址空间内,必须通过 HyperSpace 临时映射目标进程的页目录页才能访问。
问题 3:为什么 PTE 修改后必须刷 TLB? CPU 的 TLB 缓存了"虚拟地址→物理页"的映射。修改 PTE 后,如果不刷 TLB,CPU 可能继续使用旧的 TLB 项,导致"PTE 已经是新值但 CPU 仍走旧映射"的诡异 bug。这是所有操作系统内存管理的标准规则------改 PTE 后必刷 TLB。
3.1.4.12 小结
- HyperSpace 是内核"短时访问任意物理页"的标准工具。
- 每个进程有独立的 HyperSpace 槽位集合(默认 64~256 个),由
Process->HyperSpaceLock保护。 MiMapPageInHyperSpace接受 PFN 返回虚拟地址,MiUnmapPageInHyperSpace立即解除。- 典型使用场景:写入换页文件、修改 PTE 自身、DMA 准备、VirtualProtect。
- 关键安全点:Hyperspace PTE 是 Kernel + RW,禁止用户态访问。
- 与零页机制配合:COW 复制中优先从 Zeroed 列表取页,避免 memset 开销。
- 并发控制:自旋锁 + IRQL 提升 + TLB 刷实现槽位的懒复用。
3.1.5 系统空间的映射
3.1.5.0 框架图(先见森林)
┌────────────────────────────────────────────────────────────────────┐
│ 系统空间(32 位下 0x80000000~0xFFFFFFFF,2 GB) │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ ntoskrnl.exe, hal.dll, win32k.sys, .sys 驱动 │ │
│ │ (由 I/O 管理器、PE Loader 加载) │ │
│ │ 这部分映射在所有进程中都是一样的 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Session Space (Session 0 / Session 1) │ │
│ │ Win32k 子系统的会话隔离区,每个 session 独立 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ System Cache / PTE / Page Table │ │
│ │ 系统缓存区(Section 对象共享)、系统 PTE 区 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ NonPagedPool / PagedPool │ │
│ │ 非分页池(锁在物理内存)/ 分页池(可换出) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ HyperSpace(见 3.1.4)/ PCR │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────┘
本图核心要点 :系统空间是所有进程共享的,但有清晰的"分层"------顶层是 NT/HAL/驱动(加载后所有进程都一样),中层是 Session Space 和 System Cache,底层是 Pool(NonPaged / Paged),特殊用途是 HyperSpace 和 PCR。
3.1.5.0.1 设计意图
核心问题 :x86 32 位下虚拟地址空间只有 4 GB。操作系统需要在"用户态地址空间"与"内核态地址空间"之间做出划分。Windows 选择了经典的 2 GB / 2 GB 划分(可通过 /3GB boot 参数改为 3 GB / 1 GB)。如何在这有限的 2 GB 内核空间内高效组织"内核代码、内核数据、驱动、会话、缓存、池、临时映射"等多种用途?
设计哲学 :静态分区 + 动态伸缩。静态分区指"内核代码区、Session Space、System Cache、Pool、HyperSpace"在虚拟地址上有固定的范围划分------每个区域的起始和结束地址是编译时确定的。动态伸缩指 Pool(分页池与非分页池)可以根据物理内存大小动态调整占用的 PTE 数量------物理内存越大,池越大。这是"确定性的布局 + 灵活的容量"的折中方案------开发者可以假设某些地址永远是内核代码,同时系统管理员可以通过 boot 参数微调某些区域的大小。
本节定位:3.1.5 节是"系统空间的地图"。读者理解系统空间的分层后,才能理解 3.1.6 的 NtAllocateVirtualMemory 如何跨越"用户态/内核态边界"申请内存,以及 3.5 节的 Pool 管理在什么地址范围内工作。
3.1.5.1 系统空间的分层
系统空间(32 位 x86 下 0x80000000~0xFFFFFFFF)按用途分层:
| 层 | 用途 | 共享性 | 换出性 |
|---|---|---|---|
| 顶层 | ntoskrnl.exe, hal.dll, win32k.sys, .sys 驱动 | 所有进程相同 | 不可换出 |
| Session Space | Win32k GUI 子系统的会话隔离 | 每个 session 独立 | 部分可换出 |
| System Cache | Section 共享缓存区 | 全局共享 | 可换出 |
| PTE / Page Table | 页表自身 | 进程私有 | 不可换出 |
| NonPagedPool | 锁在内存的池 | 全局共享 | 不可换出 |
| PagedPool | 可换出的池 | 全局共享 | 可换出 |
| HyperSpace | 临时映射 | 进程私有 | 不可换出 |
| PCR / KPCR | 每 CPU 的控制区 | 每 CPU 独立 | 不可换出 |
关键概念:
MmSystemRangeStart(0x80000000):系统空间起点常量。所有系统空间地址都大于等于此值。- 顶层映射:内核代码、HAL、设备驱动由 PE Loader 加载,地址一旦确定所有进程都一样。
- Session Space :每个 session(终端/远程桌面)有独立 GUI 空间。
Session 0(系统服务进程)与Session 1(用户登录后)使用不同的 GUI 资源。
3.1.5.2 系统 PTE(System PTE)
系统 PTE 是系统空间中保留的一组 PTE 槽位,用于"一次性映射"。典型用途:
- MDL 映射 :
IoBuildPartialMdl等函数把一个用户态 MDL 映射到内核虚拟地址。 - I/O 缓冲映射:驱动需要把用户态缓冲区映射到内核态访问。
- 零页清零 :在内核启动阶段用
MiMapPagesInZeroSpace把 Free 页映射到 Zero Space 槽位做memset(0)。 - 换页 I/O:把换页文件读出的内容映射到内核虚拟地址。
关键函数(在 ntoskrnl/mm/ARM3/syspte.c(file:///d:/reactos/ntoskrnl/mm/ARM3/syspte.c)):
MiInitializeSystemPtes(syspte.c:388(file:///d:/reactos/ntoskrnl/mm/ARM3/syspte.c#L388)):在启动时初始化系统 PTE 池。MiReserveSystemPtes(syspte.c:246(file:///d:/reactos/ntoskrnl/mm/ARM3/syspte.c#L246)):申请一组系统 PTE。MiReleaseSystemPtes(syspte.c:264(file:///d:/reactos/ntoskrnl/mm/ARM3/syspte.c#L264)):释放一组系统 PTE。MiReserveAlignedSystemPtes:申请对齐的 PTE 组(用于 DMA 等需要页对齐的场景)。
MiReserveSystemPtes 的使用模式:
c
// 1. 申请
Pte = MiReserveSystemPtes(N, SystemPteSpace);
// 2. 用 Pte 构造虚拟地址
VirtualAddress = MiPteToAddress(Pte);
// 3. 填 PTE(建立映射)
MI_WRITE_VALID_PTE(Pte, MiBuildPte(Pfn, ...));
// 4. 访问
memcpy(LocalBuffer, VirtualAddress, PAGE_SIZE * N);
// 5. 释放
MiReleaseSystemPtes(Pte, N, SystemPteSpace);
关键点:
- 使用即释放 :申请的系统 PTE 必须在使用后立即释放。如果忘记释放,系统 PTE 池会"耗尽",后续申请失败,I/O 路径阻塞。
- 可在任何 IRQL 调用 :
MiReserveSystemPtes内部加的是LockQueueSystemSpaceLock自旋锁,所以可在DISPATCH_LEVEL调用。
3.1.5.3 System Cache
System Cache 是内核态的"系统缓存区",大小通常为物理内存的 25%~75%。它有以下几个关键属性:
- 全局共享:所有进程都通过 Section 映射访问同一份缓存。
- 可换出:当物理内存紧张时,System Cache 中的页可以被换出到 pagefile。
- 统一管理:所有 file mapping 的"工作页"都存放在 System Cache(用户态的 PTE 指向 System Cache 的虚拟地址)。
System Cache 的初始化(在 ARM3/mminit.c(file:///d:/reactos/ntoskrnl/mm/ARM3/mminit.c) 中):
c
// MmSystemCacheStart, MmSystemCacheEnd 标记 System Cache 范围
// 大小 = MmSizeOfSystemCacheInPages
// 默认是物理内存的某个百分比
Cc 子系统 (Cache Manager)建立在 System Cache 之上,提供文件缓存、延迟写、统一缓存语义等高级功能。Cc 在第 5 章(I/O 管理器)会深入讨论。
3.1.5.4 Pool(NonPaged / Paged)
池(Pool) 是内核态的"内存分配器",类似用户态的 malloc。它有两类:
- NonPagedPool(不可换出):物理页被锁定在内存,永不换出。用于:内核对象(EPROCESS/ETHREAD/IRP/MDL)、SpinLock 持有者、DPC/ISR 代码路径。
- PagedPool(可换出):占用的物理页可被换出到 pagefile。用于:注册表 hive、文件缓存(与 System Cache 协作)、注册表子树。
关键函数:
ExAllocatePoolWithTag(PoolType, Size, Tag):申请池内存。ExFreePoolWithTag(Ptr, Tag):释放池内存。ExAllocatePoolWithQuotaTag:申请带配额检查的池内存(避免驱动分配过多)。
Pool 的详细机制在 3.5 系统空间的缓冲区管理(file:///d:/reactos/doc/第3章_内存管理_下.md) 中深入讨论。
3.1.5.5 概念解释
- 系统空间(System Space):x86 32 位下 0x80000000 以上的虚拟地址区间。ReactOS 默认 2 GB;x64 下 128 TB。
MmSystemRangeStart:系统空间起点常量(0x80000000)。所有系统空间地址都大于等于此值。- Session Space :每个 session 独立的 GUI 空间。
Session 0(系统服务进程)与Session 1(用户登录后)使用不同的 GUI 资源。 - 系统 PTE(System PTE) :从
MiSystemPteRangeBase起的一组 PTE 槽位,用于"一次性映射"(如 MDL 映射、内核 MDL 写入)。MiReserveSystemPtes申请一组;用完MiReleaseSystemPtes释放。 - System Cache:内核态的"系统缓存区",大小通常为物理内存的 25%~75%。所有 file mapping 的"工作页"都存放在 System Cache。
- 非分页池(NonPagedPool):占用的物理页被锁定在内存、永不换出。用于:内核对象(EPROCESS/ETHREAD/IRP/MDL)、SpinLock 持有者、DPC/ISR 代码路径。
- 分页池(PagedPool):占用的物理页可被换出到 pagefile。用于:注册表 hive、文件缓存(与 System Cache 协作)、注册表子树。
3.1.5.6 为什么要这样设计
问题 1:为什么系统空间是所有进程共享的?
内核代码、内核对象、HAL、驱动等"全局资源"必须在所有进程的页表上都有相同的映射。否则每个进程的页表都"翻译"一遍内核代码的虚拟地址,会浪费大量页表空间(4 GB 虚拟地址 = 1 M 个 PTE)。共享 = 高效。
问题 2:为什么 Session Space 要每个 session 独立?
安全隔离。Session 0 跑系统服务(Windows 服务、计划任务),Session 1 跑用户应用;它们不能共享桌面、剪贴板、窗口。Session Space 的隔离让"服务进程"与"用户进程"在 GUI 层完全分离------这是 Windows Vista 之后的关键安全特性("Session 0 Isolation")。
问题 3:为什么 NonPagedPool 要存在?
DPC/ISR/自旋锁持有者不能换出(会被换出 = 系统崩溃)。NonPagedPool 是"绝对不会被换出"的内存池,是"必须用 ring 0 且必须在内存中"的代码路径的避难所。如果用 PagedPool 分配这些对象,在内存压力下被换出,调用 DPC/ISR 时就会触发"访问已换出页"------直接崩溃。
问题 4:为什么系统 PTE 是有限的、需要 reserve/release?
系统 PTE 占用虚拟地址空间。在 32 位下系统空间只有 2 GB,分配 10000 个 PTE = 40 MB 虚拟地址,这部分虚拟地址会"无法做其他用途"。严格管理 reserve/release 避免无节制的浪费。额外考虑:系统 PTE 是"短时映射"工具------长期占用会浪费;释放即归还,能重复利用。
问题 5:为什么 PagedPool 要可换出?
内核中很多 对象不需要"立即可用"------比如注册表 hive(只在注册表调用时访问)、文件系统路径(只在路径查询时访问)、日志缓冲(只在写入时访问)。把这些对象放到 PagedPool,可以在物理内存紧张时换出,节省 NonPagedPool(NonPagedPool 是稀缺资源)。PagedPool 的换出由 3.3 节讨论的工作集修剪触发。
3.1.5.8 Session Space 的深度解析
Session Space 是 Windows 独有的概念------每个登录会话(Session)有独立的 GUI 子系统空间。其作用是隔离"服务进程的桌面"与"用户进程的桌面"。
Session 0 Isolation(Windows Vista 引入):
- Session 0:系统服务(Windows Service)运行于此。不允许用户直接登录、不允许显示 UI。
- Session 1+:用户登录会话。每个用户一个 Session,有自己的桌面、剪贴板、窗口消息队列。
- 隔离的意义:防止"用户态进程通过窗口消息攻击系统服务"------这是历史上的常见攻击路径。
Session Space 的内存布局:
- 每个 Session 有独立的
win32k.sys映射区域(窗口类、菜单、GDI 对象)。 - 每个 Session 有独立的 Desktop 堆(用于创建窗口、菜单等对象)。
- 跨 Session 的通信必须通过 RPC 或命名管道------不能通过共享内存或窗口消息直接传递。
ReactOS 的实现 :在 ReactOS 中,Session Space 的支持是部分实现的。核心结构体 MM_SESSION_SPACE 描述了每个 Session 的内存布局,包括 Session 的页目录、Session 的工作集、Session 的映像加载基址等。
3.1.5.8.1 设计意图
核心问题:早期 Windows(NT4/2000/XP)中,服务进程与用户进程共享同一个 Session------服务可以直接弹出对话框、访问用户的剪贴板。这带来了严重的安全问题("Shatter Attack"通过向服务窗口发送恶意消息提权)。如何在不破坏兼容性的前提下隔离服务与用户?
设计哲学 :"窗口站(Window Station)→ Session → 桌面"三层隔离模型。Window Station 是最底层的安全边界(包含桌面、剪贴板、原子表),Session 是"一组窗口站的集合",桌面是"用户看到的一个屏幕"。Vista 将服务全部移到 Session 0,用户登录到 Session 1+,从架构上杜绝了"窗口消息跨边界攻击"。
3.1.5.8.2 概念解释
- Window Station(窗口站):最底层的安全对象。每个 Window Station 关联一个登录会话,包含桌面、剪贴板、全局原子表。
- Session(会话):一个登录会话对应一个 Session。服务进程都在 Session 0。
- Desktop(桌面):用户看到的一个"屏幕"。一个 Window Station 可以有多个 Desktop,但同一时间只有一个可见。
- Shatter Attack :通过向高权限服务窗口发送特制的窗口消息(如
WM_SETTEXT),利用消息处理程序中的漏洞执行任意代码。 - win32k.sys:Win32 子系统内核驱动。负责窗口管理、GDI、USER 等图形界面功能。每个 Session 有独立的 win32k 数据结构。
3.1.5.8.3 为什么要这样设计
问题 1:为什么不是简单地给服务进程设置更高权限? 高权限不等于隔离。即使服务以 SYSTEM 权限运行,如果它与用户在同一 Session,用户仍可通过窗口消息、共享内存等方式与服务交互。真正的安全需要"地址空间 + 窗口站 + 会话"三层隔离------不只是权限标记。
问题 2:为什么 Session 0 需要 GUI 支持? 许多老服务(尤其是第三方)在设计时假设"服务可以弹出窗口"。完全禁止 Session 0 的 GUI 会导致这些服务崩溃。Windows 的折中方案是"Session 0 可以创建窗口但不显示给用户"------服务的窗口在一个不可见的虚拟桌面上。
问题 3:为什么 Session Space 需要独立的内存区域? GUI 子系统(win32k.sys)的许多数据结构是"Per-Session"的(比如窗口类、菜单句柄表)。如果所有 Session 共享同一块虚拟地址空间,就需要复杂的"句柄表隔离"逻辑。将 Session Space 放在独立的虚拟地址范围(由 Session 页目录项指向不同的物理页)使得"Per-Session"数据结构天然隔离------每个 Session 看到的是自己的一份拷贝。
3.1.5.9 System Cache 与 Cc 子系统的关系
System Cache 是"系统缓存区"的虚拟地址范围。真正的缓存管理由 Cc(Cache Manager)子系统完成。
Cc 子系统的职责:
- 文件数据缓存:当应用程序读文件时,Cc 将文件内容映射到 System Cache 中。后续读取直接走缓存。
- 延迟写:写文件时先写到缓存,后台线程异步刷到磁盘。
- 统一缓存语义 :不管是
ReadFile还是内存映射文件(MapViewOfFile),都走同一份 System Cache------避免双份缓存。
System Cache 与 Section 对象的关系:
- System Cache 中的每一个"缓存视图"对应一个 Section 对象。
- 多个进程通过
MapViewOfFile映射同一个文件时,它们的页表最终都指向 System Cache 中的物理页。 - Cc 负责维护"哪些文件的哪些偏移已被缓存"的元数据。
ReactOS 中的实现 :Cc 子系统是一个独立的模块(位于 ntoskrnl/cc/)。核心结构体 SHARED_CACHE_MAP 和 VACB(Virtual Address Control Block) 分别描述"共享的缓存映射"和"单个缓存视图"。
3.1.5.9.1 设计意图
核心问题:文件 I/O 是系统中最慢的操作之一(磁盘比内存慢 10⁵ 倍)。如何让"读文件、写文件、内存映射文件"三者共享同一份缓存,避免重复占用内存?
设计哲学 :单一真相源(Single Source of Truth) 。System Cache 是"文件数据在内存中的唯一缓存副本"。读文件走缓存 → 内存映射文件也走同一份缓存 → 写文件也写同一份缓存。这样多个进程即使通过不同 API(ReadFile vs MapViewOfFile)访问同一文件,它们看到的也是相同的数据------不需要同步机制。
3.1.5.9.2 概念解释
- Cc(Cache Manager):Windows 的缓存管理器。负责将文件数据缓存在内存中,并管理缓存的生命周期。
- VACB(Virtual Address Control Block):描述 System Cache 中一个 256 KB 的视图块。每个 VACB 对应一个文件偏移上的一段缓存。
- SHARED_CACHE_MAP:描述"某个文件已被缓存"的共享映射。包含文件大小、有效数据范围、脏页标记等。
- 延迟写(Lazy Write) :Cc 在系统空闲时将脏缓存页写到磁盘。由
CcWorkerThread后台执行。 - MapViewOfFile:Win32 API,将文件的一部分映射到用户态虚拟地址空间。底层由 Cc + Section 对象共同实现。
3.1.5.9.3 为什么要这样设计
问题 1:为什么 Cc 不直接分配物理内存而是通过虚拟地址范围? System Cache 是一个"虚拟地址范围"(通常 512 MB~1 GB)。Cc 在此范围内按需分配 PTE,将文件内容映射到这些 PTE。好处是灵活性:Cc 不需要预先分配物理内存------需要时才分配,不需要时释放。如果 System Cache 直接占物理内存,在内存紧张时无法与其他需求(进程工作集、Pool)灵活调配。
问题 2:为什么 VACB 是 256 KB 而不是 4 KB 或 4 MB? 256 KB = 64 个 4 KB 页。这个大小在"减少元数据开销"(比 4 KB 少 64 倍的 VACB 结构体)与"粒度足够细"(比 4 MB 更灵活、能在不需要时部分释放)之间取折中。太小会导致元数据爆炸;太大会导致"缓存小文件时浪费空间"。
问题 3:为什么 ReadFile 与 MapViewOfFile 要共享同一份缓存? 如果两者各自缓存,一个 100 MB 的文件被 10 个进程分别打开和映射,可能占用 10 × 100 MB = 1 GB 内存------这是"双份缓存问题"。共享同一份缓存后,10 个进程共享 100 MB,省 90% 的内存。更重要的是一致性:进程 A 用 ReadFile 读取偏移 0,进程 B 用 MapViewOfFile 修改偏移 0------A 能立即看到 B 的修改(无需磁盘往返)。
3.1.5.10 系统 PTE 与 I/O 的协同
系统 PTE(System PTE) 是系统空间中一组"一次性映射"的 PTE 槽位。它与 I/O 子系统的关系最为紧密。
典型 I/O 路径:
- 应用调用
ReadFile(hFile, buffer, 4096, ...)。 - I/O 管理器创建 IRP,交给文件系统驱动。
- 文件系统驱动从 System Cache 读取数据------如果数据不在缓存,构造"页读写"IRP 交给磁盘驱动。
- 磁盘驱动需要"将磁盘读到的 DMA 数据映射到内核虚拟地址"------这一步用系统 PTE。
MiReserveSystemPtes(1, SystemPteSpace)获取一个 PTE 槽位。- 构造 PTE 指向 DMA 目标页,设置
Valid + Kernel + Write位。 - 读取完成后
MiReleaseSystemPtes释放槽位。
另一个典型场景:MDL(Memory Descriptor List):
- MDL 描述"一组物理页对应某个虚拟地址范围"。
- 当驱动需要在高 IRQL(如
DISPATCH_LEVEL)访问用户态缓冲区时,先用MmProbeAndLockPages锁定物理页,再用MmMapLockedPagesSpecifyCache通过系统 PTE 映射到内核态。 - I/O 完成后
MmUnmapLockedPages解除映射并释放系统 PTE。
系统 PTE 的池管理:
- 系统 PTE 池大小由
MiNumberOfSystemPtes决定------通常几百到几千个。 - 池管理使用"位图 + 首次适配"算法:一个位图位表示一个 PTE 槽位是否空闲;
MiReserveSystemPtes扫描位图找 N 个连续空闲位。 - 释放操作 O(1)(只改位图位);分配操作最坏 O(N)(N 为总槽位数)。
3.1.5.10.1 设计意图
核心问题:I/O 操作需要"短暂地将某段物理页映射到内核虚拟地址"------但内核虚拟地址空间有限,不可能为每个 I/O 预分配地址。如何在有限的虚拟地址空间内支撑无限多的 I/O 请求?
设计哲学 :池化 + 按需分配 + 用完即还。系统 PTE 是一个"PTE 槽位池"------申请时从池中取槽位,用完归还。池化让"槽位总数"可以远小于"并发 I/O 数"------大多数 I/O 只持续几毫秒,槽位可以快速复用。
3.1.5.10.2 概念解释
- IRP(I/O Request Packet):Windows/ReactOS 的 I/O 请求包。驱动之间通过 IRP 传递 I/O 操作。
- MDL(Memory Descriptor List):描述"虚拟地址范围 → 物理页集合"的结构体。用于跨驱动传递缓冲区。
- DISPATCH_LEVEL:IRQL 的一个级别。此级别下不能触发缺页异常(否则系统崩溃)------因此访问用户态缓冲区前必须先锁定物理页。
- DMA(Direct Memory Access):外设不通过 CPU 直接读写内存的机制。需要"物理地址连续"的缓冲区(或使用 scatter-gather)。
- 位图(Bitmap):系统 PTE 池的分配跟踪数据结构。一位对应一个 PTE 槽位,1 = 占用,0 = 空闲。
3.1.5.10.3 为什么要这样设计
问题 1:为什么系统 PTE 是"用完即还"而不是"长期持有"? 系统 PTE 占用虚拟地址空间。在 32 位下系统空间只有 2 GB,分给系统 PTE 的区域通常 16~64 MB。如果驱动长期持有系统 PTE(例如每个 I/O 都不释放),池会耗尽,后续 I/O 全部失败------这是"资源泄漏"的一种。因此 Windows 的规范要求"申请后必须在 I/O 完成时释放"。
问题 2:为什么系统 PTE 用位图而不是更复杂的分配器? 系统 PTE 的分配单元是"单个 PTE"或"连续的 N 个 PTE"------需求简单。位图在"查找 N 个连续空闲位"时的效率是 O(N),对于几千项的池完全可接受(几千次位操作不到 1 微秒)。同时位图的内存开销极低(几千位 = 几百字节)。没必要引入 AVL 树或 Slab 分配器等复杂结构。
问题 3:为什么在 DISPATCH_LEVEL 下可以用系统 PTE? 因为系统 PTE 的分配不涉及缺页异常------它只是"从已经预分配的 PTE 池中取一个槽位,并修改该 PTE 的 PFN 字段指向目标物理页"。整个过程只操作已映射的内核虚拟地址(池管理的位图、PTE 本身都是已映射的),不会触发 #PF。因此可在 DISPATCH_LEVEL 下安全使用------这是驱动在 I/O 完成例程中处理数据的关键保证。
3.1.5.11 小结
- 系统空间(32 位下 2 GB)是所有进程共享的,但按用途清晰分层。
- 系统 PTE 是保留的 PTE 槽位集合,用于"一次性映射"(MDL 映射、I/O 缓冲、零页清零等)。严格
reserve/release避免浪费。 - System Cache 是内核态的"系统缓存区",所有 Section 共享的工作页都存放在此;Cc 子系统是真正的缓存管理者。
- Pool(NonPaged / Paged)是内核态的"内存分配器"------NonPagedPool 永不换出,PagedPool 可换出。
- Session Space 是 session 隔离的 GUI 空间,Session 0 Isolation 是 Windows Vista 之后的关键安全特性。
- VACB(256 KB 块) 是 System Cache 的粒度单位;位图 是系统 PTE 池的分配跟踪结构。
3.1.6 系统调用 NtAllocateVirtualMemory()
3.1.6.0 框架图(先见森林)
┌────────────────────────────────────────────────────────────────────┐
│ 用户态 内核态 │
│ ┌────────────────┐ │
│ │ 应用程序 │ │
│ │ VirtualAlloc( │ ──── VirtualAlloc 封装到 VirtualAllocEx+Nt │
│ │ NULL, 1MB, │ ↓ │
│ │ MEM_COMMIT, │ ntdll!NtAllocateVirtualMemory (stub) │
│ │ PAGE_READWRITE) │ │
│ └────────────────┘ │ sysenter / INT 2Eh │
│ ↓ │
│ ntoskrnl!NtAllocateVirtualMemory │
│ (ARM3/virtual.c:4457) │
│ │ │
│ ① 参数检查(PreviousMode=UserMode 时 probe)│
│ ② 转换 BaseAddress 到 MEMORY_AREA / VAD │
│ ③ 检查调用进程是否有足够配额 │
│ ④ MmAllocateVirtualMemory → MiAllocate... │
│ ⑤ 返回 STATUS_SUCCESS │
└────────────────────────────────────────────────────────────────────┘
本图核心要点 :本节把第 2 章的"系统调用"知识与本章的"虚拟内存"知识完整连接 ------从用户态 VirtualAlloc 出发,追踪到 ntoskrnl 中的 NtAllocateVirtualMemory,再到 ARM3 的 MiAllocateVirtualMemory,最终到 VAD 树/PTE/PFN 数据库的修改。
3.1.6.0.1 设计意图
核心问题:用户态程序需要"申请一块内存"------这是最基础的操作系统服务之一。但从"用户态调用一个 API"到"内核真正分配物理页"之间,需要穿过系统调用层、参数验证层、VAD 树管理层、PTE 操作层、PFN 数据库层。每一层都有自己的职责和安全检查。如何设计一条清晰、安全、高效的路径?
设计哲学 :分层验证 + 延迟提交 。分层验证指"每一层只验证它能够验证的信息"------系统调用层验证用户态指针是否可访问,VAD 层验证地址范围是否与现有区段重叠,PTE 层验证物理页是否空闲。延迟提交指"物理页的分配延迟到第一次访问时才执行"------MEM_RESERVE 只是预留虚拟地址(记账),MEM_COMMIT 才分配物理页。这让 100 MB 的稀疏数组只占用几 KB 的 VAD 树开销,而不是 100 MB 的物理内存。
本节定位 :3.1.6 节是"虚拟内存系统调用的完整路径"。读者理解了 NtAllocateVirtualMemory 的实现后,就能理解 3.2 节的页面异常(第一次访问才触发 #PF)是延迟提交机制的实现细节,也能理解用户态 malloc 是如何在内核层面落地的。
3.1.6.1 从用户态到内核态的旅程
第 2 章已经详细讨论了系统调用的入口机制。这里以 VirtualAlloc 为例,完整走一遍:
┌────────────────────────────────────────────────────────────────────┐
│ 用户态代码 (kernel32.dll): │
│ LPVOID ptr = VirtualAlloc( │
│ NULL, // 让系统选择地址 │
│ 1024 * 1024, // 1 MB │
│ MEM_COMMIT, // 提交(分配物理页) │
│ PAGE_READWRITE); // 可读写 │
│ ↓ │
│ kernelbase.dll: │
│ VirtualAlloc → VirtualAllocEx(GetCurrentProcess(), ...) │
│ ↓ │
│ ntdll.dll: │
│ VirtualAllocEx → NtAllocateVirtualMemory(stub) │
│ // 准备参数: │
│ // eax = NtAllocateVirtualMemory 的系统调用号 │
│ // edx = 用户态参数区地址 │
│ // User-mode stack 包含完整的 (ProcessHandle, BaseAddress, ...) │
│ ↓ │
│ mov eax, SYSTEM_SERVICE_NUMBER │
│ mov edx, UserStackParams │
│ sysenter // 触发快速系统调用 │
└────────────────────────────────────────────────────────────────────┘
↓ sysenter
┌────────────────────────────────────────────────────────────────────┐
│ 内核态 (ntoskrnl.exe): │
│ _KiFastCallEntry: │
│ 1. 切换到内核栈(从 KPCR.CurrentThread 取内核栈) │
│ 2. 保存用户态寄存器到 KTRAP_FRAME │
│ 3. 从 KUSER_SHARED_DATA 取系统调用号 │
│ 4. 查 SSDT 找到 NtAllocateVirtualMemory │
│ 5. 跳到 KiSystemServiceHandler │
│ ↓ │
│ KiSystemServiceHandler: │
│ 1. 调用 _PreviousMode 检查 PreviousMode(UserMode/KernelMode) │
│ 2. 把用户态参数从 edx 指向的栈复制到内核栈 │
│ 3. 调用 NtAllocateVirtualMemory 真正实现 │
└────────────────────────────────────────────────────────────────────┘
第 2 章回顾 :sysenter 与 INT 2Eh 的区别、KiSystemServiceHandler 的逻辑、SSDT 的结构------这些已在第 2 章详细讨论。本节聚焦**NtAllocateVirtualMemory 的实现**。
3.1.6.2 NtAllocateVirtualMemory 的实现
`NtAllocateVirtualMemory`(file:///d:/reactos/ntoskrnl/mm/ARM3/virtual.c#L4457) 是用户态 VirtualAlloc 的内核实现。它的签名是:
c
NTSTATUS
NtAllocateVirtualMemory(
IN HANDLE ProcessHandle, // 进程句柄
IN OUT PVOID *BaseAddress, // 输入:用户偏好地址;输出:实际地址
IN ULONG_PTR ZeroBits, // 0~32:高位 0 位数
IN OUT PSIZE_T RegionSize, // 输入:请求大小;输出:实际大小
IN ULONG AllocationType, // MEM_COMMIT / MEM_RESERVE / ...
IN ULONG Protect // PAGE_READWRITE / PAGE_READONLY / ...
);
核心步骤(简化):
c
NTSTATUS NtAllocateVirtualMemory(...)
{
NTSTATUS Status;
PEPROCESS Process;
KPROCESSOR_MODE PreviousMode;
PETHREAD Thread = PsGetCurrentThread();
SIZE_T CapturedRegionSize;
PVOID CapturedBaseAddress;
ULONG AllocationTypeCap, ProtectCap;
// 1. 获取 PreviousMode(UserMode or KernelMode)
PreviousMode = KeGetPreviousMode();
// 2. 探测(probe)并捕获用户态参数
if (PreviousMode != KernelMode) {
ProbeForWrite(BaseAddress, sizeof(PVOID), sizeof(PVOID));
ProbeForWrite(RegionSize, sizeof(SIZE_T), sizeof(SIZE_T));
ProbeForRead(AllocationType, sizeof(ULONG), sizeof(ULONG));
ProbeForRead(Protect, sizeof(ULONG), sizeof(ULONG));
}
// 3. 捕获参数(避免后续 ToC/ToU 攻击)
CapturedBaseAddress = *BaseAddress;
CapturedRegionSize = *RegionSize;
AllocationTypeCap = *AllocationType;
ProtectCap = *Protect;
// 4. 参数合法性检查
if (CapturedRegionSize == 0) return STATUS_INVALID_PARAMETER_4;
if (!AlignmentOK(CapturedBaseAddress, ...)) return STATUS_INVALID_PARAMETER_2;
if (InvalidAllocationType(AllocationTypeCap)) return STATUS_INVALID_PARAMETER_5;
if (InvalidProtect(ProtectCap)) return STATUS_INVALID_PARAMETER_6;
// 5. 通过句柄获取 EPROCESS
Status = ObReferenceObjectByHandle(ProcessHandle, ...,
PsProcessType, ...,
(PVOID*)&Process, NULL);
if (!NT_SUCCESS(Status)) return Status;
// 6. 调用 MmAllocateVirtualMemory
Status = MmAllocateVirtualMemory(Process, &CapturedBaseAddress,
ZeroBits, &CapturedRegionSize,
AllocationTypeCap, ProtectCap);
// 7. 写回结果到用户态
ObDereferenceObject(Process);
*BaseAddress = CapturedBaseAddress;
*RegionSize = CapturedRegionSize;
return Status;
}
关键点:
- PreviousMode 的影响 (呼应第 2 章 §2.6):用户态调用时所有
PVOID*类型的参数都要ProbeForWrite(检查用户态地址是否合法 + 可写)。内核态调用时(ZwAllocateVirtualMemory)跳过 probe。 - 参数捕获 :probe 后把用户态参数复制到内核栈上的局部变量。这是为了避免"Time-of-Check vs Time-of-Use"(ToC/ToU)攻击------用户在 probe 后、读取前修改了参数。
- 句柄转 EPROCESS :
ObReferenceObjectByHandle内部走对象管理器的句柄表,校验句柄的有效性。引用计数 +1。 - MmAllocateVirtualMemory:内核态的"真正"实现,调用栈更深。
3.1.6.3 MmAllocateVirtualMemory 与 MiAllocateVirtualMemory
MmAllocateVirtualMemory 是 NtAllocateVirtualMemory 的"瘦身版"------它接收 EPROCESS 指针而不是 HANDLE,因此省略了句柄转换。MmAllocateVirtualMemory 内部直接调用 MiAllocateVirtualMemory(ARM3 路径):
c
NTSTATUS
MmAllocateVirtualMemory(
IN PEPROCESS Process,
IN OUT PVOID *BaseAddress,
IN ULONG_PTR ZeroBits,
IN OUT PSIZE_T RegionSize,
IN ULONG AllocationType,
IN ULONG Protect)
{
// 1. 检查进程有效性
if (Process == NULL || Process == PsIdleProcess) {
return STATUS_INVALID_PARAMETER;
}
// 2. 调用 ARM3 内部
return MiAllocateVirtualMemory(Process, BaseAddress, ZeroBits,
RegionSize, AllocationType, Protect);
}
MiAllocateVirtualMemory 的关键步骤(在 ARM3/virtual.c(file:///d:/reactos/ntoskrnl/mm/ARM3/virtual.c) 中):
c
NTSTATUS
MiAllocateVirtualMemory(
IN PEPROCESS Process,
IN OUT PVOID *BaseAddress,
IN ULONG_PTR ZeroBits,
IN OUT PSIZE_T RegionSize,
IN ULONG AllocationType,
IN ULONG Protect)
{
/* 1. 找空闲区段 */
if (*BaseAddress == NULL) {
MiFindEmptyAddressRange(...); // 找一个大小合适的"洞"
} else {
MiCheckAddressRange(...); // 检查用户指定地址是否合法
}
/* 2. 按 AllocationType 分支 */
if (AllocationType & MEM_COMMIT) {
/* 提交:申请 PTE + 物理页 */
MiCommitVirtualMemory(...);
}
if (AllocationType & MEM_RESERVE) {
/* 保留:在 VAD 树中插入 VAD 节点 */
MiInsertVad(Process, VadNode);
}
/* 3. 配额检查:MmChargeCommitment */
/* 4. 返回 */
return STATUS_SUCCESS;
}
MiCommitVirtualMemory 的关键代码(简化):
c
NTSTATUS MiCommitVirtualMemory(PEPROCESS Process, PMMVAD Vad, PVOID BaseAddress, SIZE_T Size) {
ULONG_PTR Start = (ULONG_PTR)BaseAddress;
ULONG_PTR End = Start + Size;
// 遍历每个 PTE
for (p = Start; p < End; p += PAGE_SIZE) {
Pte = MiAddressToPte(p);
if (Pte->u.Hard.Valid == 0) {
// PTE 未映射:分配物理页
Pfn = MiAllocatePage();
MI_WRITE_VALID_PTE(Pte, MiBuildPte(Pfn, Vad->Protection));
}
}
return STATUS_SUCCESS;
}
3.1.6.4 AllocationType 标志详解
AllocationType 是几个标志的位或:
| 标志 | 值 | 含义 |
|---|---|---|
MEM_COMMIT |
0x1000 | 提交(分配物理页) |
MEM_RESERVE |
0x2000 | 保留(占地址但不分配物理页) |
MEM_DECOMMIT |
0x4000 | 取消提交(释放物理页,保留地址) |
MEM_RELEASE |
0x8000 | 释放(释放地址,物理页一起释放) |
MEM_FREE |
0x10000 | 同 MEM_RELEASE(兼容别名) |
MEM_RESET |
0x80000 | 把页内容重置为"未初始化"(不会真清零) |
MEM_TOP_DOWN |
0x100000 | 从高地址向下分配 |
MEM_LARGE_PAGES |
0x20000000 | 使用大页(2 MB / 1 GB) |
MEM_WRITE_WATCH |
0x200000 | 启用写监控(GetWriteWatch API) |
MEM_PHYSICAL |
0x400000 | 申请物理地址连续的内存 |
典型组合:
VirtualAlloc(..., MEM_COMMIT | MEM_RESERVE, ...):分配并提交(最常见)VirtualAlloc(..., MEM_RESERVE, ...):仅保留地址VirtualAlloc(..., MEM_COMMIT, ...):对已保留地址提交物理页VirtualAlloc(..., MEM_RESET, ...):丢弃页内容(不清零),可立即重分配
3.1.6.5 配额(Commitment)
Windows NT 有"系统提交配额"(System Commit Limit)的概念------即所有进程累计的"已提交"虚拟内存总量 的上限。当系统 commit 接近上限时,VirtualAlloc(MEM_COMMIT) 会失败并返回 STATUS_COMMITMENT_LIMIT。
配额数据:
MmTotalCommitLimit:系统 commit 上限(受 pagefile 大小和物理内存限制)。MmTotalCommittedPages:当前已 commit 的总页数。Process->CommitCharge:该进程已 commit 的页数。
配额检查 在 MiAllocateVirtualMemory 中通过 MiChargeCommitment 完成。
3.1.6.6 概念解释
VirtualAlloc:kernel32 导出的用户态 API,把用户态虚拟地址的"申请"封装为对NtAllocateVirtualMemory的调用。Win32 程序员最熟悉的内存分配 API。MEM_COMMIT/MEM_RESERVE/MEM_RELEASE/MEM_RESET/MEM_TOP_DOWN/MEM_LARGE_PAGES:分配类型标志。COMMIT = 提交(分配物理页);RESERVE = 保留(占地址但不分配物理页);RESET = 把页内容重置为"未初始化"(不会真清零,只是标记);TOP_DOWN = 从高地址向下分配。PAGE_READONLY等保护位:与 3.1.3 提到的 PTE 保护位对应。NtAllocateVirtualMemory:ntdll 导出的真实系统调用入口。Signature 是(PHANDLE, PVOID*, ULONG_PTR, PSIZE_T, ULONG, ULONG)。MiAllocateVirtualMemory:内核态的"内部"函数,把系统调用的参数转换为 VAD / PTE 操作。- PreviousMode 的影响 :用户态调用时,所有 PVOID* 类型的参数都要 probe(检查是否合法)。内核态调用时(
ZwAllocateVirtualMemory)直接信任。 - ToC/ToU 攻击:Time-of-Check vs Time-of-Use。内核在 probe 后、读取前,用户态进程可能修改参数(特别是 PVOID* 指向的地址)。通过"先 probe + 复制到内核栈"避免这种攻击。
- Commit Limit :系统已 commit 虚拟内存总量上限。物理内存 + pagefile 大小 = 实际上限。
STATUS_COMMITMENT_LIMIT表示配额耗尽。
3.1.6.7 为什么要这样设计
问题 1:为什么 kernel32!VirtualAlloc 与 NtAllocateVirtualMemory 都有?
分层 。VirtualAlloc 是 Win32 API(提供 Win32 文档约束);NtAllocateVirtualMemory 是 NT 原生 API(提供更细粒度控制)。前者是后者的"高阶封装"------Win32 层只暴露最常用的子集,避免暴露底层细节。
问题 2:为什么 MEM_COMMIT 与 MEM_RESERVE 分离?
支持"大段保留、小段提交"模式。一个进程可以先保留 1 GB 虚拟地址空间,再分批提交。节省物理内存 (不需要时页不占物理空间)。用户态的使用模式 :游戏程序通常 VirtualAlloc(NULL, 1GB, MEM_RESERVE, ...) 保留地址空间,等关卡需要时 VirtualAlloc(addr+offset, 16MB, MEM_COMMIT, ...) 提交物理页;关卡结束 VirtualFree(addr+offset, 16MB, MEM_DECOMMIT) 释放物理页。这样峰值只用 16 MB 物理页,但虚拟地址有 1 GB 可用。
问题 3:为什么 MEM_RESET 不会真清零?
优化路径 。MEM_RESET 告诉内存管理器"这段页内容我不再需要了";内核可以立即 把它们从工作集移除(不用等换页)。但不清零 ------如果用户后来又用 VirtualAlloc(..., PAGE_READWRITE) 重置它们,会从原内容继续。这避免了"全 0 页"的不必要复制。注意 :MEM_RESET 的页内容未定义------用户不能依赖其内容。
问题 4:为什么分配粒度是 64 KB?
见 3.1.1。64 KB = 16 PTE 槽位 = 1 个 Subsection 的容量。让一个 VAD 节点最少对应 16 页,避免 VAD 树节点爆炸。额外好处:页表只占虚拟地址空间的 1/4096,64 KB 段对齐避免 VAD 数量爆炸。
问题 5:为什么 NtAllocateVirtualMemory 要 probe + 捕获?
安全 。用户态的 PVOID* 指针可能被恶意修改。如果不 probe,内核可能读到非法地址导致崩溃;如果只 probe 不捕获,用户在 probe 后修改指针(ToC/ToU 攻击)。正确做法:probe + 复制到内核栈 + 在内核栈上操作。
3.1.6.8 小结
VirtualAlloc→ntdll!NtAllocateVirtualMemory→KiSystemServiceHandler→ntoskrnl!NtAllocateVirtualMemory→MmAllocateVirtualMemory→MiAllocateVirtualMemory:完整的调用链。NtAllocateVirtualMemory处理:PreviousMode 检查、参数 probe、参数捕获、句柄转换、调用MmAllocateVirtualMemory。MiAllocateVirtualMemory处理:找空闲区段、VAD 插入、PTE 分配、配额检查。- 6 种 AllocationType(COMMIT / RESERVE / DECOMMIT / RELEASE / RESET / TOP_DOWN 等)支持灵活的内存管理策略。
- ToC/ToU 安全:probe + 复制到内核栈是必须的安全措施。
- 64 KB 段对齐 + Commit 配额是 Windows 内存管理的两个工程优化。
3.1.6.9 NtAllocateVirtualMemory 的全路径代码追踪
下面以一段完整代码追踪,展示从用户态 VirtualAlloc 到内核态的完整调用链:
c
/* ============================================================
* 用户态 (kernel32.dll)
* ============================================================ */
LPVOID WINAPI VirtualAlloc(LPVOID lpAddress, SIZE_T dwSize,
DWORD flAllocationType, DWORD flProtect)
{
// 1. 包装为 VirtualAllocEx
return VirtualAllocEx(GetCurrentProcess(), lpAddress, dwSize,
flAllocationType, flProtect);
}
LPVOID WINAPI VirtualAllocEx(HANDLE hProcess, LPVOID lpAddress, SIZE_T dwSize,
DWORD flAllocationType, DWORD flProtect)
{
// 2. 调用 ntdll 的 NtAllocateVirtualMemory
return NtAllocateVirtualMemory(hProcess, &lpAddress, 0, &dwSize,
flAllocationType, flProtect);
}
/* ============================================================
* 用户态 stub (ntdll.dll)
* ============================================================ */
NTSTATUS NTAPI NtAllocateVirtualMemory(HANDLE ProcessHandle,
PVOID *BaseAddress,
ULONG_PTR ZeroBits,
PSIZE_T RegionSize,
ULONG AllocationType,
ULONG Protect)
{
// 3. 准备系统调用
NtCurrentTeb()->SystemCallParamInfo = ...;
// 4. 触发 sysenter
asm {
mov eax, SYSCALL_NTALLOCATEVIRTUALMEMORY // 系统调用号
mov edx, UserStack // 用户态参数区
sysenter // 触发快速系统调用
}
// 5. sysenter 返回后
return (NTSTATUS)RESULT_FROM_SYSCALL; // eax 中的结果
}
/* ============================================================
* 内核态 (ntoskrnl.exe)
* ============================================================ */
/* _KiFastCallEntry: 汇编入口(在 trap.s 中) */
__declspec(naked) _KiFastCallEntry(void)
{
asm {
// 1. 切换到内核栈
mov esp, KPCR_CURRENT_THREAD->KernelStack
// 2. 保存用户态寄存器到 KTRAP_FRAME
push USER_EAX, USER_EBX, ..., USER_EIP, USER_CS, USER_EFLAGS
// 3. 查 SSDT 找到 NtAllocateVirtualMemory
mov ebx, KUSER_SHARED_DATA->SystemCall
mov edi, SSDT_BASE[ebx * 4] // SSDT[ebx]
jmp KiSystemServiceHandler
}
}
/* KiSystemServiceHandler: C 入口(在 traphdlr.c 中) */
NTSTATUS KiSystemServiceHandler(...)
{
// 1. 探测并复制用户态参数
if (PreviousMode == UserMode) {
ProbeForRead(UserStack, ...);
}
KernelArgs[0] = *(PVOID*)&UserStack[0]; // ProcessHandle
KernelArgs[1] = *(PVOID*)&UserStack[1]; // BaseAddress
// ... 复制所有 6 个参数
// 2. 调用真正的 NtAllocateVirtualMemory
return NtAllocateVirtualMemory(
KernelArgs[0], KernelArgs[1], KernelArgs[2],
KernelArgs[3], KernelArgs[4], KernelArgs[5]);
}
/* NtAllocateVirtualMemory: 真实实现 */
NTSTATUS NtAllocateVirtualMemory(HANDLE ProcessHandle, ...)
{
// 1. 探测 + 捕获参数(见 3.1.6.2)
// 2. 句柄转 EPROCESS
// 3. 调用 MmAllocateVirtualMemory
Status = MmAllocateVirtualMemory(Process, ...);
return Status;
}
/* MmAllocateVirtualMemory: 内核态 API */
NTSTATUS MmAllocateVirtualMemory(PEPROCESS Process, ...)
{
// 直接调用 MiAllocateVirtualMemory
return MiAllocateVirtualMemory(Process, ...);
}
/* MiAllocateVirtualMemory: ARM3 内部 */
NTSTATUS MiAllocateVirtualMemory(PEPROCESS Process, ...)
{
// 1. 找空闲区段
if (*BaseAddress == NULL) {
Status = MiFindEmptyAddressRange(&Base, RegionSize, ...);
}
// 2. 构造 VAD
Vad = ExAllocatePoolWithTag(NonPagedPool, sizeof(MMVAD), ' daV');
Vad->StartingVpn = ...;
Vad->EndingVpn = ...;
Vad->u.VadFlags.Private = 1;
Vad->u.VadFlags.Protection = ...;
// 3. 插入 VAD 树
MiInsertVad(Process, Vad);
// 4. 提交(分配 PTE + 物理页)
if (AllocationType & MEM_COMMIT) {
Status = MiCommitVirtualMemory(Process, Vad, Base, *RegionSize);
}
// 5. 返回结果
*BaseAddress = Base;
*RegionSize = ...;
return STATUS_SUCCESS;
}
/* MiCommitVirtualMemory: 提交 */
NTSTATUS MiCommitVirtualMemory(PEPROCESS Process, PMMVAD Vad, PVOID Base, SIZE_T Size)
{
PVOID p = Base;
// 加锁 + 配额检查
while (p < Base + Size) {
Pte = MiAddressToPte(p);
if (Pte->u.Hard.Valid == 0) {
// 分配物理页
Pfn = MiAllocatePage();
// 写 PTE
MI_WRITE_VALID_PTE(Pte, MiBuildPte(Pfn, Vad->Protection));
}
p += PAGE_SIZE;
}
// 解锁
return STATUS_SUCCESS;
}
关键观察:
- 调用链深度:用户态 4 层 → 内核态 6 层 = 总 10 层函数调用。
- 每层职责不同:用户态各层(kernel32、kernelbase、ntdll)做"Win32 语义封装";内核态各层(Ki*、Nt*、Mm*、Mi*)做"参数处理 + 业务实现"。
- 栈深度可控 :每层栈帧 64256 字节,10 层约 1~2.5 KB。在 12 KB 内核栈的限制下,安全。
- 可被优化:一些调用层在 release 版本会被内联或合并。
3.1.6.10 用户态偏好地址的处理
VirtualAlloc 接受 lpAddress 参数(用户偏好地址)。内核对该参数的处理有三种情况:
lpAddress |
含义 | 内核处理 |
|---|---|---|
NULL |
让系统选择 | MiFindEmptyAddressRange 找一个合适区段 |
非 NULL 但低 2 GB 内 |
用户偏好 | MiCheckAddressRange 验证合法后使用 |
lpAddress & 0x80000000 不为 0 |
用户态不能访问系统空间 | 返回 STATUS_INVALID_PARAMETER |
典型用户偏好场景:
- 固定地址映射(如游戏 mod 共享一段固定地址)。
- 已保留地址的提交(
VirtualAlloc(addr+offset, size, MEM_COMMIT, ...))。 - 大页分配(
MEM_LARGE_PAGES+ 特定地址)。
3.1.6.11 保护位(Protect)转换
VirtualAlloc 的 flProtect 参数是 Win32 层的保护位(PAGE_READWRITE 等)。这些标志最终会转换为 PTE 的硬件位。转换由 MiMakePteProtection 完成(ARM3/special.c(file:///d:/reactos/ntoskrnl/mm/ARM3/special.c)):
c
ULONG MiMakePteProtection(ULONG Protection) {
ULONG PteProt = 0;
// 1. User 位
if (Protection & (PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | ...)) {
PteProt |= PA_PTE_USER; // U/S=1
}
// 2. R/W 位
if (Protection & (PAGE_READWRITE | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY)) {
PteProt |= PA_PTE_READWRITE; // R/W=1
} else if (Protection & (PAGE_WRITECOPY | PAGE_EXECUTE_WRITECOPY)) {
// WRITECOPY 在 x86 上 R/W=1(硬件触发 #PF)
PteProt |= PA_PTE_READWRITE;
}
// 3. 缓存策略
if (Protection & PAGE_NOCACHE) {
PteProt |= PA_PTE_CACHE_DISABLED;
}
if (Protection & PAGE_WRITECOMBINE) {
PteProt |= PA_PTE_WRITE_THROUGH;
}
return PteProt;
}
关键点:
- WRITECOPY 在 x86 上 R/W=1:因为 x86 的"读权限触发 #PF"靠"硬件未设 R/W 位"。WRITECOPY 需要"看起来是只读+实际能写"------x86 通过 R/W=1(看似可写)+ 内核在 #PF 中做特殊处理实现。
- PAE 与非 PAE 模式位不同 :PAE 模式下 PTE 保护位位置可能略有差异。
MiMakePteProtection会根据当前模式调整。
3.1.6.12 大页(Large Pages)
MEM_LARGE_PAGES 标志(0x20000000)让 VirtualAlloc 申请大页------x86 上是 2 MB(PAE 模式)或 4 MB(非 PAE 模式)。
优势:
- TLB 压力小:一个大页 = 1 个 TLB 项覆盖 2 MB 空间(vs 普通页 1 个 TLB 项覆盖 4 KB)。
- 页表小:1 个大页 = 1 个 PDE(页目录项)就够了(vs 普通页 1 个 PDE + 1 个 PTE)。
限制:
- 必须物理地址对齐(2 MB 边界)。
- 不支持 COW(Section 不能用大页)。
- 不支持分页池(必须从 NonPagedPool 申请)。
大页的实现路径(简化):
c
NTSTATUS MiCommitLargePage(PEPROCESS Process, PVOID Address, ULONG Protection) {
// 1. 找 2 MB 对齐的物理地址范围
Pfn = MiAllocateContiguousPages(2 * 1024 * 1024 / PAGE_SIZE);
// 2. 构造大页 PDE(PS=1)
Pde = MiAddressToPde(Address);
Pde->u.Hard.PageFrameNumber = Pfn;
Pde->u.Hard.LargePage = 1; // PS=1
Pde->u.Hard.Valid = 1;
Pde->u.Hard.Write = (Protection & PAGE_READWRITE) ? 1 : 0;
Pde->u.Hard.User = (Protection & PAGE_USER) ? 1 : 0;
// 3. 刷 TLB
KeFlushProcessTb();
return STATUS_SUCCESS;
}
应用场景:
- 大文件映射(如数据库文件、内存映射文件)。
- GPU 显存映射(一些驱动使用大页降低 TLB 压力)。
- Java 堆(大堆减少 GC 压力)。
3.1.6.14 Commitment 配额的深层机制
系统提交配额(System Commit Limit) 是 Windows 防止"过度申请"的保护机制。
基本概念:
- Commit Limit = 物理内存大小 + 所有 pagefile 大小之和。
- Committed Pages = 当前已提交的总页数(由
MmTotalCommittedPages跟踪)。 - 当 Committed Pages 接近 Commit Limit 时 ,后续的
VirtualAlloc(MEM_COMMIT)会失败(STATUS_COMMITMENT_LIMIT)。
为什么需要 Commit Limit?:
- 防止"承诺过多":如果内核允许进程 commit 超过物理内存 + pagefile 的总量,那么当所有进程同时访问这些页时,没有足够的物理页和 pagefile 槽位------会导致系统死锁。
- "记账式"内存管理:Windows 对 commit 的内存做"预记账"------即使页面尚未被访问(仍为 demand-zero),也必须预留 pagefile 槽位。这确保"第一次写入时一定能找到 pagefile 位置"。
ReactOS 的实现:
MmChargeCommitment增加 commit 计数,检查是否超过 limit。MmReturnCommitment归还 commit 计数。- 每个进程的
EPROCESS->CommitCharge记录该进程已 commit 的页数。
3.1.6.14.1 设计意图
核心问题 :如果不做 commit 限制,进程可以 VirtualAlloc(NULL, 1 GB, MEM_COMMIT, ...) 多次------系统看起来有 1 GB 空闲,但当 10 个进程同时这样做时,总 commit 达到 10 GB,远超物理内存。第一次写操作时会发现没有 pagefile 槽位可用。如何防止这种情况?
设计哲学 :预留即承诺 。当调用 MEM_COMMIT 时,内核承诺"当你第一次写这页时,我一定有地方放"。为了履行承诺,内核必须提前预留 pagefile 中的槽位。这是"保守但安全"的设计------牺牲一部分灵活性(不能超额申请)来保证不出现"写时无处安放"的崩溃。
3.1.6.14.2 概念解释
- Commit Limit:系统可以 commit 的最大页数。= 物理内存大小 + 所有 pagefile 大小之和。
- Committed Pages:当前已 commit 的总页数。
MmChargeCommitment:调用时检查 Commit Limit,成功则增加计数、预留 pagefile 槽位。STATUS_COMMITMENT_LIMIT:当 commit 请求会超过 limit 时返回的错误码。- pagefile 槽位(Pagefile Slot) :pagefile 中可存放一个 4 KB 页的位置。由
MiReservePageFileSlot预留。 - demand-zero 页:已 commit 但从未被访问的页。首次读时返回全 0(零页机制),首次写时分配物理页。
3.1.6.14.3 为什么要这样设计
问题 1:为什么不在第一次写入时才预留 pagefile 槽位? 那会导致"写时才发现没有空间"------此时用户态代码已经在执行写操作(如修改栈变量、修改堆数据),返回错误会导致不可恢复的状态。更严重的是,如果内核在处理一次写操作时同时需要分配多个 pagefile 槽位,可能中途失败,导致"部分写入、部分未写入"的不一致状态。将检查前置到 MEM_COMMIT 时,能提前暴露问题、避免中间状态。
问题 2:为什么 Linux 可以 overcommit(超额承诺)而 Windows 不行? 这是设计哲学差异。Linux 使用"乐观分配"------允许 commit 超过物理内存 + swap。当实际写入超过物理内存时,触发 OOM Killer 选择一个进程杀死以释放内存。Windows 选择"保守分配"------宁愿在分配时失败也不允许"承诺无法兑现"。各有优劣:Linux 可以在内存紧张时继续运行(但会随机杀进程),Windows 保证已 commit 的内存一定可用(但分配时可能失败)。
问题 3:为什么 pagefile 大小可以调整? 管理员可以通过"系统属性→高级→性能设置→虚拟内存"调整 pagefile 大小。调大 pagefile = 增大 Commit Limit = 允许更多 commit。这让管理员可以根据工作负载调整系统的"承诺能力"------数据库服务器可能需要 3× 物理内存的 pagefile,桌面系统通常设为 1× 物理内存。
3.1.6.15 AllocationType 标志的深入解析
VirtualAlloc 的 AllocationType 参数是一组标志位的组合。理解每个标志的含义对正确使用虚拟内存至关重要。
标志位详解:
| 标志 | 十六进制值 | 含义 | 典型用法 |
|---|---|---|---|
MEM_COMMIT |
0x00001000 |
提交物理页(从 PFN 数据库分配) | VirtualAlloc(NULL, 4096, MEM_COMMIT, PAGE_READWRITE) |
MEM_RESERVE |
0x00002000 |
预留虚拟地址范围(不分配物理页) | 预分配 1 GB 地址空间供后续分段提交 |
MEM_DECOMMIT |
0x00004000 |
取消提交(释放物理页,保留地址范围) | 释放不再需要的物理页但保留地址 |
MEM_RELEASE |
0x00008000 |
释放整个区段(同时释放地址和物理页) | VirtualFree(addr, 0, MEM_RELEASE) |
MEM_RESET |
0x00080000 |
标记页内容"不再需要"------不写回 pagefile | 大型稀疏数据结构的释放优化 |
MEM_TOP_DOWN |
0x00100000 |
从高地址向低地址分配 | 避免碎片化、与低地址 DLL 不冲突 |
MEM_LARGE_PAGES |
0x20000000 |
使用大页(2 MB / 4 MB) | 大内存缓冲区减少 TLB miss |
MEM_PHYSICAL |
0x00400000 |
分配物理地址连续的内存 | DMA 缓冲区、设备驱动 |
MEM_WRITE_WATCH |
0x00200000 |
启用写监控 | GC 追踪写操作、增量备份 |
常见组合:
MEM_COMMIT | MEM_RESERVE:最常见------同时预留地址并提交物理页。MEM_RESERVE单独使用:在程序启动时预分配一大块地址范围。MEM_COMMIT之后对已 RESERVE 的区段:分段提交、按需增长。MEM_LARGE_PAGES | MEM_COMMIT | MEM_RESERVE:大页分配。
3.1.6.15.1 设计意图
核心问题:虚拟内存管理需要支持"不同粒度、不同时机、不同策略"的内存申请。一个 32 位标志位可以组合出几十种配置------如何设计这些标志,使得常见操作简单、特殊操作可行?
设计哲学 :正交设计 + 合理默认 。正交设计指每个标志位只控制一个维度(是否提交、是否保留、分配方向、页大小等),可以自由组合。合理默认指最常用的组合(MEM_COMMIT | MEM_RESERVE)不需要用户显式理解每个标志。标志位的总数被控制在 10 个以内------让开发者可以在头脑中完整枚举。
3.1.6.15.2 概念解释
- 提交(Commit):分配物理页(或 pagefile 槽位)。进程可以安全读写已提交的页。
- 预留(Reserve) :在 VAD 树中记账但不分配物理页。防止其他
VirtualAlloc使用这段地址。 - 取消提交(Decommit):释放物理页但保留虚拟地址范围。对稀疏数组、暂存缓冲区有用。
- 释放(Release):同时释放虚拟地址范围和物理页。VAD 树节点被删除。
- Reset:告诉内核"这些页的内容我不再关心"------内核可以直接丢弃、不需要写回 pagefile。
- Top-Down 分配:从用户态地址空间的高地址(靠近 0x7FFFFFFF)向下分配。减少低地址碎片化。
- Write Watch :内核记录哪些页被写入过。通过
GetWriteWatchAPI 可以查询。用于 GC、增量备份等场景。
3.1.6.15.3 为什么要这样设计
问题 1:为什么需要同时支持 Commit 和 Reserve? 这是"延迟提交"优化的关键。一个大型应用(如游戏引擎、数据库缓存)可能需要 1 GB 的虚拟地址空间,但当前只需要用到 16 MB。先 MEM_RESERVE(1 GB) 在 VAD 树中标记,后续按需 MEM_COMMIT(16 MB) 分配物理页------峰值内存 16 MB,而不是 1 GB。在 32 位系统上物理内存通常只有 1~4 GB,节省的 984 MB 让其他程序可以运行。
问题 2:为什么 MEM_RESET 不清零? 清零需要 memset(4 KB) 的 CPU 开销。如果应用明确说"这页内容我不再需要了"(如暂存缓冲区用完了),内核可以直接丢弃物理页、不清零------省下 memset 和 pagefile 写回的开销。当应用下次访问该页时,会触发 #PF 并从零页重新分配,内容自动为 0。
问题 3:为什么 MEM_LARGE_PAGES 需要特殊权限? 大页(2 MB/4 MB)在物理上要求"2 MB 对齐的连续物理页"------在运行一段时间后的系统中很难找到这样的页(物理内存碎片化)。因此大页的分配通常需要:① 特权级(SeLockMemoryPrivilege);② 尽早分配(系统启动后立即申请);③ 显式要求应用开发者知道自己在做什么。如果默认允许任意应用申请大页,会导致物理内存快速碎片化。
3.1.6.16 NtAllocateVirtualMemory 的并发控制与性能
NtAllocateVirtualMemory 是高频系统调用------多线程应用可能同时在多个线程中申请内存。需要严格的并发控制和高效的实现。
关键锁与层级:
- 进程工作集锁(Process Working Set Lock):保护进程工作集的大小和列表。
- 地址创建锁(Address Creation Lock):保护 VAD 树的结构修改。
- PFN 数据库锁(MmPfnLock):保护物理页分配。
获取顺序:工作集锁 → 地址创建锁 → PFN 锁。违反顺序会导致死锁。
性能特征:
- VAD 树查找:O(log N),树的高度通常 ≤ 15(10,000 节点)。
- 物理页分配:O(1),从 Zeroed/Free 列表取页。
- PTE 写入:O(N) 对于 N 个页,但每个 PTE 只需一次原子写。
- TLB 刷新:O(1) 单个地址、或在跨 CPU 时发送 IPI(多核同步)。
ReactOS ARM3 的性能优化:
- 懒加载 PTE :
MEM_COMMIT只在 VAD 树中标记,实际 PTE 写入延迟到第一次访问(#PF)时。 - 批量分配 :
MiAllocatePage可以一次分配多个连续页------减少 PFN 锁的获取/释放次数。 - 零页优先:优先从 Zeroed 列表取页,省去 memset。
3.1.6.16.1 设计意图
核心问题 :在多线程应用中,10 个线程同时调用 VirtualAlloc 会发生什么?如何保证 VAD 树不被破坏、物理页不被重复分配、PTE 写入不出现中间状态?
设计哲学 :粗粒度锁保护结构、原子操作保护数据、懒加载减少持锁时间 。VAD 树的插入/删除需要持工作集锁+地址创建锁(粗粒度、保证结构完整性),但 PTE 的写入使用原子操作(不需要额外锁)。懒加载让 MEM_COMMIT 只做 VAD 树操作(快速),真正的 PFN/PTE 工作延迟到 #PF 处理(可以并行)。
3.1.6.16.2 概念解释
- 工作集锁(Working Set Lock):保护进程工作集大小和列表的自旋锁。
- 地址创建锁(Address Creation Lock):保护 VAD 树结构修改的自旋锁。
- MmPfnLock:保护 PFN 数据库全局状态的自旋锁。
- IPI(Inter-Processor Interrupt):多核间发送的中断。用于通知其他 CPU 刷新 TLB。
- 懒加载(Lazy Loading / Lazy Commit):在真正需要时才分配资源,而不是在 API 调用时。
3.1.6.16.3 为什么要这样设计
问题 1:为什么 VAD 树操作需要两把锁(工作集锁 + 地址创建锁)? 工作集锁保护"工作集大小"这一数据结构(与页替换算法相关),地址创建锁保护 VAD 树的结构(AVL 树旋转)。两者在理论上可以独立,但在实践中修改 VAD 节点时通常同时影响工作集------因此需要同时持有。但在某些路径上(如缺页异常只修改 PTE、不修改 VAD 结构),可以只持工作集锁------减少锁范围、提高并发。
问题 2:为什么 PTE 写入可以用原子操作而不需要锁? PTE 是单条 32 位/64 位数据。在 x86 上,"对齐的 32 位写操作"天然是原子的(不会被打断、不会出现中间状态)。修改 PTE 时使用 InterlockedExchange 或直接写(在单 CPU 场景)足以保证完整性。相比之下,VAD 树的插入/删除涉及多个指针修改------必须用锁保护。
问题 3:为什么懒加载能提高性能? 假设一个应用调用 VirtualAlloc(NULL, 64 KB, MEM_COMMIT, PAGE_READWRITE) 分配 64 KB = 16 页。如果立即分配 16 个物理页+填写 16 个 PTE,需要持锁 ~1 微秒、访问 16 个 PFN。懒加载只需要在 VAD 树中插入一个节点(~0.1 微秒),真正的物理分配延迟到 16 次 #PF 时完成------每次 #PF 只分配 1 页。在大多数应用中,分配的内存不会被立即全部访问(稀疏访问模式)------懒加载让"分配"操作的代价从 O(N) 降到 O(1),显著减少锁竞争。
3.1.6.17 小结(增强版)
VirtualAlloc→ntdll!NtAllocateVirtualMemory→KiSystemServiceHandler→ntoskrnl!NtAllocateVirtualMemory→MmAllocateVirtualMemory→MiAllocateVirtualMemory→MiCommitVirtualMemory:完整的 10 层调用链。NtAllocateVirtualMemory处理:PreviousMode 检查、参数 probe、参数捕获、句柄转换、调用MmAllocateVirtualMemory。MiAllocateVirtualMemory处理:找空闲区段、VAD 插入、PTE 分配、配额检查。- 9 种 AllocationType 标志支持灵活的内存管理策略(COMMIT / RESERVE / DECOMMIT / RELEASE / RESET / TOP_DOWN / LARGE_PAGES / PHYSICAL / WRITE_WATCH)。
- 用户态偏好地址的三种情况:NULL 让系统选、非 NULL 偏好、系统空间地址(拒绝)。
MiMakePteProtection将 Win32 保护位转为 PTE 硬件位。- **大页(
MEM_LARGE_PAGES)**减少 TLB 压力,但有物理对齐和权限限制。 - ToC/ToU 安全:probe + 复制到内核栈是必须的安全措施。
- Commitment 配额:防止过度申请,Commit Limit = 物理内存 + pagefile 总和。
- 并发控制:工作集锁 + 地址创建锁 + PFN 锁 + 原子操作 + 懒加载 = 高并发下的正确性与性能。
- 64 KB 段对齐 + Commit 配额是 Windows 内存管理的两个工程优化。
3.2 页面异常
3.2.0 框架图(先见森林)
┌────────────────────────────────────────────────────────────────────┐
│ 缺页异常的旅程 │
│ │
│ 用户态访问 0x12345000 │
│ │ │
│ ↓ │
│ CPU 查询 PTE → PTE.Present = 0 │
│ │ │
│ ↓ │
│ CPU 触发 #PF(保存 EIP、错误码、CS) │
│ │ │
│ ↓ │
│ IDT[0x0E] → _KiTrap0E │
│ │ │
│ ↓ │
│ KiTrap0EHandler → MmAccessFault │
│ │ │
│ ↓ │
│ MiDispatchFault(ARM3/pagfault.c:1338) │
│ │ │
│ ├──→ 检查:是否在 VAD 树内(合法未映射) │
│ ├──→ 若是:分配物理页、填写 PTE、刷新 TLB │
│ ├──→ 检查:是否在 PrototypePte(Section 共享映射) │
│ ├──→ 若是:从共享页复制 / 分配新页 │
│ └──→ 若都不是:返回 ACCESS_VIOLATION │
└────────────────────────────────────────────────────────────────────┘
本图核心要点 :缺页异常是内核最频繁调用的代码路径之一------一个进程刚启动时(EXE/DLL 加载到内存但 PTE 还未建立)就会触发几十次 #PF;之后每访问一个新页(栈扩展、堆扩展、内存映射文件)都会触发 #PF。
3.2.0.1 设计意图
核心问题:当用户态程序访问一个"尚未映射到物理页"的虚拟地址时,CPU 会触发缺页异常(#PF)。操作系统必须在异常处理中完成"分配物理页→填写 PTE→刷新 TLB→恢复用户态执行"的完整流程。这是虚拟内存最核心的"按需分配"机制。但每次 #PF 的处理路径可能不同:有些是简单的 demand-zero,有些需要从 pagefile 读回,有些是 COW 写时复制,有些是 Section 共享映射的首次访问。如何设计一个统一的处理入口,覆盖所有这些场景?
设计哲学 :快速路径优先 + 类型分发 。MiDispatchFault 先做快速路径检查(该地址是否在 VAD 树内?是否是已映射但被 trim 的页?是否是 transition 状态?)------这些快速路径占总 #PF 的 80% 以上。快速路径未命中时,进入"按 VAD 类型分发"的慢速路径------Private VAD 走 demand-zero,Image/Section VAD 走 prototype PTE 解析,等等。这种设计让"常见情况快、罕见情况正确"。
本节定位 :3.2 节是"虚拟内存的动态运行机制"。前几节讲了 VAD 树/ PFN/ PTE 这些"静态结构",本节讲这些结构如何在缺页异常中被动态修改------这是用户态程序能"无感知地"使用虚拟内存的关键。理解本节后,读者能完整解释"为什么 malloc(1 GB) 不占物理内存"、"为什么父子进程可以共享内存"等经典问题。
3.2.1 CPU 触发 #PF 的场景
x86 在以下情况触发 #PF(异常号 0x0E):
| 场景 | 错误码 | 含义 |
|---|---|---|
| 页不在内存 | P=0, R/W=0/U/S=1, RSVD=0 | demand-zero 或 prototype 跳转或 transition |
| 保护违规 | P=1, R/W=1, U/S=1, RSVD=0 | 写只读页(COW 触发) |
| 用户态访问内核页 | P=1, R/W=*, U/S=0 | 用户态访问内核态页(违规) |
| 保留位被设 | RSVD=1 | PTE 字段有非法值(内核 bug 或硬件故障) |
| 指令取指失败 | P=0, I=1 | code 页不在内存(code 通常 demand-zero) |
错误码格式(x86 32 位):
| 位 | 名称 | 含义 |
|---|---|---|
| 0 | P | 0 = 页不在内存;1 = 页在内存但访问违规 |
| 1 | R/W | 0 = 读;1 = 写 |
| 2 | U/S | 0 = 内核态访问;1 = 用户态访问 |
| 3 | RSVD | 1 = 保留位被设 |
| 4 | I | 1 = 指令取指(x86 新增) |
3.2.2 _KiTrap0E 入口
_KiTrap0E 是 IDT0x0E 对应的汇编入口(在 ntoskrnl/ke/i386/trap.s(file:///d:/reactos/ntoskrnl/ke/i386/trap.s) 中)。它完成:
- 保存现场:构造 KTRAP_FRAME(保存 EAX、EBX、...、EIP、CS、EFLAGS 等)。
- 错误码入栈:把 CPU 压入的错误码也入栈。
- 调用 C 处理函数 :
KiTrap0EHandler(FaultCode, FaultAddress, TrapFrame)。
KiTrap0EHandler 是 C 函数(在 ntoskrnl/ke/i386/traphdlr.c(file:///d:/reactos/ntoskrnl/ke/i386/traphdlr.c) 中),它再调用 MmAccessFault:
c
NTSTATUS
KiTrap0EHandler(IN NTSTATUS Status,
IN PVOID FaultAddress,
IN PKTRAP_FRAME TrapFrame)
{
/* 1. 解析错误码 */
/* 2. 包装成 MiAccessFault */
return MiAccessFault(Status, FaultAddress, TrapFrame);
}
3.2.3 MiDispatchFault 的分类处理
`MiDispatchFault`(file:///d:/reactos/ntoskrnl/mm/ARM3/pagfault.c#L1338) 是 #PF 的核心 C 入口(ARM3 路径)。它按以下顺序处理:
MiDispatchFault(FaultCode, FaultAddress, TrapFrame):
│
│ 1. 错误码解析:区分页不在内存 vs 保护违规 vs 保留位被设
│
│ 2. 调用 MiIsAddressValid 快速判断:
│ 如果 FaultAddress 在内核 PTE 中有合法映射 → 命中特殊路径
│
│ 3. 查 VAD 树:
│ VAD = MiFindNodeOrParent(Process->VadRoot, FaultAddress)
│ if VAD == NULL:
│ // FaultAddress 不在用户态已分配区段
│ return STATUS_ACCESS_VIOLATION
│
│ 4. 按 VAD 类型分支:
│ if VAD->u.VadFlags.Private:
│ // 私有区段:demand-zero 或 COW
│ if COW 触发(写只读页):
│ MiCopyOnWrite(...)
│ else:
│ MiDemandZeroPage(...) // 分配新页
│
│ elif VAD->u.VadFlags.ImageSection or MappedDataFile:
│ // Section 映射:从原型 PTE 复制
│ PrototypePte = Subsection->SubsectionBase + Offset
│ // Prototype PTE 也可能不在内存 → 递归 #PF(从 pagefile 读)
│ MiResolveProtoPteFault(PrototypePte, ...)
│
│ 5. 写 PTE + 刷 TLB
│ MI_WRITE_VALID_PTE(Pte, MiBuildPte(Pfn, ...))
│ KeFlushSingleTb(FaultAddress, FALSE)
│
│ 6. 返回 STATUS_SUCCESS
关键点:
- MiIsAddressValid 快速路径 :内核态的 FaultAddress 如果 PTE.P=1,意味着已经映射------只是访问违规。这避免走 VAD 树查找。
- VAD 树查找 (第 3 步):如果找不到对应的 VAD,说明 FaultAddress 不在该进程已分配区段内------这是用户态 bug(空指针解引用、栈溢出、缓冲区溢出),返回 ACCESS_VIOLATION。
- 递归 #PF (第 4 步 Section 路径):如果原型 PTE 也不在内存(页被换出到 pagefile),
MiResolveProtoPteFault会从 pagefile 读回。这种"缺页中的缺页"在 ReactOS 内部通过 top-level PFN 锁定避免递归调用栈过深。 - 写 PTE + 刷 TLB(第 5 步):分配新页后立刻写 PTE 并刷 TLB。CPU 看到新映射后自动重试访问指令。
3.2.4 关键代码片段
MiDispatchFault 入口(ARM3/pagfault.c:1338(file:///d:/reactos/ntoskrnl/mm/ARM3/pagfault.c#L1338)):
c
NTSTATUS
NTAPI
MiDispatchFault(IN ULONG FaultCode,
IN PVOID FaultAddress,
IN PKTRAP_FRAME TrapFrame)
{
/* ... */
/* 1. 错误码解析 */
BOOLEAN P = (FaultCode & 1) == 0; // P=0
BOOLEAN W = (FaultCode & 2) != 0; // R/W=1
BOOLEAN U = (FaultCode & 4) != 0; // U/S=1
BOOLEAN Reserved = (FaultCode & 8) != 0;
/* 2. 快速路径:MiIsAddressValid */
if (MiIsAddressValid(FaultAddress)) {
/* 该地址在 PTE 中已经映射:保护违规或写监控 */
return MiCheckPdeForPteChange(Pde, ...);
}
/* 3. 查 VAD 树 */
Vad = MiFindNodeOrParent(Process->VadRoot, FaultAddress);
if (Vad == NULL) return STATUS_ACCESS_VIOLATION;
/* 4. 按 VAD 类型分支 */
if (Vad->u.VadFlags.VadType == VadTypePrivate) {
if (W && P) {
/* 写已映射的私有页:COW 触发 */
return MiCopyOnWrite(Process, Vad, FaultAddress, Pte);
} else {
/* Demand-zero 分配 */
return MiDispatchDemandZeroFault(Process, Vad, FaultAddress, Pte);
}
} else {
/* Section 映射 */
return MiDispatchSectionFault(Process, Vad, FaultAddress, Pte);
}
}
3.2.5 写时复制(COW)详解
COW 是 Windows 内存管理中最优雅的设计之一。它的核心思想:
不要在创建时复制,而是延迟到"必须复制"时。
典型场景 :父进程创建子进程(fork 在 Windows 中通过 CreateProcess + NtCreateProcess 实现)。子进程需要父进程地址空间的副本。但如果立即复制(4 GB 虚拟地址 → 4 GB 物理页),开销巨大。
COW 解决方案:
- 子进程创建时,父子共享同一组物理页。
- 所有共享的 PTE 都标记为
ReadOnly + User(即便本来是 ReadWrite)。 - 当任一方写入时,#PF 触发。
MiDispatchFault检测到"P=1, R/W=0, 但访问是写"------这是 COW 触发条件。 - 内核为写入方分配新页 (从零页或 demand-zero),复制原内容 到新页,修改 PTE 指向新页。
COW 的代码路径 (在 MiCopyOnWrite 中):
c
NTSTATUS MiCopyOnWrite(PEPROCESS Process, PMMVAD Vad, PVOID Address, PMMPTE Pte) {
PFN_NUMBER OldPfn, NewPfn;
// 1. 读旧 PFN
OldPfn = Pte->u.Hard.PageFrameNumber;
// 2. 分配新页(从 Zeroed 列表)
NewPfn = MiAllocatePage();
// 3. 通过 HyperSpace 映射旧页
OldAddr = MiMapPageInHyperSpace(Process, OldPfn, &OldIrql);
NewAddr = MiMapPageInZeroSpace(...); // Zero Space 映射新页(自动清零)
// 4. 复制旧内容到新页
RtlCopyMemory(NewAddr, OldAddr, PAGE_SIZE);
// 5. 解除 HyperSpace 映射
MiUnmapPageInZeroSpace(NewAddr, 1);
MiUnmapPageInHyperSpace(Process, OldAddr, OldIrql);
// 6. 写 PTE 指向新 PFN
PteValue = *Pte;
PteValue.u.Hard.PageFrameNumber = NewPfn;
PteValue.u.Hard.Write = 1; // 恢复 R/W
MI_WRITE_VALID_PTE(Pte, PteValue);
// 7. 刷 TLB
KeFlushSingleTb(Address, FALSE);
return STATUS_SUCCESS;
}
关键点:
- 共享读、独占写:COW 让"读"零成本(共享物理页),让"写"按需付费(按需复制)。
- 典型性能提升:进程 fork 后立即 exec(替换地址空间)的场景------COW 避免了 fork 时的大量物理页复制。
3.2.6 Section 映射的 #PF
当 VAD 是 ImageSection / MappedDataFile 时,#PF 的处理路径不同:
c
NTSTATUS MiDispatchSectionFault(PEPROCESS Process, PMMVAD Vad, PVOID Address, PMMPTE Pte) {
PSUBSECTION Subsection;
PMMPTE ProtoPte;
// 1. 通过 VAD 找到对应的 Subsection
Subsection = MiLocateSubsection(Vad, Address);
ProtoPte = Subsection->SubsectionBase + MiGetSubsectionOffset(Vad, Address);
// 2. 处理原型 PTE 的状态
return MiResolveProtoPteFault(ProtoPte, Process, Vad, Address, Pte);
}
MiResolveProtoPteFault 处理原型 PTE 的 4 种状态:
| 原型 PTE 状态 | 处理 |
|---|---|
P=1(在内存) |
直接让进程 PTE 指向该 PFN(共享) |
P=0, PFN=0(demand-zero) |
分配新页,进程 PTE 指向该 PFN |
P=0, transition(已换出) |
把 transition 恢复 Active,进程 PTE 指向该 PFN |
P=0, pagefile(在 pagefile) |
从 pagefile 读回,进程 PTE 指向该 PFN |
关键点:
- Section 映射的 PTE 是"prototype 跳转"------它们的 PTE.P=0 但有指向原型 PTE 的指针。第一次访问触发 #PF,MiDispatchFault 处理后让进程 PTE.P=1(直接指向 PFN)。
- 多个进程可同时触发同一 Section 的 #PF------共享的物理页是同一个。
3.2.7 概念解释
- #PF(Page Fault,缺页异常):x86 异常号 0x0E。CPU 在 PTE 显示"未映射"或"权限不足"时触发。错误码含 P(Present)、R/W(读写)、U/S(用户/内核)、RSVD(保留位被设)等位。
MiDispatchFault:ARM3 中处理 #PF 的 C 入口函数。MiDispatchFault根据错误码与 VAD 树中的状态决定是"分配新页"还是"返回 ACCESS_VIOLATION"。- Demand Zero(按需清零):VAD 节点有 commit 标志但未实际分配物理页。第一次缺页时从"全局零页"按需复制。
- 写时复制(COW, Copy-On-Write):Section 共享映射下,所有进程共享同一物理页。当某个进程写时,#PF 触发,内核为该进程分配新页并复制。
- Prototype PTE(原型 PTE):Section 中的"共享 PTE 模板"。所有映射的进程的对应 PTE 都指向同一组 Prototype PTE;Prototype PTE 决定该 Section 偏移上的"主"物理页。
- Transition PTE:当 PTE 不在 Active 状态时,可以编码"过渡态"------含 P=0 但保留 PFN。这让"我暂时没在用但还占着 PFN"的页可以保留映射关系。
- 递归 #PF :处理 Section 映射的 #PF 时,原型 PTE 也可能不在内存------
MiResolveProtoPteFault内部会再次触发 #PF,从 pagefile 读回。ReactOS 通过 top-level PFN 锁定避免栈过深。
3.2.8 为什么要这样设计
问题 1:为什么 #PF 不直接归类为"错误"?
绝大多数 #PF 是合法的 (按需分配、COW、按需清零)。错误码中的 P=0 vs 权限不匹配让内核能区分"未映射"和"非法访问"。这是"虚拟内存缺页机制"成为基础。如果 #PF 都被视为错误,那么任何 malloc、栈扩展、内存映射文件访问都会失败------整个虚拟内存机制无法工作。
问题 2:为什么 MiDispatchFault 走 VAD 树查找?
"合法但未映射"必须用 VAD 树确认该虚拟地址在用户进程已分配区段内。VAD 树查找的 O(log N) 复杂度比线性扫描 O(N) 快得多------#PF 是内核最频繁调用的路径之一(一个进程启动会触发几十次 #PF;正常使用每秒触发上千次)。O(log N) 是工业级性能要求。
问题 3:为什么 Prototype PTE 要作为"主源"?
共享映射的本质 :多个进程的 PTE 都指向同一组 Prototype PTE。修改 Prototype PTE 的 PFN 字段会"瞬时"让所有映射的进程看到新页(写时复制)。这是 DLL/数据共享页的关键。如果没有 Prototype PTE,每个进程都需要"自己复制一份 PTE 数组"------Section 共享的内存开销爆炸。
问题 4:为什么 demand-zero 优先用全局零页?
见 3.1.2 §zero page。新分配的虚拟地址大部分不会立即被读;共享零页省去了 memset(4 KB)。性能提升:从 ~1 微秒(memset)到 ~0 微秒(直接用零页)。
问题 5:为什么 COW 在写时复制而不是创建时复制?
绝大多数 fork 后立即 exec 的场景 ------如果创建时复制(4 GB 物理页),开销巨大且白白浪费;COW 延迟到"必须复制"时(写时),实际可能只复制 1~10 MB(实际用到的部分)。典型性能提升:fork 从 ~1 秒(创建时复制)到 ~10 毫秒(COW)。
问题 6:为什么 Section 映射的 PTE 初始是"prototype 跳转"而非"demand-zero"?
Section 映射的页不是"私有"------它们是"共享的"。demand-zero 表示"私有,全 0";prototype 跳转表示"共享,初始内容在 Section 的原型 PTE 中"。区分两种 PTE 状态让 MiDispatchFault 能走不同路径。
3.2.10 Transition PTE 的深层机制
**Transition PTE(过渡态 PTE)是 Windows/ReactOS 中最精巧的设计之一。
Transition 状态的含义:
- PTE 中
Present = 0(CPU 认为未映射),但 PFN 字段存储了"该页在 PFN 数据库中的"信息。 - 即:页不在进程的工作集中,但物理页仍然有效(在 Standby/Modified 链表中)。
Transition 状态的生命周期:
- 进入 :当工作集修剪(
MmTrimWorkingSet)或页面从工作集移除时,PTE 被设置为 transition 状态。 - 保留:PFN 被放入 Standby 链表。
- 恢复:原进程再次访问该虚拟地址时,#PF 检测到 PTE 是 transition 状态,内核从 Standby 链表中取出该 PFN,重新填写 PTE,恢复映射。
为什么这是一个 O(1) 操作------不需要磁盘 I/O、不需要重新分配物理页。
与 Pagefile PTE 与 Transition PTE 的区别:
- Transition PTE:PFN 字段存储的是 PFN 编号(物理页号)。
- Pagefile PTE:PFN 字段存储的是 pagefile 编号 + 偏移量("页面在 pagefile 中的位置)。
两种状态由 PTE 的 bit 11 位(MI_PTE_PROTOTYPE)决定。
3.2.10.1 设计意图
核心问题:当一个页被从工作集移除(trim)后,如何让原进程再次访问它时,最快速恢复?
设计哲学:**"软删除"而非"真删除"。从工作集移除时不立即释放物理页,而是将页放入 Standby/Modified 链表,通过 PTE 的 transition 状态保留 PFN 信息。下次访问时只需 O(1) 的页表。这是典型的"懒回收"策略------让最近最少使用的页先被放入 Standby 链表中,最先被偷走;局部性原理让大多数页在被偷走之前被重新激活。
3.2.10.2 概念解释
- Transition PTE :
Present=0但 PFN 字段包含有效的物理页号。表示页在 PFN 数据库中的状态为 Standby/Modified。 - Standby 链表:页被从工作集移除但内容仍有效的页组成的链表。按"最近最少使用"排序。
- Modified 链表:被修改过且需要写回磁盘的页。
- Pagefile PTE :
Present=0但 PFN 字段存储的是 pagefile 编号 + 偏移。表示页已被换出到磁盘。 - 页恢复(Fault):从 Transition 状态激活 → 重新映射到 Active 的过程。只需要 O(1)。
3.2.10.3 为什么要这样设计
问题 1:为什么不直接释放物理页? 如果释放物理页意味着下次访问时需要:① 重新分配 PFN(可能需要等待 Free 链表;② 需要从 pagefile 读回内容(如果是 Modified 页);③ 需要从 disk I/O(10⁵ CPU 周期)。Transition 状态让恢复只需 ~100 周期。
问题 2:为什么不保留在工作集? 工作集大小有限制------每个进程的 WorkingSetMaximum 限制了它能同时拥有的 Active 页数。如果所有页都保留在 Active 状态,物理内存会很快耗尽。工作集修剪让"暂时不用的页让位给需要的进程------这是虚拟内存"在多任务系统的核心策略。
问题 3:为什么 Transition 和 Standby/Modified 是两个不同的状态? Standby/Modified 是 PFN 数据库中的页状态(物理页层面的状态),而 Transition 是 PTE 中的标志(虚拟地址层面的状态。两者配合使用:PFN 状态记录"该物理页当前处于什么状态",Transition 记录"这个虚拟地址对应的物理页号是什么"。两个层面的信息让内核可以在"不修改 PTE 的情况下处理页的换出和回收。
3.2.11 #PF 的性能优化路径
缺页异常是系统中最频繁的异常------性能优化至关重要。Windows/ReactOS 采取了多层优化策略:
优化 1:快速路径(Fast Path)
- 在
MiDispatchFault开头先检查:是否是"已经在 PFN 数据库中的 transition 状态?是否是 demand-zero?如果是,直接分配物理页并填写 PTE,不需要 VAD 树遍历。 - 在 PTE 层面判断 PTE 的软件状态:
Present=0但 PFN 字段是零?直接从零页分配。
优化 2:预读(Read Ahead)
- 当一个 Pagefile 中的页被访问时,
MiResolvePageFileFault不仅读回当前页,同时预读后面的几页(预读窗口通常 8~64 KB)。 - 局部性原理:程序访问一个页后,很可能很快访问相邻的页。
- 预读让后续 #PF 在"页已经在内存中",不需要再次磁盘 I/O。
优化 3:页合并(Page Clustering)
- 换出时不单独换出一个页,而是一次换出一组连续的页(cluster 大小 8~64 KB)。
- 减少磁盘 I/O 次数------一次写 pagefile 的次数,让换出效率提升。
优化 4:零页机制(Zero Page)
- demand-zero 分配时优先从 Zeroed 列表取页,省去
memset的 ~1000 CPU 周期。 - global zero page 全局零页让只读共享------第一次写入时才分配真正的物理页(COW 风格)。
优化 5:懒加载 PTE(Lazy PTE Commit)
MEM_COMMIT不立即填写 PTE。- 只在 VAD 树中记账,第一次访问时才真正分配 PFN + 填写 PTE。
- 让
VirtualAlloc(1 GB)的代价只需要分配 VAD 节点而不需要分配 PFN。
3.2.11.1 设计意图
核心问题:#PF 是最频繁的内核路径------10%~30% 的系统时间在处理缺页异常。如何让常见 case 尽可能快?
设计哲学:**分层快速路径优先(common-case fast)。常见场景(demand-zero、transition recovery、COW on write)占总 #PF 的 95%。优化这些路径的处理步骤越少越好。罕见场景(Section 的原型 PTE、pagefile 读回)虽然复杂,但出现概率低,允许较慢的处理。快速路径只做最少的最小必要操作(分配 PFN + 写 PTE + 刷 TLB)。
3.2.11.2 概念解释
- 预读窗口(Read Ahead Window):一次读回当前页加随后的后续页。大小通常是 8~64 KB。
- Page Clustering:将多个连续页合并为一次磁盘 I/O。减少 I/O 次数,提高换出效率。
- 懒加载 PTE(Lazy PTE Commit):MEM_COMMIT 时不填写 PTE 只在第一次访问时填写。减少 upfront 的分配成本。
- 全局零页(Global Zero Page):一个全局共享的全零物理页。demand-zero 读取时 PTE 指向零页。写入时才分配真正的物理页(COW)。
- 懒释放(Lazy Freeing):释放操作只做标记不立即回收。真正回收延迟到"内存紧张时才真回收。
3.2.11.3 为什么要这样设计
问题 1:为什么预读后续页? 程序局部性原理:当程序有两种局部性:时间局部性和空间局部性。时间局部性指"最近访问的数据很可能再次访问;空间局部性指"访问了地址 A 之后很可能访问 A+4 KB、A+8 KB(相邻页)。预读利用空间局部性------将后续几页一起读入内存,让后续 #PF 命中缓存。
问题 2:为什么不预读更多页? 预读过多会占用物理内存------如果预读了程序"不需要的页,会浪费物理内存、增加换页 I/O。预读窗口大小是一个折中(8~64 KB 之间的折中,不读更多。ReactOS 通常采用动态调整------程序顺序访问时加大预读窗口;随机访问时减小或取消预读窗口。
问题 3:为什么懒加载 PTE? VirtualAlloc 时立即分配 PTE 会让 1 GB 需要分配 262,144 个 PTE(1 GB / 4 KB = 262,144 PTE)= 256 MB = 1 MB。这是 1 MB 的开销------不小的开销。懒加载让这一成本延迟到真正访问时才承担------不访问就不分配。实际中大多数应用分配的内存中只有 10%~30% 被真正访问------节省 70%~90% 的分配开销。
3.2.12 Section 映射的递归缺页处理
当进程第一次映射一个 Section(内存映射文件)时,#PF 处理路径是最复杂的场景之一。
**完整流程(内存映射文件的首次访问):
-
**第一次访问虚拟地址 VA
→ 触发 #PF
→ 查 VAD 树 → 发现是 Section(VAD->u.VadFlags.Section = 1
→ 获取 Subsection 指针 → 获取原型 PTE(
SubsectionBase→ 读取原型 PTE
→ 判断原型 PTE 的状态:
原型 PTE 状态 处理方式 Present=1, PFN=xxx 进程 PTE 直接指向这个 PFN(共享) Present=0, transition 从 Standby/Modified 链表恢复,进程 PTE 指向该 PFN Present=0, pagefile 从 pagefile 读回内容,分配新 PFN,填写进程 PTE Present=0, demand-zero 从零页分配新 PFN,填写进程 PTE -
递归 #PF 的特殊情况:
- 如果原型 PTE 是 pagefile 状态 → 需要从 pagefile 读回内容。
- 但读回时需要分配 PFN → 可能触发另一次 PFN 数据库的修改 → 可能需要换出其他页 → 可能再次触发 #PF(递归调用)。
- 为防止递归深度过大,top-level PFN 锁定机制确保:一次 #PF 处理期间,已经锁定正在处理的 PFN,不允许换出该 PFN。
-
多个进程共享同一块 Section 的情况:
- 进程 A 首次访问 Section 的某页 → #PF → 分配物理页 → 填写进程 A 的 PTE 和原型 PTE 为 Present。
- 进程 B 随后访问同一页 → #PF → 查原型 PTE 已是 Present → 直接把进程 B 的 PTE 指向同一 PFN(共享)。
- 共享计数 +1。
3.2.12.1 设计意图
核心问题:Section 让多个进程共享同一块文件内容------但首次访问时,谁来负责分配物理页?如何保证所有进程看到同一份物理页?
设计哲学:**原型 PTE 作为"主源"(Master Source of Truth)。每个进程的 PTE 是"副本"。第一次访问时,由 #PF 处理在原型 PTE 中查找答案,而不是各自独立分配。这是"间接层解决一切问题的经典案例。多个进程通过原型 PTE 实现"写时共享、读时共享"。任何一个进程写入共享页时触发 COW,复制到自己的私有副本。
3.2.12.2 概念解释
- 原型 PTE(Prototype PTE):Section 对象内部的一组 PTE。每个 Subsection 对应文件偏移处有一个原型 PTE。
- Subsection:Section 内部按 64 KB 切分的子块。对应 VAD 树中的区间。
- 递归 #PF(Recursive Page Fault) :处理 Section 映射时,原型 PTE 不在内存,触发再次调用
MiResolveProtoPteFault处理------这是"缺页中的缺页。 - Top-level PFN 锁定:防止递归 #PF 递归深度过大导致栈溢出。在 #PF 处理开始时记录"正在处理的 PFN",后续换出操作不允许换出该 PFN。
3.2.12.3 为什么要这样设计
问题 1:为什么不直接让每个进程独立分配? 如果每个进程各自分配,不共享,同一块文件内容会被多次加载------10 个进程映射同一 DLL 会导致 10 份物理内存。浪费 900%。这是不可接受的。原型 PTE 让所有进程共享同一份物理页------节省 90%。
**问题 2:为什么需要递归而不是 iteration(循环?
写时复制(COW)?当进程写入共享页时,需要为写入进程分配新页,复制内容------这是"写时复制(Copy-On-Write)的本质。进程读到的是共享页的"主源,但进程写入时自己的私有副本。
3.2.13 保护违规(Protection Violation)与安全意义
**保护违规(Protection Violation)是 P=1(页已映射)但访问权限不足时触发的 #PF。这与"页不在内存"的 #PF 是不同的路径。
典型场景:
- 写只读页:PTE 标志位 Read-only,进程写入该页。
- 用户态访问内核页:PTE 的 User=0,用户态访问。
- DEP/NX 页执行:PTE 的 Execute Disable 位为 1,CPU 尝试取指。
- 写 Guard Page:访问 Guard Page。
保护违规的处理:
- COW 场景:PTE 是 Write 位为 0,但 VAD 标记为 Copy-on-write。写时复制语义。
- 真正违规:PTE 确实不可写且 VAD 也不可写 → 返回 `STATUS_ACCESS_VIOLATION。
- DEP/NX 违规:触发 DEP 异常,通常导致进程被系统中断。
保护违规与 COW 的区别:
| 场景 | PTE.Write | VAD 标志 | 处理 |
|---|---|---|---|
| 写只读数据 | 0 | 0 | 违规 → ACCESS_VIOLATION |
| 写共享内存映射文件写时复制 | 0 | CopyOnWrite | COW → 分配新页,复制 → 恢复执行 |
| NX 页执行 | NX | 0 | 违规 → 程序崩溃 |
| Guard Page 访问 | 0 | Guard | 触发 Guard 页,调用异常 |
**保护违规是安全防线之一------确保用户态不能访问内核态数据、代码页不能被写入(缓冲区溢出防护)。
3.2.13.1 设计意图
核心问题:如何让"写入共享页"和"写入私有页"在同一条异常路径中区分?
设计哲学:**PTE 标志位(硬件层面快速路径)区分"可以访问"和"不可以访问"。VAD 标志位(软件层面)区分"这是共享页还是私有页。COW 是"看起来不可以写但实际上写时复制"------PTE.Write=0,但 VAD.Flags.CopyOnWrite=1。#PF 处理时,先看 PTE(硬件标志位),再看 VAD(软件标志位)。两者配合让"写共享页"和"写保护违规"在硬件层面完全相同(Write=0),但在软件层面通过 VAD 标志区分:是 COW 还是真正违规。
3.2.13.2 概念解释
- DEP/NX(Data Execution Prevention/No eXecute):PTE 的"不可执行位。防止缓冲区溢出漏洞中常用的保护机制。
- Guard Page:特殊的保护页。访问时触发特殊 #PF,用于栈自动扩展。
- STATUS_ACCESS_VIOLATION:Windows 对用户态返回的错误码。表示"访问了不允许访问的内存"。
- Write Watch(写时复制标志位):VAD 标志位。标记该 VAD 区间是"写时复制"。
3.2.13.3 为什么要这样设计
问题 1:为什么不在 PTE 中加一个 CopyOnWrite 位? x86 硬件不支持"写时复制标志位------硬件只支持 Present/RW/US/RW/US 四个权限位。操作系统必须用软件标志(VAD 中记录"这个区间是否是写时复制)区分。这是"软件语义通过硬件权限位实现"的典型案例。
问题 2:为什么 DEP/NX 是硬件层面的? 缓冲区溢出漏洞是最常见的攻击方式------攻击者通过缓冲区溢出修改返回地址,然后执行注入的代码。DEP/NX 让数据页不可执行------即使攻击者溢出也无法执行。这是 Windows XP SP2 之后引入的关键安全特性。
问题 3:为什么保护违规需要区分不同的原因? 是的。对于应用程序而言:① 合法写只读数据是程序可能崩溃(例如尝试写只读内存段通常是程序 bug------应该崩溃。② 写 COW 共享页是正常的------需要处理复制。③ 写内核页是恶意的------应该被系统杀死。④ 执行数据页是常见的攻击向量------需要 DEP 拦截。区分这些场景让内核在"同一硬件异常"中做出不同的响应------崩溃、正常处理或杀死攻击者。
3.2.14 小结
- **#PF(异常号 0x0E)是 CPU 在 PTE 未映射或权限不足时触发的异常。
MiDispatchFault(ARM3/pagfault.c(file:///d:/reactos/ntoskrnl/mm/ARM3/pagfault.c#L1338) 是 #PF 的核心 C 入口,按"快速路径 → VAD 树查找 → 类型分支"处理。- 4 种典型处理:demand-zero(私有零页)、COW(写共享页)、Section 映射(从原型 PTE 共享)、Pagefile 读回(从 pagefile 读回)。
- COW(写时复制)让"fork 后立即 exec"的场景性能提升 100 倍。
- Prototype PTE 是 Section 共享映射的"主源",让多个进程能高效共享同组物理页。
- Transition PTE 是"软删除"------从工作集移除但保留 PFN 信息,下次访问 O(1) 恢复。
- 快速路径优化(demand-zero、transition 恢复、COW 处理占 95% 的 #PF 快速路径。
- 递归 #PF(处理 Section 时原型 PTE 又触发 #PF)通过 top-level PFN 锁定避免栈过深。
- 保护违规(权限不足的 #PF)区分"真违规"与"COW 触发",通过 PTE 标志位 + VAD 标志位联合判断。
本篇小结
本篇是第 3 章"内存管理"的中篇,聚焦虚拟内存的肌肉------系统空间的特殊用途、Hyperspace 临时映射、内存分配 API、缺页异常处理。
本篇涉及的四个核心主题:
| 主题 | 描述对象 | 关键文件 | 关键 API |
|---|---|---|---|
| HyperSpace(3.1.4) | 内核"短时访问任意物理页" | ARM3/hypermap.c |
MiMapPageInHyperSpace / MiUnmapPageInHyperSpace |
| 系统空间(3.1.5) | 内核共享的虚拟地址空间 | ARM3/syspte.c |
MiReserveSystemPtes / MiReleaseSystemPtes |
| NtAllocateVirtualMemory(3.1.6) | 用户 VirtualAlloc 的内核实现 | ARM3/virtual.c:4457 |
MiAllocateVirtualMemory |
| 页面异常(3.2) | 缺页异常的分类处理 | ARM3/pagfault.c:1338 |
MiDispatchFault |
四个主题的协作关系:
用户态 VirtualAlloc ───→ NtAllocateVirtualMemory ───→ MiAllocateVirtualMemory
│
├─→ VAD 树(3.1.1)
├─→ PTE 数组(3.1.3)
└─→ PFN Database(3.1.2)
首次访问 demand-zero 页 ───→ #PF ───→ MiDispatchFault
│
├─→ VAD 树查找
├─→ PTE 写入(3.1.3)
├─→ PFN 分配(3.1.2)
└─→ 可能是 COW(写时复制)
写换页文件 ───→ MiMapPageInHyperSpace(3.1.4)─→ 临时访问物理页
驱动 I/O ───→ MiReserveSystemPtes(3.1.5)─→ 一次性映射内核缓冲区
**本篇的"概念解释"**回答了 21 个术语(Hyperspace、临时映射、MiMapPageInHyperSpace、MiUnmapPageInHyperSpace、DMA、Recursive Mapping、系统空间、MmSystemRangeStart、Session Space、系统 PTE、System Cache、NonPagedPool、PagedPool、VirtualAlloc、MEM_COMMIT 等 6 个标志、NtAllocateVirtualMemory、MiAllocateVirtualMemory、PreviousMode 影响、ToC/ToU、Commit Limit、#PF、MiDispatchFault、Demand Zero、COW、Prototype PTE、Transition PTE、递归 #PF)。
**本篇的"设计哲学"**集中回答了 17 个"为什么要这样设计"的核心问题:
- 为什么需要 Hyperspace 而不是 KSEG?(x86 硬件不支持)
- 为什么 HyperSpace 槽位有限且每进程独立?(虚拟地址资源+锁竞争)
- 为什么映射时设 Kernel + RW?(安全:禁止用户态访问)
- 为什么 unmap 清零而非 invalid?(避免"翻页"误处理)
- 为什么系统空间是所有进程共享的?(节省页表空间)
- 为什么 Session Space 每个 session 独立?(Session 0 安全隔离)
- 为什么 NonPagedPool 要存在?(DPC/ISR 不能换出)
- 为什么系统 PTE 有限且需 reserve/release?(虚拟地址资源管理)
- 为什么 PagedPool 要可换出?(节省 NonPagedPool 稀缺资源)
- 为什么 VirtualAlloc 与 NtAllocateVirtualMemory 都有?(Win32/NT API 分层)
- 为什么 MEM_COMMIT 与 MEM_RESERVE 分离?(大段保留小段提交)
- 为什么 MEM_RESET 不真清零?(优化路径)
- 为什么 64 KB 段对齐?(VAD 树与磁盘 I/O 的最小区间单位)
- 为什么 NtAllocateVirtualMemory 要 probe + 捕获?(ToC/ToU 安全)
- 为什么 #PF 不直接归类为错误?(绝大多数合法)
- 为什么 MiDispatchFault 走 VAD 树查找?(O(log N) 性能)
- 为什么 Prototype PTE 作为主源?(多进程共享的关键)
- 为什么 demand-zero 优先用零页?(sparse array 模式)
- 为什么 COW 在写时复制?(fork 后立即 exec 的性能)
下篇预告 :3.3 页面的换出(file:///d:/reactos/doc/第3章_内存管理_下.md)、3.4 共享映射区(Section)(file:///d:/reactos/doc/第3章_内存管理_下.md)、3.5 系统空间的缓冲区管理(file:///d:/reactos/doc/第3章_内存管理_下.md)------讨论"换页、Section、池",是虚拟内存的工程实现。