简介:基于WDM模型的PCI驱动开发资料包,面向Windows平台底层驱动开发者,可系统理解PCI设备与操作系统的交互机制,并掌握WDK工具链下PCI/PCIe驱动的编写与调试流程。包内共20个文件,以C++源码(.cpp/.h)、工程配置文件(.vcxproj/.sln/.dsw)及INF安装文件等为主,整体仅110KB,结构紧凑,便于快速对照学习。目前已有331人学习/下载,具备切实的参考价值。内容围绕MyDriver与Test两个工程展开,覆盖PnP即插即用、电源管理、IRP请求处理、设备与驱动对象链接、PCI配置空间读取等关键概念,同时附有驱动测试与检查工程,方便进行设备枚举和功能验证。通过阅读源码并结合WDK实践,可形成从驱动框架搭建到实际部署的完整路径,为后续扩展其他PCI/PCIe设备开发打下扎实基础。
1. 从一块识别不到的 PCIe 板卡说起:为什么还得啃 WDM 驱动
拿到一块 PCIe 接口的采集卡或 FPGA 开发板,插上 Windows 主机,设备管理器里出现一个带黄色感叹号的"未知设备",这是绝大多数驱动开发入行的起点。你想要的无非是让它被系统认出来、能读写它的寄存器、能把它的中断和 DMA 用起来。这个目标在 Windows 下有多种实现路径,而WDM_PCI_Driver这个标题指向的,就是用WDM(Windows Driver Model)配合WDK(Windows Driver Kit)手写一个 PCI/PCIe 功能驱动。
WDM 是 Windows 2000 时代定下的驱动模型,到今天仍然没有过时。原因是 PCI/PCIe 设备的基本交互方式——配置空间、BAR 空间、中断、DMA——三十年来没有本质变化,WDM 提供的 IRP 分发、PnP 启动、电源管理这套框架,依然是把这些资源正确接进 Windows 的最底层途径。后面出现的 KMDF/WDF 只是把 WDM 的样板代码封装了一层,真正要跟硬件打交道的地方,你迟早还是得回到 WDM 的语义上来。
这篇笔记适合两类人:一是刚被分配了 PCIe 驱动任务、手里只有一块板卡和一份寄存器手册的嵌入式工程师;二是做过 Linux 下 PCI 驱动、第一次转 Windows 平台的老手。我会把环境搭建、驱动骨架、BAR 访问、中断处理、常见翻车点一路写到底,所有代码都基于 WDK 的通用框架,你可以直接当模板用。
2. WDM、WDF 与 KMDF:PCI 驱动在 Windows 下的选型,别一上来就写代码
2.1 为什么老项目大量停留在 WDM,新项目该怎么选
WDM 和 WDF(Windows Driver Frameworks)的关系不是替代,而是继承。WDF 里的 KMDF 把 PnP、电源管理、即插即启动这些固定流程封装成回调,让驱动开发者不用手动处理 IRP_MJ_PNP 和 IRP_MN_START_DEVICE。好处是代码量少、不容易把状态机写错;坏处是封装层在调试某些硬件异常时会变成黑匣子——你想在设备启动序列中间插一步自定义操作,得弄清楚框架在哪一环调用你的 EvtDevicePrepareHardware,以及失败时返回值怎么传递。
WDM 恰恰相反,一切从 DriverEntry 开始,全部靠你手动注册 Dispatch routines,然后按 IRP 的走向一步步走。它在今天仍然大量存在,主要因为三类场景:
- 存量驱动维护:大量工控机、医疗设备、老式采集卡上的驱动是 2005 年前后写的 WDM,没人愿意冒风险重写成 KMDF。
- 需要最底层控制:KMDF 对某些 PCIe 高级特性(比如多 BAR 的资源配置顺序、MSI/MSI-X 的逐个向量控制)做了框架层面的默认行为,绕开它反而比在它里面做定制更直接。
- 学习价值:WDM 把所有机制都摊在明面上,读懂 WDM 再去看 KMDF 的封装,会有一种"原来它替我干了这些"的透彻感。
我做这类驱动时的选型原则是:板卡是自家 FPGA、寄存器手册齐全、生命周期长,就用 WDM;如果是帮客户包一层简单 PCIe 设备、对方只看交付速度,KMDF 更合适。本文按 WDM 为主线展开,但避坑章节里也会提 KMDF 的对应注意点。
2.2 WDM 驱动处理硬件事件的核心机制:IRP 与 PnP 回调
在 WDM 的世界里,应用程序通过 CreateFile/DeviceIoControl 与驱动通信,内核里的一切都是一条条 IRP(I/O Request Packet)。PCI 驱动最关心的 IRP 类型有:
| IRP 类型 | 对应请求 | 驱动里该做的事 |
|---|---|---|
| IRP_MJ_CREATE | CreateFile 打开设备 | 增加打开计数,可做权限检查 |
| IRP_MJ_DEVICE_CONTROL | DeviceIoControl 发控制码 | 按 IOCTL 分发读写 BAR、控制中断、启动 DMA |
| IRP_MJ_READ / WRITE | 应用程序读/写 | 可选,很多 PCI 驱动只用 IOCTL 间接访问 |
| IRP_MJ_PNP 子码: IRP_MN_START_DEVICE | 系统分配硬件资源 | 映射 BAR、连接中断、初始化设备 |
| IRP_MJ_PNP 子码: IRP_MN_REMOVE_DEVICE | 设备被拔出/禁用 | 释放中断、解除映射、删除设备对象 |
这套机制对 PCIe 设备的一个重要含义是:你的驱动不是一个主动去探测硬件的程序,而是被动响应系统 PnP 管理器的调用。系统枚举 PCIe 总线、读设备的 Vendor ID / Device ID、找到匹配的 INF 后加载你的驱动,然后按顺序发送 START_DEVICE、给你分配资源。因此 WDM 驱动里没有"入口检测硬件"这种逻辑——一切从 DriverEntry 注册回调开始,硬件在 START_DEVICE 才真正被激活。
2.3 一个 WDM PCI 驱动的完整链路:DriverEntry 到 START_DEVICE
一个最小可工作的 WDM PCI 驱动,文件结构通常是这样的:
WDM_PCI_Driver/ ├── driver.c // DriverEntry、IRP 分发表、设备控制 ├── hw_access.c // BAR 映射、寄存器读写、中断处理 ├── hw_access.h ├── driver.inf // 硬件 ID、驱动安装信息 └── sources / .vcxproj // 构建配置,WDK 下用 MSBuild 编译链路的关键节点是 DriverEntry→AddDevice→START_DEVICE。DriverEntry 里你要做的是:设置 DriverObject 的各个 MajorFunction、创建设备扩展结构模板、然后调用 IoCreateDevice 创建设备对象。AddDevice 是 PnP 管理器在发现匹配硬件时回调你的地方,在这里创建设备对象、建立设备链接(符号链接名,比如 \Device\PCIeDev0 和 \DosDevices\PCIeDev0)。
到这一步,设备对象存在了,但硬件资源还没有分配。真正的资源交接发生在 IRP_MN_START_DEVICE 处理里:系统在 Parameters.StartDevice.AllocatedResources 中给出设备的资源列表,驱动在这里提取 PhysicalMemory 范围(对应 BAR 空间)、中断向量/级别,然后调用 MmMapIoSpace 把物理地址映射到非分页池,中断用 IoConnectInterrupt 挂接。
这也是 WDM 与用户态驱动最大的感受差异:用户态读写寄存器像访问内存一样简单,内核态却要时刻记着"我现在在 IRQL 级别多少、这段代码能不能分页、这个操作能不能睡眠"。绝大部分蓝屏和驱动开发翻车,根源都在这里。
3. 搭建 WDK 开发环境:版本匹配、目标机配置与常见绕弯路
3.1 WDK 与 Visual Studio 版本的配套关系:这是新手第一个坑
安装 WDK 最忌讳的就是版本随意配。WDK 不是独立编译工具链,它依赖 Visual Studio 的 MSBuild 集成。官方版本的配套关系如下:
| Visual Studio 版本 | 对应 WDK 版本 | 支持的 Windows 目标 |
|---|---|---|
| VS 2019 16.x | WDK 10.0.19041.x 及以上 | Windows 10 1809+ / Server 2019+ |
| VS 2022 17.x | WDK 10.0.22621.x 及以上 | Windows 10 2004+ / Server 2022+ |
| VS 2017 | WDK 10.0.16299.x | Windows 10 1709 |
常见翻车是装了 VS 2022 却下载了非常老版本的 WDK,安装器直接提示"不支持当前的 Visual Studio 版本"。我的做法是先确认目标运行环境是 Win10 还是 Win11,再按微软官方页面上匹配关系倒推选版本。如果你在 Win11 上做开发、目标机也是 Win11,直接选 WDK 10.0.22621 对应的最新版即可。
安装完 WDK 后,在 VS 里新建项目时选择"Empty WDM Driver"模板。这里有个值得注意的点:WDK 自带模板生成的项目是 KMDF 的,WDM 的话选 Empty WDM Driver 才行。项目创建后确认两个配置:Target Platform 选 Desktop,而不是 Universal;驱动类型选 Kernel。这两个地方选错,编译出来的 INF 会带有错误装饰,导致真机安装时签名校验失败。
3.2 目标机、调试机与 WinDbg 的连接配置
WDM 驱动开发必须有目标机——本机改驱动、本机测试,一旦蓝屏,开发环境跟着遭殃,而且很多设备在开发机上根本没有物理插槽。标准做法是准备一台独立的调试目标机(可以是旧电脑),目标机通过网口或串口与开发机相连,开发机上用 WinDbg 做内核调试。
配置调试机时注意这几步:
在目标机上以管理员身份执行:
bcdedit /debug on bcdedit /dbgsettings net hostip:192.168.1.100 port:50000 key:1.2.3.4这里 hostip 是开发机的 IP,port 是 WinDbg 监听端口,key 是调试会话密钥。目标机重启后开机阶段会等待调试器连接。测试驱动前,建议先把目标机系统设置为"自动重新启动"关闭——否则蓝屏一闪而过,你连 Bugcheck 信息都看不到:
bcdedit /set {current} bootstatuspolicy displayallfailures连接调试会话时,开发机 WinDbg 选择 File→Kernel Debug→Net,填入目标机 IP 和端口。连接成功后那条命令提示符变成kd>,你就可以在驱动加载前下断点了。
3.3 签名配置与测试签名模式:跑通驱动安装的最后一道坎
64 位 Windows 要求驱动必须签名,开发阶段不想买 EV 证书,就打开测试签名模式:
bcdedit /set testsigning on之后生成的自签名测试证书需要导入目标机的"受信任的根证书颁发机构"和"受信任的发布者"存储。这里有个隐蔽的坑:WDK 编译驱动时默认生成的是 .cer 证书文件,你需要在目标机上右键 CER 文件选择"安装证书",手工选存储位置为"本地计算机→受信任的根证书颁发机构"。如果只双击导入到当前用户存储,INF 安装时会报错"驱动程序无法验证此设备的签名"。
更彻底的做法是用工具包里的 inf2cat 和 signtool 对驱动包做交叉签名(cross-sign):先用 makecert 生成测试证书,再用 signtool sign 对 .sys 文件签名,最后用 inf2cat 生成目录文件并签名。步骤多但一次配好,后面每次编译只需要跑一个批处理。
我一般把这套签名流程写成一个 build_and_sign.bat,放在项目根目录,内容大致是:
@echo off set SYS_FILE=WDM_PCI_Driver.sys signtool sign /v /s My /n MyTestCert /t http://timestamp.digicert.com /f testcert.pfx %SYS_FILE% inf2cat /driver:. /os:10_X64 signtool sign /v /f testcert.pfx /t http://timestamp.digicert.com WDM_PCI_Driver.cat注意 inf2cat 的/driver:参数必须指向包含 .inf 和 .sys 的目录,如果这一行报错"Unable to produce catalog",多半是 INF 里 Manufacturer 段的 Provider 名与签名机构不一致。这个点我后面避坑章节还会再提。
4. 写一个可编译的 WDM PCIe 驱动骨架:从 DriverEntry 到 BAR 空间访问
4.1 DriverEntry 与设备创建:三行关键代码背后的必要逻辑
先给一个最简 DriverEntry,它在整个 PCI 驱动生命周期里只做一次:
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) { NTSTATUS status; UNICODE_STRING deviceName; UNICODE_STRING symLinkName; PDEVICE_OBJECT deviceObject = NULL; // 注册派发例程,未注册的 MajorFunction 会被系统默认处理为 STATUS_INVALID_DEVICE_REQUEST DriverObject->MajorFunction[IRP_MJ_CREATE] = PciDrvCreateClose; DriverObject->MajorFunction[IRP_MJ_CLOSE] = PciDrvCreateClose; DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = PciDrvDeviceControl; DriverObject->MajorFunction[IRP_MJ_PNP] = PciDrvPnP; DriverObject->DriverUnload = PciDrvUnload; // 创建设备对象 RtlInitUnicodeString(&deviceName, L"\\Device\\PciDev0"); status = IoCreateDevice(DriverObject, sizeof(DEVICE_EXTENSION), &deviceName, FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN, FALSE, &deviceObject); if (!NT_SUCCESS(status)) { return status; } // 创建符号链接,让 Win32 应用可以通过 CreateFile 打开设备 RtlInitUnicodeString(&symLinkName, L"\\DosDevices\\PciDev0"); status = IoCreateSymbolicLink(&symLinkName, &deviceName); if (!NT_SUCCESS(status)) { IoDeleteDevice(deviceObject); return status; } // 设备扩展清零并初始化 PDEVICE_EXTENSION dx = (PDEVICE_EXTENSION)deviceObject->DeviceExtension; memset(dx, 0, sizeof(DEVICE_EXTENSION)); dx->DeviceObject = deviceObject; return STATUS_SUCCESS; }代码逻辑上,最关键的一点是设备扩展(Device Extension)的设计。这个结构体是你的驱动在设备生命周期内的"记事本"——BAR 映射后的虚拟地址、中断对象指针、设备状态标志都存在这里。每次 IRP 到达时,你通过 IoGetCurrentIrpStackLocation 拿到 IRP 栈的位置,再通过 DeviceObject->DeviceExtension 取回这些数据。设备扩展的设计直接决定了驱动在多设备实例下能否正常工作,因此从 DriverEntry 起就应该规划好它的字段。
IoCreateDevice 的 FILE_DEVICE_SECURE_OPEN 标志建议加上,否则任何进程只要知道符号链接名就能打开设备,这对工业设备来说等于裸奔。从安全角度,WDM 里没有现成的权限控制机制,需要你在 IRP_MJ_CREATE 里自己检查进程状态或使用访问控制列表,但至少加上这个标志能让系统默认拦掉大部分非法访问。
4.2 START_DEVICE:把 BAR 空间变成可访问的内存
设备对象创建完毕,等 PnP 管理器把资源分配好再真正初始化硬件。标准写法是在 IRP_MJ_PNP 处理函数里分派子码:
NTSTATUS PciDrvPnP(PDEVICE_OBJECT deviceObject, PIRP irp) { PIO_STACK_LOCATION irpStack = IoGetCurrentIrpStackLocation(irp); PDEVICE_EXTENSION dx = (PDEVICE_EXTENSION)deviceObject->DeviceExtension; switch (irpStack->MinorFunction) { case IRP_MN_START_DEVICE: return PciDrvStartDevice(dx, irp); case IRP_MN_REMOVE_DEVICE: if (dx->InterruptObject) { IoDisconnectInterrupt(dx->InterruptObject); dx->InterruptObject = NULL; } if (dx->BarVirtualAddress) { MmUnmapIoSpace(dx->BarVirtualAddress, dx->BarLength); dx->BarVirtualAddress = NULL; } return PciDrvFinishRemoveDevice(dx, irp); default: IoSkipCurrentIrpStackLocation(irp); return IoCallDriver(dx->LowerDeviceObject, irp); // 下层设备对象在 AddDevice 时挂接 } }START_DEVICE 的具体处理里,资源提取是关键:
NTSTATUS PciDrvStartDevice(PDEVICE_EXTENSION dx, PIRP irp) { PCM_PARTIAL_RESOURCE_LIST resourceList; ULONG i; PHYSICAL_ADDRESS barPhys; ULONG barLength; resourceList = &irp->Parameters.StartDevice.AllocatedResources->List[0].PartialResourceList; for (i = 0; i < resourceList->Count; i++) { PCM_PARTIAL_RESOURCE_DESCRIPTOR desc = &resourceList->PartialDescriptors[i]; if (desc->Type == CmResourceTypeMemory) { // 取 BAR0 对应的物理地址和长度 barPhys = desc->u.Memory.Start; barLength = desc->u.Memory.Length; dx->BarPhysicalAddress = barPhys; dx->BarLength = barLength; // 把物理地址映射到内核虚拟地址,之后才能直接读写 dx->BarVirtualAddress = MmMapIoSpace(barPhys, barLength, MmNonCached); if (!dx->BarVirtualAddress) { return STATUS_INSUFFICIENT_RESOURCES; } // 清掉 BAR 空间里的遗留状态,防止设备在上电假死状态 WRITE_REGISTER_ULONG((PULONG)((PUCHAR)dx->BarVirtualAddress + 0x00), 0xFFFFFFFF); KdPrint(("PCI: BAR0 mapped at %p, len %lu\n", dx->BarVirtualAddress, barLength)); break; } } if (resourceList->Count) { // 挂接中断——注意:PCIe MSI/MSI-X 在 WDM 里也是用 CmResourceTypeInterrupt 上报 for (i = 0; i < resourceList->Count; i++) { PCM_PARTIAL_RESOURCE_DESCRIPTOR desc = &resourceList->PartialDescriptors[i]; if (desc->Type == CmResourceTypeInterrupt) { IoConnectInterrupt(&dx->InterruptObject, PciDrvIsr, // 中断服务例程 dx, // 传给 ISR 的上下文 NULL, desc->u.Interrupt.Vector, desc->u.Interrupt.Level, desc->u.Interrupt.Affinity); KdPrint(("PCI: Interrupt connected vector=%lu\n", desc->u.Interrupt.Vector)); break; } } } IoCompleteRequest(irp, IO_NO_INCREMENT); return STATUS_SUCCESS; }这里有一点必须说清楚:BAR 空间映射用 MmMapIoSpace 时,缓存属性必须指定为 MmNonCached。PCIe 设备的寄存器映射到 CPU 地址空间后,如果 CPU 的缓存策略把它当普通内存,读同一个寄存器可能得到旧值——因为硬件端改变了数据但 CPU 缓存还没失效。这个"玄学"问题一旦出现,表现就是死循环读状态寄存器永远等不到设备就绪。另一个选择是 MmMapIoSpace 的变种 MmMapIoSpaceEx 可以指定更多属性,但默认场景 MmNonCached 就是对的。
WRITE_REGISTER_ULONG 这类宏的本质是 _declspec(noinline) 的内联函数,它保证编译器和 CPU 都不会对这个 volatile 地址的访问做重排。访问 PCIe 设备寄存器时永远不要用直接解引用指针的方式,除非你明确知道自己在做什么并加了 volatile 强制,但即便如此,用 WRITE_REGISTER* 系列才是 WDK 推荐做法。
4.3 设备控制(IOCTL):让用户态程序安全地读写你的寄存器
驱动写完,用户态程序要能访问。通常做法是定义一组 IOCTL,把"读某个偏移的寄存器、写某个偏移的寄存器、获取设备状态"这些操作暴露出去:
#define IOCTL_PCI_READ_REG CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS) #define IOCTL_PCI_WRITE_REG CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS) NTSTATUS PciDrvDeviceControl(PDEVICE_OBJECT deviceObject, PIRP irp) { PIO_STACK_LOCATION irpStack = IoGetCurrentIrpStackLocation(irp); PDEVICE_EXTENSION dx = (PDEVICE_EXTENSION)deviceObject->DeviceExtension; ULONG ioctlCode = irpStack->Parameters.DeviceIoControl.IoControlCode; PVOID systemBuffer = irp->AssociatedIrp.SystemBuffer; ULONG inputLen = irpStack->Parameters.DeviceIoControl.InputBufferLength; ULONG outputLen = irpStack->Parameters.DeviceIoControl.OutputBufferLength; NTSTATUS status = STATUS_SUCCESS; ULONG bytesReturned = 0; switch (ioctlCode) { case IOCTL_PCI_READ_REG: { if (inputLen < sizeof(PCI_REG_ACCESS) || outputLen < sizeof(ULONG)) { status = STATUS_BUFFER_TOO_SMALL; break; } PCI_REG_ACCESS *access = (PCI_REG_ACCESS *)systemBuffer; if (access->Offset + sizeof(ULONG) > dx->BarLength) { status = STATUS_INVALID_PARAMETER; break; } *(PULONG)systemBuffer = READ_REGISTER_ULONG( (PULONG)((PUCHAR)dx->BarVirtualAddress + access->Offset)); bytesReturned = sizeof(ULONG); break; } case IOCTL_PCI_WRITE_REG: { if (inputLen < sizeof(PCI_REG_ACCESS)) { status = STATUS_BUFFER_TOO_SMALL; break; } PCI_REG_ACCESS *access = (PCI_REG_ACCESS *)systemBuffer; if (access->Offset + sizeof(ULONG) > dx->BarLength) { status = STATUS_INVALID_PARAMETER; break; } WRITE_REGISTER_ULONG((PULONG)((PUCHAR)dx->BarVirtualAddress + access->Offset), access->Value); bytesReturned = sizeof(PCI_REG_ACCESS); break; } default: status = STATUS_INVALID_DEVICE_REQUEST; break; } irp->IoStatus.Status = status; irp->IoStatus.Information = bytesReturned; IoCompleteRequest(irp, IO_NO_INCREMENT); return status; }参数说明里,IOCTL 的 METHOD_BUFFERED 方式对小型寄存器访问是够用的,它把输入输出都放在系统缓冲区里,内核帮你处理好用户态/内核态内存拷贝。缺点是每次 IOCTL 调用都会有一次系统缓冲区分配,如果采集场景要求高频率读寄存器(比如每毫秒读取一次状态位),效率会不够。高频场景应该改用 METHOD_IN_DIRECT 或 METHOD_OUT_DIRECT,配合用户态 MDL 做直接 IO,但线程同步和缓冲区生命周期管理就没这么省心。
这里没有加任何锁。如果你的设备控制函数会被多线程并发调用,必须用自旋锁或者互斥锁保护对寄存器序列的访问。否则两个线程交错写一个多字节的寄存器组,设备可能收到一个中间状态。驱动里的竞争条件不一定会立刻蓝屏,但表现往往是"偶发性设备不响应"——这种问题最难排查,全靠锁提前防住。
4.4 中断与 DPC:为什么 ISR 里绝对不能直接做耗时操作
WDM 的中断处理分两部分:ISR(Interrupt Service Routine)在 DIRQL 执行,必须极短;剩下的工作在 DPC(Deferred Procedure Call)里完成。ISR 只做两件事——判断中断是否来自你的设备、如果是否认则返回 FALSE 让系统继续分发;如果是则清除中断源(写入中断状态寄存器)并调度 DPC:
BOOLEAN PciDrvIsr(PKINTERRUPT InterruptObject, PVOID Context) { PDEVICE_EXTENSION dx = (PDEVICE_EXTENSION)Context; ULONG status = READ_REGISTER_ULONG((PULONG)((PUCHAR)dx->BarVirtualAddress + INT_STATUS_REG)); if (!(status & INT_STATUS_VALID)) { return FALSE; // 不是本设备中断 } // 立刻清中断源,避免同一中断反复触发 WRITE_REGISTER_ULONG((PULONG)((PUCHAR)dx->BarVirtualAddress + INT_STATUS_REG), status); // 调度 DPC 做后续工作 KeInsertQueueDpc(&dx->InterruptDpc, NULL, NULL); return TRUE; }DPC 例程里可以做需要加锁的、稍重的操作,比如读取一大块 FIFO 数据、更新计数器、向用户态应用发通知事件:
VOID PciDrvDpc(KDPC *Dpc, PVOID Context, PVOID SystemArgument1, PVOID SystemArgument2) { PDEVICE_EXTENSION dx = (PDEVICE_EXTENSION)Context; // 从 FIFO 搬数据 ULONG count = READ_REGISTER_ULONG((PULONG)((PUCHAR)dx->BarVirtualAddress + FIFO_COUNT_REG)); while (count > 0) { dx->FifoData[dx->FifoIndex++] = READ_REGISTER_ULONG((PULONG)((PUCHAR)dx->BarVirtualAddress + FIFO_DATA_REG)); count--; } if (dx->FifoIndex >= FIFO_THRESHOLD) { KeSetEvent(&dx->DataReadyEvent, IO_NO_INCREMENT, FALSE); } }强调一点:ISR 里如果执行超过几十微秒,会拖累整个系统的中断响应,甚至导致其他设备的中断超时。像读取 PCIe 设备 FIFO 这种事,原则上也可以全部放 DPC,ISR 只做中断仲裁。另外,如果设备支持 MSI/MSI-X,WDM 层不需要区分——资源列表里报什么,你就连什么,ISR 签名一样。
4.5 DMA 传输的最小实现:缓冲区锁页与地址映射
PCIe 驱动的高吞吐场景绕不开 DMA。WDM 下最简单的方式是让用户态程序提供一个缓冲区,驱动用 MmProbeAndLockPages 锁页后,把物理地址给设备做 DMA 传输:
// 假设 METHOD_OUT_DIRECT,irp->MdlAddress 指向用户缓冲区 NTSTATUS PciDrvStartDma(PDEVICE_EXTENSION dx, PIRP irp) { PMDL mdl = irp->MdlAddress; if (!mdl) { return STATUS_INVALID_PARAMETER; } // 锁页,防止用户缓冲被换出物理内存 __try { MmProbeAndLockPages(mdl, UserMode, IoReadAccess); } __except (EXCEPTION_EXECUTE_HANDLER) { return STATUS_INVALID_USER_BUFFER; } PHYSICAL_ADDRESS pa = MmGetMdlPhysicalAddress(mdl); ULONG len = MmGetMdlByteCount(mdl); // 通知设备 DMA 目标地址和长度 WRITE_REGISTER_ULONG((PULONG)((PUCHAR)dx->BarVirtualAddress + DMA_ADDR_LO_REG), (ULONG)pa.QuadPart); WRITE_REGISTER_ULONG((PULONG)((PUCHAR)dx->BarVirtualAddress + DMA_ADDR_HI_REG), (ULONG)(pa.QuadPart >> 32)); WRITE_REGISTER_ULONG((PULONG)((PUCHAR)dx->BarVirtualAddress + DMA_LEN_REG), len); WRITE_REGISTER_ULONG((PULONG)((PUCHAR)dx->BarVirtualAddress + DMA_GO_REG), 1); return STATUS_PENDING; }注意:DMA 的方向如果是从设备到内存,用户缓冲区的缓存属性必须一致。MmProbeAndLockPages 锁住的页面可能仍带 CPU 缓存,导致设备写内存后 CPU 读出来是旧数据。解决方法是让设备侧关闭 FIFO 的 snoop,或者改用 MmAllocateContiguousMemory 分配一致性内存做中转。工业级的做法是单独开一个环形缓冲区,用"生产者-消费者"指针管理,避免 DMA 完成后还有缓存同步的开销。
DMA 完成的中断响应就是 4.4 里的 ISR+DPC 流程的延伸:ISR 处理"传输完成"中断,DPC 里检查 DMA 状态寄存器、解锁页面、完成挂起的 IRP,然后用户态被唤醒。这个完整闭环是 PCIe 驱动性能的核心,一步做错就是数据错误或者驱动挂死。
5. WDM PCIe 驱动开发的避坑指南:现象、原因、解决
5.1 现象:编译报错 STATUS_INVALID_DEVICE_REQUEST,或 IRP 无法下发到驱动
- 原因:最常见的原因是 DriverEntry 里没有注册 IRP_MJ_PNP,或注册了但 Inc 函数没有正确导出。很多人会漏掉
DriverObject->MajorFunction[IRP_MJ_POWER],系统在设备启动序列里发电源 IRP 时得不到响应,默认按失败处理,导致设备启动中断。电源 IRP 也是 WDM 驱动被忽视的大头,特别在从休眠恢复时最容易翻车。 - 解决:DriverEntry 里把 PnP 和 Power 都显式注册单独处理函数。即使你的设备不需要特殊电源管理,也要写一个例程原样向下传递,并在完成后设置 IoStatus 为 STATUS_SUCCESS。我见过不少项目在这上面栽跟头,设备在冷启动后立即工作正常,但睡眠唤醒后主机直接蓝屏或者设备丢失——这就是 Power IRP 没有正确透传的下场。
5.2 现象:设备在设备管理器里显示"代码 10:设备无法启动"
- 原因:START_DEVICE 处理返回了非成功状态。可能是 MmMapIoSpace 失败(通常是 BAR 资源被 BIOS 分配在了 32 位地址空间之外,或者 INF 中请求的资源范围超出实际分配的长度)、也可能是中断连接失败。但新手最容易忽略的是:你的 StartDevice 函数根本没有被 PnP 管理器调用,因为 AddDevice 里没有设置设备状态为"开始"。
- 解决:调试器里在 PciDrvStartDevice 入口下断点,看有没有命中。没命中,说明 AddDevice 有问题或 INF 硬件 ID 不匹配;命中了,返回值的具体 NTSTATUS 就是线索。0xC0000001 是无效参数、0xC000009A 是资源不足,对应着查。另外很多 PCIe 板卡上电默认处于复位态,START_DEVICE 时 BAR 空间读回来全是 0xFF。如果驱动在这个状态下做校验(比如读版本号寄存器),就会报设备未就绪。正确顺序是先做复位释放(写控制寄存器或置 PERST 引脚),再轮询设备就绪位,超时再返回失败。
5.3 现象:访问 BAR 空间后系统冻屏或蓝屏(IRQL_NOT_LESS_OR_EQUAL)
- 原因:这是 PCI 驱动最经典的蓝屏,原因无外乎三种:BAR 虚地址已被 MmUnmapIoSpace 释放后仍被访问、读写寄存器的位置超出了映射长度、或者把分页地址当成 BAR 地址在用。还有一种隐蔽场景:PCIe 设备处于 D3 电源状态时,对 BAR 空间发起的访问会得到全 0xFF 响应,某些硬件桥片会把这种访问直接变成 Machine Check Exception,表现为冻屏而不是蓝屏。
- 解决:代码里杜绝裸指针访问,必须走 READ_REGISTER_* 系列。访问之前检查 dx->BarVirtualAddress 非空和偏移不越界。设备挂起后要做一次电源状态恢复(置 PERST 的重置序列、或重新完成一次 START_DEVICE 周期),再重新映射。这部分的坑在于 WDM 里设备的电源状态由 IRP_MJ_POWER 管理,如果你只处理 PnP 不处理电源,系统进入 S3 后设备就处于未知状态了。
5.4 现象:ISR 被调用但中断状态寄存器是 0,或中断永远不来
- 原因:PCIe 的中断线是 Level 触发的(INTx),而 Windows 的中断模型期望中断服务例程明确判断中断是否属于本设备。如果设备的中断状态寄存器未置位但 ISR 返回 TRUE,系统会认为这个中断已处理,实际上别的设备的中断被吞掉了,结果就是系统中其他设备随机卡死。反过来,如果 ISR 里没读中断状态寄存器就直接返回 FALSE,本设备的中断就被认成杂散中断,触发次数多了 Windows 会直接禁用中断向量。
- 解决: ISR 的第一条指令就是读取中断状态寄存器并做位检查。必须记住返回 TRUE 前先清中断源。如果设备支持 MSI,优先在 FPGA 里配置成 MSI,因为 MSI 是边沿触发、发送即知归属,省掉这一整类排查问题。注意:MSI 在 WDM 资源列表里也是 CmResourceTypeInterrupt,但 Vector 不能自己指定——所以要能区分两种方式,不要硬编码期望的 Vector。
5.5 现象:驱动签名校验失败,或 INF 安装后设备仍显示"未知设备"
- 原因:64 位系统强制签名,而 WDK 默认的测试签名只是让本地内核允许未签名驱动加载,INF 安装时针对 .cat 或 .sys 的签名校验依然严格。另一个常见问题是 INF 里的 Hardware ID 字符串写错,导致设备管理器里无法匹配。
- 解决:先用设备管理器的"详细信息→硬件 ID"查到真实值,比如
PCI\VEN_1234&DEV_5678&SUBSYS_00011234,把它原样写进 INF 的[Models]段:
[Version] Signature="$Windows NT$" Class=System ClassGuid={4D36E97D-E325-11CE-BFC1-08002BE10318} Provider=%ProviderName% CatalogFile=WDM_PCI_Driver.cat DriverVer=06/28/2024,1.0.0.0 [Manufacturer] %ProviderName%=DriverInstall,NTamd64 [DriverInstall.NTamd64] CopyFiles=FilesToCopy [FilesToCopy] WDM_PCI_Driver.sys [DriverInstall.NTamd64.Services] AddService=WDM_PCI_Driver,0x00000002,ServiceInstall [ServiceInstall] DisplayName=%ServiceName% ServiceType=1 StartType=3 ErrorControl=1 ServiceBinary=%12%\WDM_PCI_Driver.sys [Strings] ProviderName="MyCompany" ServiceName="WDM PCI Driver"同时确认 INF 文件编码为 UTF-8 无 BOM,否则字符串段里的非 ASCII 字符会让驱动安装服务报错。签名方面,测试签名只要保证签名证书安全导入目标机、并在目录文件里包含 INF 中列出的全部文件即可。
6. 把骨架变成工程:验证驱动的加载状态与三个进阶检查习惯
驱动编译通过、能安装只是开始。代码写完之后,我一般会按下面的清单做一轮验证排查,把问题在交付前尽量消化掉。
第一步:查看驱动加载是否真的成功。设备管理器里确认设备没有感叹号,然后打开 C:\Windows\System32\drivers 确认 .sys 文件存在。进一步用内核调试器lm m WDM_PCI_Driver看模块是否加载,再用!devnode 0 1查看设备节点状态。这一轮能筛掉 80% 的 INF 和签名问题。
第二步:验证 BAR 空间访问的有效性。用 IOCTL 读一个已知寄存器,比对与寄存器手册预期值的差异。推荐用一个简单的用户态控制台程序循环读写 1000 次,每次检查返回值落在合法范围。如果出现个别读取值异常,优先怀疑缓存一致性和读写宏使用不规范——而不是硬件坏了。这是 PCIe 驱动最容易自欺欺人的地方:一次读写正常不代表驱动是对的,FPGA 或设备端缓冲未稳定时,偶发错误是常见现象。
第三步:中断链路验证。把 IRP_MJ_DEVICE_CONTROL 里加一个"等待中断事件"的 IOCTL,用户态调用后阻塞等待 KeSetEvent。然后在硬件侧手动触发一次中断(FPGA 调试时往往有测试按钮),观察驱动是否在几十毫秒内响应。这个测试是中断链路是否完整的最直接证据。如果没有响应,按 5.4 节的排查法看 ISR 有没有被调用、返回值是什么。
第四步:检查资源释放路径。把设备禁用再启用(设备管理器右键禁用/启用),循环 20 次,观察内存泄漏和设备节点泄漏。WDM 驱动的 REMOVE_DEVICE 处理极容易漏掉 MmUnmapIoSpace 或 IoDisconnectInterrupt,而又因为设备仍然存在,虚拟机里根本看不出异常。手工做这种热插拔模拟,能暴露大量生命周期问题。
最后一条个人习惯:每次改动寄存器访问相关代码后,都做一次全量编译 + 目标机重启 + 冷启动验证。PCIe 设备在热启动(Ctrl+Alt+Del 重启)下和冷启动(关机再开机)下的枚举时序有差异,很多驱动问题只在冷启动出现。这是老工程师拿经验换来的教训——一次交付前没有做冷启动验证,对方现场一开机设备就丢,最后发现是驱动在 START_DEVICE 里等了一个在热启动下早已置位、但冷启动下要晚 200ms 才置位的状态位。从此之后,冷启动永远在我的测试清单第一位。
希望这篇笔记能把 WDM 写 PCI/PCIe 驱动的全链路讲清楚。从环境搭建到骨架代码,从 BAR 访问到中断处理,每一步都值得你在自己板卡上做一次最小验证——然后你会发现,真正花时间的从来不是写第一版驱动,而是你在 5.4 和 5.5 节看到的那些"玄学"问题,它们才是 Windows 驱动开发的经验沉淀所在。
本文还有配套的精品资源,点击获取