4. 内核内存管理:内存分配与释放、分页与非分页内存、内存池标签、内存泄漏检测

驱动开发里,内存管理是基本功。说句实话,用户态程序里内存泄漏了,顶多进程退出时系统帮你回收。但在内核里,你泄漏一次,系统可能就蓝给你看。我早年刚转内核开发时,就因为一个内存泄漏,让测试机跑了一周后死机,被老大骂得狗血淋头。从那以后,我对内存管理就格外上心。

这一章,咱们就把内核内存管理的几个核心点掰开揉碎讲清楚。包括怎么分配、怎么释放、分页与非分页内存的区别、内存池标签怎么用,以及如何检测泄漏。

4.1 分页内存与非分页内存

先搞清楚这两个概念。这是内核开发里最基础、也最容易踩坑的地方。

分页内存(Paged Pool):说白了就是可以被换出到磁盘的内存。系统内存紧张时,这部分数据会被写到页面文件里。优点是省物理内存,缺点是你不能在DISPATCH_LEVEL及以上的IRQL级别访问它。为什么?因为访问时如果发生缺页中断,系统会尝试从磁盘读回数据,而这个操作需要等待,在高IRQL下是不允许等待的。

非分页内存(Non-Paged Pool):永远驻留在物理内存中,不会被换出。访问它没有任何IRQL限制。代价就是占用宝贵的物理内存,不能释放给其他用途。

我个人习惯是:能用分页就用分页,但一旦涉及DPC、中断处理、自旋锁保护的临界区,必须用非分页。我曾经在一个项目的DPC例程里用了分页内存,结果系统在内存压力下频繁蓝屏,查了两天才定位到问题。

核心原则:IRQL >= DISPATCH_LEVEL 时,只能访问非分页内存。
特性 分页内存 非分页内存
可被换出
IRQL限制 ≤ APC_LEVEL 无限制
物理内存占用 按需 始终占用
分配函数 ExAllocatePoolWithTag(PagedPool, ...) ExAllocatePoolWithTag(NonPagedPool, ...)

4.2 内存分配与释放

内核里分配内存,最常用的就是 ExAllocatePoolWithTagExFreePoolWithTag。Windows 10 1809之后,微软推荐用 ExAllocatePool2ExAllocatePool3,但老代码里还是前者居多。咱们先讲经典的。

// 分配非分页内存,大小为 1024 字节,标签为 'TAG1'
PVOID pBuffer = ExAllocatePoolWithTag(NonPagedPool, 1024, 'TAG1');
if (pBuffer == NULL) {
    // 分配失败处理
    DbgPrint("内存分配失败!\n");
    return STATUS_INSUFFICIENT_RESOURCES;
}

// 使用内存...
RtlZeroMemory(pBuffer, 1024);

// 释放内存
ExFreePoolWithTag(pBuffer, 'TAG1');
pBuffer = NULL;  // 我习惯释放后立即置空,防止野指针
小技巧:分配后立即用 RtlZeroMemory 清零是个好习惯。内核内存不会自动清零,里面可能残留敏感数据。

你想想看,如果忘了释放,或者释放了两次,后果都很严重。忘了释放就是内存泄漏,释放两次就是双重释放,系统可能直接蓝屏。我见过最离谱的案例,有人把同一个指针释放了三次,第三次时系统直接崩溃。

4.3 内存池标签

标签是什么?就是一个4字节的整数,通常用ASCII字符表示,比如 'TAG1'。它的作用是帮你追踪内存的来源。用 !poolused!poolfind 调试命令时,你可以根据标签找到哪些内存是哪个驱动分配的。

我个人习惯用驱动名称缩写加序号,比如我的驱动叫 MyDrv,标签就用 'Md01''Md02' 这样。注意标签在内存中是倒序存储的,所以 'TAG1' 实际存的是 0x31474154

// 分配时指定标签
PVOID pData = ExAllocatePoolWithTag(NonPagedPool, sizeof(MY_STRUCT), 'Md01');

