7、IO请求包(IRP):IRP结构详解、MajorFunction分发、完成例程、取消例程
说实话,IRP 是 Windows 驱动开发里最绕不开的东西。你写驱动,本质上就是在跟 IRP 打交道。我刚开始学驱动那会儿,看到 IRP 那一大堆字段,头都大了。后来踩了几个坑,才慢慢摸清楚门道。
今天咱们就把 IRP 彻底聊透。从结构到分发,再到完成和取消,一条线串下来。
7.1 IRP 结构长什么样?
IRP 的全称是 I/O Request Packet,说白了就是操作系统用来描述一个 I/O 请求的数据结构。每个 IRP 都对应一次 I/O 操作,比如读文件、写设备、控制设备等等。
它的核心结构大致分三块:
- 头部(Header):包含请求类型、状态、优先级等元信息
- 栈空间(Stack Locations):每个驱动层都有自己的栈位置,存着该层需要的参数
- 关联缓冲区(Associated Buffer):存放用户数据或系统缓冲区
我习惯把 IRP 想象成一个快递包裹。头部是快递单号,栈空间是每个中转站的贴条,缓冲区就是包裹里的货物。每一站处理完,就在自己的贴条上写点东西,然后传给下一站。
关键字段速查表:
| 字段 | 含义 | 我常用的场景 |
|---|---|---|
| IoStatus.Status | 请求最终状态 | 完成例程里必设 |
| IoStatus.Information | 传输字节数 | 读/写完成后赋值 |
| RequestorMode | 发起者模式(内核/用户) | 判断是否要 Probe |
| PendingReturned | 是否挂起过 | 完成例程里检查 |
| Cancel | 是否被取消 | 取消例程里置位 |
| CancelRoutine | 取消例程指针 | 注册取消回调 |
| UserBuffer | 用户模式缓冲区 | METHOD_NEITHER 时用 |
| AssociatedIrp | 关联 IRP(主/从) | 拆分大请求时用 |
7.2 MajorFunction 分发机制
驱动收到 IRP 后,怎么知道该调用哪个函数?答案就在 MajorFunction 表里。
每个驱动对象(DRIVER_OBJECT)都有一张函数指针表,叫 MajorFunction。索引是 IRP 的主功能码,比如 IRP_MJ_READ、IRP_MJ_WRITE、IRP_MJ_DEVICE_CONTROL。你注册驱动时,把对应的处理函数填进去就行。
// 典型的分发函数注册
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
DriverObject->MajorFunction[IRP_MJ_CREATE] = DispatchCreate;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = DispatchClose;
DriverObject->MajorFunction[IRP_MJ_READ] = DispatchRead;
DriverObject->MajorFunction[IRP_MJ_WRITE] = DispatchWrite;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DispatchIoControl;
DriverObject->DriverUnload = DriverUnload;
return STATUS_SUCCESS;
}
分发函数长这样:
NTSTATUS DispatchRead(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
PDEVICE_EXTENSION devExt = (PDEVICE_EXTENSION)DeviceObject->DeviceExtension;
// 检查设备状态
if (!devExt->DeviceStarted) {
Irp->IoStatus.Status = STATUS_DEVICE_NOT_READY;
Irp->IoStatus.Information = 0;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_DEVICE_NOT_READY;
}
// 把 IRP 挂起,稍后处理
IoMarkIrpPending(Irp);
// ... 启动异步操作
return STATUS_PENDING;
}
这里有个坑:如果你返回 STATUS_PENDING,必须调用 IoMarkIrpPending。否则系统会认为你忘了挂起,直接蓝屏。我曾经因为这个 bug 调试了整整一个下午,最后发现是少调了这个宏。
7.3 完成例程(CompletionRoutine)
完成例程是 IRP 在驱动栈中传递时,每一层都可以注册的回调。当下层驱动完成请求后,会沿着栈向上回调。
注册完成例程用 IoSetCompletionRoutine:
IoSetCompletionRoutine(
Irp, // 要注册的 IRP
MyCompletionRoutine, // 完成函数
Context, // 上下文指针
TRUE, // InvokeOnSuccess
TRUE, // InvokeOnError
TRUE // InvokeOnCancel
);
完成例程长这样:
NTSTATUS MyCompletionRoutine(
PDEVICE_OBJECT DeviceObject,
PIRP Irp,
PVOID Context)
{
// 检查下层是否挂起过
if (Irp->PendingReturned) {
IoMarkIrpPending(Irp);
}
// 检查是否被取消
if (Irp->Cancel) {
// 清理资源
ExFreePool(Context);
return STATUS_MORE_PROCESSING_REQUIRED;
}
// 正常完成
return STATUS_SUCCESS;
}
注意返回值:
- STATUS_SUCCESS:继续向上传递
- STATUS_MORE_PROCESSING_REQUIRED:停止传递,自己处理
我遇到过一种情况:下层驱动返回了错误,但上层完成例程里没检查 Irp->IoStatus.Status,直接用了缓冲区数据。结果就是读到一堆垃圾。所以我的习惯是:完成例程里第一件事就是检查状态。
7.4 取消例程(CancelRoutine)
用户程序随时可以取消一个 I/O 请求。比如用户按了取消按钮,或者进程被强制终止。这时候系统会调用你注册的取消例程。
取消例程的注册时机很关键:
// 在挂起 IRP 之前注册取消例程
IoAcquireCancelSpinLock(&Irp->CancelIrql);
IoSetCancelRoutine(Irp, MyCancelRoutine);
IoReleaseCancelSpinLock(Irp->CancelIrql);
// 然后挂起
IoMarkIrpPending(Irp);
取消例程的实现:
VOID MyCancelRoutine(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
// 注意:此时已持有取消自旋锁
// 从队列中移除 IRP
RemoveEntryList(&Irp->Tail.Overlay.ListEntry);
// 释放自旋锁
IoReleaseCancelSpinLock(Irp->CancelIrql);
// 设置完成状态
Irp->IoStatus.Status = STATUS_CANCELLED;
Irp->IoStatus.Information = 0;
// 完成请求
IoCompleteRequest(Irp, IO_NO_INCREMENT);
}
取消例程的陷阱:
- 取消例程运行时,系统已经帮你拿到了取消自旋锁。你必须在返回前释放它。
- 不要在取消例程里做耗时操作,比如等待事件、分配内存。它运行在 DISPATCH_LEVEL。
- 如果 IRP 已经被完成了,再调用 IoSetCancelRoutine 会覆盖之前的取消例程指针,导致内存泄漏。
7.5 核心流程:一张图说清楚
下面这张图展示了 IRP 从创建到完成的完整生命周期。我画的时候特意把每个阶段的关键动作标出来了。
7.6 避坑指南
我的经验总结:
- IRP 的栈空间索引从 0 开始,当前层用 IoGetCurrentIrpStackLocation,下一层用 IoGetNextIrpStackLocation。别搞混了,我见过有人把这两个用反,结果参数全错。
- 完成例程里如果要修改 IRP 的完成状态,记得先检查 IoStatus.Status。不要盲目覆盖下层返回的错误码。
- 取消例程里不要调用 IoCompleteRequest 之后再去访问 IRP 的字段。IRP 可能已经被释放了。
- 如果你在完成例程里返回 STATUS_MORE_PROCESSING_REQUIRED,你必须自己负责最终调用 IoCompleteRequest。否则 IRP 永远不会被释放,造成内存泄漏。
嗯,IRP 这块内容确实不少。但只要你把结构、分发、完成、取消这四条线理清楚,写驱动的时候心里就有底了。我当年就是靠这张流程图,把 IRP 的整个流转过程刻在脑子里的。