第 3 章 内存管理 — 【下篇】换出、Section、池

[上篇](file:///d:/reactos/doc/第3章_内存管理_上.md) 讲述了虚拟内存的骨架(VAD、PFN、PTE);[中篇](file:///d:/reactos/doc/第3章_内存管理_中.md) 讲述了虚拟内存的肌肉(Hyperspace、系统空间、NtAllocateVirtualMemory、缺页异常)。本篇要讨论的是工程实现——当物理内存耗尽时,操作系统怎么把"不活跃"的页换出到磁盘;Windows 内存模型中"文件 → 内存"映射的 Section 对象怎么工作;以及内核态"自己用的内存"(池)如何分配与回收。

这 3 个小节共同构成了虚拟内存的"外部接口"——3.3 处理"物理内存不足"的情况,3.4 处理"文件与内存的桥接",3.5 处理"内核自身的内存管理"。学完本篇,读者应当能完整画出"虚拟地址 → 物理页 → 磁盘文件"的完整数据流图。


3.3 页面的换出

3.3.0 框架图(先见森林)

┌────────────────────────────────────────────────────────────────────┐
│       内存压力下的换出流程                                          │
│                                                                    │
│  ┌─────────────────┐                                              │
│  │ Working Set     │ ← Trim 工作集时,目标 WorkingSetSize 之上    │
│  │  (进程工作集)    │    的页面被老化(age)和换出                   │
│  └────────┬────────┘                                              │
│           ↓                                                        │
│  ┌─────────────────┐                                              │
│  │ Standby List    │ ← 不活跃但尚未修改的页面                    │
│  └────────┬────────┘                                              │
│           ↓                                                        │
│  ┌─────────────────┐                                              │
│  │ Modified List   │ ← 已修改但未写回的页面                      │
│  └────────┬────────┘                                              │
│           ↓                                                        │
│  ┌─────────────────┐                                              │
│  │ MmWriteToSwapPage│ ← 通过 MmReadFromSwapPage + I/O            │
│  │ + I/O 写回      │    写入 pagefile.sys                       │
│  └─────────────────┘                                              │
│                                                                    │
│  Trigger: MmTrimWorkingSet、MemoryLow、MiPageOutProcessBulk         │
└────────────────────────────────────────────────────────────────────┘

本图核心要点:当物理内存不足时,操作系统把不活跃的页从"工作集"逐级下推到"Standby 列表"、“Modified 列表”,最终写入 pagefile。这个过程是分阶段异步的——内存压力触发时不是一次性把所有页写盘,而是按需逐步推进。

3.3.0.1 设计意图

核心问题:32 位系统物理内存有限(通常 1~4 GB),但每个进程都声称拥有 2 GB 虚拟地址空间,且同时运行几十个进程。当物理内存不足时,如何保证系统仍能正常响应?

设计哲学“最近最少使用”(LRU)的工业级实现。操作系统观察进程的内存访问模式——“最近刚访问过的页很可能很快再被访问;长期未访问的页可以暂时’挪到磁盘上放一放’”。通过工作集(Working Set)跟踪活跃页,Clock 算法老化不活跃页,Modified Page Writer 后台线程异步写盘,让前台用户态始终感知不到物理内存不足

本节定位:3.3 节是"虚拟内存的溢出层"。讲完 VAD 树(骨架)、NtAllocateVirtualMemory(分配 API)、#PF(缺页)后,本节解决一个更深层的问题——页满了怎么办? 理解换出机制后,读者才能完整理解"为什么 malloc(1 GB) 不占 1 GB 物理内存"、“为什么电脑’卡’时磁盘灯常亮”。

3.3.1 何时换出

页面换出有三个主要触发器:

  1. 工作集修剪MmTrimWorkingSet):当用户态或内核态调用 SetProcessWorkingSetSizeEmptyWorkingSet 时,强制把进程工作集修剪到指定大小。
  2. 内存压力MiPageOutProcessBulk):当系统检测到物理内存不足(MmLowMemoryEvent)时,主动扫描所有进程工作集,把不活跃的页换出。
  3. 主动弃页(API 触发):用户态调用 VirtualUnlock(如果之前 VirtualLock 锁过)会触发部分换出。

典型流程

物理内存接近上限
    ↓
MmLowMemoryEvent 触发
    ↓
MiPageOutProcessBulk 启动后台线程
    ↓
扫描所有进程工作集
    ↓
对每个进程,遍历工作集
    │  老化(age 递减)不活跃的 PTE
    │  把 age=0 的 PTE 从 Active 移到 Standby
    ↓
后台线程 MiModifiedPageWriter 启动
    │  扫描 Modified 列表
    │  写回到 pagefile.sys 或原文件
    │  写完后 PFN 状态:Modified → Standby
    ↓
前台线程继续分配时优先用 Standby 页(被偷)
    ↓
系统内存压力解除

3.3.2 页面状态转换回顾

3.1.2 节已经详细讨论了 PFN 的 6 种状态。这里回顾换出过程中的关键转换:

主动换出(pageout)的状态流

工作集中的 Active 页(属于进程 PTE)
    │ 老化(age=0)
    ↓
Standby 列表(PFN 仍映射到原 PTE,PTE 是 transition 状态)
    │ 进程访问该页
    ↓
回到 Active(transition 恢复)

或

Standby 列表
    │ 被另一进程偷走(page reclaim)
    ↓
Free 列表或 Zeroed 列表
    │ 分配给新进程
    ↓
新 Active 页

或

Active 页(被修改)
    │ 修改时 CPU 设置 Dirty 位
    │ 内核扫描时发现
    ↓
Modified 列表
    │ 写回线程处理
    ↓
Standby 列表

关键转换点

  • Active → Standby:进程主动弃页(VirtualFree、工作集 trim、内存压力)。
  • Standby → Free / Zeroed:被偷走(page reclaim)。
  • Active → Modified:被修改时,PFN 状态在写时变为 Modified(写时转换)。
  • Modified → Standby:写回 pagefile 后。

3.3.3 MmReadFromSwapPage 与 MmWriteToSwapPage

MmReadFromSwapPageMmWriteToSwapPage 是 pagefile I/O 的核心函数。

