Reactos 第 3 章 内存管理 — 【下篇】换出、Section、池
第 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 何时换出
页面换出有三个主要触发器:
- 工作集修剪(
MmTrimWorkingSet):当用户态或内核态调用SetProcessWorkingSetSize或EmptyWorkingSet时,强制把进程工作集修剪到指定大小。 - 内存压力(
MiPageOutProcessBulk):当系统检测到物理内存不足(MmLowMemoryEvent)时,主动扫描所有进程工作集,把不活跃的页换出。 - 主动弃页(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
MmReadFromSwapPage 和 MmWriteToSwapPage 是 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/O:
IoSynchronousPageWrite是同步写——调用者阻塞直到 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.dll、ntdll.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)
关键步骤:
- 检查句柄(如果
FileHandle非 NULL):句柄必须可读、文件支持 mapping。 - 创建 SECTION 内核对象:
ObCreateObject创建一个 Section 对象。 - 构造 CONTROL_AREA:
ControlArea包含文件信息、引用计数等。 - 构造 SEGMENT:根据文件大小创建 SEGMENT(按 64 KB 切分)。
- 构造 SUBSECTION:每个 SEGMENT 对应一组 SUBSECTION,关联到磁盘扇区。
- 构造 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)
关键步骤:
- 解析 Section:从
SectionObject取CONTROL_AREA。 - 找空闲区段(如果
BaseAddress == NULL):MiFindEmptyAddressRange找一段合适空洞。 - 构造 VAD 节点:在 VAD 树中插入新 VAD,指向 Section 的 SUBSECTION。
- 分配 PTE:在进程的页表空间中为该区段分配 PTE。
- 设置 PTE 为"prototype 跳转":每个 PTE.P=0,“PFN” 指向 SEGMENT 中的 Prototype PTE。
- 更新引用计数:
ControlArea->NumberOfMappedViews++。
关键点:
- VAD 是 Section 与进程的"纽带":VAD 节点的
StartingVpn、EndingVpn描述该映射的虚拟地址范围;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)
关键步骤:
- 查 VAD 树:在进程的
VadRoot树中找该地址对应的 VAD。 - 删除 VAD:
MiRemoveVad。 - 修改 PTE:把该区段的所有 PTE 设为 demand-zero 或清零。
- 更新引用计数:
ControlArea->NumberOfMappedViews--。 - 如果最后一个映射:释放 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 写入该页时:
- CPU 查 TLB → 进程 A 的 PTE(P=0)→ 跳到 Prototype PTE。
- Prototype PTE 也 P=0 → #PF。
MiDispatchFault处理 →MiDispatchSectionFault。- 找到对应的 SUBSECTION,解析原型 PTE。
- 如果是 Image Section 的只读段:
- 分配新页(PFN 200)。
- 从原 PFN 100 复制内容到 PFN 200。
- 进程 A 的 PTE 改为 P=1, PFN=200(私有 copy)。
- 原型 PTE 不变(其他进程仍共享 PFN 100)。
- 如果是 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_HEADER、IMAGE_NT_HEADERS、IMAGE_OPTIONAL_HEADER、IMAGE_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 → 使用 → UnmapViewOfFile → CloseHandle。统一的对象模型降低了学习成本,简化了跨进程共享。
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:
MmCreateSection、MmMapViewOfSection、MmUnmapViewOfSection。 - 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) 顶部):
- 保留 NonPagedPool 虚拟地址空间。
- 保留 PagedPool 虚拟地址空间。
- 初始化桶(每个桶的空闲链表头)。
- 初始化 SLIST 头。
- 为 NonPagedPool 预分配初始页。
- 为 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(
ExQueryPoolUsage、ExGetPoolTagInfo)、调试工具(!poolfind、!vm)。
本篇小结
本篇是第 3 章"内存管理"的下篇,聚焦虚拟内存的工程实现——换出、Section、池。
本篇涉及的三个核心主题:
| 主题 | 描述对象 | 关键文件 | 关键 API |
|---|---|---|---|
| 页面换出(3.3) | 物理内存耗尽时的页换出 | mm/pagefile.c、mm/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、ExAllocatePoolWithTag、ExFreePoolWithTag、!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 树,每个线程都有独立的栈和上下文。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)