0xCE蓝屏与驱动卸载:Rundown保护与IRP收尾

发布时间:2026/10/24 3:27:35
0xCE蓝屏与驱动卸载:Rundown保护与IRP收尾

1. 蓝屏现场:0xCE 到底在抱怨什么

DRIVER_UNLOADED_WITHOUT_CANCELLING_PENDING_OPERATIONS,蓝屏代码 0x000000CE。这个 Bug Check 的描述直白到近乎羞辱:驱动已经从内核映像里卸掉了,可 I/O 管理器手上还攥着这个驱动没有完成的挂起操作。想让驱动安全卸载,绕不开一个朴素的构件——引用计数器。我把踩过的坑、改过的代码和验证方法完整摊开,从蓝屏现场一路讲到计数器怎么收尾。适合正在写内核态过滤驱动、遗留型设备驱动,或者刚被 0xCE 折腾过的同行看;有 WDM 基础读起来最顺,没有也能看懂思路,因为大半力气花在"为什么这么设计"上。

1.1 一次能稳定复现的卸载事故

我手上那个设备过滤驱动最早是这么写的:DriverEntry里IoCreateDevice、IoCreateSymbolicLink,写派遣函数把 IRP 标成 pending 然后丢进一个自己维护的链表,卸载函数只有四行——IoDeleteSymbolicLink、IoDeleteDevice、返回。开发机上跑得好好的,因为开发机上没人在这时候发请求。测试机上换了个玩法:起二十个线程反复CreateFile→ 阻塞写 →CloseHandle,主线程同时执行卸载。第三次就蓝了。

WinDbg 加载 dump 后!analyze -v给出的关键信息是:BugCheck CE,参数 1 指向一个 IRP 地址,参数 2 是我那个驱动的基址。后面两个参数按文档描述一般填 0,实际调试时以你自己 dump 里的输出为准。lm一看,驱动模块已经不在列表里了,说明卸载动作完成了。再!irp参数 1 那个地址,堆栈还是nt!IopXxx加一个被清零的回调地址——典型的"尸体还在,灵魂已经飞走"。

那一刻我意识到问题不在派遣函数写错了什么,而在于我从来没有定义过"这个驱动什么时候算真正干完活"。DriverUnload被调用,只代表内核允许你收拾东西走人,不代表别人不再找你。这是两件完全不同的事。

1.2 I/O 管理器是怎么发现你"赖账"的

机制其实很简单。每个 IRP 都记着自己的来路,内核在卸载映像时会把这个驱动的信息从内存里抹掉。如果之后还有代码路径试图沿着这个 IRP 走完流程——比如完成它、调用它的取消例程、或者它的完成例程指向已释放的代码页——I/O 管理器就会发现"这个操作属于一个已经不在的驱动",于是直接 BugCheck。参数 1 给你指出那个悬着的操作对象,方便你回头去代码里找是谁忘了收尾。

内核选择蓝屏而不是静默忽略,逻辑上完全正确。放任这种 IRP 继续走下去,执行的是已释放的内存页,运气好是随即崩溃,运气坏是被别的模块复用后执行任意代码。相比之下,蓝屏反而是最友善的处置方式。所以看到 0xCE 别急着骂内核,这相当于银行在你账户已经注销后仍然收到扣款请求,只能拉闸。

同样的判定逻辑还覆盖工作项、定时器 DPC、系统回调等一批"驱动注册进去、内核替它调度"的对象。它们的共同点是:注册时给了内核一个函数指针,内核不知道你什么时候会卸载,只能在你卸载后才发现指针失效。这也是为什么单纯在DriverUnload里加个Sleep或KeStallExecutionProcessor完全没用——睡眠不会让别人的指针变合法,只会把蓝屏推迟几毫秒。

1.3 卸载为什么是整个生命周期里最危险的窗口

因为卸载是唯一一个"三股并发同时收口"的时刻。第一股是新的请求还在从用户态涌进来,CreateFile、ReadFile、DeviceIoControl都可能在IoDeleteDevice前后踩进来。第二股是已经在处理中的请求,可能卡在某个等待、某个队列、某个硬件状态里,迟迟不返回。第三股最隐蔽:后台的定时器、DPC、工作项、进程创建回调、注册表回调、电源状态回调,这些不占线程也不占 IRP,但它们的代码全在你的映像里。

