22、即插即用(PnP):设备栈、IRP_MJ_PNP、资源仲裁、启动/停止顺序、热插拔处理
即插即用,简称PnP。说实话,这是Windows驱动开发里最绕的一块。
我刚开始做驱动那会儿,最怕的就是PnP。为什么?因为它的状态机太复杂了。一个设备从插入到正常工作,中间要经历好几个阶段,每个阶段都会发不同的IRP。你漏处理一个,设备就起不来。你处理错了,系统可能直接蓝屏。
嗯,今天我们就把它彻底讲清楚。
设备栈:你的驱动在哪儿干活?
先说说设备栈。这个概念特别重要。你想想看,一个设备插到系统里,不是只有一个驱动在管它。而是有一堆驱动,一层一层叠起来,形成一个栈。
举个例子。你插了一个USB键盘。最底层是USB总线驱动,它负责跟USB硬件打交道。中间可能有个HID类驱动,它把USB信号翻译成键盘事件。最上层是键盘类驱动,它跟操作系统交互。
这三层驱动,就组成了一个设备栈。每一层只处理自己关心的事,剩下的传给下一层。
关键点:你的驱动在设备栈里的位置,决定了你要处理哪些IRP。如果你写的是功能驱动(Function Driver),你主要处理IRP_MJ_PNP。如果你写的是过滤驱动(Filter Driver),你更多是拦截和传递。
IRP_MJ_PNP:核心消息循环
PnP的核心,就是IRP_MJ_PNP这个主功能码。它下面有几十个子功能码。每个子功能码代表一个PnP事件。
我个人习惯,在DriverEntry里注册一个统一的PnP处理函数。然后在函数里用switch-case分发。这样代码结构清晰,也好维护。
NTSTATUS MyPnpDispatch(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
PIO_STACK_LOCATION irpStack = IoGetCurrentIrpStackLocation(Irp);
NTSTATUS status = STATUS_SUCCESS;
switch (irpStack->MinorFunction)
{
case IRP_MN_START_DEVICE:
// 设备启动处理
status = HandleStartDevice(DeviceObject, Irp);
break;
case IRP_MN_STOP_DEVICE:
// 设备停止处理
status = HandleStopDevice(DeviceObject, Irp);
break;
case IRP_MN_REMOVE_DEVICE:
// 设备移除处理
status = HandleRemoveDevice(DeviceObject, Irp);
break;
case IRP_MN_SURPRISE_REMOVAL:
// 意外移除处理
status = HandleSurpriseRemoval(DeviceObject, Irp);
break;
case IRP_MN_QUERY_RESOURCES:
// 资源查询
status = HandleQueryResources(DeviceObject, Irp);
break;
default:
// 其他子功能码,默认传递下去
status = IoSkipCurrentIrpStackLocation(Irp);
return IoCallDriver(GetLowerDeviceObject(DeviceObject), Irp);
}
// 完成IRP
Irp->IoStatus.Status = status;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return status;
}
我的经验:千万不要在PnP处理函数里做耗时操作。比如等待某个事件、申请大量内存。PnP IRP是有超时机制的。你卡住了,系统会认为驱动无响应,然后强制重启。我曾经踩过这个坑,调试了整整两天才找到原因。
资源仲裁:谁用哪个IO端口?
资源仲裁,说白了就是系统给设备分配硬件资源。包括IO端口、内存地址、中断号、DMA通道。
你想想看,一台电脑上插了那么多设备。每个设备都需要中断号。如果两个设备用了同一个中断号,那就冲突了。谁都用不了。
PnP管理器会做这件事。它先收集所有设备需要的资源,然后统一分配。分配好了,再通过IRP_MN_START_DEVICE告诉你的驱动。
NTSTATUS HandleStartDevice(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
PIO_STACK_LOCATION irpStack = IoGetCurrentIrpStackLocation(Irp);
PCM_RESOURCE_LIST resourceList = NULL;
PCM_RESOURCE_LIST translatedList = NULL;
// 获取分配的资源
resourceList = irpStack->Parameters.StartDevice.AllocatedResources;
translatedList = irpStack->Parameters.StartDevice.AllocatedResourcesTranslated;
if (resourceList == NULL || translatedList == NULL)
{
// 没有资源?那设备没法工作
return STATUS_INSUFFICIENT_RESOURCES;
}
// 解析资源列表
for (ULONG i = 0; i < resourceList->Count; i++)
{
PCM_PARTIAL_RESOURCE_DESCRIPTOR desc =
&resourceList->List[i].PartialResourceList.PartialDescriptors[0];
for (ULONG j = 0; j < resourceList->List[i].PartialResourceList.Count; j++)
{
switch (desc[j].Type)
{
case CmResourceTypeInterrupt:
// 记录中断号
g_InterruptVector = desc[j].u.Interrupt.Vector;
g_InterruptLevel = desc[j].u.Interrupt.Level;
break;
case CmResourceTypePort:
// 记录IO端口基址
g_IoPortBase = desc[j].u.Port.Start;
g_IoPortLength = desc[j].u.Port.Length;
break;
case CmResourceTypeMemory:
// 记录内存映射地址
g_MemoryBase = desc[j].u.Memory.Start;
g_MemoryLength = desc[j].u.Memory.Length;
break;
}
}
}
// 注册中断
// ... 这里调用IoConnectInterrupt
return STATUS_SUCCESS;
}
注意:资源列表里的地址,是逻辑地址,不是物理地址。如果你的驱动需要直接访问硬件,记得用HalTranslateBusAddress做转换。我见过有人直接用逻辑地址去读写硬件,结果在x64系统上直接蓝屏。
启动/停止顺序:别搞反了
PnP设备的状态转换是有严格顺序的。你不能从停止状态直接跳到移除状态。也不能从启动状态直接跳到停止状态。
正确的顺序是这样的:
| 当前状态 | 触发IRP | 下一状态 | 驱动该做什么 |
|---|---|---|---|
| 未启动 | IRP_MN_START_DEVICE | 已启动 | 分配资源、注册中断、初始化硬件 |
| 已启动 | IRP_MN_STOP_DEVICE | 已停止 | 注销中断、释放资源、停止硬件 |
| 已停止 | IRP_MN_REMOVE_DEVICE | 已移除 | 清理设备扩展、删除设备对象 |
| 任意状态 | IRP_MN_SURPRISE_REMOVAL | 已移除 | 紧急清理,不能访问硬件 |
我曾经在项目里犯过一个低级错误。我在STOP_DEVICE里释放了中断,但在START_DEVICE里忘了重新注册。结果设备第二次启动时,中断根本收不到。调试了三个小时才发现。
建议:在设备扩展结构里加一个状态字段。每次处理PnP IRP时,先检查当前状态是否合法。如果不合法,直接返回错误。这样可以避免很多低级bug。
热插拔处理:随时可能被拔掉
热插拔,就是设备在系统运行时被插入或拔出。USB设备就是典型的热插拔设备。
热插拔最难处理的是意外移除。你正在读写设备,突然用户把设备拔了。这时候你的驱动必须优雅地处理。
系统会先发IRP_MN_SURPRISE_REMOVAL。这个IRP告诉你:设备已经不在了,别碰硬件了。你的驱动需要做的是:
- 取消所有未完成的IRP
- 标记设备为已移除状态
- 释放所有硬件资源
- 通知上层驱动设备已失效
NTSTATUS HandleSurpriseRemoval(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
PDEVICE_EXTENSION devExt = (PDEVICE_EXTENSION)DeviceObject->DeviceExtension;
// 标记设备已移除
devExt->DeviceRemoved = TRUE;
// 取消所有待处理的IRP
IoCancelIrp(devExt->PendingReadIrp);
IoCancelIrp(devExt->PendingWriteIrp);
// 注销中断(如果之前注册了)
if (devExt->InterruptObject != NULL)
{
IoDisconnectInterrupt(devExt->InterruptObject);
devExt->InterruptObject = NULL;
}
// 释放IO端口
if (devExt->IoPortBase != 0)
{
MmUnmapIoSpace(devExt->IoPortBase, devExt->IoPortLength);
devExt->IoPortBase = 0;
}
// 注意:这里不要删除设备对象
// 删除设备对象是在IRP_MN_REMOVE_DEVICE里做的
return STATUS_SUCCESS;
}
重要:在SURPRISE_REMOVAL里,绝对不能访问硬件。因为设备已经物理断开了。你读IO端口,读到的全是垃圾数据。你写IO端口,可能造成总线错误。我见过最严重的情况,是有人在SURPRISE_REMOVAL里还去读硬件状态寄存器,结果导致整个USB总线挂死。
热插拔还有一个常见场景:设备插入。系统检测到新设备后,会枚举设备,加载驱动,然后发送IRP_MN_START_DEVICE。这个过程对驱动来说是透明的。你只需要在START_DEVICE里做好初始化就行。
嗯,PnP这块内容确实多。但只要你理解了设备栈的结构,掌握了IRP_MJ_PNP的分发机制,再记住启动/停止的顺序,基本上就能应付大部分场景了。
我个人觉得,写PnP驱动最重要的不是技术,而是细心。每一个IRP都要正确处理,每一个资源都要妥善管理。少处理一个分支,设备就可能工作不正常。