概述通常驱动收到 IRP 后会立即处理并完成。但在某些场景下驱动无法立即响应——例如用户态程序等待内核事件时内核可能暂时没有数据。这时需要用到IRP Pending机制将 IRP 挂起等到数据就绪时再完成它。本文以 src/drv/ioctl.cc 的实现为例分析 IRP Pending 在实际使用中需要考虑的问题。问题一如何标记和返回 Pending 状态挂起 IRP 需要两个配合动作缺一不可staticvoidPendingIrpRoutine(PIRP irp){IoMarkIrpPending(irp);// 在 IRP 栈位置设置 SL_PENDING_RETURNED 标志// ...}NTSTATUSDispatcher(PIRP irp){// ...PendingIrpRoutine(irp);returnSTATUS_PENDING;// 分发函数必须返回 STATUS_PENDING}IoMarkIrpPending告知 I/O 管理器此 IRP 将异步完成分发函数必须同步返回STATUS_PENDING。两者必须同时满足否则 I/O 管理器的状态与驱动行为不一致会导致系统崩溃或数据损坏。问题二挂起前与取消之间的竞态在调用IoMarkIrpPending之后、注册取消例程之前存在一个窗口期。如果 I/O 管理器在这个窗口期取消 IRP由于还没有取消例程I/O 管理器什么也不做而驱动随后将这个已被取消的 IRP 加入挂起队列——IRP 将永远不会被完成造成泄漏。解决方法是在Cancel SpinLock的保护下先检查irp-Cancel再注册取消例程staticvoidPendingIrpRoutine(PIRP irp){IoMarkIrpPending(irp);KIRQL irql;IoAcquireCancelSpinLock(irql);if(irp-Cancel){// IRP 在进入这里之前已被取消直接完成不入队IoReleaseCancelSpinLock(irql);CompleteIrp(irp,STATUS_CANCELLED,0);return;}IoSetCancelRoutine(irp,NotifyIrpCancelRoutine);ioctl_ctx_-irp_list.Push(irp);IoReleaseCancelSpinLock(irql);}Cancel SpinLock是系统全局锁I/O 管理器在调用取消例程前也必须持有它。因此在持有该锁期间检查irp-Cancel能保证检查与注册之间不会被取消逻辑插入。问题三取消例程的约定取消例程进入时I/O 管理器已持有Cancel SpinLock驱动必须在例程内释放它释放后再处理 IRPstaticVOIDNotifyIrpCancelRoutine(PDEVICE_OBJECT,PIRP irp){IoReleaseCancelSpinLock(irp-CancelIrql);// 必须先释放锁if(!ioctl_ctx_||!ioctl_ctx_-irp_list.Pop(irp))return;CompleteIrp((PIRP)irp,STATUS_CANCELLED,0);}取消例程的职责从挂起队列中移除该 IRP并以STATUS_CANCELLED完成它。问题四完成挂起 IRP 时与取消的竞态当数据到来时SendMessage从irp_list取出 IRP 并准备完成它。与此同时I/O 管理器可能也在并发地触发取消流程。两条路径如下路径 ASendMessage先到达SendMessage通过irp_list.Pop()取出 IRPI/O 管理器随后调用取消例程取消例程执行irp_list.Pop(irp)按指针查找但 IRP 已不在队列中返回 nullptr直接退出SendMessage独占 IRP写入数据并以STATUS_SUCCESS完成。路径 BI/O 管理器先到达I/O 管理器先于SendMessage清空 IRP 的取消例程槽并调用取消例程取消例程执行irp_list.Pop(irp)从队列中取出 IRP并以STATUS_CANCELLED完成它SendMessage随后执行irp_list.Pop()队列已空返回 nullptr不操作任何 IRP。两条路径互斥看似干净。但两者之间存在一个真正的竞态窗口SendMessage已从队列取出 IRP但 I/O 管理器也已清空了取消例程槽清空槽和调用例程之间有间隙。此时取消例程尚未执行SendMessage却已持有 IRP——如果SendMessage直接写入数据完成 IRP后续取消例程再次完成同一个 IRP就会发生双重完成double-complete导致崩溃。区分这一窗口的方法是IoSetCancelRoutine的返回值autoirp(PIRP)ioctl_ctx_-irp_list.Pop();if(irp){if(IoSetCancelRoutine(irp,nullptr)nullptr){// 返回 nullptrI/O 管理器已清空取消例程槽// 取消例程即将或正在执行但不清理IRPCleanupPendingIrp(irp);}else{// 返回非 nullptrSendMessage 抢先清空了取消例程槽// 取消例程不会再被调用SendMessage 独占 IRPCompleteIrpWithPendingData(irp,msg);}}IoSetCancelRoutine(irp, nullptr)将取消例程槽原子地清零并返回旧值。I/O 管理器在调用取消例程前也会做同样的原子清零操作因此两者必然只有一个能拿到非空的旧值从而确定唯一的 IRP 所有者。路径 B 中SendMessage调用的CleanupPendingIrp(irp)并非防御性代码而是这条路径下完成 IRP 的唯一出口取消例程因队列中找不到 IRP 而退出不会完成 IRPSendMessage必须负责以STATUS_CANCELLED将其完成否则 IRP 泄漏。问题五驱动卸载时必须完成所有挂起的 IRP驱动卸载时irp_list中可能仍有挂起的 IRP。这些 IRP 对应的用户态请求在等待完成如果驱动直接卸载而不处理它们I/O 管理器在后续操作中访问已卸载驱动的内存必然蓝屏。正确做法是在finalize时遍历队列以STATUS_CANCELLED完成所有挂起 IRP// IOCTLContext 析构时SafeList::Clear() 对每个元素调用注册的清理函数IOCTLContext(DataClean irp_clean,DataClean data_clean):irp_list(irp_clean),data_list(data_clean){}// irp_clean 即staticinlinevoidCleanupPendingIrp(PVOID buffer){if(!buffer)return;CompleteIrp((PIRP)buffer,STATUS_CANCELLED,0);}通过将清理函数注入队列析构时自动完成清理不遗漏。总结IRP Pending 的实现需要在多个环节处理并发安全问题环节问题解决方法挂起时必须同时标记和返回 PendingIoMarkIrpPending 返回STATUS_PENDING挂起前标记后、注册取消例程前的取消竞态持有Cancel SpinLock检查irp-Cancel取消例程锁的所有权转移进入后立即释放Cancel SpinLock完成时完成 IRP 与取消例程的并发竞态用IoSetCancelRoutine返回值判断归属卸载时遗留挂起 IRP 导致蓝屏卸载前以STATUS_CANCELLED完成所有挂起 IRP