3. IRP与分发函数:深入理解Windows内核的IO请求机制
说实话,IRP(I/O Request Packet)是Windows驱动开发里最绕不开的东西。我刚开始学驱动时,看到IRP那一大堆字段就头大。后来踩了不少坑,才慢慢摸清楚它的脾气。今天咱们就把它彻底讲透。
3.1 IRP结构详解
IRP说白了就是一个数据结构,它描述了上层应用想要干什么。比如你想读文件、写设备、还是控制设备,这些请求都会被操作系统打包成一个IRP,然后扔给驱动去处理。
IRP的核心字段,我挑几个最重要的说:
| 字段 | 含义 | 我的经验 |
|---|---|---|
| MajorFunction | 主功能码,比如IRP_MJ_READ | 这是入口,驱动根据它决定走哪个分支 |
| MinorFunction | 子功能码,用于细分操作 | 有些IRP_MJ_SCSI就有几十个子功能 |
| IoStatus | 操作结果,包含状态码和信息字节数 | 别忘了填这个,否则上层会拿到垃圾数据 |
| UserBuffer | 用户模式缓冲区指针 | 小心!这个指针在非当前进程上下文可能无效 |
| AssociatedIrp | 用于缓冲区和直接IO的联合体 | 新手容易搞混SystemBuffer和MdlAddress |
嗯,这里要注意一个细节:IRP是分层的。一个IRP可能经过多个驱动,每个驱动都可以在IRP上挂自己的扩展信息。这就是IoStackLocation的作用。
核心概念:每个IRP都对应一组IO_STACK_LOCATION结构。驱动通过IoGetCurrentIrpStackLocation获取当前层的信息,通过IoGetNextIrpStackLocation获取下一层的信息。千万别搞反了,我见过有人把这两个函数用混,结果蓝屏了一整天。
3.2 MajorFunction与MinorFunction
MajorFunction是IRP的主功能码。Windows定义了一大堆,常用的有这些:
- IRP_MJ_CREATE — 打开设备,对应应用层的CreateFile
- IRP_MJ_CLOSE — 关闭设备,对应CloseHandle
- IRP_MJ_READ — 读操作,对应ReadFile
- IRP_MJ_WRITE — 写操作,对应WriteFile
- IRP_MJ_DEVICE_CONTROL — 设备控制,对应DeviceIoControl
- IRP_MJ_INTERNAL_DEVICE_CONTROL — 内部控制,驱动之间通信用
MinorFunction呢?它是对MajorFunction的补充。比如IRP_MJ_SCSI这个主功能码下,MinorFunction可以指定是SCSIOP_READ还是SCSIOP_WRITE。说白了,就是大类下面再分小类。
我遇到过的一个坑:有些总线驱动会复用IRP_MJ_PNP,然后通过MinorFunction来区分是IRP_MN_START_DEVICE还是IRP_MN_STOP_DEVICE。如果你只检查MajorFunction不检查MinorFunction,那代码逻辑就全乱了。
3.3 派遣函数注册
派遣函数(Dispatch Function)就是处理IRP的函数。注册方式很简单,在DriverEntry里填表就行:
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
// 注册派遣函数
DriverObject->MajorFunction[IRP_MJ_CREATE] = MyCreate;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = MyClose;
DriverObject->MajorFunction[IRP_MJ_READ] = MyRead;
DriverObject->MajorFunction[IRP_MJ_WRITE] = MyWrite;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = MyIoControl;
// 不处理的IRP,统一交给默认派遣
DriverObject->MajorFunction[IRP_MJ_PNP] = MyDefault;
DriverObject->MajorFunction[IRP_MJ_POWER] = MyDefault;
return STATUS_SUCCESS;
}
派遣函数的原型长这样:
NTSTATUS MyDispatch(PDEVICE_OBJECT DeviceObject, PIRP Irp);
我个人习惯把不处理的IRP都指向一个默认函数,在里面直接完成IRP并返回STATUS_SUCCESS或STATUS_NOT_SUPPORTED。千万别留空指针,否则系统会蓝屏给你看。
警告:DriverObject->MajorFunction数组默认全是0。如果你不注册某个IRP的处理函数,系统会调用IopInvalidDeviceRequest,直接返回STATUS_INVALID_DEVICE_REQUEST。但如果你注册了函数却在里面什么都不做也不完成IRP,那上层应用就会永远挂起。我曾经犯过这个错,调试了整整一个下午。
3.4 同步与异步IO
这是驱动开发里最容易出问题的地方。同步IO和异步IO,说白了就是「等不等」的问题。
同步IO:应用调用ReadFile,驱动在派遣函数里直接完成操作,然后返回。应用线程一直等着,直到驱动说「搞定了」。
异步IO:应用调用ReadFile,驱动立刻返回STATUS_PENDING。应用线程可以去做别的事。等驱动真正完成操作后,再通知应用。
驱动里怎么判断是同步还是异步?看IRP的Flags字段:
if (Irp->Flags & IRP_NOCACHE) {
// 异步IO
IoMarkIrpPending(Irp);
return STATUS_PENDING;
} else {
// 同步IO,直接完成
Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information = 0;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}
你想想看,如果驱动返回STATUS_PENDING,但应用那边用的是同步方式打开设备,会发生什么?应用会一直等,直到超时。所以驱动开发者一定要处理好这两种情况。
小技巧:我建议在设备扩展结构里加一个标志位,记录当前IRP是否已经挂起。这样在后续处理中,可以避免重复调用IoMarkIrpPending。另外,记得在取消例程里清理这个标志位。
3.5 完成例程
完成例程(Completion Routine)是IRP处理链上的一个回调函数。当IRP被下层驱动完成后,系统会调用上层驱动的完成例程。这玩意儿在分层驱动里特别重要。
注册完成例程用IoSetCompletionRoutine:
IoSetCompletionRoutine(
Irp, // IRP
MyCompletionRoutine, // 完成函数
Context, // 上下文
TRUE, // 是否在成功时调用
TRUE, // 是否在出错时调用
TRUE // 是否在取消时调用
);
完成例程的原型:
NTSTATUS MyCompletionRoutine(
PDEVICE_OBJECT DeviceObject,
PIRP Irp,
PVOID Context
);
完成例程返回STATUS_SUCCESS时,系统会继续向上层传递IRP。返回STATUS_MORE_PROCESSING_REQUIRED时,系统会停止传递,IRP由你自行处理。
嗯,这里有个经典用法:在完成例程里释放资源。比如你分配了一个缓冲区给下层驱动用,等下层用完了,在完成例程里释放它。我做过一个USB驱动,就是用完成例程来管理URB的生命周期。
避坑指南:我曾经在完成例程里直接调用了IoCompleteRequest,结果造成了递归调用,栈溢出了。正确的做法是:如果你在完成例程里想再次完成IRP,先返回STATUS_MORE_PROCESSING_REQUIRED,然后在别的地方再调用IoCompleteRequest。
知识体系总览
下面这张图把IRP和分发函数的核心逻辑串起来了,你可以对照着看:
这张图展示了IRP从应用层发起,经过I/O管理器创建,再到驱动派遣函数处理,最后根据同步或异步方式走不同路径的完整流程。完成例程只出现在异步IO的场景中,这点要记住。
好了,IRP和分发函数的核心内容就这些。说白了,驱动开发就是跟IRP打交道。你把IRP的结构搞清楚了,派遣函数注册对了,同步异步处理好了,完成例程用明白了,那驱动开发的基本功就算扎实了。
公众号:蓝海资料掘金营,微信deep3321