Reactos 第 3 章 内存管理 — 【中篇】Hyperspace、系统空间、API 与异常

第 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

内核在以下场景需要"零时访问一块物理页":

  1. 写入换页文件 :当一个 Modified 状态的 PFN 需要写回 pagefile 时,内核需要"读源页内容 → 写到 pagefile"。但 pagefile 是普通磁盘 I/O 路径,不能直接操作 PTE。解决:把源页临时映射到 HyperSpace,内核从这个虚拟地址读出内容。

  2. 修改 PTE 自身 :当需要修改某 PTE 的内容时(如 COW 时把 PTE 从 "R/W=1" 改为 "R/W=0"),内核需要先找到该 PTE 的虚拟地址。但如果该 PTE 描述的是"用户态虚拟地址对应的 PTE",它的虚拟地址在内核中没有直接对应------PTE 在内核中通过"页表基址 + 索引"计算得到。HyperSpace 提供"已知虚拟地址 → 直接读 PTE 内容"的桥。

  3. DMA 准备:DMA 控制器需要物理地址,但内核代码处理的是虚拟地址。内核通过 HyperSpace 把"目标页"临时映射,然后取该虚拟地址对应的物理地址(PFN)交给 DMA 控制器。

  4. 修改页表项(如 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) 接受三个参数:ProcessPage(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,所有槽位重新可用。
  • 保存旧 IRQLOldIrqlKeAcquireSpinLock 提升 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_LEVELAPC_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_LEVELAPC_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 复制的开销。

核心流程

  1. COW 触发时需要分配一个新物理页 ------ 优先从 Zeroed 列表取页(内容已全 0)。
  2. 如果 Zeroed 列表为空,则从 Free 列表取页并通过 HyperSpace 临时映射做 memset(0)
  3. 复制源页内容到新页时,源页也通过 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, eaxinvlpg 让 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 的能力。

典型流程

  1. 内核拿到用户态地址 0x12345000 的 PTE 物理地址:PTE_PA = PageTableBase + (0x12345000 >> 12) * 8
  2. 将 PTE 所在的物理页映射到 HyperSpace:PteVA = MiMapPageInHyperSpace(Process, PFN_from_PTE_PA, &OldIrql)
  3. 读取 PTE 值:OldValue = *PteVA;
  4. 修改字段:NewValue = OldValue; NewValue.u.Hard.Write = 1;(比如提升写权限)。
  5. 原子写入:MI_WRITE_VALID_PTE(PteVA, NewValue);
  6. 刷 TLB:KeFlushSingleTb(0x12345000, FALSE);
  7. 解除 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 独立 不可换出

关键概念

  • MmSystemRangeStart0x80000000):系统空间起点常量。所有系统空间地址都大于等于此值。
  • 顶层映射:内核代码、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)):

  • MiInitializeSystemPtessyspte.c:388(file:///d:/reactos/ntoskrnl/mm/ARM3/syspte.c#L388)):在启动时初始化系统 PTE 池。
  • MiReserveSystemPtessyspte.c:246(file:///d:/reactos/ntoskrnl/mm/ARM3/syspte.c#L246)):申请一组系统 PTE。
  • MiReleaseSystemPtessyspte.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 子系统的职责

  1. 文件数据缓存:当应用程序读文件时,Cc 将文件内容映射到 System Cache 中。后续读取直接走缓存。
  2. 延迟写:写文件时先写到缓存,后台线程异步刷到磁盘。
  3. 统一缓存语义 :不管是 ReadFile 还是内存映射文件(MapViewOfFile),都走同一份 System Cache------避免双份缓存。

System Cache 与 Section 对象的关系

  • System Cache 中的每一个"缓存视图"对应一个 Section 对象。
  • 多个进程通过 MapViewOfFile 映射同一个文件时,它们的页表最终都指向 System Cache 中的物理页。
  • Cc 负责维护"哪些文件的哪些偏移已被缓存"的元数据。

ReactOS 中的实现 :Cc 子系统是一个独立的模块(位于 ntoskrnl/cc/)。核心结构体 SHARED_CACHE_MAPVACB(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 路径

  1. 应用调用 ReadFile(hFile, buffer, 4096, ...)
  2. I/O 管理器创建 IRP,交给文件系统驱动。
  3. 文件系统驱动从 System Cache 读取数据------如果数据不在缓存,构造"页读写"IRP 交给磁盘驱动。
  4. 磁盘驱动需要"将磁盘读到的 DMA 数据映射到内核虚拟地址"------这一步用系统 PTE。
  5. MiReserveSystemPtes(1, SystemPteSpace) 获取一个 PTE 槽位。
  6. 构造 PTE 指向 DMA 目标页,设置 Valid + Kernel + Write 位。
  7. 读取完成后 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 后、读取前修改了参数。
  • 句柄转 EPROCESSObReferenceObjectByHandle 内部走对象管理器的句柄表,校验句柄的有效性。引用计数 +1。
  • MmAllocateVirtualMemory:内核态的"真正"实现,调用栈更深。
3.1.6.3 MmAllocateVirtualMemory 与 MiAllocateVirtualMemory

MmAllocateVirtualMemoryNtAllocateVirtualMemory 的"瘦身版"------它接收 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_COMMITMEM_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 小结
  • VirtualAllocntdll!NtAllocateVirtualMemoryKiSystemServiceHandlerntoskrnl!NtAllocateVirtualMemoryMmAllocateVirtualMemoryMiAllocateVirtualMemory:完整的调用链。
  • 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)转换

VirtualAllocflProtect 参数是 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?

  1. 防止"承诺过多":如果内核允许进程 commit 超过物理内存 + pagefile 的总量,那么当所有进程同时访问这些页时,没有足够的物理页和 pagefile 槽位------会导致系统死锁。
  2. "记账式"内存管理: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 标志的深入解析

VirtualAllocAllocationType 参数是一组标志位的组合。理解每个标志的含义对正确使用虚拟内存至关重要。

标志位详解

标志 十六进制值 含义 典型用法
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 :内核记录哪些页被写入过。通过 GetWriteWatch API 可以查询。用于 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 是高频系统调用------多线程应用可能同时在多个线程中申请内存。需要严格的并发控制和高效的实现。

关键锁与层级

  1. 进程工作集锁(Process Working Set Lock):保护进程工作集的大小和列表。
  2. 地址创建锁(Address Creation Lock):保护 VAD 树的结构修改。
  3. 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 的性能优化

  • 懒加载 PTEMEM_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 小结(增强版)
  • VirtualAllocntdll!NtAllocateVirtualMemoryKiSystemServiceHandlerntoskrnl!NtAllocateVirtualMemoryMmAllocateVirtualMemoryMiAllocateVirtualMemoryMiCommitVirtualMemory:完整的 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) 中)。它完成:

  1. 保存现场:构造 KTRAP_FRAME(保存 EAX、EBX、...、EIP、CS、EFLAGS 等)。
  2. 错误码入栈:把 CPU 压入的错误码也入栈。
  3. 调用 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 解决方案

  1. 子进程创建时,父子共享同一组物理页。
  2. 所有共享的 PTE 都标记为 ReadOnly + User(即便本来是 ReadWrite)。
  3. 当任一方写入时,#PF 触发。MiDispatchFault 检测到"P=1, R/W=0, 但访问是写"------这是 COW 触发条件。
  4. 内核为写入方分配新页 (从零页或 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 状态的生命周期

  1. 进入 :当工作集修剪(MmTrimWorkingSet)或页面从工作集移除时,PTE 被设置为 transition 状态。
  2. 保留:PFN 被放入 Standby 链表。
  3. 恢复:原进程再次访问该虚拟地址时,#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 PTEPresent=0 但 PFN 字段包含有效的物理页号。表示页在 PFN 数据库中的状态为 Standby/Modified。
  • Standby 链表:页被从工作集移除但内容仍有效的页组成的链表。按"最近最少使用"排序。
  • Modified 链表:被修改过且需要写回磁盘的页。
  • Pagefile PTEPresent=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 处理路径是最复杂的场景之一。

**完整流程(内存映射文件的首次访问):

  1. **第一次访问虚拟地址 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
  2. 递归 #PF 的特殊情况

    • 如果原型 PTE 是 pagefile 状态 → 需要从 pagefile 读回内容。
    • 但读回时需要分配 PFN → 可能触发另一次 PFN 数据库的修改 → 可能需要换出其他页 → 可能再次触发 #PF(递归调用)。
    • 为防止递归深度过大,top-level PFN 锁定机制确保:一次 #PF 处理期间,已经锁定正在处理的 PFN,不允许换出该 PFN。
  3. 多个进程共享同一块 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 是不同的路径。

典型场景

  1. 写只读页:PTE 标志位 Read-only,进程写入该页。
  2. 用户态访问内核页:PTE 的 User=0,用户态访问。
  3. DEP/NX 页执行:PTE 的 Execute Disable 位为 1,CPU 尝试取指。
  4. 写 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 未映射或权限不足时触发的异常。
  • MiDispatchFaultARM3/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、池",是虚拟内存的工程实现

相关推荐
人道领域1 小时前
【LeetCode刷题日记】93.复原IP地址
java·开发语言·算法·leetcode
摇滚侠1 小时前
JavaWeb 全套教程 Listener 112-113
java·开发语言·servlet·tomcat·intellij-idea
ysu_03142 小时前
leetcode数据结构与算法1~4
c语言·数据结构·学习·算法·leetcode
hixiong1232 小时前
C# Tokenizers.DotNet测试工具
开发语言·人工智能·llm
曹牧2 小时前
Java:Deprecated 是
java·开发语言
zzz_23682 小时前
【RabbitMQ】面试系列 · 第三期:从线上故障到架构选型
面试·架构·rabbitmq
提子拌饭1332 小时前
爆发效果技术——基于鸿蒙PC Electron框架实现
华为·架构·electron·开源·harmonyos·鸿蒙·鸿蒙系统
caimouse2 小时前
Reactos 第 4 章 对象管理 — 4.1 对象与对象目录
服务器·c语言·开发语言·windows·架构
C137的本贾尼2 小时前
InnoDB 内存架构:Buffer Pool、Change Buffer 与 Log Buffer
数据库·oracle·架构