三股并发里,第一股最好处理,删符号链接、关设备接口、置一个标志位拒绝新请求,基本能挡住。第二股需要队列管理和取消逻辑配合。第三股最容易被忘掉,因为它平时完全静默,只在卸载那一瞬间咬你一口,而且往往蓝在别的模块的堆栈里,看起来像是别人的锅。

我后来总结了一条粗暴但有效的判断规则:凡是驱动注册给内核的东西,卸载路径里必须有一句对应的"撤销注册",而且撤销之后要能证明内核真的不再调用它了。这条规则比任何文档都管用,因为它逼着你去数自己到底注册了多少东西。

2. 方案选型:为什么把命运交给一个计数器

2.1 四套方案摆上台面做对比

遇到 0xCE 之后,我在几个项目里先后试过四种做法,效果差异很大。第一种是"加延时",卸载开头KeStallExecutionProcessor或者用个事件睡个几十毫秒,赌别人在这段时间里跑完。这种方法在实验室里成功率挺高,在用户现场的成功率取决于机器负载,本质上是把正确性建立在运气上。第二种是"自建计数 + 事件",用一个LONG变量记在场人数,卸载时等到零。第三种是用内核自带的那套 rundown 保护例程。第四种是换成 KMDF,让框架帮我管对象生命周期。

我把它们的差异列成一张表,这几列是我实际评估时用的维度:

方案正确性实现成本能否覆盖非 IRP 路径主要风险
加延时重试低,靠概率极低否高负载机器上必翻车
自建 LONG 计数 + KEVENT中,取决于细节中能,但每处都要手工接判断与递减之间的竞态、计数泄漏
内核 rundown 保护高低能,语义统一IRQL 约束、一次性使用需重新初始化
KMDF 框架托管高学习中能,框架强制顺序老代码迁移成本、框架回调里的坑

选型的核心不在"哪个最先进",而在"哪个的失败模式最可控"。加延时的失败模式是随机蓝屏,这是最不能接受的;自建计数的失败模式是卡死或者偶发竞态,排查成本高;rundown 保护的失败模式是编码阶段就暴露的 IRQL 断言和返回值检查,属于"编译期和测试期能抓住"的那一类。我选第三种。

2.2 rundown 保护到底比手写计数器强在哪

手写计数最常见的一个 bug 是判断和递减之间的窗口。卸载线程判断计数为零,正要往下走,另一条线程恰好在同一瞬间递减到了零,两条线程都认为自己是最后一个,于是并发地做清理动作。要堵这个洞,你得把"判断"和"置终止标志"做成一个原子操作,再把"递减"和"唤醒"做成另一个原子操作。听起来不难,写对很难,尤其是在多处理器上还得考虑内存屏障。

rundown 保护把这套状态机封在里面。ExAcquireRundownProtection返回FALSE就代表"已经进入收尾阶段,你不要再进来了",返回TRUE代表"我记你一笔,你随便用"。ExReleaseRundownProtection归还这一笔。ExWaitForRundownProtectionRelease会一直阻塞到所有已记的账都还清,并且在这之后所有新的 acquire 一律失败。三句话,语义闭合。

还有一个我特别喜欢的性质:acquire 失败这件事本身就是一个可编程的信号。你可以在派遣函数入口 acquire 一次,失败就直接用STATUS_DELETE_PENDING把 IRP 打回去;可以在定时器 DPC 里 acquire 一次,失败就什么都不做直接返回。同一套语义贯穿所有路径,不需要为定时器单独设计一套"我是不是该停了"的判断逻辑。这种一致性在后期维护时省下的时间远超实现成本。

需要注意的约束也有两条,都是硬性的。ExWaitForRundownProtectionRelease必须在PASSIVE_LEVEL调用,因为它可能阻塞;PASSIVE_LEVEL之外想等只能自己搭事件。另外这套结构是一次性的,等待返回之后它就处于"已收尾"状态,如果还想复用同一个对象,得调重新初始化的例程。这两条我在第一次用时都踩过,后面会详细说。

