第6章 内核机制

用 DPC、APC、工作项、SEH 与同步原语构建内核执行机制,理解不同 IRQL 与上下文下“什么事能做、什么事不能做”的完整地图。

学习目标

  • 能解释 DPC、APC、工作项各自的执行上下文(IRQL、线程归属)与适用场景
  • 能改出在 DPC 中排队工作项的效果,并说明为什么重活必须外包到 PASSIVE_LEVEL
  • 能回答:在 DISPATCH_LEVEL 上调用 KeWaitForSingleObject 等待事件会发生什么?为什么?

为什么内核里“做事的时机”比“做什么”更重要

想象一条工厂流水线。设备报警了,维修工不能扔下扳手直接修——他得先按下急停(短、快、必须立刻做),剩下的检修流程等工人回到工位再做(长、慢、可以排队)。还有一类活根本不该维修工干:比如补货、记账,直接派给后勤部门,谁有空谁干。

内核也是这样。同一件事,在哪个“工位”(执行上下文)做,决定了你能等多久、能碰什么内存、能拿什么锁。搞错工位——比如在急停状态下等供应商送货——整条流水线直接锁死。

这一章把内核的“工位地图”画全:哪些活必须抢着做(中断处理)、哪些可以排队做(延迟调用)、哪些必须送回原工位做(定点投递)、哪些可以外包(工作项),以及工位之间怎么协调(同步)。记住这张地图,写内核代码才不会“在错误的工位干错事”。

DPC:排队做,但别等

(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 章的主角)。代价:目标线程必须活着且愿意被中断,投递不等于立即执行。

工作项:外包给后勤部门

(Work Item)是 DPC 的“重活出口”:DPC 里做不了的事(等待、访问分页内存、拿锁),可以排队成工作项,由系统工作线程在 PASSIVE_LEVEL 下执行——可等待、可分页、可持锁,什么都能干。

典型模式是“中断/DPC 里只登记,工作项里干重活”:比如网络驱动收到数据,DPC 里把数据挪进非分页缓冲区,排队工作项,工作线程里再慢慢做协议解析和用户态通知。代价是执行时机不确定(可能延迟几百微秒),实时性要求高的路径不能依赖它。

SEH:安全气囊

(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 等待一个事件,会发生什么?动手看看路由,再对照下面的代码。

⚡ 内核机制:延迟执行与同步路由图
同一件事,三个执行上下文触发场景硬件中断定时器到期线程退出I/O 完成当前线程执行流(任意 IRQL)场景发生时,工作被路由到下方三个执行上下文之一DPC 延迟过程调用执行:DISPATCH_LEVEL · 任意线程不可等待 · 只能非分页短活APC 异步过程调用执行:APC_LEVEL · 目标线程快递投递 · 等目标线程可打断工作项 Work Item执行:PASSIVE_LEVEL · 系统线程可等待 · 可访问分页内存异常:结构化异常处理(SEH)__try/__except 捕获 → 检查现场 → 修正后继续未捕获 → 系统 bug check(蓝屏)低 IRQL 同步:可等待的分发器对象事件(通知)/ 互斥体(独占)/ 信号量(名额)Wait 只能在 PASSIVE_LEVEL——高 IRQL 等待 = 蓝屏高 IRQL 同步:自旋锁(忙等)与互锁操作(原子指令)临界区必须极短 · 锁序必须一致 · 不能等待只能空转锁视图关闭:只看同步原语概览提示:IRQL 选择规则见第 3 章;本图展示机制之间的路由关系
延迟过程调用(DPC)

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

段二:工作项 —— 把重活外包

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);
    // ... 耗时处理 ...
}

段三: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;
}

段四:事件 —— 低 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);
}

段五:自旋锁 —— 高 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);
}

容易踩的坑

小结

  • 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)没有红绿灯只有交警(自旋锁)。

讨论

评论区加载中…