// 释放时必须使用相同的标签
ExFreePoolWithTag(pData, 'Md01');
注意:释放时标签必须与分配时一致。如果标签不匹配,系统会触发断言检查(在Checked Build下),或者导致内存管理结构损坏。

4.4 内存泄漏检测

说实话,内核内存泄漏比用户态难查得多。用户态有各种工具,内核里主要靠调试器和驱动验证器(Driver Verifier)。

方法一:Driver Verifier

这是微软官方提供的驱动验证工具。启用后,它会监控你的驱动内存操作,一旦发现泄漏或非法访问,立即触发蓝屏并生成dump文件。我建议开发阶段一直开着它。

// 在命令行启用 Driver Verifier
verifier.exe /flags 0x209BB /driver MyDriver.sys

常用的flags包括:

  • 0x1:特殊内存池(Special Pool)—— 分配内存前后加保护页,检测越界
  • 0x8:强制IRQL检查
  • 0x200:内存泄漏检测

方法二:使用 !poolused 调试命令

在WinDbg里,你可以用这个命令查看所有内存池的使用情况。按标签分组,能看到每个标签分配了多少内存、有多少未释放。

kd> !poolused 2
Pool Used:
            NonPaged            Paged
 Tag    Allocs     Used    Allocs     Used
 Md01        5      512        0        0
 Md02        3      256        0        0

如果某个标签的 AllocsUsed 一直在增长,而你的驱动又没有对应的释放逻辑,那基本就是泄漏了。

方法三:手动跟踪

我自己的项目里,会写一个简单的内存跟踪模块。每次分配时记录调用栈和大小,释放时删除记录。驱动卸载时,打印所有未释放的记录。虽然有点笨,但很有效。

// 伪代码示例
typedef struct _MEM_TRACK {
    PVOID Address;
    SIZE_T Size;
    ULONG Tag;
    char File[64];
    ULONG Line;
} MEM_TRACK, *PMEM_TRACK;

// 分配时记录
PMEM_TRACK Track = ExAllocatePoolWithTag(NonPagedPool, sizeof(MEM_TRACK), 'Trk1');
Track->Address = pBuffer;
Track->Size = 1024;
Track->Tag = 'TAG1';
// 插入到全局链表...

// 释放时删除
// 从链表中找到对应记录,删除并释放Track结构
避坑指南:我曾经在卸载例程里忘了释放一个全局缓冲区,结果每次卸载驱动后,系统可用内存就少几KB。累积了上百次后,系统开始频繁报内存不足。后来用 !poolused 一查,发现有个标签的分配数远大于释放数,才定位到问题。

4.5 知识体系总览

下面这张图把本章的核心逻辑串起来了。你可以看到,内存分配从选择分页还是非分页开始,然后通过带标签的分配函数获取内存,使用完毕后必须用相同标签释放。泄漏检测则从Driver Verifier、调试命令和手动跟踪三个维度入手。

内核内存管理核心逻辑 分页内存 IRQL ≤ APC_LEVEL 非分页内存 任意IRQL 选择依据 ExAllocatePoolWithTag (PoolType, Size, Tag) 使用内存(读写) ExFreePoolWithTag 泄漏检测手段 1. Driver Verifier 2. !poolused 命令 3. 手动跟踪模块 核心:分配带标签 → 使用 → 释放同标签 → 泄漏检测闭环

4.6 总结

内核内存管理,说白了就是三件事:选对类型、带好标签、记得释放。分页和非分页的选择决定了你的代码能在什么IRQL下运行。内存池标签是调试时的救命稻草。泄漏检测则靠工具和好习惯。

我个人建议,每个驱动项目从一开始就建立内存跟踪机制。哪怕只是简单的链表记录,也比出了问题再查要省事得多。毕竟,内核里没有垃圾回收,全靠开发者自觉。

记住:每一次 ExAllocatePoolWithTag,都必须有一个对应的 ExFreePoolWithTag。这是内核开发者的基本素养。