第3章 内核程序设计基础
IRQL 优先级阶梯、NTSTATUS 返回值契约、UNICODE_STRING 字符串操作、分页与非分页池内存分配、LIST_ENTRY 链表,以及驱动对象与设备对象的 C 实战代码。
学习目标
- 能解释 IRQL 五个级别的含义和优先级关系,判断给定内核操作允许的最高 IRQL
- 能改出在正确 IRQL 下使用 UNICODE_STRING、ExAllocatePool2 和 LIST_ENTRY 的内核 C 代码
- 能回答:在 DISPATCH_LEVEL 下调用 ExAllocatePool2 分配分页池内存会发生什么?为什么?
为什么内核代码不能随便写
想象一条工厂流水线。普通工人在自己的工位上干活,可以随时去仓库取料、等供应商送货。但紧急抢修工一响警报就必须放下手头工作冲过去——这时候他不能去仓库(仓库门可能锁着),也不能等送货(停机一秒损失巨大)。
内核也是这条流水线。代码运行在不同的“优先级”上:低优先级时什么都能干,高优先级时手脚被绑——不能等、不能碰某些内存、不能调大部分接口。搞不清自己处在哪个级别就乱来,系统直接蓝屏。
这一章教你内核编程的基本工具:怎么报告错误、怎么操作字符串、怎么分配内存、怎么管理链表。每个工具都有使用前提,前提就是“你现在在哪个优先级”。
IRQL:内核的优先级阶梯
↡Interrupt Request Level,中断请求级别,是 Windows 内核中定义 CPU 当前执行优先级的整数数值,数值越高优先级越高 是内核编程中最核心的约束概念。它不是一个队列,而是一个“当前优先级”标记:CPU 在任何时刻都处于某个 IRQL 上,只有更高 IRQL 的中断才能打断当前执行。
Windows 定义了几个关键级别:PASSIVE_LEVEL(0) 是最低级别,用户模式代码和大多数驱动入口例程运行于此;APC_LEVEL(1) 用于异步过程调用;DISPATCH_LEVEL(2) 是调度器工作级别,DPC 和自旋锁持有者运行于此;更高级别(3-31)用于硬件中断和极端临界区。
IRQL 的核心规则是:IRQL 越高,能做的事越少。在 PASSIVE_LEVEL 你可以访问分页内存、等待事件、调用几乎所有 API;到了 DISPATCH_LEVEL,访问分页内存会直接蓝屏,等待操作被禁止。下面的交互组件让你直观感受这个阶梯。
NTSTATUS:内核的返回值契约
↡Windows 内核 API 统一使用的 32 位返回值类型,用位域编码成功、信息、警告和错误 是所有内核 API 的标准返回类型。它不是简单的整数错误码,而是一个结构化的 32 位值:高位表示严重程度(成功/信息/警告/错误),低位表示具体代码。
判断成功用 NT_SUCCESS(status) 宏,它检查高位是否为 0。常见的返回值包括 STATUS_SUCCESS(成功)、STATUS_INVALID_PARAMETER(参数错误)、STATUS_INSUFFICIENT_RESOURCES(内存不足)等。内核 API 调用后必须检查返回值——忽略它是最常见的驱动崩溃原因之一。
UNICODE_STRING:内核的字符串
↡Windows 内核中表示 Unicode 字符串的结构体,包含长度、最大长度和缓冲区指针三个字段,不以 null 结尾 是内核中字符串的标准表示。它不依赖 null 终止符,而是用 Length(当前字节长度)、MaximumLength(缓冲区容量)和 Buffer(指向 WCHAR 数组的指针)三个字段精确描述字符串。
这种设计比 C 字符串更安全:长度是显式的,不需要遍历查找终止符,也不怕字符串中间出现零字节。初始化用 RtlInitUnicodeString,格式化用 RtlStringCchPrintfW(支持 %wZ 直接格式化 UNICODE_STRING)。
内存池:分页与非分页
内核内存分配的核心抉择是↡可被换出到磁盘的内核内存池,只在 IRQL <= APC_LEVEL 时安全访问与↡始终驻留物理内存的内核内存池,可在 DISPATCH_LEVEL 及更高 IRQL 安全访问的选择。分页池的内存可能被换出到磁盘,访问时触发页错误——这在低 IRQL 下是正常的,但在 DISPATCH_LEVEL 会导致蓝屏。非分页池永远驻留在物理内存中,任何 IRQL 都能安全访问,代价是占用宝贵的物理内存。
分配用 ExAllocatePool2(取代已废弃的 ExAllocatePool),释放用 ExFreePoolWithTag。每个分配都关联一个 4 字节标签(Tag),用于调试时追踪内存泄漏。
LIST_ENTRY:内核的链表
↡Windows 内核提供的侵入式双向链表节点结构,只含两个指针(Flink 和 Blink),需嵌入到自定义数据结构中使用 是内核链表的基础。它是一个“侵入式”设计:不存储数据,只维护前向指针(Flink)和后向指针(Blink)。你的自定义结构体把 LIST_ENTRY 作为成员嵌入,通过 CONTAINING_RECORD 宏从 LISTENTRY 指针反算出宿主结构的地址。
核心操作:InitializeListHead 初始化链表头,InsertTailList/InsertHeadList 插入节点,RemoveHeadList/RemoveEntryList 移除节点,IsListEmpty 判断是否为空。
动手:探索 IRQL 优先级阶梯
下面的可视化展示了 IRQL 的五个级别。点击每个横条查看该级别能做什么、不能做什么。然后试试“操作检查器”——点击一个操作,看哪些 IRQL 级别允许执行它。
猜一猜:点击“访问分页内存”操作,观察哪些级别变绿(允许)、哪些变红(禁止)。DISPATCH_LEVEL 会是哪个颜色?动手试试再看答案。
操作检查器:点击操作,查看哪些 IRQL 级别允许执行
能做什么
- · 访问分页内存
- · 等待事件 / 信号量
- · 文件 I/O
- · 调用几乎所有 API
- · ExAllocatePool2(任意池类型)
不能做什么
- · 不能假设不被抢占(调度器仍可切换线程)
典型场景:DriverEntry、DriverUnload、大多数 IRP 分发例程运行于此。最常见、最宽松的运行环境。
代码对照:内核编程核心操作
UNICODE_STRING 的初始化与格式化
内核字符串操作的第一步是把 C 字符串字面量包装成 UNICODE_STRING,然后用 RtlStringCchPrintfW 格式化输出。
#include <ntstrsafe.h>
// 把字符串字面量包装成 UNICODE_STRING
UNICODE_STRING devName;
RtlInitUnicodeString(&devName, L"\\Device\\MyDevice");
// 手动构造缓冲区并格式化
WCHAR buffer[256];
UNICODE_STRING result;
result.Length = 0;
result.MaximumLength = sizeof(buffer);
result.Buffer = buffer;
// %wZ 是 UNICODE_STRING 的格式说明符
NTSTATUS status = RtlStringCchPrintfW(
buffer, sizeof(buffer) / sizeof(WCHAR),
L"Device: %wZ, PID: %d", &devName, 1234);UNICODE_STRING 结构布局:
┌──────────────────────────────────┐
│ USHORT Length (已用字节) │
│ USHORT MaximumLength (缓冲区容量) │
│ PWSTR Buffer ──→ WCHAR[] │
└──────────────────────────────────┘
RtlInitUnicodeString:
C 字符串 L"..." → 自动算长度 + 设指针
RtlStringCchPrintfW:
类似 swprintf,但保证不越界
%wZ = 直接格式化 UNICODE_STRINGRtlInitUnicodeString 不会复制字符串数据,只是让 Buffer 指向传入的字面量地址。如果原始字符串被修改或释放,UNICODE_STRING 也会受影响——字面量在只读段中所以安全,但动态字符串需要先分配缓冲区再复制。
ExAllocatePool2 与 ExFreePoolWithTag
内核内存分配必须指定池类型和标签。标签是 4 字符的调试标识,用单引号语法写成字符常量。
// 分配非分页内存(DISPATCH_LEVEL 可用)
PVOID ptr = ExAllocatePool2(
POOL_FLAG_NON_PAGED, // 非分页池
1024, // 字节数
'MyTg'); // 4 字符标签
if (ptr == NULL) {
// 必须检查!分配可能失败
return STATUS_INSUFFICIENT_RESOURCES;
}
// ... 使用内存 ...
// 释放时传入同一标签
ExFreePoolWithTag(ptr, 'MyTg');分页池 (POOL_FLAG_PAGED) 非分页池 (POOL_FLAG_NON_PAGED)
│ │
├─ 可被换出到磁盘 ├─ 始终驻留物理内存
├─ IRQL <= APC_LEVEL ├─ IRQL <= DISPATCH_LEVEL
├─ 省物理内存 ├─ 耗物理内存
└─ 访问可能触发页错误 └─ 访问永不触发页错误
标签 'MyTg':
4 字符 ASCII,逆序存储
调试器中 !poolused 按标签统计泄漏LIST_ENTRY 链表的完整操作
链表是内核中最常用的数据结构之一。LIST_ENTRY 是侵入式的——它不包含你的数据,而是嵌入在你的结构体中。
// 自定义结构体,内嵌 LIST_ENTRY
typedef struct _MY_ITEM {
LIST_ENTRY ListEntry; // 链表节点
ULONG Value; // 业务数据
} MY_ITEM, *PMY_ITEM;
LIST_ENTRY g_ListHead; // 全局链表头
// 初始化
InitializeListHead(&g_ListHead);
// 分配并插入节点
PMY_ITEM item = ExAllocatePool2(
POOL_FLAG_NON_PAGED, sizeof(MY_ITEM), 'Itm');
if (item) {
item->Value = 42;
InsertTailList(&g_ListHead, &item->ListEntry);
}侵入式链表结构:
g_ListHead ⇄ ItemA ⇄ ItemB ⇄ ItemC ⇄ g_Head
↑
MY_ITEM { ListEntry, Value }
CONTAINING_RECORD 宏:
从 ListEntry 的地址
减去 ListEntry 在 MY_ITEM 中的偏移
= 得到 MY_ITEM 起始地址
等价于:container = (PMY_ITEM)
((char*)entry - offsetof(MY_ITEM, ListEntry))遍历和移除节点时,CONTAINING_RECORD 是关键宏——它从 LIST_ENTRY 指针反算出宿主结构的地址。
// 遍历链表
PLIST_ENTRY entry = g_ListHead.Flink;
while (entry != &g_ListHead) {
PMY_ITEM myItem = CONTAINING_RECORD(
entry, MY_ITEM, ListEntry);
// 处理 myItem->Value ...
entry = entry->Flink;
}
// 逐个移除并释放
while (!IsListEmpty(&g_ListHead)) {
PLIST_ENTRY head = RemoveHeadList(&g_ListHead);
PMY_ITEM myItem = CONTAINING_RECORD(
head, MY_ITEM, ListEntry);
ExFreePoolWithTag(myItem, 'Itm');
}移除流程(必须先移除再释放):
1. RemoveHeadList 从链表摘下节点
→ 链表指针已修复,节点孤立
2. CONTAINING_RECORD 反算宿主地址
3. ExFreePoolWithTag 释放宿主结构
错误顺序(先释放再移除):
→ 链表指针指向已释放内存
→ 下次遍历 = use-after-free = 蓝屏驱动对象与设备对象
↡内核中代表已加载驱动模块的结构体,包含入口点、卸载回调和 IRP 分发函数表 和 ↡内核中代表一个设备实例的结构体,驱动通过它接收 I/O 请求 是驱动程序的核心对象。DriverEntry 收到一个 DRIVER_OBJECT 指针,驱动用它注册回调并创建设备对象。
NTSTATUS DriverEntry(
PDRIVER_OBJECT DriverObject,
PUNICODE_STRING RegistryPath)
{
UNICODE_STRING devName;
RtlInitUnicodeString(&devName, L"\\Device\\MyDev");
PDEVICE_OBJECT devObj;
NTSTATUS status = IoCreateDevice(
DriverObject, 0, &devName,
FILE_DEVICE_UNKNOWN, 0, FALSE, &devObj);
if (!NT_SUCCESS(status))
return status;
// 注册卸载回调
DriverObject->DriverUnload = MyUnload;
return STATUS_SUCCESS;
}DRIVER_OBJECT(驱动对象)
├─ DriverEntry ← 系统调用入口
├─ DriverUnload ← 卸载时回调
├─ MajorFunction[] ← IRP 分发表
│ [IRP_MJ_CREATE]
│ [IRP_MJ_READ]
│ [IRP_MJ_WRITE]
│ [IRP_MJ_CLOSE]
└─ DeviceObject ──→ DEVICE_OBJECT 链表
├─ DeviceExtension(自定义数据)
├─ DeviceType
└─ DeviceNameC++ 边界与构建配置
内核驱动可以用 C++ 编写,但有严格限制。C++ 异常(try/catch)在内核中不可用——内核的异常处理机制(SEH)与 C++ 异常不兼容,使用异常会导致未定义行为。RTTI(运行时类型信息)默认禁用。全局 operator new / operator delete 需要自定义实现,默认版本调用 ExAllocatePool 行为不可控。
构建配置方面,Debug 构建启用断言和驱动验证器支持,Release 构建开启优化。当前支持 x64 和 ARM64 两种目标架构,两者在调用约定和数据对齐上有差异。无论哪种配置,内核代码都不应使用 C++ 标准库(STL)。
容易踩的坑
小结
- IRQL 是内核优先级阶梯:PASSIVE 最宽松,DISPATCH 不能分页不能等待,更高是中断专属
- NTSTATUS 是内核返回值契约:用 NT_SUCCESS 宏检查,忽略返回值是蓝屏常见原因
- UNICODE_STRING 用长度+缓冲区描述字符串,RtlInitUnicodeString 初始化,%wZ 格式化
- 分页池省内存但只能低 IRQL 用,非分页池随时可用但占物理内存,ExAllocatePool2 分配
- LIST_ENTRY 是侵入式链表:先移除节点再释放内存,CONTAINING_RECORD 反算宿主地址
练习
问题 1: 在 IRQL 阶梯组件中点击“等待事件”操作。PASSIVE_LEVEL 和 APC_LEVEL 分别显示什么颜色?用 IRQL 的规则解释为什么。
问题 2: 以下代码有什么问题?指出错误并给出修正版本。
PLIST_ENTRY entry = RemoveHeadList(&g_ListHead);
PMY_ITEM item = CONTAINING_RECORD(entry, MY_ITEM, ListEntry);
ExFreePoolWithTag(item, 'Itm');
// 继续使用 entry 指针...
item->Value = 0;问题 3(独立实现): 编写一个内核函数 AddItem,它分配一个 MY_ITEM 结构(内含 LIST_ENTRY 和一个 ULONG Value 字段),从非分页池分配,设置 Value 为传入参数,插入到全局链表 g_ListHead 尾部。函数返回 NTSTATUS,分配失败时返回 STATUS_INSUFFICIENT_RESOURCES。要求:使用正确的标签,检查返回值。
知识点对照
3.1 内核程序设计的一般准则
内核程序设计的一般准则包括避免阻塞、注意 IRQL 级别、使用安全函数并保持代码短小高效。
3.2 调试构建与发布构建
调试构建与发布构建的区别在于优化级别、断言与符号信息,调试版运行较慢但更便于定位和验证问题。
3.4 函数和错误代码
函数和错误代码用 NTSTATUS 表达,STATUS_SUCCESS 表示成功,其余为具体错误码。
3.1.1 未处理的异常
未处理的异常在内核态会直接引发系统崩溃蓝屏,驱动程序必须妥善处理所有可能的错误路径,这也是本章的核心要点。
3.1.3 函数返回值
函数返回值必须检查,尤其是 NTSTATUS 表示成功或失败,忽略返回值是常见的隐患问题。
3.1.5 C++用法
C++用法在内核中受限:不能用异常和标准库,但可用类、构造函数与简单模板来管理资源,这也是本章的核心要点。
3.1.6 测试和调试
测试和调试在内核中更为重要,需要借助 WinDbg、驱动验证器与虚拟机等工具链来完成,这也是本章的核心要点。
3.6 动态内存分配
动态内存分配使用 ExAllocatePool2 从分页池或非分页池申请,注意及时释放与打标签。
3.8 驱动程序对象
驱动程序对象代表已加载的驱动程序,包含分发例程表与设备对象链表,由系统在加载时创建,这也是本章的核心要点。
3.10 总结
本章总结了内核编程的基本准则、常用数据结构、内存管理要点与链表用法,为后续实战奠定扎实基础。
术语表
名词解释
本章出现的专业名词,用大白话再讲一遍。
- IRQL
Interrupt Request Level,中断请求级别。是 Windows 内核给 CPU 当前执行优先级打的“标签号”。号码越高,优先级越高,能被打断的越少,但能做的事也越少。PASSIVE_LEVEL(0) 最低最宽松,HIGH_LEVEL(31) 最高最受限。详见本章“IRQL:内核的优先级阶梯”一节。
- NTSTATUS
Windows 内核 API 的统一返回值类型,是一个 32 位整数。高位表示成功还是失败,低位表示具体原因。用
NT_SUCCESS(status)宏判断是否成功。调用内核 API 后必须检查它,忽略返回值是驱动蓝屏的常见原因。- UNICODE_STRING
内核中表示字符串的标准结构体,有三个字段:Length(已用字节)、MaximumLength(缓冲区容量)、Buffer(指向字符数组的指针)。不以 null 结尾,长度是显式记录的,比 C 字符串更安全。用 RtlInitUnicodeString 初始化,用 %wZ 在 printf 中直接格式化。
- 分页池
一种内核内存池,分配的内存可能被系统换出到磁盘。优点是节省物理内存,缺点是访问时可能触发页错误。因此只能在 IRQL 不高于 APC_LEVEL 时安全使用。用 ExAllocatePool2(POOL_FLAG_PAGED, ...) 分配。
- 非分页池
始终驻留在物理内存中的内核内存池,永远不会被换出。任何 IRQL(包括 DISPATCH_LEVEL 和 ISR)都能安全访问。代价是占用宝贵的物理内存。用 ExAllocatePool2(POOL_FLAG_NON_PAGED, ...) 分配。
- LIST_ENTRY
内核提供的双向链表节点结构,只含两个指针:Flink(前向)和 Blink(后向)。它是“侵入式”的——不存储你的数据,而是嵌入到你的结构体中作为成员。用 CONTAINING_RECORD 宏从 LIST_ENTRY 地址反算出宿主结构的起始地址。
- DRIVER_OBJECT
内核中代表一个已加载驱动模块的结构体。系统加载驱动时创建它,把指针传给 DriverEntry。它包含驱动的入口点、卸载回调、IRP 分发函数表(MajorFunction 数组),以及该驱动创建的设备对象链表。
- DEVICE_OBJECT
内核中代表一个设备实例的结构体。驱动通过 IoCreateDevice 创建它,I/O 管理器通过它把请求(IRP)路由给驱动。每个设备对象有设备类型、名称和自定义大小的 DeviceExtension(驱动用来存自己的上下文数据)。