第3章 内核程序设计基础

IRQL 优先级阶梯、NTSTATUS 返回值契约、UNICODE_STRING 字符串操作、分页与非分页池内存分配、LIST_ENTRY 链表,以及驱动对象与设备对象的 C 实战代码。

学习目标

  • 能解释 IRQL 五个级别的含义和优先级关系,判断给定内核操作允许的最高 IRQL
  • 能改出在正确 IRQL 下使用 UNICODE_STRING、ExAllocatePool2 和 LIST_ENTRY 的内核 C 代码
  • 能回答:在 DISPATCH_LEVEL 下调用 ExAllocatePool2 分配分页池内存会发生什么?为什么?

为什么内核代码不能随便写

想象一条工厂流水线。普通工人在自己的工位上干活,可以随时去仓库取料、等供应商送货。但紧急抢修工一响警报就必须放下手头工作冲过去——这时候他不能去仓库(仓库门可能锁着),也不能等送货(停机一秒损失巨大)。

内核也是这条流水线。代码运行在不同的“优先级”上:低优先级时什么都能干,高优先级时手脚被绑——不能等、不能碰某些内存、不能调大部分接口。搞不清自己处在哪个级别就乱来,系统直接蓝屏。

这一章教你内核编程的基本工具:怎么报告错误、怎么操作字符串、怎么分配内存、怎么管理链表。每个工具都有使用前提,前提就是“你现在在哪个优先级”。

IRQL:内核的优先级阶梯

是内核编程中最核心的约束概念。它不是一个队列,而是一个“当前优先级”标记:CPU 在任何时刻都处于某个 IRQL 上,只有更高 IRQL 的中断才能打断当前执行。

Windows 定义了几个关键级别:PASSIVE_LEVEL(0) 是最低级别,用户模式代码和大多数驱动入口例程运行于此;APC_LEVEL(1) 用于异步过程调用;DISPATCH_LEVEL(2) 是调度器工作级别,DPC 和自旋锁持有者运行于此;更高级别(3-31)用于硬件中断和极端临界区。

IRQL 的核心规则是:IRQL 越高,能做的事越少。在 PASSIVE_LEVEL 你可以访问分页内存、等待事件、调用几乎所有 API;到了 DISPATCH_LEVEL,访问分页内存会直接蓝屏,等待操作被禁止。下面的交互组件让你直观感受这个阶梯。

NTSTATUS:内核的返回值契约

是所有内核 API 的标准返回类型。它不是简单的整数错误码,而是一个结构化的 32 位值:高位表示严重程度(成功/信息/警告/错误),低位表示具体代码。

判断成功用 NT_SUCCESS(status) 宏,它检查高位是否为 0。常见的返回值包括 STATUS_SUCCESS(成功)、STATUS_INVALID_PARAMETER(参数错误)、STATUS_INSUFFICIENT_RESOURCES(内存不足)等。内核 API 调用后必须检查返回值——忽略它是最常见的驱动崩溃原因之一。

UNICODE_STRING:内核的字符串

是内核中字符串的标准表示。它不依赖 null 终止符,而是用 Length(当前字节长度)、MaximumLength(缓冲区容量)和 Buffer(指向 WCHAR 数组的指针)三个字段精确描述字符串。

这种设计比 C 字符串更安全:长度是显式的,不需要遍历查找终止符,也不怕字符串中间出现零字节。初始化用 RtlInitUnicodeString,格式化用 RtlStringCchPrintfW(支持 %wZ 直接格式化 UNICODE_STRING)。

内存池:分页与非分页

内核内存分配的核心抉择是的选择。分页池的内存可能被换出到磁盘,访问时触发页错误——这在低 IRQL 下是正常的,但在 DISPATCH_LEVEL 会导致蓝屏。非分页池永远驻留在物理内存中,任何 IRQL 都能安全访问,代价是占用宝贵的物理内存。

分配用 ExAllocatePool2(取代已废弃的 ExAllocatePool),释放用 ExFreePoolWithTag。每个分配都关联一个 4 字节标签(Tag),用于调试时追踪内存泄漏。

LIST_ENTRY:内核的链表

是内核链表的基础。它是一个“侵入式”设计:不存储数据,只维护前向指针(Flink)和后向指针(Blink)。你的自定义结构体把 LIST_ENTRY 作为成员嵌入,通过 CONTAINING_RECORD 宏从 LISTENTRY 指针反算出宿主结构的地址。

核心操作:InitializeListHead 初始化链表头,InsertTailList/InsertHeadList 插入节点,RemoveHeadList/RemoveEntryList 移除节点,IsListEmpty 判断是否为空。

动手:探索 IRQL 优先级阶梯

下面的可视化展示了 IRQL 的五个级别。点击每个横条查看该级别能做什么、不能做什么。然后试试“操作检查器”——点击一个操作,看哪些 IRQL 级别允许执行它。

猜一猜:点击“访问分页内存”操作,观察哪些级别变绿(允许)、哪些变红(禁止)。DISPATCH_LEVEL 会是哪个颜色?动手试试再看答案。

IRQL 优先级阶梯与操作检查器
IRQL 优先级阶梯HIGH_LEVEL最高优先级,屏蔽一切中断31Device IRQ硬件中断,ISR 专属区域3+DISPATCH_LEVEL调度器禁用,持有自旋锁2APC_LEVELAPC 执行,可访问分页内存1PASSIVE_LEVEL最宽松,可等待可分页0

操作检查器:点击操作,查看哪些 IRQL 级别允许执行

PASSIVE_LEVEL (IRQL = 0)最宽松,可等待可分页

能做什么

  • · 访问分页内存
  • · 等待事件 / 信号量
  • · 文件 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);

RtlInitUnicodeString 不会复制字符串数据,只是让 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');

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);
}

遍历和移除节点时,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');
}

驱动对象与设备对象

是驱动程序的核心对象。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;
}

C++ 边界与构建配置

内核驱动可以用 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(驱动用来存自己的上下文数据)。

讨论

评论区加载中…