22、即插即用(PnP):设备栈、IRP_MJ_PNP、资源仲裁、启动/停止顺序、热插拔处理

即插即用,简称PnP。说实话,这是Windows驱动开发里最绕的一块。

我刚开始做驱动那会儿,最怕的就是PnP。为什么?因为它的状态机太复杂了。一个设备从插入到正常工作,中间要经历好几个阶段,每个阶段都会发不同的IRP。你漏处理一个,设备就起不来。你处理错了,系统可能直接蓝屏。

嗯,今天我们就把它彻底讲清楚。

设备栈:你的驱动在哪儿干活?

先说说设备栈。这个概念特别重要。你想想看,一个设备插到系统里,不是只有一个驱动在管它。而是有一堆驱动,一层一层叠起来,形成一个栈。

举个例子。你插了一个USB键盘。最底层是USB总线驱动,它负责跟USB硬件打交道。中间可能有个HID类驱动,它把USB信号翻译成键盘事件。最上层是键盘类驱动,它跟操作系统交互。

这三层驱动,就组成了一个设备栈。每一层只处理自己关心的事,剩下的传给下一层。

关键点:你的驱动在设备栈里的位置,决定了你要处理哪些IRP。如果你写的是功能驱动(Function Driver),你主要处理IRP_MJ_PNP。如果你写的是过滤驱动(Filter Driver),你更多是拦截和传递。

Windows 设备栈结构示意图 应用层 (Win32 API) 功能驱动 (Function Driver) 过滤驱动 (Filter Driver) 总线驱动 (Bus Driver) 物理硬件 (Physical Device) IRP流向 完成返回

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都要正确处理,每一个资源都要妥善管理。少处理一个分支,设备就可能工作不正常。

公众号:蓝海资料掘金营,微信deep3321