16、内存管理与保护:虚拟地址空间、物理内存映射、内存描述符(MDL)、内存保护、内存扫描
内存管理,说白了就是驱动和操作系统之间最核心的「地盘划分」。你想想看,驱动跑在内核态,权限极高,但权限越高越容易出事。我做了这么多年驱动开发,见过太多蓝屏都是因为内存操作不当——要么访问了不该访问的地址,要么映射错了物理页。
这一章,我们就来啃这块硬骨头。我会把虚拟地址空间、物理内存映射、MDL、内存保护、内存扫描这几个点串起来讲。嗯,都是实战中绕不开的东西。
核心观点:驱动开发中的内存管理,本质是在「虚拟地址」和「物理地址」之间搭桥,同时确保这座桥不会塌。
16.1 虚拟地址空间:驱动眼中的世界
每个进程都有自己的虚拟地址空间,4GB(32位)或更大(64位)。但驱动跑在系统空间,也就是高2GB(32位)或更高区域。我个人习惯把虚拟地址空间想象成一张「地图」,驱动只能在高位区域活动。
为什么要有虚拟地址?说白了就是为了隔离。每个进程以为自己在独占内存,实际上都是假的。驱动要访问某个进程的内存,得先「进入」它的地址空间——这就要用到 KeAttachProcess 或 MmMapLockedPages 之类的函数。
我记得有一次调试一个文件过滤驱动,发现读取用户态缓冲区时总是蓝屏。查了半天,原来是没判断地址是否在用户空间范围内。嗯,这个坑后面会细说。
16.2 物理内存映射:从虚拟到物理的桥梁
虚拟地址最终要落到物理内存上。Windows 内核提供了几种映射方式:
- 直接映射: 系统空间的一部分直接映射到物理内存,比如
0x80000000开始的区域(32位)。 - 动态映射: 通过
MmMapIoSpace把物理地址映射到虚拟地址,常用于访问硬件寄存器。 - MDL 映射: 通过内存描述符表(MDL)来管理物理页的映射,后面会重点讲。
你可能会问:为什么不直接用物理地址?因为驱动不能直接操作物理地址,必须通过虚拟地址。这是操作系统保护机制的一部分。
实战经验: 我在写一个 PCIe 驱动时,需要访问设备的 BAR 空间。直接用 MmMapIoSpace 映射物理地址,然后通过返回的虚拟地址读写。注意,映射大小要对齐到页边界,否则会失败。
16.3 内存描述符(MDL):管理物理页的利器
MDL 是 Windows 内核中一个非常重要的数据结构。它描述了一段虚拟地址对应的物理页集合。结构体定义大致如下:
typedef struct _MDL {
struct _MDL *Next;
CSHORT Size;
CSHORT MdlFlags;
struct _EPROCESS *Process;
PVOID MappedSystemVa;
PVOID StartVa;
ULONG ByteCount;
ULONG ByteOffset;
} MDL, *PMDL;
MDL 的核心作用有两个:
- 锁定物理页: 防止页面被换出到磁盘。
- 映射到系统空间: 让驱动可以访问用户态缓冲区。
我建议你在使用 MDL 时,一定要配对调用 IoAllocateMdl 和 IoFreeMdl,以及 MmBuildMdlForNonPagedPool 和 MmUnlockPages。我曾经见过一个同事,只分配了 MDL 但没解锁,结果内存泄漏,系统跑几天就崩了。
避坑指南: 我曾经在 64 位系统上犯过一个错误——MDL 的 StartVa 是虚拟地址的页基址,不是实际起始地址。实际数据偏移在 ByteOffset 中。如果你直接拿 StartVa 当缓冲区用,数据会错位。
16.4 内存保护:别让驱动乱踩内存
内存保护是操作系统给驱动上的「紧箍咒」。主要分几个层面:
| 保护机制 | 说明 | 常见违规后果 |
|---|---|---|
| 页保护(PAGE_READONLY 等) | 每个虚拟页有读/写/执行权限 | 访问违规蓝屏(0x50) |
| 用户/内核隔离 | 用户态代码不能直接访问内核地址 | 权限错误(0xC0000005) |
| DEP(数据执行保护) | 禁止在非可执行页上执行代码 | 异常终止 |
| PatchGuard | 保护内核关键结构不被修改 | 系统崩溃(0x109) |
嗯,这里要注意:驱动虽然权限高,但也不能为所欲为。比如你想修改 SSDT(系统服务描述表),在 PatchGuard 开启的 64 位系统上,直接写内存会触发保护机制。我见过有人用 MmMapIoSpace 绕过,但那是刀尖上跳舞,不推荐。
16.5 内存扫描:查找特定模式
内存扫描在驱动开发中常用于:
- 检测恶意软件(扫描内核模块)
- Hook 检测(查找被修改的代码)
- 特征码搜索(比如找某个函数的地址)
一个简单的内存扫描函数实现如下:
PVOID ScanMemory(PVOID StartAddress, SIZE_T Size, const BYTE* Pattern, SIZE_T PatternSize) {
BYTE* Current = (BYTE*)StartAddress;
BYTE* End = Current + Size - PatternSize;
while (Current <= End) {
if (RtlCompareMemory(Current, Pattern, PatternSize) == PatternSize) {
return Current; // 找到匹配
}
Current++;
}
return NULL; // 未找到
}
这个函数很简单,但实际项目中要考虑更多:
- 内存类型: 非分页内存才能直接扫描,分页内存可能不在物理内存中。
- 对齐问题: 某些架构要求对齐访问,否则会异常。
- 性能: 扫描大块内存时,建议用更高效的算法(如 Boyer-Moore)。
个人经验: 我写过一个内核模块扫描器,用来检测 rootkit。核心思路是遍历系统模块列表,然后对每个模块的代码段做特征匹配。但要注意,有些恶意软件会 hook 遍历函数,所以最好直接解析内存中的 _LDR_DATA_TABLE_ENTRY 链表。
16.6 知识体系总览
下面这张图把本章的核心知识点串起来了。你可以看到,虚拟地址空间是起点,物理内存映射是桥梁,MDL 是管理工具,内存保护是规则,内存扫描是应用。五者缺一不可。
好了,这一章的内容就到这里。内存管理是驱动开发的基础,也是最容易出问题的地方。我个人建议你多写几个小实验,比如用 MDL 映射用户态缓冲区,或者写一个简单的内存扫描工具。踩过坑,才能真正记住。
总结: 虚拟地址空间是舞台,物理内存映射是道具,MDL 是剧本,内存保护是规则,内存扫描是表演。五者配合,才能写出稳定高效的驱动。