如果你的目标平台的内核头文件里没有导出这几个例程,那就退回自建结构,逻辑等价但要自己保证原子性和屏障,我会在第三章末尾给出等价写法。

2.3 三阶段卸载模型:静默、排空、拆除

我把卸载拆成三个阶段,这个划分是整套方案的骨架。第一阶段叫静默化,目标是让"外部世界"再也进不来:删符号链接、关设备接口、置静默标志。第二阶段叫排空,目标是处理"已经进来但还没走完"的那部分:取消定时器、冲刷 DPC、等 rundown 计数归零、把队列里的挂起 IRP 全部完成。第三阶段才叫拆除:IoDeleteDevice、释放自己申请的资源。

三阶段的顺序不能乱,尤其第二阶段内部还有严格的先后。后面我会专门讲一个反直觉的结论:排空的顺序应该是"先等计数归零,再清空 IRP 队列",而不是反过来。这个顺序搞反了,照样蓝屏,而且蓝得更隐蔽——因为绝大多数时候队列是空的,问题只在压力测试下才暴露。

阶段动作所处 IRQL顺序搞错的后果
静默化删符号链接、关接口、置标志PASSIVE新请求继续涌入,永无宁日
排空-后台取消定时器、冲刷 DPC、注销回调PASSIVE回调在拆除后触发,蓝屏
排空-计数等待 rundown 归零PASSIVE正在执行的 handler 访问已释放内存
排空-队列完成所有挂起 IRP任意0xCE,正是本文起点
拆除删设备对象、释放内存PASSIVE访问野指针

3. 落地实现:计数器怎么接进每一条路径

3.1 设备扩展定义与初始化顺序

先把数据结构定下来。我把 rundown 引用、取消安全队列、定时器和静默标志全部塞进设备扩展,这样多设备实例天然隔离,不会出现"一个实例卸载把另一个实例的计数清零"这种灾难:

