第6章 内核机制
用 DPC、APC、工作项、SEH 与同步原语构建内核执行机制,理解不同 IRQL 与上下文下“什么事能做、什么事不能做”的完整地图。
学习目标
- 能解释 DPC、APC、工作项各自的执行上下文(IRQL、线程归属)与适用场景
- 能改出在 DPC 中排队工作项的效果,并说明为什么重活必须外包到 PASSIVE_LEVEL
- 能回答:在 DISPATCH_LEVEL 上调用 KeWaitForSingleObject 等待事件会发生什么?为什么?
为什么内核里“做事的时机”比“做什么”更重要
想象一条工厂流水线。设备报警了,维修工不能扔下扳手直接修——他得先按下急停(短、快、必须立刻做),剩下的检修流程等工人回到工位再做(长、慢、可以排队)。还有一类活根本不该维修工干:比如补货、记账,直接派给后勤部门,谁有空谁干。
内核也是这样。同一件事,在哪个“工位”(执行上下文)做,决定了你能等多久、能碰什么内存、能拿什么锁。搞错工位——比如在急停状态下等供应商送货——整条流水线直接锁死。
这一章把内核的“工位地图”画全:哪些活必须抢着做(中断处理)、哪些可以排队做(延迟调用)、哪些必须送回原工位做(定点投递)、哪些可以外包(工作项),以及工位之间怎么协调(同步)。记住这张地图,写内核代码才不会“在错误的工位干错事”。
DPC:排队做,但别等
↡把中断后必须执行但不紧急的工作推迟到 IRQL DISPATCH_LEVEL 执行的内核机制(Deferred Procedure Call,延迟过程调用)解决“中断处理必须短”的约束。硬件中断到来时,ISR(中断服务例程)在高 IRQL 上运行,只能做必要操作(确认中断、读状态),剩下的工作排进 DPC 队列,系统在中断返回后、IRQL 降到 DISPATCH_LEVEL 时批量执行。
DPC 的执行上下文很特殊:运行在任意线程的上下文(谁被中断就是谁),IRQL 是 DISPATCH_LEVEL——这意味着不能等待(等待会让系统死锁)、不能访问分页内存、不能长时间占用 CPU。DPC 适合 50 微秒级的小活;想干重活,看下面的工作项。
APC:快递要送回原地址
↡投递到指定线程、在该线程可打断时执行的异步回调机制,运行在目标线程上下文(Asynchronous Procedure Call,异步过程调用)是“定向投递”:每个线程有一条 APC 队列,别的代码可以往队列里投递一个函数,目标线程下次进入可打断状态(如等待事件)时执行它。APC 运行在目标线程的上下文,IRQL 是 APC_LEVEL。
APC 的价值是“回到正确的人手里办事”:比如 I/O 完成后要更新发起者的数据结构,就得在发起线程的上下文里改,直接改可能撞上并发。内核 APC 还用于进程/线程通知回调(第 8 章的主角)。代价:目标线程必须活着且愿意被中断,投递不等于立即执行。
工作项:外包给后勤部门
↡把耗时任务排队到系统工作线程池、在 PASSIVE_LEVEL 下执行的内核机制(Work Item)是 DPC 的“重活出口”:DPC 里做不了的事(等待、访问分页内存、拿锁),可以排队成工作项,由系统工作线程在 PASSIVE_LEVEL 下执行——可等待、可分页、可持锁,什么都能干。
典型模式是“中断/DPC 里只登记,工作项里干重活”:比如网络驱动收到数据,DPC 里把数据挪进非分页缓冲区,排队工作项,工作线程里再慢慢做协议解析和用户态通知。代价是执行时机不确定(可能延迟几百微秒),实时性要求高的路径不能依赖它。
SEH:安全气囊
↡内核中用于捕获异常的机制,用 __try/__except 把崩溃点包起来,避免直接触发 bug check(Structured Exception Handling,结构化异常处理)是内核的安全气囊:代码访问了非法地址、除数为零,CPU 抛出异常——如果没接住,系统直接蓝屏;用 __try/__except 包住可疑代码,异常发生时控制流跳到 __except 块,可以检查现场、修正状态、继续执行或优雅返回错误码。
注意:SEH 不是万能保险。捕获后系统可能处于部分更新状态(锁还握着、引用没释放),必须在异常块里恢复一致;而且许多崩溃(分页错误、高 IRQL 违规)根本不该捕获——那是逻辑 bug,该蓝屏暴露出来。SEH 用于处理可预期的外部异常(探测用户缓冲区),不是给代码 bug 兜底。
同步原语:工位之间的红绿灯
多个执行上下文(线程、DPC、中断)同时碰同一份数据时,必须有协调机制。内核提供两档红绿灯:低 IRQL(PASSIVE_LEVEL)用↡可等待的内核同步对象,包括事件、互斥体、信号量等,线程可阻塞等待其信号——事件(通知)、互斥体(独占所有权)、信号量(固定名额)、执行体资源(读写分离),线程可以真正睡下去等;高 IRQL(DISPATCH_LEVEL 及以上)不能睡觉,只能用自旋锁(忙等,持有时间必须极短)和互锁操作(单条原子指令改整数)。
选哪一档,取决于你的代码会在什么 IRQL 被访问:只有低 IRQL 访问 → 分发器对象;会被 DPC/中断访问 → 自旋锁或互锁。选错(高 IRQL 下等待)不是性能问题,是直接蓝屏。
动手:走一遍机制路由图
下面的可视化把五种机制画成一张路由图:触发场景(中断、定时器、线程退出、I/O 完成)把工作路由到 DPC、APC 或工作项;下方是 SEH 与同步原语两个横条。点击任意区域看详情;点场景按钮看路由高亮。
猜一猜:点“定时器到期”场景——定时器的回调运行在哪个 IRQL?如果回调里直接调用 KeWaitForSingleObject 等待一个事件,会发生什么?动手看看路由,再对照下面的代码。
DPC 把必须执行但不紧急的工作推迟到中断返回后:ISR 只做必要操作,把剩余工作排进 DPC 队列,系统在 IRQL DISPATCH_LEVEL 下执行 DPC。DPC 运行在任意线程上下文、不可等待——只能做非分页、短小、无锁等待的工作。
代码对照:五种机制的最小实现
这一节给出每个机制的最小可运行代码骨架。五个段对应:DPC 排队执行、工作项外包、SEH 捕获、事件同步、自旋锁保护。完整工程见章末来源链接。
段一:DPC —— 中断后排队的小活
初始化 DPC 对象 → 在需要时插入队列 → 系统在 DISPATCH_LEVEL 调用回调。回调里只做非分页的短小操作。注意 KeInsertQueueDpc 返回 FALSE 表示该 DPC 已在队列中——同一 DPC 不能重复排队。
// 驱动对象里挂一个 DPC
KDPC g_dpc;
// 初始化:绑定回调函数(在 DriverEntry 中调用一次)
VOID InitDpc(PDRIVER_OBJECT DriverObject)
{
KeInitializeDpc(&g_dpc, OnDpc, DriverObject);
}
// DPC 回调:DISPATCH_LEVEL,任意线程上下文
VOID OnDpc(KDPC* Dpc, PVOID Context, PVOID Arg1, PVOID Arg2)
{
UNREFERENCED_PARAMETER(Dpc); UNREFERENCED_PARAMETER(Context);
UNREFERENCED_PARAMETER(Arg1); UNREFERENCED_PARAMETER(Arg2);
// 只能做非分页、短小、不可等待的工作
InterlockedIncrement(&g_dpcCount);
}
// 某处需要延迟处理时(如 ISR 末尾):
VOID RequestDpc()
{
KeInsertQueueDpc(&g_dpc, nullptr, nullptr);
}硬件中断(IRQL >= DEVICE_LEVEL)
│
├─→ ISR:只清中断、读状态(必须极短)
│
└─→ KeInsertQueueDpc(g_dpc) 排队
│
▼
中断返回后,IRQL 降到 DISPATCH_LEVEL
│
└─→ OnDpc 执行(任意线程上下文)
· 非分页内存:✓
· 等待事件:✗(系统死锁)
· 分页内存:✗(页错误 = bug check 0xA)段二:工作项 —— 把重活外包
DPC 里发现活太重(要等待、要碰分页内存),立即排队工作项。回调运行在系统工作线程、PASSIVE_LEVEL:可以等待、可以访问分页内存、可以拿分发器对象。代价是执行时机不保证。
IO_WORKITEM* g_wi; // DriverEntry 创建,Unload 释放
VOID InitWorkItem(PDRIVER_OBJECT DriverObject)
{
g_wi = IoAllocateWorkItem(DriverObject);
// 每次需要时插入队列(可多次插入)
}
// 在 DPC 或低 IRQL 路径调用:
VOID ScheduleHeavyWork()
{
IoQueueWorkItem(g_wi, OnHeavyWork,
CriticalWorkQueue, nullptr);
}
// 回调:PASSIVE_LEVEL,系统工作线程上下文
VOID OnHeavyWork(PDEVICE_OBJECT DeviceObject, PVOID Context)
{
UNREFERENCED_PARAMETER(DeviceObject);
UNREFERENCED_PARAMETER(Context);
// 可以等待、访问分页内存、拿分发器对象
KeWaitForSingleObject(&g_event, Executive,
KernelMode, FALSE, nullptr);
// ... 耗时处理 ...
}DPC(DISPATCH_LEVEL)
│
├─→ 发现需要等待/分页/持锁
│
└─→ IoQueueWorkItem(g_wi, OnHeavyWork, ...) 外包
│
▼
系统工作线程池(PASSIVE_LEVEL)
│
├─→ OnHeavyWork 执行
│ · 等待事件:✓
│ · 分页内存:✓
│ · 持分发器对象:✓
│
└─→ 完成 → 通知发起者(事件/APC)段三:SEH —— 接住可预期的异常
典型场景:用户模式传来的指针不可信,内核在访问前用 SEH 包一层。访问非法地址时异常被捕获,返回错误码而不是蓝屏。注意 __try/__except 只能在 C 函数里用,且不能跨越函数边界。
NTSTATUS SafeReadUserPtr(PVOID UserPtr, ULONG* OutValue)
{
ULONG value = 0;
__try {
// 探测 + 读取用户指针(可能非法)
value = *(volatile ULONG*)UserPtr;
}
__except (EXCEPTION_EXECUTE_HANDLER) {
// 捕获:返回错误,不蓝屏
return STATUS_ACCESS_VIOLATION;
}
*OutValue = value;
return STATUS_SUCCESS;
}访问非法地址
│
├─→ CPU 抛异常(页面错误/访问违规)
│
├─→ 内核检查异常表
│ ├─→ 有 __try/__except 包住?→ 跳 __except 块
│ │ · 返回错误码,系统继续
│ │ · 注意:锁/引用要在块内恢复
│ │
│ └─→ 没有保护 → bug check(蓝屏)
│
└─→ 记录 BugCheck 0xA / 0xD1 等段四:事件 —— 低 IRQL 的等待与唤醒
发起者等待事件,完成者设置事件。这个模式贯穿内核:I/O 完成、工作项结束、通知到达,都用事件传递信号。注意等待只能在 PASSIVE_LEVEL(APC 级在特定条件下也可以),高 IRQL 等待 = 死锁蓝屏。
KEVENT g_event; // 事件对象(全局或设备扩展)
VOID InitSync(PDEVICE_OBJECT DeviceObject)
{
KeInitializeEvent(&g_event, NotificationEvent, FALSE);
}
// 线程 A(PASSIVE_LEVEL):等待信号
NTSTATUS WaitForWork(PDEVICE_OBJECT DeviceObject)
{
UNREFERENCED_PARAMETER(DeviceObject);
return KeWaitForSingleObject(&g_event, Executive,
KernelMode, FALSE, nullptr);
}
// 线程 B:完成工作后发信号
VOID SignalWorkDone()
{
KeSetEvent(&g_event, IO_NO_INCREMENT, FALSE);
}线程 A 线程 B
│ WaitForSingleObject │
│ ── 睡下去(让出 CPU)──→ │
│ │ 干完活
│ │ KeSetEvent → 亮灯
│ ←── 被唤醒 ────────────────│
│ 继续执行 │段五:自旋锁 —— 高 IRQL 的短临界区
数据会被 DPC(DISPATCH_LEVEL)访问时,普通等待不可用,只能用自旋锁:持锁线程空转等待。临界区必须极短(几十条指令),且所有访问路径必须按同一顺序拿锁——锁序不一致 = 多核死锁。
KSPIN_LOCK g_lock; // DriverEntry 中 KeInitializeSpinLock
ULONG g_counter; // 被保护的共享数据
// 低 IRQL 路径
VOID UpdateFromPassive()
{
KIRQL irql;
KeAcquireSpinLock(&g_lock, &irql);
g_counter++; // 临界区:极短
KeReleaseSpinLock(&g_lock, irql);
}
// DPC 路径(DISPATCH_LEVEL)
VOID UpdateFromDpc()
{
KIRQL irql;
KeAcquireSpinLock(&g_lock, &irql); // DPC 里也可用
g_counter += 2;
KeReleaseSpinLock(&g_lock, irql);
}CPU 0(普通线程) CPU 1(DPC)
│ AcquireSpinLock │ AcquireSpinLock
│ 拿到锁 ✓ │ 拿不到 ✗
│ g_counter++ │ ── 空转等待(忙等)──
│ ReleaseSpinLock │ (不能睡:DISPATCH_LEVEL)
│ │ ← 锁释放
│ │ 拿到锁 ✓ → 更新 → 释放容易踩的坑
小结
- DPC 在 DISPATCH_LEVEL 任意线程执行:非分页、短小、不可等待
- APC 投递到目标线程上下文执行,等目标线程可打断
- 工作项外包到 PASSIVE_LEVEL 系统线程:可等待可分页可持锁
- SEH 捕获可预期异常,先校验探测再兜底,不替逻辑 bug 背锅
- 低 IRQL 用可等待的分发器对象,高 IRQL 用自旋锁与互锁
练习
问题 1: 在 Demo 中点“定时器到期”场景,定时器回调运行在什么 IRQL?为什么回调里不能等待事件?如果要等待,应该怎么做?
问题 2: 下面这段 DPC 回调有什么问题?至少列出两处并给出修正思路。
VOID OnTimerDpc(KDPC* Dpc, PVOID Ctx, PVOID A1, PVOID A2)
{
MY_DATA* data = (MY_DATA*)Ctx;
data->logBuffer = ExAllocatePool2(POOL_FLAG_PAGED, 4096, 'golM');
KeWaitForSingleObject(&data->lock, Executive, KernelMode, FALSE, nullptr);
memcpy(data->logBuffer, data->pending, 4096);
KeReleaseMutex(&data->lock, FALSE);
}问题 3(独立实现题): 实现一个“DPC 计数器”驱动功能:1)初始化一个 DPC,回调里用 InterlockedIncrement 递增全局计数;2)提供 ScheduleDpc() 函数插入 DPC(注意重复插入返回 FALSE 的处理);3)用 KeInitializeSpinLock 加锁保护另一个共享 ULONG,提供加锁/解锁更新它的函数。写出完整代码骨架并注释每个函数的 IRQL 约束。
知识点对照
6.1 中断请求级别
中断请求级别简称 IRQL,是处理器上的中断优先级等级,驱动程序用它保护共享数据的访问安全。
6.1.1 提升和降低IRQL
提升和降低IRQL使用 KeRaiseIrql 与 KeLowerIrql 成对调用,避免遗留高等级。
6.5 系统崩溃
系统崩溃触发蓝屏并生成转储文件,常见原因是非法内存访问、断言失败或未处理的异常,需要分析转储定位。
6.6 线程同步
线程同步机制包括互锁操作、自旋锁、互斥量、信号量、事件和执行体资源等内核对象,防止数据竞争。
6.6.3 互斥量
互斥量是分发器对象,支持等待超时与递归获取,用于线程互斥访问共享资源并避免数据竞态问题,普通互斥量可分页。
6.7 高IRQL同步
高IRQL同步依赖自旋锁或互锁操作,因为此时不能等待可调度的分发器对象,也不能获取普通锁。
6.1.2 线程优先级与IRQL
线程优先级与IRQL是两套不同机制:优先级调度线程执行,IRQL 决定中断响应的先后顺序。
6.4.1 使用__try/__except
使用 __try/__except 捕获并妥善处理异常,避免用户态指针访问导致系统崩溃蓝屏或数据损坏。
6.4.2 使用__try/__finally
使用 __try/__finally 保证资源正确释放,无论正常退出或发生异常都会执行清理代码块。
6.4.3 使用C++ RAII代替__try/__finally
使用 C++ RAII 代替 __try/__finally 用析构函数自动释放资源,代码更清晰也更安全。
6.5.1 崩溃转储信息
崩溃转储信息包括异常码、参数与栈回溯等,是分析蓝屏原因并定位问题驱动的第一手资料,这也是本章的核心要点。
6.5.2 分析转储文件
分析转储文件用 WinDbg 加载 dump 文件,配合符号查看崩溃点的代码与调用链现场。
6.5.3 系统挂起
系统挂起不蓝屏但系统完全无响应,常由死锁引起,需抓取内核内存或用调试器中断排查定位,这也是本章的核心要点。
6.6.4 快速互斥量
快速互斥量是轻量级互斥对象,不支持递归获取但开销更小,适合短临界区的高并发使用场景,这也是本章的核心要点。
6.9 总结
本章总结了 IRQL、各类同步机制与结构化异常处理等内核机制的使用要点、常见陷阱和注意事项。
术语表
名词解释
本章出现的专业名词,用大白话再讲一遍。
- DPC
Deferred Procedure Call,延迟过程调用。把中断后不紧急的工作排队,等 IRQL 降到 DISPATCH_LEVEL 再执行。运行在任意线程上下文,不能等待、不能碰分页内存、只能做短活。相当于车间广播:紧急但小的事,插队做。
- APC
Asynchronous Procedure Call,异步过程调用。把函数投递到指定线程的队列,等那个线程可打断时执行,运行在目标线程上下文。相当于快递:必须送到指定收件人手里,收件人没空就等着。
- 工作项
Work Item,把耗时任务排队给系统工作线程池执行,运行在 PASSIVE_LEVEL。可以等待、访问分页内存、拿锁。相当于把活外包给后勤部门:什么都能干,但时间不保证。
- SEH
Structured Exception Handling,结构化异常处理。用 __try/__except 捕获代码中的异常,避免直接蓝屏。只适合接住可预期的外部异常(如非法用户指针),逻辑 bug 不该靠它兜底。相当于安全气囊:关键时刻弹开,但不能当安全带系。
- 分发器对象
可等待的内核同步对象:事件、互斥体、信号量、执行体资源等。线程在 PASSIVE_LEVEL 下等它的信号,等到了被唤醒。相当于红绿灯:绿灯亮了车才走,但只能在低速区(低 IRQL)用,高速区(高 IRQL)没有红绿灯只有交警(自旋锁)。