[MmReadFromSwapPage](file:///d:/reactos/ntoskrnl/mm/pagefile.c#L202-L207) 从 pagefile 读一页到内存:

NTSTATUS
NTAPI
MmReadFromSwapPage(SWAPENTRY SwapEntry, PFN_NUMBER Page)
{
    return MiReadPageFile(Page, FILE_FROM_ENTRY(SwapEntry), OFFSET_FROM_ENTRY(SwapEntry));
}

[MmWriteToSwapPage](file:///d:/reactos/ntoskrnl/mm/pagefile.c#L145-L199) 把一页写入 pagefile:

NTSTATUS
NTAPI
MmWriteToSwapPage(SWAPENTRY SwapEntry, PFN_NUMBER Page)
{
    /* 1. 解析 SWAPENTRY:得到 pagefile index + offset */
    i = FILE_FROM_ENTRY(SwapEntry);
    offset = OFFSET_FROM_ENTRY(SwapEntry) - 1;
    
    /* 2. 构造 MDL 描述源页 */
    MmInitializeMdl(Mdl, NULL, PAGE_SIZE);
    MmBuildMdlFromPages(Mdl, &Page);
    Mdl->MdlFlags |= MDL_PAGES_LOCKED;
    
    /* 3. 发起同步 I/O 写 */
    file_offset.QuadPart = offset * PAGE_SIZE;
    Status = IoSynchronousPageWrite(MmPagingFile[i]->FileObject, Mdl, &file_offset, ...);
    
    /* 4. 等待 I/O 完成 */
    if (Status == STATUS_PENDING) {
        KeWaitForSingleObject(&Event, Executive, KernelMode, FALSE, NULL);
    }
    return Status;
}

关键点

  • SWAPENTRY 编码:用 32 位编码"pagefile index(4 位)+ offset(28 位)"。最多 16 个 pagefile,每个最多 256K 页 = 1 GB。
  • 同步 I/OIoSynchronousPageWrite 是同步写——调用者阻塞直到 I/O 完成。换页路径必须同步(因为释放锁后状态可能变化)。
  • MDL 描述源页:源 PFN 通过 MDL 描述——避免 Hyperspace 临时映射(因为 pagefile I/O 路径直接用 MDL)。

3.3.4 换页文件管理

换页文件(Pagefile)MmPagingFile[] 数组管理([pagefile.c:57](file:///d:/reactos/ntoskrnl/mm/pagefile.c#L57)),最多 MAX_PAGING_FILES = 16 个。

每个 MMPAGING_FILE 结构关键字段:

typedef struct _MMPAGING_FILE {
    HANDLE FileHandle;
    PFILE_OBJECT FileObject;
    PFN_COUNT Size;            // 总页数
    PFN_COUNT MaximumSize;
    PFN_COUNT MinimumSize;
    PFN_COUNT FreeSpace;       // 空闲页数
    PFN_COUNT CurrentUsage;    // 已用页数
    PRTL_BITMAP Bitmap;        // 空闲页位图
    UNICODE_STRING PageFileName;
} MMPAGING_FILE, *PMMPAGING_FILE;

关键 API

  • MmCreatePagingFile([pagefile.c:366](file:///d:/reactos/ntoskrnl/mm/pagefile.c#L366)):创建/扩展 pagefile。需要 SeCreatePagefilePrivilege 权限。
  • MmAllocSwapPage([pagefile.c:320](file:///d:/reactos/ntoskrnl/mm/pagefile.c#L320)):从 pagefile 分配一个槽位,返回 SWAPENTRY
  • MmFreeSwapPage([pagefile.c:289](file:///d:/reactos/ntoskrnl/mm/pagefile.c#L289)):释放一个 pagefile 槽位。

槽位分配算法:用 RtlFindClearBitsAndSet 在位图中找第一个 0 位。

3.3.5 换页策略:Clock 算法

ReactOS 的工作集管理使用Clock(时钟)算法——LRU 的近似实现。

Clock 算法

工作集被组织成一个循环链表("钟面")
    │
    │  每次扫描时:
    │  - 遇到 age>0 的页:age 减 1
    │  - 遇到 age=0 的页:换出(移到 Standby)
    │  - 扫描一遍后回到起点
    ↓
效果:长期不用的页被换出,最近用的页保留

为什么叫 “Clock”? 因为指针绕着工作集环形扫描,像时钟的秒针。为什么是 LRU 的近似? 因为 age 是"上次访问后到现在的时间"——精确的 LRU 需要"全局时间戳",开销大;Clock 用"局部 age" 近似。

ARM3 中的实现:在 [ntoskrnl/mm/ARM3/wslist.c](file:///d:/reactos/ntoskrnl/mm/ARM3/wslist.c) 中(如果存在)—— ReactOS 在 ARM3 重构中可能与 Windows Research Kernel 同步使用 Clock 算法。

3.3.6 Modified Page Writer

Modified Page Writer 是内核中的后台线程(系统线程 MiModifiedPageWriter),负责把 Modified 列表的页写回 pagefile 或原文件。

流程

MmLowMemoryEvent 触发
    ↓
启动 MiModifiedPageWriter 线程
    ↓
循环:
    │  1. 扫描 Modified 列表
    │  2. 取出一个 PFN
    │  3. 通过 MmWriteToSwapPage 写回 pagefile
    │     或通过 Cc 写回原文件
    │  4. PFN 状态:Modified → Standby
    │  5. 更新 swap entry、释放 pagefile 槽位
    ↓
直到 Modified 列表空

关键点

  • 后台异步:换页是后台异步的,不阻塞用户态。
  • 优先级低:Modified Page Writer 优先级低于用户态线程——避免换页抢占用户态。
  • 批量写:可以一次写多页(pagefile I/O 路径支持 scatter/gather)。

3.3.7 概念解释

  • Working Set(工作集):每个进程一个工作集——记录"该进程最近用过的物理页"。大小由 WorkingSetSize(硬上限)与 WorkingSetMinimum(最低保证)定义。
  • MmTrimWorkingSet:把指定进程工作集修剪到指定大小。修剪时把 WorkingSetSize 之外的页"老化"(age 递减)并最终从工作集移除。
  • Standby List(待命列表):从工作集移除但仍映射到进程页表的物理页,状态为 Standby。Standby 状态的页可以被任何进程"偷走"作为新分配——比从 Free 列表取页快。
  • Modified List(已修改列表):已修改但未写回磁盘的页。处于此状态的页必须写回 pagefile 或原文件后才能进入 Free。
  • pagefile.sys(换页文件):用户态/内核态虚拟地址的"溢出空间"。当物理内存不足时,被换出的虚拟页暂时存放在此。
  • Memory Low 状态MmLowMemoryEvent 触发。所有进程的空闲工作集被收紧。这是"内存压力"的最常见触发器。
  • Clock 算法:LRU 的近似实现。维护一个环形"钟面"指针,每次扫描时 age 减 1,age=0 的页换出。
  • SWAPENTRY:32 位编码的"pagefile index + offset",唯一标识一个 pagefile 槽位。

3.3.8 为什么要这样设计

问题 1:为什么工作集是按"时间局部性"原则设计的?
操作系统观察到"刚被访问的页近期很可能再被访问"(程序局部性原理)。工作集机制让"被访问的页留在内存、未访问的页让出物理空间"。这是 LRU 思想的工业级实现——比纯 FIFO 性能好 10~100 倍。

问题 2:为什么 Standby 列表的页可以被"偷走"?
性能优化。一个 Standby 页已经是"被映射到进程 PTE 的";如果另一个进程需要分配页,直接修改 PTE 让它指向这个 Standby PFN,就完成了"分配 + 内容填好"两步。这是"用页回填"(Page Reclaim)优化——避免清零、避免读盘。

问题 3:为什么需要 pagefile?
虚拟地址空间是"逻辑空间"(用户态 2 GB),物理内存是"实际空间"(4~64 GB)。在两者之间必须有一个"溢出"层——pagefile 是"未被访问的虚拟页"的暂存区。它让系统能"超量"承诺虚拟内存(典型情况下 virtual 总量 > 物理总量)。

问题 4:为什么 Modified 页必须先写回才能 Free?
完整性。一个 Modified 页承载的是"用户/内核修改过的内容",没有磁盘副本。如果直接丢弃,修改会丢失。写回 pagefile 是"为内容找个落点"。

问题 5:为什么 Clock 算法而不是精确 LRU?
精确 LRU 需要每次访问更新"全局时间戳"——开销大。Clock 算法用"局部 age" 近似——每次扫描只更新"当前 PTE 的 age",复杂度 O(1)。在工程上是"性能与精度的最佳折中"。

问题 6:为什么 Modified Page Writer 是后台线程而不是同步?
用户态响应。如果换页同步阻塞用户态,内存压力时用户态会感觉"卡顿"。后台异步换页让用户态继续运行,换页慢慢"追上来"。额外考虑:后台线程的 I/O 调度可以和用户态 I/O 共用,提高磁盘 I/O 效率。

3.3.9 小结

  • 页面换出是虚拟内存管理"溢出物理内存"的核心机制。
  • 三个触发器:工作集修剪MmTrimWorkingSet)、内存压力MiPageOutProcessBulk)、主动弃页(API 触发)。
  • 状态转换流:Active → Standby → Modified → Standby / Free。
  • 关键 I/O 函数:MmReadFromSwapPage([pagefile.c:204](file:///d:/reactos/ntoskrnl/mm/pagefile.c#L204))、MmWriteToSwapPage([pagefile.c:147](file:///d:/reactos/ntoskrnl/mm/pagefile.c#L147))。
  • 换页策略:Clock 算法(LRU 的近似)。
  • 写回由 Modified Page Writer 后台线程异步执行,不阻塞用户态。

3.3.10 详细的状态转换场景

3.3.10.1 设计意图

核心问题:换出过程中页在多个状态之间流转(Active → Standby → Modified → Standby → Free)。不同场景下状态转换路径不同——主动弃页走一条路径,内存压力走另一条路径,Modified 写回又不同。如何让读者清楚地"看到"每条路径的完整转换流?

设计哲学场景驱动的路径列举。每个典型场景作为一个独立的"案例研究"——用户态调用 API 会触发什么?内存压力触发了什么?Modified 写回时又发生了什么?通过"操作步骤 + PFN 状态变化 + 副作用(PTE 更新、工作集更新)"的三栏式呈现,让读者在脑中形成完整的状态机模型。

3.3.10.2 概念解释
  • Active(活跃):页当前在某进程工作集中,PTE.P=1,PFN 状态为 Active。
  • Standby(待命):页从工作集移除但仍有效,PTE.P=0(transition),PFN 状态为 Standby。
  • Modified(已修改):页内容被修改且与磁盘不一致,PFN 状态为 Modified。
  • Free(空闲):页已释放,可被重新分配。
  • Dirty 位:PTE 中的硬件标志位——页被写入时 CPU 自动置位。内核扫描时根据 Dirty 位判断是否要写回。
  • Working Set Trim:把工作集中超过 WorkingSetSize 的页设为 transition 状态,PFN 放入 Standby 链表。
  • Page Reclaim:从 Standby 链表"偷走"一页给新分配,省去清零和磁盘 I/O。
3.3.10.3 为什么要这样设计

问题 1:为什么 Active → Standby 而不是直接 Active → Free? 如果直接 Free,下次访问该页需要:① 分配新 PFN;② 从 pagefile 读回内容;③ 清零或复制。这三步加起来 ≈ 10⁵ CPU 周期。Active → Standby 后,下次访问只需:① PTE 从 transition 恢复;② PFN 从 Standby 链表取出;③ 刷新 TLB。这三步 ≈ 100 周期。Standby 是"软删除"——用最小的代价换取最大的恢复速度。

问题 2:为什么 Standby 和 Modified 是两个独立的链表? Modified 页的"回收"成本更高——要先写回 pagefile 才能 Free。把 Modified 页从 Standby 中独立出来,让"快速回收"走 Standby 链表,"需要写回"走 Modified 链表。这样前台分配不需要等待磁盘 I/O——Modified Page Writer 后台异步处理。

问题 3:为什么状态转换是 PFN 层面而非 VAD 层面? VAD 描述的是"虚拟地址区间的属性"(私有/映射/保护位),但它不追踪"某一页当前处于什么状态"。PFN 数据库记录物理页的状态——每一个物理页都是一个状态机。 VAD 是"静态描述",PFN 是"动态状态"。两者配合:VAD 定义属性,PFN 跟踪状态。

场景 1:进程主动弃页(VirtualFree)

用户态调用 VirtualFree(addr, size, MEM_DECOMMIT)
    ↓
内核态 MiDecommitVirtualMemory
    ↓
对每个 PTE:
    if PTE.P=1:
        // 取消该页的映射
        Pfn = Pte->u.Hard.PageFrameNumber
        // 把 PTE 设为 demand-zero
        Pte->u.Long = 0
        // 把 PFN 状态改为 Standby
        MiUnlinkPageFromList(Pfn)
        MiInsertPageInList(Pfn, StandbyPageList)
    ↓
不更新工作集大小(用户态可能稍后重新 commit)

关键点:MEM_DECOMMIT 不减小 VAD 节点,只是把物理页释放到 Standby 列表。如果用户再 MEM_COMMIT,#PF 触发时从 Standby 偷走该 PFN(page reclaim 优化)。

场景 2:内存压力下的工作集修剪

MmLowMemoryEvent 触发
    ↓
MiPageOutProcessBulk 启动
    │  对每个进程:
    │  1. MmTrimWorkingSet(Process, NewSize)
    │  2. 把工作集中超过 NewSize 的页移到 Standby
    │  3. 如果有 Modified 页,启动 Modified Page Writer
    ↓
前台线程继续

关键观察:内存压力是后台异步的——用户态不需要等待。

场景 3:Modified 写回

Modified Page Writer 扫描
    ↓
取一个 Modified PFN
    ↓
查 PFN 的共享计数
    if 共享计数 == 0:
        // 该页只被一个 Section 使用
        // 写回原文件(如果是 Image Section 的 code 段,写回原 .dll 文件)
    else:
        // 写回 pagefile
    ↓
PFN 状态:Modified → Standby
    ↓
释放 swap entry

关键点:Modified 写回不是无脑写 pagefile——能写回原文件的优先写回原文件,节省 pagefile 空间。

3.3.11 锁顺序

3.3.11.1 设计意图

核心问题:换出路径需要同时修改"进程工作集"、“PFN 数据库"和"Section 对象”。这三者都有自己的锁。如何设计锁的获取顺序,保证绝不可能发生死锁

设计哲学全局约定的锁层次(Lock Hierarchy)。所有内核代码必须遵守同一套锁顺序:工作集锁 → PFN 锁 → Section 锁(“高"层锁先拿)。反向顺序(先拿 PFN 锁再拿工作集锁)是严格禁止的——会导致 AB/BA 死锁。这套锁层次不是"建议”,而是"硬性约定"——违反会触发死锁 BUGCHECK。

3.3.11.2 概念解释
  • 死锁(Deadlock):线程 A 持有锁 X,等待锁 Y;线程 B 持有锁 Y,等待锁 X。互相永远等不到对方释放锁。
  • 锁层次(Lock Hierarchy):将所有锁按"层次"编号,所有代码必须按"高编号先拿"的顺序获取锁。
  • 工作集锁(Working Set Lock):保护进程工作集结构。粒度最粗(一个进程一把锁)。
  • PFN 数据库锁(PFN Lock):保护 PFN 数据库。粒度较细(可以按 PFN 分片)。
  • Section 锁:保护 CONTROL_AREA/SEGMENT/SUBSECTION。
  • SpinLock(自旋锁):多处理器下忙等的锁。不能持锁太久(通常 < 25 微秒)。
3.3.11.3 为什么要这样设计

问题 1:为什么工作集锁在最上层? 工作集是"进程视角"的内存管理——一次工作集修剪可能扫描数千个 PTE,修改数百个 PFN 状态。如果工作集锁不在最上层,先拿 PFN 锁再拿工作集锁,就可能出现"线程 A 拿 PFN 锁等工作集锁;线程 B 拿工作集锁等 PFN 锁"的死锁。把最粗粒度的锁放在最上层,意味着"一次只处理一个进程的工作集"——简单但正确。

问题 2:为什么需要全局锁层次?能不能用 try-lock 替代? Try-lock(尝试获取,失败立即返回)理论上可行,但代价是:① 失败时需要释放已持有的锁并重试,这会导致 livelock(活锁);② 难以调试(崩溃时难以分析"谁在等谁")。全局锁层次让所有代码路径在设计时就避免了死锁的可能性——不需要运行时检测。

问题 3:为什么换出路径中的锁获取顺序如此重要? 换出路径是"最热"的内核代码路径之一——内存压力下每秒可能触发数千次换出。如果换出路径的锁顺序不明确,死锁可能在"某种特定负载下"突然触发——极难重现。Windows/ReactOS 将锁层次作为代码审查的必查项——任何提交违反锁层次的代码都会被立即拒绝。

换出路径涉及多个锁,顺序很重要

工作集锁(读) → PFN 数据库锁(读) → 段/Section 锁

反例

PFN 锁 → 工作集锁  // 错误!可能死锁

关键死锁场景

  • 线程 A:拿 PFN 锁 → 试图拿工作集锁。
  • 线程 B:拿工作集锁 → 试图拿 PFN 锁。
  • 互相等待 → 死锁。

ReactOS 内部约定所有锁按"高 → 低"顺序:工作集锁 > PFN 锁 > Section 锁。

3.3.12 换出与缺页的交互

3.3.12.1 设计意图

核心问题:Modified Page Writer 后台线程正在把某页写入磁盘,但与此同时,前台进程可能正好访问该页——两个不同的内核路径同时操作同一个 PFN,如何避免竞态?

设计哲学transition 状态作为"暂停标志"。写回前,先把进程 PTE 设为 transition(Present=0, PFN 仍有效)。这样:① 前台访问触发 #PF → MiDispatchFault 发现 PFN 仍在 Standby/Modified 链表 → 立即恢复 PTE.P=1 → 跳过 I/O;② Modified Page Writer 写回完成后,检查 PFN 是否仍在 Modified 链表——如果 PFN 已被进程"复活"(访问后 PFN 状态变回 Active),则跳过释放操作。transition 状态让"异步写回"与"前台访问"通过"观察 PFN 状态"实现无锁协作。

3.3.12.2 概念解释
  • Transition 状态:PTE.P=0,但 PFN 字段仍指向有效的物理页号,且该 PFN 处于 Standby 或 Modified 状态。
  • PFN 复活(Page Reactivation):transition 状态的页被进程重新访问,#PF 处理将 PTE 恢复为 P=1,PFN 状态改回 Active。
  • Modified Page Writer 的幂等性:写回操作在每次写前检查 PFN 状态。如果 PFN 已不在 Modified 链表(被复活了),则跳过写回——操作是"安全的跳过"而非"崩溃"。
  • 竞态窗口(Race Window):Modified Page Writer 拿 PFN 锁 → 写回 I/O → 释放 PFN 锁。在这个窗口内,进程访问时触发 #PF → 拿工作集锁 → 查 PFN → 发现 PFN 状态 → 恢复。如果 Modified Page Writer 在 #PF 之前完成写回,则进程访问的是"刚被写回的 Standby PFN"——仍可正常恢复。
3.3.12.3 为什么要这样设计

问题 1:为什么不直接在写回时禁止进程访问? 如果禁止访问,进程会被阻塞直到 I/O 完成——这等同于"同步换出",会让用户态卡顿。transition 状态让"访问"与"写回"并行进行——进程可以复活页,也可以让 Modified Page Writer 继续写回。两者不互相阻塞。

问题 2:为什么不用一个显式的"正在写回"标志位? 如果在 PFN 上加一个 BeingWritten 标志位,#PF 需要处理"被写回时的特殊状态"——逻辑更复杂,且标志位的设置/清除本身就是竞态。transition 状态是"天然的暂停"——硬件天然支持(P=0 就是未映射),软件利用这一点实现"软删除 + 可恢复"。不新增任何标志位。

问题 3:如果 Modified Page Writer 写回完成后,进程才访问呢? 此时 PFN 已在 Standby 链表。进程访问触发 #PF → 查 PFN 状态 → 从 Standby 恢复(O(1),无 I/O)。这是最常见的 case——Modified Page Writer 写回后,进程不久就再次访问——Page Reclaim 机制正好覆盖这种场景。这就是"写回 → Standby → 快速恢复"的完整闭环。

关键交互:当 Modified 写回时,进程 A 可能正在访问该页。

解决:写回前把 PTE 设为 “transition”(P=0, PFN=有效)。这样:

  • 进程 A 访问该页时,#PF 触发,MiDispatchFault 处理 transition 状态——把 PFN 状态改为 Active,PTE.P=1。
  • Modified Page Writer 写回时,如果发现 PFN 不在 Modified 列表了(已被进程 A 复活),跳过该 PFN。

这种机制让"异步写回"和"进程访问"可以并发——没有"正在写回时用户访问"的窗口。

3.3.13 性能特性

3.3.13.1 设计意图

核心问题:换出操作涉及磁盘 I/O(毫秒级)、锁操作(微秒级)、TLB 刷新(数十纳秒级)。不同操作的时间差达 6 个数量级。如何设计换出路径,让常见 case 尽可能快,罕见 case 尽可能安全?

设计哲学分层性能优化。① 快速路径(O(1),不触发 I/O):从 Standby 链表偷走一页、transition 恢复——这些是"分配时的快速路径"。② 中速路径(毫秒级):Modified Page Writer 后台批量写回——异步执行,不阻塞前台。③ 慢速路径(秒级):严重内存压力下的强制换出——尽可能慢但必须成功。每条路径的优化目标不同,使用不同的算法和数据结构。

3.3.13.2 概念解释
  • 磁盘 I/O(Disk I/O):读写 pagefile.sys 或原文件。典型延迟 5~20 ms(机械硬盘)或 0.1~1 ms(SSD)。
  • 批量写回(Batch Writeback):一次写回多页,减少磁盘 I/O 次数。利用 scatter/gather I/O 一次提交多个 PFN。
  • 预读(Read Ahead):从 pagefile 读回一页时,同时读入后续几页——利用空间局部性。
  • TLB 刷新(TLB Flush):PTE 变更后需刷新 TLB。多 CPU 下需要 IPI(Inter-Processor Interrupt)发送到其他 CPU,开销大。
  • 大页面写(Large Page Write):如果连续多个 PFN 对应连续的 pagefile 偏移,一次写回 64 KB 而非 4 KB。
3.3.13.3 为什么要这样设计

问题 1:为什么批量写回比逐页写回快? 磁盘 I/O 的主要开销在"命令提交 + 机械定位"(机械硬盘)。一次写回 16 页(64 KB)的 I/O 次数 = 1 次,而逐页写回 = 16 次。I/O 次数减少 16 倍,吞吐量提升一个数量级。这是"将 N 次小 I/O 合并为 1 次大 I/O"的经典优化。

问题 2:为什么预读后续页而不是只读当前页? 程序的空间局部性原理——访问了虚拟地址 VA,大概率很快访问 VA+4KB、VA+8KB。预读 8 页让后续 7 次 #PF 命中内存缓存(即 Standby 链表),省去 7 次磁盘 I/O。代价是"浪费"最多 28 KB 的预读页——与节省的 7 次 I/O 相比完全值得。

问题 3:为什么 TLB 刷新是换出的性能瓶颈? 在多 CPU 系统中,修改一个 PTE 需要向所有其他 CPU 发送 IPI(Inter-Processor Interrupt)通知它们刷新各自的 TLB。IPI 开销 ≈ 1 微秒 × CPU 数——16 核系统 ≈ 16 微秒。换出 1000 页需 16 毫秒的 TLB 刷新时间——与磁盘 I/O 延迟(10 ms)相当。 优化方法是"批量刷新"——攒一批 PTE 变更后只发一次 IPI,让其他 CPU 一次性刷新整个 TLB。

换出的性能影响

操作 时间 频率
工作集修剪 O(N) 每分钟 1~10 次
Modified 写回 I/O 延迟 每秒 10~100 次
进程访问触发 #PF 微秒 每秒上千次

性能瓶颈

  • 磁盘 I/O:pagefile I/O 是同步阻塞,会让 Modified Page Writer 线程等待。
  • 锁竞争:工作集锁是热路径,频繁的 trim 操作导致锁竞争。
  • TLB 刷新:换出时修改 PTE,需要刷 TLB。

优化

  • 批量写回:Modified Page Writer 一次写多页(scatter/gather I/O)。
  • 预读:当 PFN 进入 Standby 时预读相邻 PFN。
  • 大页面写:如果 pagefile 块连续,一次 I/O 写 64 KB 而非 4 KB。

3.3.14 小结(增强版)

  • 页面换出是虚拟内存管理"溢出物理内存"的核心机制。
  • 三个触发器:工作集修剪MmTrimWorkingSet)、内存压力MiPageOutProcessBulk)、主动弃页(API 触发)。
  • 状态转换流:Active → Standby → Modified → Standby / Free。
  • 关键 I/O 函数:MmReadFromSwapPage([pagefile.c:204](file:///d:/reactos/ntoskrnl/mm/pagefile.c#L204))、MmWriteToSwapPage([pagefile.c:147](file:///d:/reactos/ntoskrnl/mm/pagefile.c#L147))。
  • 换页策略:Clock 算法(LRU 的近似)。
  • 写回由 Modified Page Writer 后台线程异步执行,不阻塞用户态。
  • 换出的锁顺序:工作集锁 → PFN 锁 → Section 锁(避免死锁)。
  • transition 状态让异步写回和进程访问可以并发。

3.4 共享映射区(Section)

3.4.0 框架图(先见森林)

┌────────────────────────────────────────────────────────────────────┐
│       Section 对象的层次结构                                        │
│                                                                    │
│  File(kernel32.dll)                                               │
│    │                                                               │
│    ↓                                                               │
│  SECTION 对象(CONTROL_AREA + SEGMENT)                             │
│  ┌──────────────────────────────────────┐                          │
│  │  CONTROL_AREA                        │                          │
│  │   - FilePointer / PointerToFileName  │                          │
│  │   - NumberOfSections                 │                          │
│  │   - NumberOfSubsections              │                          │
│  │   - SegmentListHead                  │                          │
│  │   - Flink/Blink(在 Section 树中)    │                          │
│  ├──────────────────────────────────────┤                          │
│  │  SEGMENT(一个分段,常见 64 KB)      │                          │
│  │   - BaseAddress                      │                          │
│  │   - TotalNumberOfPtes                │                          │
│  │   - WriteUsage / SubsectionListHead  │                          │
│  │   - PrototypePte[0..N]               │  ← 共享原型 PTE 数组     │
│  ├──────────────────────────────────────┤                          │
│  │  SUBSECTION × N                       │                          │
│  │   - ControlArea 指针                  │                          │
│  │   - StartingSector                    │                          │
│  │   - NumberOfFullSectors              │                          │
│  │   - SubsectionBase(PTE 子数组)      │                          │
│  └──────────────────────────────────────┘                          │
│         │                                                          │
│         ↓ 多个进程映射同一个 Section                                │
│                                                                    │
│  进程 A 的 VAD 节点 ──→ 指向 SUBSECTION ──→ 共享原型 PTE 数组     │
│  进程 B 的 VAD 节点 ──→ 指向 SUBSECTION ──→ 共享原型 PTE 数组     │
│  进程 C 的 VAD 节点 ──→ 指向 SUBSECTION ──→ 共享原型 PTE 数组     │
└────────────────────────────────────────────────────────────────────┘

本图核心要点Section 是"文件 → 内存"映射的核心数据结构。一个 Section 包含一个 CONTROL_AREA(全局元数据)、一个或多个 SEGMENT(按 64 KB 切分的段),每个 SEGMENT 包含一组 SUBSECTION(对应磁盘扇区)和一组 PrototypePte(共享 PTE 模板)。多个进程映射同一 Section 时,它们的 VAD 都指向同一组 Prototype PTE。

3.4.0.1 设计意图

核心问题:多个进程需要共享同一份文件内容(典型的如 kernel32.dllntdll.dll)。如果每个进程各自加载一份 DLL,100 个进程加载 10 个 DLL = 1000 份副本——浪费 90% 的物理内存。如何让多个进程"看到同一份物理页",同时每个进程仍有自己的私有数据段?

设计哲学“文件的主源” + "进程的副本"的两级模型。Section 对象是"文件在内存中的主源"——CONTROL_AREA + SEGMENT + SUBSECTION + Prototype PTE 描述了文件到内存的映射。每个进程通过 VAD 节点"挂载"到这个主源上。进程读取共享页时,直接共享原型 PTE 指向的物理页(零成本共享);进程写入共享页时,触发 COW 创建私有副本(按需付费)。这种"主源 + 副本"的两级模型,既节省了 90% 的物理内存,又保证了进程间的隔离。

本节定位:3.4 节是"虚拟内存与文件系统的桥梁"。前几节讲了纯虚拟内存(VAD、PTE、#PF),本节讲虚拟内存如何与文件系统交互——DLL 加载、EXE 加载、内存映射文件,本质上都是 Section 的变种。理解 Section 后,读者能回答经典问题:“为什么 100 个进程加载同一个 DLL,物理内存里只有一份 code 段?”

3.4.1 Section 的两种类型

Windows 中 Section 分为三大类:

类型 创建 API 用途 例子
Image Section LoadLibrary (内部) 加载 PE 文件 kernel32.dll、user32.dll
Data Section (Mapped File) CreateFileMapping + MapViewOfFile 内存映射文件 数据库、共享内存文件
Pagefile-backed Section CreateFileMapping(INVALID_HANDLE_VALUE, ..., PAGE_READWRITE, ...) 进程间共享内存 共享内存对象

Image Section 的特殊性

  • 由 PE Loader 调用 MmCreateSection 创建。
  • 创建时直接映射 PE 文件的多个段(.text、.data、.rsrc)。
  • PE 头部的 IMAGE_SECTION_HEADER 数组被翻译为 SUBSECTION 数组。
  • 共享:所有加载同一 DLL 的进程共享 code 段(read-only)。
  • 私有:每个进程有独立的 data 段副本(COW)。

Data Section 的特点

  • 通过 CreateFileMapping 创建。
  • 只有一个 SEGMENT(覆盖整个文件)。
  • 共享:所有映射同一文件的进程共享文件内容。
  • 写时复制:如果不指定 SEC_RESERVE,写操作会触发 COW。

Pagefile-backed Section 的特点

  • 句柄是 INVALID_HANDLE_VALUE,不映射到任何文件。
  • 内容存在 pagefile 中。
  • 用途:进程间共享内存(共享内存对象)。

3.4.2 CONTROL_AREA 结构

CONTROL_AREA(在 [ntoskrnl/mm/ARM3/section.c](file:///d:/reactos/ntoskrnl/mm/ARM3/section.c) 中定义)描述 Section 的全局元数据:

typedef struct _CONTROL_AREA {
    PSEGMENT Segment;                 // 第一个 SEGMENT(多 SEGMENT 用链表)
    LIST_ENTRY DereferenceList;       // 反向引用链表(用于 section 关闭)
    ULONG NumberOfSectionReferences;  // 引用计数
    ULONG NumberOfPfnReferences;      // PFN 引用计数
    ULONG NumberOfMappedViews;        // 映射计数
    ULONG NumberOfSubsections;        // 子段数
    PFILE_OBJECT FilePointer;         // 关联的文件对象
    ...
    PCACHE_MANAGER_CALLBACKS CacheManagerCallbacks;
    ...
} CONTROL_AREA, *PCONTROL_AREA;

关键字段

  • NumberOfMappedViews:当前有多少 VAD 节点指向该 Section。
  • NumberOfPfnReferences:当前有多少 PFN 引用了该 Section 的内容。
  • FilePointer:关联的文件对象(用于回写到磁盘)。
  • CacheManagerCallbacks:Cc 子系统的回调(用于 lazy write、lazy read 等)。

3.4.3 SEGMENT 结构

SEGMENT 描述一个段(通常是 64 KB)的 Section 数据:

typedef struct _SEGMENT {
    PCONTROL_AREA ControlArea;       // 反向指针
    ULONG TotalNumberOfPtes;         // 该段总 PTE 数
    ULONG NonExtendedPtes;           // 非扩展 PTE 数
    ULONG WritableUserReferences;    // 可写引用数
    ...
    MMSUBSECTION_NODE SubsectionListHead;  // SUBSECTION 链表
    MMPTE PrototypePtes[ANYSIZE_ARRAY];  // 原型 PTE 数组
} SEGMENT, *PSEGMENT;

关键字段

  • PrototypePtes[]:该段的"共享 PTE 模板"——所有映射的进程的对应 PTE 都指向这里。
  • SubsectionListHead:该段包含的 SUBSECTION 链表。

3.4.4 SUBSECTION 结构

SUBSECTION 描述一段 Section 数据如何对应到磁盘扇区:

typedef struct _SUBSECTION {
    PCONTROL_AREA ControlArea;
    PMMPTE SubsectionBase;           // 指向 SEGMENT 中的 PTE 子数组起点
    ULONG_PTR StartingSector;        // 磁盘上的起始扇区
    ULONG_PTR NumberOfFullSectors;   // 完整扇区数
    ...
} SUBSECTION, *PSUBSECTION;

关键字段

  • StartingSector:在文件中的起始扇区号。
  • NumberOfFullSectors:该子段占用的完整扇区数。
  • SubsectionBase:指向 SEGMENT 中该子段对应的 Prototype PTE 起点。

3.4.5 MmCreateSection / MiCreateSection

MmCreateSection 是创建 Section 的 API(在 [ntoskrnl/mm/ARM3/section.c](file:///d:/reactos/ntoskrnl/mm/ARM3/section.c)):

NTSTATUS
MmCreateSection(
    OUT PVOID *SectionObject,
    IN ACCESS_MASK DesiredAccess,
    IN POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL,
    IN PLARGE_INTEGER MaximumSize OPTIONAL,
    IN ULONG SectionPageProtection,
    IN ULONG AllocationAttributes,
    IN HANDLE FileHandle OPTIONAL,
    IN PFILE_OBJECT FileObject OPTIONAL)

关键步骤

  1. 检查句柄(如果 FileHandle 非 NULL):句柄必须可读、文件支持 mapping。
  2. 创建 SECTION 内核对象ObCreateObject 创建一个 Section 对象。
  3. 构造 CONTROL_AREAControlArea 包含文件信息、引用计数等。
  4. 构造 SEGMENT:根据文件大小创建 SEGMENT(按 64 KB 切分)。
  5. 构造 SUBSECTION:每个 SEGMENT 对应一组 SUBSECTION,关联到磁盘扇区。
  6. 构造 Prototype PTE 数组:SEGMENT 内部分配 PTE 数组,初始为 demand-zero。

Image Section 的特殊处理

  • 解析 PE 头:遍历 IMAGE_SECTION_HEADER 数组。
  • 每个 Section 头转换为一个 SEGMENT + 一组 SUBSECTION。
  • 共享 / 私有:标记 IMAGE_SCN_MEM_SHARED 的段为共享(code 段),IMAGE_SCN_MEM_WRITE 的段为可写。
  • 关联 EXE/DLL 加载时还会设置 ImageBase、reloc 处理等。

3.4.6 MmMapViewOfSection

MmMapViewOfSection 把一个 Section 映射到进程的用户态虚拟地址空间:

NTSTATUS
MmMapViewOfSection(
    IN PVOID SectionObject,
    IN PEPROCESS Process,
    IN OUT PVOID *BaseAddress,
    IN ULONG_PTR ZeroBits,
    IN SIZE_T CommitSize,
    IN OUT PLARGE_INTEGER SectionOffset OPTIONAL,
    IN OUT PSIZE_T ViewSize,
    IN SECTION_INHERIT InheritDisposition,
    IN ULONG AllocationType,
    IN ULONG Protect)

关键步骤

  1. 解析 Section:从 SectionObjectCONTROL_AREA
  2. 找空闲区段(如果 BaseAddress == NULL):MiFindEmptyAddressRange 找一段合适空洞。
  3. 构造 VAD 节点:在 VAD 树中插入新 VAD,指向 Section 的 SUBSECTION。
  4. 分配 PTE:在进程的页表空间中为该区段分配 PTE。
  5. 设置 PTE 为"prototype 跳转":每个 PTE.P=0,“PFN” 指向 SEGMENT 中的 Prototype PTE。
  6. 更新引用计数ControlArea->NumberOfMappedViews++

关键点

  • VAD 是 Section 与进程的"纽带":VAD 节点的 StartingVpnEndingVpn 描述该映射的虚拟地址范围;VAD 的 Subsection 指针指向具体的 SUBSECTION。
  • PTE 是"prototype 跳转":所有映射的进程 PTE 都指向同一组 Prototype PTE——这是共享的物理基础。

3.4.7 MmUnmapViewOfSection

[MmUnmapViewOfSection](file:///d:/reactos/ntoskrnl/mm/ARM3/section.c#L2759) 解除一个进程对 Section 的映射:

NTSTATUS
NTAPI
MmUnmapViewOfSection(IN PEPROCESS Process,
                     IN PVOID BaseAddress,
                     IN BOOLEAN Rundown)

关键步骤

  1. 查 VAD 树:在进程的 VadRoot 树中找该地址对应的 VAD。
  2. 删除 VADMiRemoveVad
  3. 修改 PTE:把该区段的所有 PTE 设为 demand-zero 或清零。
  4. 更新引用计数ControlArea->NumberOfMappedViews--
  5. 如果最后一个映射:释放 SEGMENT、SUBSECTION 等资源。

关键点

  • COW 处理:如果映射的 PTE 有私有的 COW 副本(写时复制过),释放时要回收。
  • 缓存同步:如果 Section 是 Image,且该进程有"脏"的 code 段副本,要确保脏数据写回原文件。

3.4.8 写时复制(COW)深入

3.2.5 节已经讨论了 COW 的基本概念。这里深入 Section 共享映射场景下的 COW:

Section 共享映射的 PTE 状态

进程 A 的 PTE:P=0, "PFN" = 原型 PTE 地址
                │
                ↓
Section 的原型 PTE:P=0, PFN=100, dirty=0
                │
                ↓
物理页 PFN 100(共享)

当进程 A 写入该页时

  1. CPU 查 TLB → 进程 A 的 PTE(P=0)→ 跳到 Prototype PTE。
  2. Prototype PTE 也 P=0 → #PF。
  3. MiDispatchFault 处理 → MiDispatchSectionFault
  4. 找到对应的 SUBSECTION,解析原型 PTE。
  5. 如果是 Image Section 的只读段
    • 分配新页(PFN 200)。
    • 从原 PFN 100 复制内容到 PFN 200。
    • 进程 A 的 PTE 改为 P=1, PFN=200(私有 copy)。
    • 原型 PTE 不变(其他进程仍共享 PFN 100)。
  6. 如果是 Data Section 的可写段
    • 第一次写:分配新页,复用原内容。
    • 后续写:直接写新页。
    • 触发原 PFN 的 dirty 位——后台 Modified Page Writer 写回原文件。

关键点

  • COW 让"读"零成本、"写"按需付费
  • Image Section 的 code 段:所有进程共享同一份物理页。任何写都触发 COW——但通常 code 段不会写。
  • Data Section 的 data 段:第一个写进程触发 COW;后续写都是私有。
  • 可写引用计数 WritableUserReferences:当一个可写段的所有用户都退出时,原型 PTE 可释放。

3.4.9 概念解释

  • Section(共享映射区):Windows 内存模型中"文件 → 内存"映射的核心对象。包含 CONTROL_AREA + SEGMENT + SUBSECTION。
  • Image Section:由 PE Loader 调用 MmCreateSection 创建的 Section,映射 PE 文件(DLL、EXE)。
  • Data Section:由用户态 CreateFileMapping 创建的 Section,映射普通文件或 pagefile。
  • Pagefile-backed Section:内容存在 pagefile 的 Section(用于进程间共享内存)。
  • CONTROL_AREA:Section 的全局元数据(文件信息、引用计数、Cc 回调等)。
  • SEGMENT:Section 的一个分段(常见 64 KB)。包含 Prototype PTE 数组。
  • SUBSECTION:SEGMENT 内部的一个子段,对应磁盘扇区。64 KB 对齐。
  • Prototype PTE(原型 PTE):SEGMENT 中的"共享 PTE 模板"。所有映射的进程的对应 PTE 都指向同一组 Prototype PTE。
  • PE Loader:Windows 加载器,解析 PE 文件并创建 Image Section。

3.4.10 为什么要这样设计

问题 1:为什么 Section 内部要分 SEGMENT 和 SUBSECTION?

  • SEGMENT 64 KB 切分:与 VAD 树的最小区间单位(64 KB)匹配。便于 Subsection 复用。
  • SUBSECTION 关联磁盘扇区:每个 SUBSECTION 描述一段磁盘数据,便于 I/O。
  • CONTROL_AREA 全局:多个 SEGMENT 共享同一文件信息。

问题 2:为什么用 Prototype PTE 而不是"每个进程一份 PTE 数组"?
内存效率。如果每个进程都维护一份 PTE 数组(指向 PFN),那 100 个进程映射同一 DLL 就需要 100 份 PTE 数组——浪费。Prototype PTE 让"所有进程共享一份 PTE 数组 + 按需复制 PTE 内容"——多进程共享的关键

问题 3:为什么 Image Section 共享 code 段、但 data 段是 COW?

  • Code 段只读:共享零成本。
  • Data 段可写:每个进程应有独立副本(一个进程的修改不应影响其他)。COW 延迟复制——只有当某进程第一次写时才分配私有副本。

问题 4:为什么 MmCreateSection 与 MmMapViewOfSection 分离?
解耦MmCreateSection 是"创建对象";MmMapViewOfSection 是"映射到进程"。一个 Section 可以被多次映射(多个进程),但只创建一次。分离让"创建"和"使用"独立。

问题 5:为什么 MmUnmapViewOfSection 还要更新 CONTROL_AREA 引用计数?
生命周期管理。Section 的所有映射都解除后,Section 对象才能释放。引用计数让 Section 在"无映射但还有未完成的 I/O" 时仍能存活。

3.4.12 Image Section 的加载细节

3.4.12.1 设计意图

核心问题:PE(Portable Executable)文件是 Windows 的可执行文件格式。一个 EXE/DLL 包含多个段:.text(代码,只读,共享)、.data(数据,可读可写,私有)、.rsrc(资源,只读,共享)、.reloc(重定位表)等。如何把不同属性的段加载到同一进程的虚拟地址空间,同时让 code 段跨进程共享、data 段每个进程一份?

设计哲学“按段创建 Section + 按段设置保护位”。PE Loader 遍历 PE 头中的 IMAGE_SECTION_HEADER[] 数组,每个段创建一个独立的 SEGMENT(或共用一个 CONTROL_AREA 下的多个 SEGMENT)。每个 SEGMENT 有自己的保护位:.text 段 = PAGE_EXECUTE_READ(可执行、只读、共享);.data 段 = PAGE_READWRITE(可读写、写时复制)。共享由 SECTION 对象实现,私有由 COW 实现。

3.4.12.2 概念解释
  • PE(Portable Executable):Windows 的可执行文件格式。扩展名为 .exe.dll.sys
  • PE Header:PE 文件头部,包含 IMAGE_DOS_HEADERIMAGE_NT_HEADERSIMAGE_OPTIONAL_HEADERIMAGE_SECTION_HEADER[]
  • 段(Section):PE 文件内按属性划分的区域(.text.data.rsrc.bss 等)。每个段有自己的 VirtualAddress、VirtualSize、PointerToRawData、Characteristics。
  • ImageBase:PE 文件期望加载的基地址。32 位 EXE 默认 0x00400000;DLL 默认 0x10000000
  • ASLR(Address Space Layout Randomization):Windows Vista 之后引入的安全特性——每次加载时随机化 ImageBase,增加缓冲区溢出攻击的难度。
  • 重定位(Relocation):当实际加载地址 ≠ ImageBase 时,需要修改指令中的绝对地址指向新位置。.reloc 段存储"需要修正的地址"。
3.4.12.3 为什么要这样设计

问题 1:为什么每个段创建一个 SEGMENT,而不是整个文件一个 SEGMENT? 不同段有不同属性——.text 不可写、.data 可写、.rsrc 只读但不可执行。如果整个文件一个 SEGMENT,那所有页的保护位必须相同——要么 data 段不可写(崩溃),要么 code 段可写(安全漏洞)。 按段拆分让每个 SEGMENT 有独立的保护位,既保证安全(code 不可写、data 不可执行),又保证功能性(data 可写)。

问题 2:为什么 ImageBase 不是硬编码的? 早期 Windows(Windows 95)中 EXE 加载地址固定为 0x00400000,但多个 DLL 可能冲突——如果 DLL A 和 DLL B 都期望 0x10000000,第二个 DLL 必须重定位。现代 Windows 让 ImageBase 只是"推荐值"——实际加载地址由内存管理器决定。 这样可以避免 DLL 地址冲突,并支持 ASLR。

问题 3:为什么 ASLR 是默认启用的? 缓冲区溢出攻击依赖"知道代码在内存中的地址"——攻击者通过溢出修改返回地址为"某个已知 gadget 的地址"。ASLR 让每次加载时 code 段的地址随机化——攻击者猜不到 gadget 在哪里。这是"安全与性能的折中"——ASLR 让 code 段共享的性能优化不变(仍可多进程共享物理页),但安全性提升一个数量级。

Image Section 由 PE Loader 创建。PE Loader 解析 PE 头,按以下步骤创建 Section:

PE Loader 处理 PE 文件
    ↓
1. 解析 IMAGE_NT_HEADERS(包含 OptionalHeader、SectionHeaders)
    ↓
2. 遍历 IMAGE_SECTION_HEADER[] 数组
    │  每个段:
    │  - VirtualAddress, VirtualSize(VA 范围)
    │  - PointerToRawData, SizeOfRawData(文件位置)
    │  - Characteristics(属性:READ/WRITE/EXEC/SHARED)
    ↓
3. 对每个段调用 MmCreateSection
    │  参数:
    │  - MaximumSize = ALIGN_UP(VirtualSize, 64KB)
    │  - SectionPageProtection = PAGE_EXECUTE_READ(code)
    │                            PAGE_READWRITE(data)
    │  - AllocationAttributes = SEC_IMAGE
    ↓
4. 在内核态虚拟地址空间映射 Section
    │  加载基址 = OptionalHeader.ImageBase
    │  调整(如果有 ASLR)
    ↓
5. 处理 reloc(如果 ImageBase != 实际加载地址)
    ↓
6. 处理 import(解析 DLL 依赖)
    ↓
7. 调用 MmMapViewOfSection 把 Section 映射到进程

关键点

  • 每个段创建一个 SEGMENT(不是整个 PE 创建一个 SEGMENT)。这样 code / data / rsrc 等不同属性的段可以独立管理。
  • Characteristics 决定共享/私有
    • IMAGE_SCN_MEM_SHARED 段(如 code 段):共享同一份物理页。
    • IMAGE_SCN_MEM_WRITE 段(如 data 段):每个进程有 COW 副本。
  • 加载基址 (ImageBase):默认在 0x00400000(32 位)。ASLR 让基址随机化(提高安全性)。

3.4.13 MmMapViewOfSection 详解

3.4.13.1 设计意图

核心问题:已经有一个 Section 对象(文件已被映射到内核态),现在要把它"挂"到某个进程的虚拟地址空间上。核心挑战是:如何在不分配物理页的情况下,让进程"看到"Section 的内容?

设计哲学"延迟分配 + prototype 跳转"的懒惰策略。映射时不分配任何物理页。只做两件事:① 在进程的 VAD 树中插入一个新节点,记录"这个虚拟地址区间对应哪个 Section 的哪个偏移";② 在进程的页表中分配 PTE 槽位,但设置 PTE.P=0(无效),Prototype=1(原型跳转),PFN 字段指向 Section 的原型 PTE。真正的物理页分配延迟到第一次访问时——由 #PF 触发。 这种懒惰策略让 MapViewOfFile 的开销 ≈ O(VAD 插入 + PTE 分配),而不是 O(Section 大小)。

3.4.13.2 概念解释
  • Prototype 跳转:进程 PTE 中 P=0,但 PFN 字段指向 SEGMENT 中的 Prototype PTE。这是"页表的页表"——两级跳转。
  • View:Section 在某进程的虚拟地址空间中的一个映射实例。同一个 Section 可以被多次映射(不同 View)。
  • BaseAddress:调用者期望的虚拟地址。如果为 NULL,内存管理器找一个空闲区域。
  • ZeroBits:要求分配的虚拟地址的高位必须是 0——用于兼容 16/32 位程序的地址限制。
  • SectionOffset:从 Section 的哪个偏移处开始映射。通常为 0(映射整个文件),但也支持部分映射。
  • Protect:新映射的保护位(PAGE_READONLY、PAGE_READWRITE、PAGE_EXECUTE_READ 等)。
3.4.13.3 为什么要这样设计

问题 1:为什么 MapView 不直接分配物理页? 如果映射时直接分配物理页,MapViewOfFile(1 GB) 就需要分配 256,000 个 PFN——这会立即消耗 1 GB 物理内存。实际中大多数程序映射了文件后只访问一小部分(如只读前 4 KB 的文件头)。延迟分配让 99% 的 PFN 永远不需要分配——节省了大量物理内存。

问题 2:为什么进程 PTE 要指向 Prototype PTE,而不是直接指向物理页? 如果进程 A 的 PTE 直接指向物理页 PFN 100,进程 B 的 PTE 也直接指向 PFN 100——那这两个 PTE 必须"同步":当 PFN 100 被换出到 pagefile 时,两个 PTE 都要更新。这意味着换出路径需要知道"有多少个 PTE 指向了这个 PFN"——需要反向映射(Reverse Mapping),复杂度 O(N)。Prototype PTE 让反向映射简化为 O(1):每个物理页有一个"主源"(原型 PTE),所有进程的 PTE 都指向原型 PTE。换出时,只需要更新原型 PTE——进程的 PTE 不需要变(因为它们只是指向原型 PTE 的"跳转")。下次进程访问时,#PF 重新查原型 PTE,发现 PFN 已不在 → 触发真正的换入。

问题 3:为什么不同 View 可以有不同的保护位? 一个进程可能以 PAGE_READONLY 映射某个 Section,另一个进程以 PAGE_READWRITE 映射同一 Section。这是通过"进程 PTE 保护位"实现的——Section 的 Prototype PTE 有"默认保护位",但每个进程的 PTE 可以覆盖为更严格或更宽松的保护位。例如:第一个进程的 PTE 写位为 0(只读),第二个进程的 PTE 写位为 1(可写,但写入时触发 COW)。保护位在进程 PTE 层面而不是 Section 层面,让同一 Section 可以在不同进程中有不同的访问权限。

MmMapViewOfSection 把 Section 映射到进程用户态地址空间。详细步骤:

NTSTATUS MmMapViewOfSection(
    PVOID SectionObject,
    PEPROCESS Process,
    PVOID *BaseAddress,
    ULONG_PTR ZeroBits,
    SIZE_T CommitSize,
    PLARGE_INTEGER SectionOffset,
    PSIZE_T ViewSize,
    SECTION_INHERIT InheritDisposition,
    ULONG AllocationType,
    ULONG Protect)
{
    /* 1. 解析 Section */
    Section = (PSECTION)SectionObject;
    ControlArea = Section->Segment->ControlArea;
    
    /* 2. 找空闲区段(如果 BaseAddress == NULL) */
    if (*BaseAddress == NULL) {
        MiFindEmptyAddressRange(ViewSize, ..., &BaseAddress);
    }
    
    /* 3. 计算 View 偏移 */
    ViewOffset = (ULONG_PTR)*BaseAddress;
    
    /* 4. 构造 VAD */
    Vad = ExAllocatePoolWithTag(NonPagedPool, sizeof(MMVAD), ' daV');
    Vad->StartingVpn = ViewOffset >> PAGE_SHIFT;
    Vad->EndingVpn = (ViewOffset + *ViewSize - 1) >> PAGE_SHIFT;
    Vad->u.VadFlags.ImageSection = 1;  // 或 MappedDataFile
    Vad->u.VadFlags.Protection = Protect;
    Vad->Subsection = ...;  // 指向 Section 的 SUBSECTION
    
    /* 5. 插入 VAD 树 */
    MiInsertVad(Process, Vad);
    
    /* 6. 分配 PTE */
    for each page in View:
        Pte = MiAllocatePte(Process, ...);
        /* PTE 设为 prototype 跳转 */
        ProtoPte = &ControlArea->Segment->PrototypePtes[(ViewOffset - SectionBase) >> PAGE_SHIFT];
        Pte->u.Long = 0;  // P=0
        Pte->u.Soft.Prototype = 1;
        Pte->u.Soft.PageFrameNumber = (PFN_NUMBER)ProtoPte;  // "PFN" 实际是 ProtoPte 地址
    
    /* 7. 更新引用计数 */
    ControlArea->NumberOfMappedViews++;
    Section->Segment->WritableUserReferences++;
    
    /* 8. 返回 BaseAddress */
    *BaseAddress = ViewOffset;
    return STATUS_SUCCESS;
}

关键点

  • PTE 是"prototype 跳转"(P=0, Prototype=1, PageFrameNumber=ProtoPte 地址)——所有进程的 PTE 指向同一组 Prototype PTE。
  • VAD 节点保存 SUBSECTION 指针——VAD 树查找时能快速定位对应的 SUBSECTION。
  • 引用计数:每次映射 NumberOfMappedViews++;每次解除映射 --

3.4.14 Section 的 ReleaseView 流程

3.4.14.1 设计意图

核心问题:当进程释放一个 Section 的映射时(如进程退出、调用 UnmapViewOfFile),需要清理 VAD 节点、PTE、以及更新 Section 的引用计数。但挑战是:如何在"不影响其他共享该 Section 的进程"的前提下,安全地释放当前进程的映射?

设计哲学“只清理自己的副本” + “引用计数决定主源的生死”。释放映射时,当前进程只清理:① 自己的 VAD 节点;② 自己的页表 PTE;③ 如果有 COW 过的私有副本(写入过的 data 段页面),释放那些 PFN。Section 对象本身(CONTROL_AREA + SEGMENT + Prototype PTE)不立即释放——通过引用计数(NumberOfMappedViews)判断:只有当最后一个映射解除时,Section 才真正释放。这是典型的"引用计数 + 延迟释放"策略。

3.4.14.2 概念解释
  • ReleaseView:解除一个进程对 Section 的映射。不等于释放 Section 对象。
  • COW 私有副本:进程写入 data 段后,该页成为该进程私有的 PFN(不再与原型 PTE 共享)。释放时,这些 PFN 需要归还到 Free 列表。
  • 引用计数(Reference Counting):CONTROL_AREA->NumberOfMappedViews,每次 MapView+1,UnmapView-1。
  • 最后一个映射(Last Reference):当 NumberOfMappedViews 减到 0 时,可以释放 CONTROL_AREA、SEGMENT、Prototype PTE。
  • Cascade Release(级联释放):当最后一个映射解除,触发 Section 对象的释放流程——释放 SEGMENT、CONTROL_AREA、FileObject 引用等。
3.4.14.3 为什么要这样设计

问题 1:为什么 UnmapView 不直接释放 Section? Section 可能被 100 个进程共享。如果进程 A 解除映射就释放 Section,进程 B 的映射会立即失效——崩溃。引用计数让 Section 的生命周期与"当前有多少个进程正在使用它"绑定。 每个进程的 UnmapView 只是"我不再使用了",不影响其他进程。

问题 2:为什么 COW 过的页面必须单独释放? 当进程写入一个共享页时,触发 COW 分配了新的 PFN(私有副本)。这个 PFN 不属于 Section 的原型 PTE——它属于该进程的私有内存。如果释放时不清理这些 PFN,它们会变成"悬空的 PFN"——没有 VAD 描述,也没有原型 PTE 指向——永远不会被回收,导致内存泄漏。

问题 3:为什么 UnmapView 的 PTE 清理不立即回收 pagefile 槽位? PTE 从 prototype 跳转变回"无效 + 清零"即可。pagefile 槽位的释放由 Section 的生命周期管理(当最后一个映射解除时才释放)。这是一种"懒惰的资源释放"策略——不立即回收,而是延迟到最后一个引用消失。

MmUnmapViewOfSection([ARM3/section.c:2759](file:///d:/reactos/ntoskrnl/mm/ARM3/section.c#L2759))的详细步骤:

NTSTATUS MmUnmapViewOfSection(PEPROCESS Process, PVOID BaseAddress, BOOLEAN Rundown) {
    /* 1. 查 VAD 树 */
    Vad = MiFindNodeOrParent(Process->VadRoot, BaseAddress);
    if (Vad == NULL) return STATUS_NOT_MAPPED_VIEW;
    
    /* 2. 检查 Vad 范围 */
    if (BaseAddress != Vad->StartingAddress) return STATUS_NOT_MAPPED_VIEW;
    
    /* 3. 解除 PTE 映射 */
    for each page in View:
        Pte = MiAddressToPte(BaseAddress + i);
        if Pte->u.Hard.Valid:
            Pfn = Pte->u.Hard.PageFrameNumber;
            // 释放 Pfn (但保留内容)
            MiReleasePageFileSpace(Pfn);
        Pte->u.Long = 0;
    
    /* 4. 删除 VAD */
    MiRemoveVad(Process, Vad);
    
    /* 5. 减少引用计数 */
    ControlArea->NumberOfMappedViews--;
    if (ControlArea->NumberOfMappedViews == 0) {
        // 最后一个映射:清理 Segment / Section
        MiCleanupSection(ControlArea);
    }
    
    /* 6. 释放 VAD 节点 */
    ExFreePoolWithTag(Vad, ' daV');
    return STATUS_SUCCESS;
}

关键点

  • Cascade 释放:当最后一个映射解除时,整个 Section 对象可以释放(MiCleanupSection)。
  • COW 处理:如果该进程有私有副本(COW 触发过),那些 PFN 在 VAD 解除时归还到 Free 列表。
  • PROTOTYPE 跳转解除:进程 PTE 从 prototype 跳转变回 demand-zero——下次访问触发新 #PF。

3.4.15 Section 与对象管理器

3.4.15.1 设计意图

核心问题:Section 需要在进程间传递(如父进程创建 Section 并让子进程继承),需要被命名(如 \BaseNamedObjects\SharedMemory),需要被关闭并正确释放资源。如何让 Section 的生命周期与 Windows 的对象模型无缝集成?

设计哲学Section 是一等公民的内核对象——与 File、Key(注册表)、Process、Thread、Event 等一样,通过对象管理器(Object Manager)统一管理。每个 Section 对象有:① 句柄表(Handle Table)——进程可以通过句柄访问;② 名称(Name)——可以被多进程通过名字查找;③ 引用计数(Reference Count)——句柄数 + 映射数决定生死。这种设计让 Section 的使用方式与其他内核对象完全一致:CreateFileMapping → 返回句柄 → MapViewOfFile → 使用 → UnmapViewOfFileCloseHandle统一的对象模型降低了学习成本,简化了跨进程共享。

3.4.15.2 概念解释
  • 内核对象(Kernel Object):由对象管理器管理的、有句柄的内核态结构。常见类型:Process、Thread、File、Event、Mutex、Section、Key 等。
  • 句柄(Handle):进程通过句柄间接访问内核对象。句柄是"进程句柄表"中的一个索引。
  • 对象管理器(Object Manager):Windows 内核的子系统,负责创建、管理、销毁所有内核对象。
  • BaseNamedObjects:Windows 的命名空间,用于存储共享的内核对象名称(如 Section、Event、Mutex)。路径:\BaseNamedObjects\MySharedSection
  • DuplicateHandle:把一个进程的句柄复制到另一个进程,实现句柄的跨进程传递。
  • Rundown(析构流程):当最后一个句柄关闭 + 最后一个映射解除时,对象管理器调用 Section 的析构回调,释放 CONTROL_AREA、SEGMENT、PFN。
3.4.15.3 为什么要这样设计

问题 1:为什么 Section 是内核对象而不是"纯内存结构"? 如果 Section 只是一个纯内存结构(没有句柄),它就无法:① 被命名(其他进程无法通过名字找到它);② 被跨进程传递(父进程无法让子进程继承);③ 与 Windows 的安全模型集成(无法对 Section 设置 ACL)。成为内核对象让 Section 自然地融入了 Windows 的对象体系——命名、安全、句柄传递、引用计数都是免费获得的。

问题 2:为什么 Section 的释放需要同时考虑"句柄数"和"映射数"? 一个进程可能已经 CloseHandle(SectionHandle)MapViewOfFile 的映射仍在使用。如果关闭句柄就立即释放 Section,进程的虚拟地址空间中会有"悬空的 PTE"——指向已释放的 Prototype PTE。两个引用计数(句柄计数 + 映射计数)的 AND 逻辑让 Section 只有在"没人持有句柄"且"没人映射"时才真正释放。这是"安全与正确"的关键。

问题 3:为什么需要 BaseNamedObjects? 两个无父子关系的进程(如同一台机器上的两个独立程序)如何共享内存?如果没有命名空间,它们需要某种 IPC 传递句柄——复杂。BaseNamedObjects 提供了"文件系统式"的命名空间——一个进程 CreateFileMapping("Global\\MyShared"),另一个进程 OpenFileMapping("Global\\MyShared") 就能找到同一个 Section。简单且符合 Unix 的"一切皆文件"哲学。

Section 是 Windows 内核对象(有句柄)——通过对象管理器管理:

CreateFileMapping(...)  →  NtCreateSection  →  ObCreateObject(Section)  →  返回句柄
    ↓
MapViewOfFile(...)  →  NtMapViewOfSection  →  MmMapViewOfSection  →  返回地址
    ↓
CloseHandle(...)  →  ObCloseHandle  →  引用计数--(不释放)
    ↓
UnmapViewOfFile(...)  →  NtUnmapViewOfSection  →  MmUnmapViewOfSection  →  引用计数--(可能释放)

关键点

  • Section 是"可共享的内核对象"——通过句柄可以跨进程传递。
  • 跨进程映射:进程 A 创建 Section,把句柄 Duplicate 到进程 B,进程 B 用 MapViewOfSection 映射同一 Section。
  • 生命周期:Section 在所有映射解除 + 所有句柄关闭后释放。

3.4.16 小结(增强版)

  • Section 是"文件 → 内存"映射的核心数据结构。
  • 三层结构:CONTROL_AREA(全局元数据)→ SEGMENT(按 64 KB 切分)→ SUBSECTION(关联磁盘扇区)。
  • Prototype PTE 是共享 PTE 模板——所有映射的进程的 PTE 都指向同一组 Prototype PTE。
  • 三种 Section:Image Section(PE 文件)、Data Section(普通文件)、Pagefile-backed Section(共享内存)。
  • 关键 API:MmCreateSectionMmMapViewOfSectionMmUnmapViewOfSection
  • COW 让"读"零成本、"写"按需付费——这是多进程共享的核心性能优化。
  • Image Section 由 PE Loader 创建,每个 PE 段对应一个 SEGMENT。
  • MmMapViewOfSection 设置 PTE 为 prototype 跳转,VAD 节点保存 SUBSECTION 指针。
  • Section 是可共享的内核对象——通过对象管理器管理句柄。

3.5 系统空间的缓冲区管理

3.5.0 框架图(先见森林)

┌────────────────────────────────────────────────────────────────────┐
│       池(Pool)的结构                                               │
│                                                                    │
│  ┌────────────────────────────────────────┐                       │
│  │          NonPagedPool(不可换出)       │                       │
│  │  物理页直接锁定,不参与换出              │                       │
│  │  用于:内核对象、设备、IRP、MDL、SpinLock│                       │
│  │  ┌────┐┌────┐┌────┐┌────┐┌────┐        │                       │
│  │  │ 8  ││ 16 ││ 24 ││ 32 ││ .. │        │ ← 桶(按 size)        │
│  │  │byte││byte││byte││byte││    │        │                       │
│  │  └────┘└────┘└────┘└────┘└────┘        │                       │
│  └────────────────────────────────────────┘                       │
│  ┌────────────────────────────────────────┐                       │
│  │          PagedPool(可换出)            │                       │
│  │  占用的物理页可被换出到 pagefile         │                       │
│  │  用于:文件系统缓存、注册表、注册表...  │                       │
│  │  ┌────┐┌────┐┌────┐┌────┐┌────┐        │                       │
│  │  │ 8  ││ 16 ││ 24 ││ 32 ││ .. │        │                       │
│  │  │byte││byte││byte││byte││    │        │                       │
│  │  └────┘└────┘└────┘└────┘└────┘        │                       │
│  └────────────────────────────────────────┘                       │
│                                                                    │
│  ExAllocatePoolWithTag(NonPagedPool, size, 'tAgS')                   │
│    → 查找 NonPagedPool 桶 / 大块分配走 MiAllocatePoolPages         │
│    → 返回内核虚拟地址(锁在内存)                                  │
│  ExFreePoolWithTag(ptr, 'tAgS')                                     │
│    → 归还到对应桶 / 大块直接释放                                    │
└────────────────────────────────────────────────────────────────────┘

本图核心要点:**池(Pool)**是内核态的"内存分配器",分 NonPaged(不可换出)和 Paged(可换出)两类。每类池内部按"桶"组织(8 字节、16 字节、…),桶内用空闲链表管理。Tag 标签让内存泄漏诊断(!poolfind)成为可能。

3.5.0.1 设计意图

核心问题:内核态代码需要频繁地"分配一小块内存"——分配一个 EPROCESS(~3 KB)、分配一个 ETHREAD(~2 KB)、分配一个 IRP(~1 KB)、分配一个 VAD 节点(~32 字节)。这些分配的大小不一,生命周期各异,释放时机分散。如何设计一个通用的、高效的、可诊断的内核态内存分配器?

设计哲学"大小分层 + 延迟释放 + 元数据标记"的三层模型。① 按大小分桶——小对象(<1024 字节)走桶分配(O(1),从空闲链表取),大对象(>1024 字节)走 PFN 分配(连续物理页)。② 释放后不立即归还给系统,而是放回桶的空闲链表(LIFO 策略,cache 友好)。③ 每个分配都打一个 4 字符的 Tag 标签,让 !poolfind 可以事后诊断"谁分配的、泄漏了多少"。这种设计让"分配快、释放快、可诊断"三者兼得。

本节定位:3.5 节是"内核态的 malloc"。用户态有 malloc/free,内核态有 ExAllocatePoolWithTag/ExFreePoolWithTag。理解池后,读者能回答"为什么内核驱动里不能用 malloc?"、"为什么泄漏了一个 ETHREAD 就可能让系统崩溃?"等经典问题。

3.5.1 池的基本概念

池(Pool) 是 Windows 内核态的"内存分配器"——类似用户态的 malloc 但与硬件紧密集成。内核态有两类池

  • NonPagedPool(不可换出池):物理页被锁定在内存,永不换出。用于:内核对象(EPROCESS/ETHREAD/IRP/MDL)、SpinLock 持有者、DPC/ISR 代码路径。
  • PagedPool(可换出池):占用的物理页可被换出到 pagefile。用于:注册表 hive、文件缓存(与 System Cache 协作)、注册表子树。

两类的关键区别

  • NonPagedPool 的虚拟地址始终映射到物理页(永不换出)——任何访问都不会触发 #PF。
  • PagedPool 的虚拟地址在内存压力时可能被换出——访问时若不在内存会触发 #PF。

典型使用

// NonPagedPool 例子:分配一个不可换出的内核对象
PETHREAD Thread = ExAllocatePoolWithTag(NonPagedPool, sizeof(ETHREAD), 'hrT');

// PagedPool 例子:分配一个可换出的路径缓冲
PWSTR Buffer = ExAllocatePoolWithTag(PagedPool, 256, 'gaP');

3.5.2 桶(Bucket)机制

桶(Bucket) 是池内部的"按大小分配的缓存"。桶的大小按 8 字节递增(8、16、24、32、…、1024 字节)。分配时按请求大小"向上取桶":

// ExAllocatePoolWithTag 内部的桶选择逻辑(简化)
ULONG BucketIndex = (Size + 7) / 8;  // 向上对齐到 8 字节
if (BucketIndex >= MAX_BUCKETS) {
    // 大块分配:走 MiAllocatePoolPages
    return MiAllocatePoolPages(...);
} else {
    // 桶分配:从对应桶的空闲链表取
    return PopFromBucket(BucketIndex, ...);
}

桶的空闲链表:每个桶维护一个 LIST_ENTRY 空闲链表。ExAllocatePoolWithTag 时从链表头取,ExFreePoolWithTag 时归还到链表头。LIFO 策略——最新释放的最先被分配(cache 友好)。

桶内单块结构

┌─────────────────────────────────────────────┐
│  块头(4~8 字节)                              │
│  - 块大小 / 桶索引 / 调试信息                  │
├─────────────────────────────────────────────┤
│  用户数据区(>= SizeRequested)                 │
│  ...                                           │
└─────────────────────────────────────────────┘

关键点

  • 桶分配比大块分配快得多——只需链表头插入/删除。
  • 桶分配有少量内部碎片——例如 17 字节的请求会用 24 字节桶。
  • 桶上限通常是 1024 字节(或 PAGE_SIZE/2);超过的走大块分配。

3.5.3 Tag 标签

Tag 是 4 字符的 ASCII 标识(实际是 32 位无符号整数),用于内存诊断:

ExAllocatePoolWithTag(NonPagedPool, 256, 'tAgS');
// 'tAgS' = 0x53674174(小端)= 'tAgS'(ASCII)

Tag 的作用

  • 内存泄漏诊断:用 !poolfind <tag> 工具找所有用该 tag 分配的内存。
  • 内存使用统计:每个 tag 有一个"分配次数 / 释放次数"统计——次数差 = 泄漏量。
  • 代码审查:通过 tag 知道"是谁分配的这段内存"。

常见 Tag 示例(在 ReactOS 源码中):

  • 'tAgS'(= 'SGat' 反转)—— 通用 tag。
  • ' daV' —— VAD 节点。
  • 'hrT' —— ETHREAD。
  • ' gaP' —— 路径缓冲。
  • 'MMLT' —— MmLargePageTable。

Tag 命名约定:通常把字符串反转过来存——这样 DWORD tag = 'tAgS'; 在调试器中显示为 “SGat” 看起来是反的,但实际上转成字节序是 “tAgS”——便于阅读。

3.5.4 大块分配(MiAllocatePoolPages)

当请求大小超过桶上限(如 1024 字节),走 大块分配 [MiAllocatePoolPages](file:///d:/reactos/ntoskrnl/mm/ARM3/pool.c#L422):

PVOID
NTAPI
MiAllocatePoolPages(IN POOL_TYPE PoolType, IN ULONG NumberOfPages)
{
    /* 1. 分配一组连续的物理页 */
    PFN_NUMBER StartPfn = MiAllocatePages(NumberOfPages);
    
    /* 2. 映射到池虚拟地址空间 */
    PVOID VirtualAddress = MiInsertPoolInVad(StartPfn, NumberOfPages);
    
    /* 3. 写 PTE */
    /* ... */
    
    return VirtualAddress;
}

关键点

  • 物理页可能不连续(取决于 MiAllocatePages 的实现)。
  • 虚拟地址连续:内核态虚拟地址连续(线性映射)。
  • 可能跨多个桶:大块分配内部可能由多个连续虚拟页组成。

3.5.5 池的并发控制

桶的并发控制:每个桶有自己的自旋锁(PoolDescriptor->Lock)。分配/释放时获取该锁。

大块分配的并发控制:用全局 PoolDescriptor 锁。

问题:桶越小(如 8 字节桶)锁竞争越严重(频繁分配小对象)。优化

  • SLIST(无锁单链表):每个桶维护一个 SLIST 头。ExAllocatePoolWithTag 优先从 SLIST 弹(无锁),失败才加桶锁。
  • 线程局部缓存:在 SLIST 之上,每个 CPU 还有"本地缓存"——进一步降低锁竞争。

ReactOS ARM3 中有简单的 SLIST 实现([pool.c:32](file:///d:/reactos/ntoskrnl/mm/ARM3/pool.c#L32)):

SLIST_HEADER MiNonPagedPoolSListHead;
ULONG MiNonPagedPoolSListMaximum = 4;

3.5.6 PagedPool 的换出

PagedPool 的特殊处理:分配的 PagedPool 内存可能被换出到 pagefile。

实现

  • PagedPool 的虚拟地址空间是预留的(系统空间),但物理页是"按需"映射的。
  • 当 PagedPool 块被换出时,PTE.P=0,PFN=0(或 transition)。
  • 访问触发 #PF → MiDispatchFault → 找到 PagedPool 虚拟地址 → 重新映射物理页(可能从 pagefile 读回)。
  • 特殊处理:PagedPool 的 #PF 比普通用户态 #PF 复杂——内核代码路径不能因 PagedPool #PF 而死锁(会自递归)。

PagedPool 的换出触发

  • 系统内存压力(MiPageOutProcessBulk)。
  • 主动调用 MmTrimWorkingSet
  • PagedPool 自身配额耗尽(如果设置了 MmPagedPoolQuota)。

3.5.7 池的初始化

MiInitializePool(在 [ARM3/pool.c](file:///d:/reactos/ntoskrnl/mm/ARM3/pool.c) 顶部):

  1. 保留 NonPagedPool 虚拟地址空间
  2. 保留 PagedPool 虚拟地址空间
  3. 初始化桶(每个桶的空闲链表头)。
  4. 初始化 SLIST 头
  5. 为 NonPagedPool 预分配初始页
  6. 为 PagedPool 预分配初始页

关键参数

  • MmNonPagedPoolStart / MmNonPagedPoolEnd:NonPagedPool 虚拟地址范围。
  • MmSizeOfNonPagedPoolInBytes:NonPagedPool 大小(默认约 256 MB)。
  • MmPagedPoolStart / MmPagedPoolEnd:PagedPool 虚拟地址范围。
  • MmSizeOfPagedPoolInBytes:PagedPool 大小(默认约 384 MB)。

3.5.8 池的使用模式

典型使用模式

// 1. 申请
PVOID Buffer = ExAllocatePoolWithTag(NonPagedPool, Size, 'tAgS');
if (Buffer == NULL) {
    return STATUS_INSUFFICIENT_RESOURCES;
}

// 2. 使用
RtlCopyMemory(Buffer, Source, Size);

// 3. 释放
ExFreePoolWithTag(Buffer, 'tAgS');

常见错误

  • 忘记释放 → 内存泄漏(!poolfind <tag> 找)。
  • 重复释放 → 池损坏(Pooldamage BUGCHECK)。
  • 使用已释放内存 → 难以诊断(可能立即崩溃,可能 N 步后崩溃)。
  • 错配 Tag → 调试不便(tag 错乱)。

3.5.9 池的监控与诊断

监控 API

  • ExQueryPoolUsage:查询当前池使用情况。
  • ExGetPoolTagInfo:查询 tag 的分配/释放计数。

调试工具

  • !poolfind <tag>(WinDbg):找所有用某 tag 分配的内存。
  • !vm:显示虚拟内存概览。
  • !memusage:按 tag 排序显示内存使用。

典型泄漏诊断流程

!poolfind tAgS
→ 显示所有用 'tAgS' 分配的内存
→ 找到泄漏的地址
→ !pool <addr> 查看分配上下文
→ !stacks 2 查看调用栈

3.5.10 概念解释

  • 池(Pool):Windows 内核态的"内存分配器"。分 NonPaged(不可换出)和 Paged(可换出)两类。
  • 桶(Bucket):池内部按 8 字节递增的"按大小分配的缓存"。桶内用空闲链表管理。
  • Tag(标签):4 字符 ASCII 标识,用于内存诊断。
  • 大块分配:超过桶上限(如 1024 字节)的分配,走 MiAllocatePoolPages
  • SLIST(无锁单链表):每个桶的无锁分配路径,减少锁竞争。
  • ExAllocatePoolWithTag:池分配 API。
  • ExFreePoolWithTag:池释放 API(Tag 必填)。
  • !poolfind:WinDbg 命令,按 tag 找内存。
  • 池损坏(Pool corruption):重复释放、写入越界等导致的池元数据损坏。

3.5.11 为什么要这样设计

问题 1:为什么内核要"自己管内存"而不是用 malloc

  • malloc 不可靠malloc 可能在任意时刻换出(如果底层是 mmap’d 文件)。内核代码路径不能换出——会死锁。
  • malloc 不可控malloc 不支持 Tag 诊断、不可换出控制、不支持分配在 NonPaged 等。
  • malloc 不可中断malloc 的锁不能在内核 ISR/DPC 上下文使用。
  • 结论:内核需要自己的分配器——池(Pool)。

问题 2:为什么 NonPagedPool 和 PagedPool 分开?

  • NonPagedPool 必须存在——DPC/ISR 路径不能换出。
  • PagedPool 节省 NonPagedPool——注册表 hive、路径缓冲等"不紧急"的对象放到 PagedPool,可换出。
  • 分开后可以独立管理配额——避免 PagedPool 用尽 NonPagedPool。

问题 3:为什么用桶(Bucket)而不是统一的空闲链表?

  • 分配速度:桶分配只需 O(1) 的链表头插入/删除;统一空闲链表需遍历。
  • 缓存友好:相同大小的对象在桶内连续,cache 命中率高。
  • 碎片控制:桶内碎片可控(最多 7 字节);统一链表碎片不可控。

问题 4:为什么 Tag 是必需的?
内存诊断。内核对象生命周期长,泄漏难定位。Tag 让"找谁分配的这段内存"成为可能——通过泄漏的地址查 tag、查 tag 找所有同类、查代码路径。Tag 的"无成本"优势:32 位整数,不占内存,只是元数据。

问题 5:为什么 SLIST 而不是加锁的桶?
性能。频繁分配/释放小对象时,锁竞争是瓶颈。SLIST 用 InterlockedCompareExchange 等无锁原子操作——避免锁开销。代价:SLIST 只能栈式(LIFO)访问,但池的 LIFO 策略正好契合。

问题 6:为什么 PagedPool 要可换出?
节省 NonPagedPool。NonPagedPool 是稀缺资源(不能换出 = 永远占物理空间)。PagedPool 可换出,让"非关键路径"的对象不占 NonPagedPool 配额。

3.5.13 池的内存碎片与整理

3.5.13.1 设计意图

核心问题:池的分配模式是"任意大小、任意时机、任意顺序"——一个驱动可能先分配 32 字节,再分配 128 字节,再释放 32 字节。长期运行后,池的虚拟地址空间会变得碎片化——有大量小空闲区域,但没有足够大的连续空间来满足一个较大的分配请求。如何控制碎片化,让系统长时间运行后仍能正常分配?

设计哲学“按大小分桶 + Look-aside 列表 + 延迟合并”。① 按大小分桶让碎片化被"局部化"——8 字节桶的碎片不会影响 256 字节桶。② Look-aside List(后备列表)为高频分配模式缓存"已分配好的块"——减少对主池的直接分配请求。③ 延迟合并:释放时不急着合并相邻空闲块,只在"分配失败触发整理"时才合并——让日常路径尽可能快。

3.5.13.2 概念解释
  • 外部碎片(External Fragmentation):空闲内存总量足够,但被分成小块,无法满足一个连续分配。
  • 内部碎片(Internal Fragmentation):分配 30 字节但拿到 32 字节桶——浪费 2 字节。
  • Look-aside List(后备列表):内核为高频分配模式提供的 per-CPU 缓存链表。分配时优先从 Look-aside 弹,释放时优先 Push 回 Look-aside。
  • Pool Descriptor(池描述符):描述 NonPaged/Paged 池的元数据结构——包含桶数组、大小限制、已用统计。
  • Buddy System(伙伴系统):某些内存分配器使用的"相邻块合并算法"——ReactOS 池使用简化版本。
3.5.13.3 为什么要这样设计

问题 1:为什么按大小分桶能减少碎片? 如果所有大小的请求混在一个空闲链表中,小请求会"打散"大空闲区域——分配 8 字节会把一个 64 KB 的空闲块切成 8 + 65528。分桶后,8 字节请求只从 8 字节桶取,16 字节请求只从 16 字节桶取——桶内碎片是"同质的",相邻释放时可以立即合并。碎片化从"全局问题"变成了"每个桶的局部问题"。

问题 2:为什么 Look-aside List 能提升性能? Look-aside List 是 per-CPU 的无锁单链表——ExAllocatePoolWithTag 先看 Look-aside 有没有缓存的块。分配时从 Look-aside 弹(1 条原子指令),释放时 Push 回 Look-aside(也是 1 条原子指令)。这让高频分配/释放路径完全无锁,无需进入主池的自旋锁。 在多核系统中,锁竞争是最大的性能瓶颈——Look-aside List 让高频路径绕开锁竞争。

问题 3:为什么不立即合并相邻空闲块? 立即合并(每次释放时检查前一个和后一个块是否空闲,若空闲则合并)会让释放路径的开销从 O(1) 变成 O(lg N)——因为要在空闲链表中查找相邻块。延迟合并让释放路径保持 O(1),只有在"分配失败"时才触发整理。 这是"让常见路径快、罕见路径做重活"的经典工程权衡。

3.5.14 池的并发控制与锁优化

3.5.14.1 设计意图

核心问题:内核态的池分配/释放可能发生在任意上下文——线程上下文、DPC 上下文、甚至中断上下文(ISR)。如何在这些"随时可能发生"的并发分配中保证数据结构的正确性?

设计哲学“自旋锁保护共享结构 + 每 CPU 缓存减少锁竞争 + 中断禁用保护临界区”。① 每个桶有自己的自旋锁——分配/释放该桶时持有锁。② Look-aside List 是 per-CPU 的——同一 CPU 上的分配/释放不需要锁(利用 InterlockedPushEntrySList 等原子指令实现无锁)。③ NonPagedPool 的分配路径禁用中断——防止 ISR 重入导致死锁。这套设计让"高频路径无锁、低频路径轻锁、极端路径安全"三者兼得。

3.5.14.2 概念解释
  • SpinLock(自旋锁):多核下忙等的锁。持锁时间必须 < 25 微秒,且持锁期间不能被中断(否则会死锁)。
  • IRQL(Interrupt Request Level):Windows 的中断优先级。线程=PASSIVE_LEVEL(0),DPC=DISPATCH_LEVEL(2),ISR=DIRQL(>2)。高 IRQL 可打断低 IRQL。
  • Per-CPU 缓存:每个 CPU 有独立的 Look-aside List 头。CPU 0 的分配从 CPU 0 的 List 弹,CPU 1 的分配从 CPU 1 的 List 弹——无需跨 CPU 同步。
  • SLIST(无锁单链表):Windows 提供的无锁单链表。使用 InterlockedCompareExchange 实现原子 Push/Pop。
  • Pool Quota(池配额):每个进程可以分配的池上限,防止单个进程耗尽系统池。
3.5.14.3 为什么要这样设计

问题 1:为什么 NonPagedPool 的分配必须在 DISPATCH_LEVEL 以下? 如果在 DPC(DISPATCH_LEVEL)中分配 NonPagedPool,需要拿桶的自旋锁。自旋锁的持锁期间不能被更高优先级的中断打断——因为更高优先级的 ISR 可能也需要分配池。ISR 需要分配池 → 等待桶锁 → 桶锁被 DPC 持有 → DPC 无法继续(因为 ISR 打断了它)→ 死锁。 这是经典的"优先级倒置 + 死锁"。Windows 明确规定:池分配只能在 APC_LEVEL 以下(线程上下文或低优先级 DPC)。

问题 2:为什么 Look-aside List 要用 per-CPU 而不是全局链表? 如果 Look-aside List 是全局的,每次分配都需要拿全局锁——这就是新的锁竞争热点。Per-CPU 让每个 CPU 的分配独立于其他 CPU——CPU 0 分配时不需要等 CPU 1 释放锁。跨 CPU 同步只在"Look-aside 为空,需要从主池补充"时发生——这是低频路径。

问题 3:为什么 SLIST 是无锁的? 传统链表的 Push/Pop 需要锁来保护"指针更新"。SLIST 使用 InterlockedCompareExchange(原子比较并交换)来修改链表头——硬件保证这是一个原子操作(其他 CPU 要么看到旧状态要么看到新状态,不会看到中间状态)。无锁链表的优势是:即使在中断上下文中也能安全操作——不需要禁用中断,减少了系统的整体延迟。

3.5.15 NonPagedPool 与 PagedPool 的选择策略

3.5.15.1 设计意图

核心问题:开发者写内核驱动时,每次分配都要选择:用 NonPagedPool 还是 PagedPool?选错了——要么死锁(PagedPool 用在 ISR,触发 #PF 死锁),要么浪费(NonPagedPool 用在普通路径,占稀缺资源)。如何提供清晰的选择指南,让开发者做出正确选择?

设计哲学“根据上下文的最高 IRQL 决定”。简单规则:① 如果分配/释放可能发生在 DISPATCH_LEVEL 或更高(如 DPC、ISR),必须用 NonPagedPool。② 如果分配只发生在 PASSIVE_LEVEL(普通线程上下文),可以用 PagedPool。③ 有疑问时,先默认 PagedPool,发现性能问题再切 NonPagedPool。这套规则的本质是:“PagedPool 可能触发 #PF → #PF 可能换入 → 换入需要 I/O → I/O 在高 IRQL 下死锁。”

3.5.15.2 概念解释
  • IRQL 约束:DISPATCH_LEVEL 及以上不能访问 PagedPool(可能触发 #PF → #PF 需要从 pagefile 读 → pagefile I/O 需要等待磁盘中断 → 但当前在 DISPATCH_LEVEL,磁盘中断无法及时处理 → 死锁)。
  • 池配额(Pool Quota):进程可用的池上限。超过配额时分配失败,返回 STATUS_INSUFFICIENT_RESOURCES
  • 池扩展(Pool Expansion):池用尽时,内存管理器可以扩展池的虚拟地址空间——前提是系统空间还有空闲区域。
  • 池耗尽(Pool Exhaustion):NonPagedPool 耗尽是系统级灾难——新进程无法创建、新线程无法分配、I/O 无法完成。典型表现为 0x000000AB (SESSION_HAS_VALID_POOL_ON_EXIT) 或系统挂死。
  • Secure Pool(安全池):Windows 后续版本引入的池安全特性——随机化池地址、池头完整性检查、防止溢出覆写相邻块。
3.5.15.3 为什么要这样设计

问题 1:为什么 PagedPool 不能在高 IRQL 使用? 假设在 DPC(DISPATCH_LEVEL)中访问 PagedPool 的某页,而该页恰好被换出到 pagefile。CPU 触发 #PF → 内核的 #PF 处理需要从 pagefile 读回该页 → 读 pagefile 需要磁盘 I/O → 磁盘 I/O 需要等待磁盘中断(DIRQL)→ 但当前 CPU 正在 DISPATCH_LEVEL 处理 DPC,磁盘中断无法抢占 DPC(DIRQL > DISPATCH_LEVEL,可以抢占,但真正的问题是:#PF 处理需要分配 NonPagedPool 做 I/O 缓冲,此时又回到池分配路径)。最终结果:DPC 在等待磁盘 I/O,但磁盘 I/O 的完成需要 DPC 让出 CPU → 互相等待 → 死锁。

问题 2:为什么 NonPagedPool 是稀缺资源? 32 位系统中,NonPagedPool 的上限通常是 256 MB(基于系统 RAM 的百分比)。64 位系统中 NonPagedPool 很大(数 GB),但仍不是无限的——每个 EPROCESS ~3 KB、每个 ETHREAD ~2 KB、每个 IRP ~1 KB。系统有 1000 个进程时,仅进程/线程对象就占 3 MB + 2 MB = 5 MB。每个驱动都可能分配池——系统启动后 NonPagedPool 会持续被消耗,从不释放(除非驱动卸载)。耗尽 NonPagedPool 是内核驱动最常见的稳定性问题。

问题 3:为什么有疑问时优先 PagedPool? PagedPool 的优点是"可换出"——不常用的池内容会被换到 pagefile,释放物理内存给更需要的地方。缺点是"访问时可能触发 #PF → 可能引起 I/O 延迟"。但对大多数驱动来说,池访问的"时间局部性"很好——刚分配的块很快被访问,之后长期不用。PagedPool 让"冷的池数据"换出,"热的池数据"留在内存——性能几乎与 NonPagedPool 相同,但不占稀缺资源。

3.5.16 小结

  • 池(Pool) 是内核态的"内存分配器"——分 NonPaged(不可换出)和 Paged(可换出)两类。
  • 桶(Bucket)机制让小对象(<= 1024 字节)分配 O(1) 完成;大对象走 MiAllocatePoolPages
  • Tag 标签 让内存诊断(!poolfind)成为可能——32 位整数不占内存。
  • **SLIST(无锁单链表)**减少桶的锁竞争——每个桶有 SLIST 头作为无锁快速路径。
  • 池的并发用自旋锁保护——桶锁 / 池描述符锁。
  • PagedPool 通过"按需物理页映射"实现可换出——访问时可能触发内核态 #PF。
  • 池的诊断:监控 API(ExQueryPoolUsageExGetPoolTagInfo)、调试工具(!poolfind!vm)。

本篇小结

本篇是第 3 章"内存管理"的下篇,聚焦虚拟内存的工程实现——换出、Section、池。

本篇涉及的三个核心主题

主题 描述对象 关键文件 关键 API
页面换出(3.3) 物理内存耗尽时的页换出 mm/pagefile.cmm/ARM3/wslist.c MmReadFromSwapPage / MmWriteToSwapPage
Section(3.4) 文件 → 内存的映射 mm/ARM3/section.c MmCreateSection / MmMapViewOfSection
池(Pool)(3.5) 内核态内存分配器 mm/ARM3/pool.c ExAllocatePoolWithTag / ExFreePoolWithTag

三个主题的协作关系

虚拟地址空间耗尽
    ↓
页面换出(3.3)
    │ 把不活跃的页写入 pagefile
    │ Modified Page Writer 后台线程
    ↓
    物理页被释放,可用于新分配

文件加载(DLL/EXE)
    ↓
Section(3.4)
    │ PE Loader 解析 PE 头
    │ MmCreateSection → MmMapViewOfSection
    │ VAD 节点 + Prototype PTE
    ↓
    文件内容映射到进程虚拟地址空间

内核代码路径需要分配内存
    ↓
池(3.5)
    │ ExAllocatePoolWithTag
    │ 桶分配(小对象) / 大块分配(大对象)
    ↓
    获得 NonPagedPool 或 PagedPool 虚拟地址

**本篇的"概念解释"**回答了 21 个术语(Working Set、Standby List、Modified List、pagefile.sys、Memory Low、Clock 算法、SWAPENTRY、Section、Image Section、Data Section、Pagefile-backed Section、CONTROL_AREA、SEGMENT、SUBSECTION、Prototype PTE、PE Loader、Pool、桶、Tag、大块分配、SLIST、ExAllocatePoolWithTagExFreePoolWithTag!poolfind、池损坏)。

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

  • 为什么工作集按时间局部性?(程序局部性原理 + LRU 工业实现)
  • 为什么 Standby 页可被偷走?(page reclaim 优化)
  • 为什么需要 pagefile?(虚拟空间溢出层)
  • 为什么 Modified 页必须先写回才能 Free?(数据完整性)
  • 为什么 Clock 算法而不是精确 LRU?(性能与精度的折中)
  • 为什么 Modified Page Writer 是后台线程?(用户态响应 + I/O 调度)
  • 为什么 Section 内部要分 SEGMENT 和 SUBSECTION?(与 VAD 树最小区间单位匹配)
  • 为什么用 Prototype PTE 而不是每进程一份 PTE 数组?(内存效率)
  • 为什么 Image Section 共享 code 但 data 是 COW?(读零成本 / 写按需)
  • 为什么 MmCreateSection 与 MmMapViewOfSection 分离?(解耦:创建与使用)
  • 为什么 MmUnmapViewOfSection 要更新 CONTROL_AREA 引用计数?(生命周期管理)
  • 为什么内核要"自己管内存"而不是用 malloc?(DPC/ISR 不能换出、Tag 诊断、不可中断)
  • 为什么 NonPagedPool 和 PagedPool 分开?(NonPagedPool 稀缺资源 + 节省配额)
  • 为什么用桶而不是统一空闲链表?(分配速度 O(1) + cache 友好)
  • 为什么 Tag 是必需的?(内存诊断 32 位整数零成本)
  • 为什么 SLIST 而不是加锁的桶?(无锁原子操作减少锁竞争)
  • 为什么 PagedPool 要可换出?(节省 NonPagedPool)

全章收尾

[上篇](file:///d:/reactos/doc/第3章_内存管理_上.md) 讲了虚拟内存的骨架(VAD、PFN、PTE);[中篇](file:///d:/reactos/doc/第3章_内存管理_中.md) 讲了虚拟内存的肌肉(Hyperspace、系统空间、NtAllocateVirtualMemory、缺页异常);[下篇**(本篇**)讲了虚拟内存的工程实现(换出、Section、池)。三篇合在一起,构成了 ReactOS 内存管理的完整图景。

第 4 章预告:进程与线程——EPROCESS/ETHREAD 对象的创建、调度、同步、终止。理解了内存管理后,下一章将看到"进程和线程"如何建立在虚拟内存之上——每个进程都有独立的 VAD 树,每个线程都有独立的栈和上下文。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