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