typedef struct _DEV_EXT { PDEVICE_OBJECT Self; PDEVICE_OBJECT LowerDevice; IO_CSQ Csq; // 取消安全队列 LIST_ENTRY PendingList; // 由 Csq 管理的挂起 IRP KSPIN_LOCK PendingLock; EX_RUNDOWN_REF Rundown; // 核心计数器 KTIMER PollTimer; KDPC PollDpc; volatile LONG Quiescing; // 0=正常,非0=静默中 } DEV_EXT, *PDEV_EXT;

初始化顺序有讲究。ExInitializeRundownProtection必须最先调用,因为后面所有初始化路径如果失败要回滚,回滚逻辑里可能就用得上它。CSQ 的初始化必须在任何请求可能进来之前完成,通常放在IoCreateDevice之后、IoCreateSymbolicLink之前——符号链接一建立,用户态就能打开句柄,此时 CSQ 还没准备好就是现成的竞态窗口。

定时器的初始化要在设备对象创建成功之后,因为 DPC 里往往要通过设备对象拿扩展指针。我在这一段犯过一次错:把KeInitializeTimer放在IoCreateDevice之前,然后在 DPC 里访问DeviceObject->DeviceExtension,设备对象还没创建,指针是空的。这种错误在单次测试里不一定触发,因为定时器要等到第一次到期才执行,如果这中间设备创建成功了,就碰巧安全了。这类"碰巧正确"的代码最可怕,它能潜伏几个月。

3.2 请求入口的收口:每一对 acquire 必须有 release

派遣函数入口的模板长这样,我把它当成纪律来执行:

NTSTATUS DispatchWrite(PDEVICE_OBJECT DevObj, PIRP Irp) { PDEV_EXT ext = (PDEV_EXT)DevObj->DeviceExtension; NTSTATUS status; if (InterlockedCompareExchange(&ext->Quiescing, 0, 0) != 0) { Irp->IoStatus.Status = STATUS_DELETE_PENDING; Irp->IoStatus.Information = 0; IoCompleteRequest(Irp, IO_NO_INCREMENT); return STATUS_DELETE_PENDING; } if (!ExAcquireRundownProtection(&ext->Rundown)) { Irp->IoStatus.Status = STATUS_DELETE_PENDING; Irp->IoStatus.Information = 0; IoCompleteRequest(Irp, IO_NO_INCREMENT); return STATUS_DELETE_PENDING; } IoMarkIrpPending(Irp); IoCsqInsertIrpEx(&ext->Csq, Irp, NULL); status = STATUS_PENDING; ExReleaseRundownProtection(&ext->Rundown); return status; }

这段代码有一个特别容易被误解的地方:acquire 和 release 只包住了"入队"这一瞬间。IRP 入队之后,handler 立刻返回,计数归零,但 IRP 还在队列里挂着。所以计数归零不等于安全——这是整套方案里最重要的一句话,我在第一章的蓝屏就是这么来的。

那 acquire 到底保护了什么?它保护的是"这条代码路径还在我驱动的映像里执行"这件事。如果卸载发生在IoCsqInsertIrpEx执行到一半的时刻,没有 acquire 保护,卸载线程可能已经把设备对象删了,插入动作访问的就是已释放的链表头。有了 acquire,卸载线程必须等到这次插入完成才能继续,插入是短路径,等得住。

所有错误分支都必须 release,这是纪律。我习惯用单一出口模式:进函数先 acquire,中间所有失败分支只设置 status 然后goto Done,Done标签里统一 release 再返回。这样代码看起来啰嗦一点,但不会漏。漏一次 release 的后果不是蓝屏,而是卸载时永久卡住——这比蓝屏难查得多,因为机器只是关机卡在"正在关闭",没有任何 dump 可看。

3.3 取消安全队列:别自己维护 IRP 链表

早期我用自己维护的LIST_ENTRY加自旋锁放挂起 IRP,问题出在取消路径上。用户态CloseHandle或者超时会触发 IRP 取消,取消例程可能在任意 CPU、任意 IRQL 上被调用,你要和插入路径、完成路径三方同步。我踩过的坑是取消例程里把 IRP 从链表摘了、状态设成 CANCELLED、然后调IoCompleteRequest,同时另一条路径在遍历链表,摘到一半链表结构被破坏,直接0xD1或者死锁。

换成IO_CSQ之后这些交给内核队列框架处理。回调只要实现六个函数,插入、移除、取下一个、加锁、解锁、完成被取消的 IRP:

NTSTATUS CsqInsertIrpEx(PIO_CSQ Csq, PIRP Irp, PVOID Ctx) { PDEV_EXT ext = CONTAINING_RECORD(Csq, DEV_EXT, Csq); InsertTailList(&ext->PendingList, &Irp->Tail.Overlay.ListEntry); return STATUS_SUCCESS; } VOID CsqRemoveIrp(PIO_CSQ Csq, PIRP Irp) { RemoveEntryList(&Irp->Tail.Overlay.ListEntry); } PIRP CsqPeekNextIrp(PIO_CSQ Csq, PIRP Irp, PVOID Ctx) { PDEV_EXT ext = CONTAINING_RECORD(Csq, DEV_EXT, Csq); PLIST_ENTRY next = Irp ? Irp->Tail.Overlay.ListEntry.Flink : ext->PendingList.Flink; if (next == &ext->PendingList) return NULL; return CONTAINING_RECORD(next, IRP, Tail.Overlay.ListEntry); } VOID CsqAcquireLock(PIO_CSQ Csq, PKIRQL Irql) { PDEV_EXT ext = CONTAINING_RECORD(Csq, DEV_EXT, Csq); KeAcquireSpinLock(&ext->PendingLock, Irql); } VOID CsqReleaseLock(PIO_CSQ Csq, KIRQL Irql) { PDEV_EXT ext = CONTAINING_RECORD(Csq, DEV_EXT, Csq); KeReleaseSpinLock(&ext->PendingLock, Irql); } VOID CsqCompleteCanceledIrp(PIO_CSQ Csq, PIRP Irp) { Irp->IoStatus.Status = STATUS_CANCELLED; Irp->IoStatus.Information = 0; IoCompleteRequest(Irp, IO_NO_INCREMENT); }

用IoCsqInitializeEx注册这套回调,插入时调IoCsqInsertIrpEx,卸载时用IoCsqRemoveNextIrp循环把队列掏空。IoCsqInsertIrpEx内部会帮你处理取消例程的设置和解除,这部分逻辑如果你自己写,至少两百行,还容易漏掉"取消例程已经触发但插入还在进行"这个交叉情况。

注意:IoCsqRemoveNextIrp掏出来的 IRP 要自己设状态并完成,而且完成之前不能持有队列的自旋锁,否则完成例程万一回头再操作队列就是死锁。正确做法是循环里每掏一个就立刻完成,不要先全掏出来再批量完成。

3.4 定时器、DPC、工作项、系统回调的清算

这四类对象是我认为最容易漏的,因为它们不在 IRP 路径上,跑压力测试时也看不出来。

定时器先KeCancelTimer。返回值表示是否成功取消,但取消成功不代表 DPC 没在跑——如果 DPC 已经在另一个 CPU 上进入执行,KeCancelTimer只会阻止下一次触发。要彻底排空,必须再调KeFlushQueuedDpcs,它会在PASSIVE_LEVEL等到所有已排队的 DPC 执行完毕。这两句是成对出现的,缺一句都可能留下一个正在访问设备扩展的 DPC。

工作项用IoAllocateWorkItem分配的那套,卸载前调IoFreeWorkItem。文档说这个例程会等正在执行的工作项回调返回,实际用下来确实如此,前提是你没有在回调里又去排队新的工作项——那样就是自己给自己续命,永远等不完。我在一个项目里见过这种循环排队的写法,卸载时表现是关机卡住,查了两天才发现。

系统回调分几类:进程创建通知、线程创建通知、注册表操作通知、电源状态通知。注销方式各不相同,但共同点是注销调用本身保证正在执行的回调已经返回。比如注销进程通知的那个 API,最后一个参数传TRUE表示移除并等待,这个等待语义是内核保证的。所以要做的只有一件事:确保在排空阶段调用注销,不要拖到拆除阶段。

电源回调稍微特殊,因为注册时是往设备对象或驱动对象上挂的,IoUnregisterShutdownNotification或者电源管理的那套注销例程要和设备对象的生命周期对齐。顺序错了会收到一个"设备已删除但电源回调还在"的0x9F,蓝屏代码看起来和卸载毫无关系,非常迷惑。

3.5 DriverUnload 的最终实现与顺序辩论

完整实现长这样,每一句的顺序都是被坑出来的:

VOID DriverUnload(PDRIVER_OBJECT DriverObject) { PDEVICE_OBJECT devObj = DriverObject->DeviceObject; PDEV_EXT ext; UNICODE_STRING symLink; if (devObj == NULL) return; ext = (PDEV_EXT)devObj->DeviceExtension; // 阶段一:静默化 RtlInitUnicodeString(&symLink, L"\\DosDevices\\MyDemoDev"); IoDeleteSymbolicLink(&symLink); InterlockedExchange(&ext->Quiescing, 1); // 阶段二之一:后台资源清算 KeCancelTimer(&ext->PollTimer); KeFlushQueuedDpcs(); // 阶段二之二:等所有在用引用归还 ExWaitForRundownProtectionRelease(&ext->Rundown); // 阶段二之三:掏空挂起 IRP for (;;) { PIRP irp = IoCsqRemoveNextIrp(&ext->Csq, NULL); if (irp == NULL) break; irp->IoStatus.Status = STATUS_CANCELLED; irp->IoStatus.Information = 0; IoCompleteRequest(irp, IO_NO_INCREMENT); } // 阶段三:拆除 IoDeleteDevice(devObj); }

这里有个我反复推翻过的顺序问题:为什么先等 rundown 归零,再去掏 IRP 队列?直觉上应该先把队列清空,让所有挂着的请求有个了断,然后再等还在执行的人退出。

问题在于,如果你先掏队列,掏完之后、等 rundown 之前的这个窗口里,某条已经在执行的 handler 可能刚刚通过了静默标志的检查、正卡在IoCsqInsertIrpEx前面。等你把 rundown 等完,它已经插入了一个新的 IRP 然后返回。这个 IRP 就永久悬在队列里,你紧接着IoDeleteDevice,0xCE准时到访。

反过来,先等 rundown 归零,就等于向全世界宣告"所有正在我驱动里执行的代码路径都已经退出了"。这时候队列里剩下的 IRP 只可能来自已经返回的 handler,不会有新的插入者。此时掏空队列就是安全的,掏完再也没有人会往里塞东西。这个顺序看起来反直觉,但它是唯一自洽的。

再补一个细节:ExWaitForRundownProtectionRelease没有超时参数,这是它的局限。如果你的 handler 里存在可能长时间阻塞的调用(比如同步等待硬件、等待用户态缓冲),卸载线程会一直卡在这里,用户看到的现象是关机进度条不动。我在一个项目里的处理是保证所有 handler 都不阻塞,凡是需要等待的一律走 CSQ 挂起模式,让 IRP 排队而不是让线程排队。这条纪律比任何超时兜底都可靠。

4. 实操验证与调试:怎么证明它真的安全了

4.1 Driver Verifier 要开哪几项

光靠肉眼看代码是不够的,必须让工具替你抓。我固定开这几项:特殊池,能抓到越界和释放后使用;池跟踪,能在卸载时报告未释放的分配;IRP 日志,记录每个 IRP 的来龙去脉;强制 IRQL 检查,把所有内核 API 的 IRQL 要求卡死;死锁检测,抓自旋锁和互斥体的获取顺序问题;杂项检查,这一项里包含了对卸载时仍存在挂起操作的检查,正好对应本文主题。

低资源模拟也建议开,它会让分配失败率上升,把你在错误分支里漏掉 release 的问题暴露出来。我的经验是:正常跑一万次不一定现形的 bug,开低资源模拟跑一百次就出来了,因为那些"分配成功所以走不到的错误分支"被强行走到了。

验证流程我固定成三步。第一步是单次加载卸载,确认没有立即蓝屏。第二步是带负载的加载卸载,起若干线程做开合句柄风暴,同时反复加载卸载驱动,循环次数不少于一千。第三步是取消风暴,专门在读或写阻塞的时候关闭句柄,把取消路径打出足够多的样本。这三步过了,我基本敢发版。

4.2 WinDbg 里我常用的几条命令

!analyze -v是第一入口,重点看 BugCheck 代码和参数。lm确认驱动模块在不在,卸载后还在的话说明你的DriverUnload根本没被调,问题在别处。!drvobj 驱动对象地址 2能列出驱动对象下的设备对象和派遣表,用来确认派遣表有没有被意外改掉。!devobj 设备对象地址看设备对象的引用计数和挂接情况,引用计数不为零时删不掉,这个数字很关键。

!irp 地址看单个 IRP 的完整状态,包括当前栈位置、状态码、哪个驱动在处理。!stacks 2看所有线程栈,排查是不是有线程卡在你的驱动里没出来。池相关的用开启池标记后用!pool 地址看这块内存属于谁、多大、是否已释放。

还有一个笨办法但极其有效:在DriverUnload开头和IoDeleteDevice之前各加一条调试输出,把PendingList的长度打出来。如果删除前长度不是零,说明你掏队列的动作漏了或者顺序错了。这个打印在 Debug 版里保留,Release 版关掉,成本几乎为零。

4.3 一个能压出问题的测试脚本思路

我不写具体脚本,只说结构,因为环境差异太大。核心是三条并发线:第一条不断打开设备句柄、发起请求、关闭句柄,频率尽可能高;第二条定期触发取消,也就是在请求还没完成时强制关闭句柄;第三条反复加载卸载驱动。三条线跑上半小时,如果能稳定不蓝,基本可以认为这套计数机制是成立的。

再加一条可选的第四条线:触发系统休眠和唤醒。电源状态切换会走一套完全不同的回调路径,我见过不止一个驱动在普通卸载下完全正常,一休眠唤醒就报0x9F。原因是电源回调没有在设备删除前注销干净,休眠时内核还在找那个已经消失的设备。

5. 常见问题与排查速查

5.1 故障现象与对应原因速查表

现象大概率的根因快速定位手段
0xCE,参数 1 指向 IRP队列没掏空就删了设备卸载前打印队列长度
0xCE,参数 1 指向工作项工作项没释放检查IoFreeWorkItem是否执行
关机卡住,无蓝屏计数泄漏,rundown 永远不归零在 acquire/release 处加计数打印
偶发 0xD1handler 访问了已释放的设备扩展开特殊池,看释放后使用的调用栈
休眠唤醒后 0x9F电源回调未注销检查注销调用是否在删除设备之前
卸载后设备仍能被打开符号链接或设备接口没关卸载后手工尝试打开
第二次加载失败第一次没清干净,名字被占用检查错误路径的资源释放

5.2 几个不看文档绝对踩不到的坑

第一个坑是KeSetEvent的等待参数。早期我用自建计数 + 事件,边递减边判断是否归零,归零就KeSetEvent(&evt, IO_NO_INCREMENT, TRUE)。第三个参数传TRUE表示立刻切换,结果在DISPATCH_LEVEL的路径上直接触发 IRQL 违规。改成FALSE就好了。rundown 保护内部处理了这些问题,这也是我最终换掉自建方案的原因之一。

第二个坑是事件对象的位置。如果你把KEVENT放在设备扩展里,而设备扩展是随设备对象一起分配释放的,那么卸载流程里一旦提前IoDeleteDevice,事件对象的内存就没了,后续任何KeSetEvent或等待都变成野指针操作。正确做法要么保证事件对象的生命周期比所有使用者都长,要么用驱动级的全局对象。

第三个坑是DriverUnload一旦进入就不可撤销。我一度想用"超时后放弃卸载"来解决卡死,后来想明白了:DriverUnload返回之后内核就认为卸载完成了,映像会被释放,你没有任何办法让这个驱动"活回来"。所以超时只能用来争取时间做善后,比如强行把所有 IRP 完成掉,而不能用来逃脱。这个认知让我彻底放弃了超时兜底,转而保证正常路径绝不死等。

第四个坑是自建计数的递减顺序。如果你写成"先判断是否为零,为零则设置终止标志,然后递减",那在判断和递减之间会有别的线程进来;如果写成"先递减,减完为零则终止",那在递减完成到终止标志置位之间,别的线程可能刚刚通过检查。唯一的正确解法是用原子比较交换把"递减"和"检查是否最后一人"合并成一个动作。手写这段代码一定要在纸面上把两条线程的交错顺序全部画一遍,光靠直觉写不对。

第五个坑是计数粒度。一开始我在驱动对象层面放了一个全局计数,结果发现多设备实例时,插件一个设备会让另一个设备的 rundown 提前收尾,另一个设备上正在跑的请求直接访问已释放内存。改成每个设备扩展一份计数之后问题消失。这条经验适用于所有"生命周期相关的状态",原则很简单:谁的生命周期,计数就挂在谁身上。

第六个坑是我个人的体会,关于调试节奏。0xCE 这类问题最忌讳的是看到蓝屏就去改代码,改完跑一次没蓝就以为好了。这类问题的触发依赖并发时序,单次不蓝说明不了任何事。我现在的做法是先把所有可疑路径的计数打出来,用压力测试跑一段时间,确认"每个 acquire 都有对应 release、每个插入都有对应完成",再做修改。先把账算清楚,再动手,比反复试错快得多。

最后分享一个我在实际排查中养成的习惯:给每个 rundown acquire 点编号,失败时把编号写进调试输出。这样一旦出现计数泄漏导致关机卡住,看一眼最后没归零的编号,立刻知道是哪条路径漏了 release。这个技巧在三个不同的项目里救过我,成本就是几个宏定义,收益是把"查两天的卡死"压缩到"看一眼日志"。