news 2026/9/14 3:23:43

WDM PCI驱动开发实战:从设备枚举到IRP分发与INF安装

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
WDM PCI驱动开发实战:从设备枚举到IRP分发与INF安装

简介:面向Windows平台驱动开发者的PCI/PCIe驱动程序开发资料包,基于WDM(Windows Driver Model)模型编写,适合需要从零上手WDM驱动、理解PCI设备与系统交互的工程师。资源共20个文件,包含头文件(h)、C++源代码(cpp)、Visual Studio工程文件(vcxproj/sln)、INF安装配置、DSP/DSW等传统构建脚本,以及模拟测试工程,可覆盖驱动编写、编译、安装、验证的完整链路。整个压缩包仅110KB,内容精炼聚焦核心代码与配置。已有330人学习下载。资料围绕WDM关键机制展开,涉及PnP即插即用、电源管理、IRP请求包处理、设备对象与驱动对象创建、PCI配置空间访问、PDO/FDO设备层次等核心概念;通过阅读和编译示例,读者能掌握使用WDK构建PCIe设备驱动的流程,学会设备资源获取、中断处理及I/O操作等底层开发技能,适合作为Windows驱动入门实践与项目参考。

1. 从设备管理器感叹号说起:WDM_PCI_Driver 为什么值得拆开看

用户插入一块 PCIe 采集卡,系统毫无反应,设备管理器多出一个带黄色感叹号的“PCI 简单通讯控制器”。厂商光盘里没有 Windows 驱动,只有寄存器手册,硬件 ID 是 PCI\VEN_10EE&DEV_9128。接到这类任务时,最省事的做法是拿一套能用的 WDM 驱动框架作为起点,而不是从零去读 WDK 文档。这个压缩包内正好是一份完整的 WDM PCI 驱动样例:HelloWDM 驱动工程,外加一个 Test.exe 用户态测试程序,覆盖了驱动入口、PnP AddDevice、设备控制 IRP 和 INF 安装文件。对做 Windows 底层开发的人,读这份源码能理清 PCI 设备从枚举到打开句柄的完整路径;对只负责集成的人,也能直接改 VID/DID 和配置空间查询逻辑,把它变成一块板卡的基础驱动。

2. WDM 与 PCI 的接口层:配置空间、设备栈和 IRP 分发

PCI 设备在 Windows 下并不是由寄存器直接驱动的。PnP 管理器在枚举 PCI 总线时,会通过总线驱动读取设备的配置头,得到 Vendor ID、Device ID、Class Code、中断引脚和 BAR 地址,然后为这个设备创建 PDO。功能驱动收到 PnP 通知后,创建设备对象并附加到设备栈上。因此,一个 WDM 驱动真正的工作量在于三块:向上回应用户态 IRP、向下访问总线配置空间、中间处理好设备生命周期。

2.1 配置空间是 PCI 设备的“身份证”:枚举之后还得自己读

配置空间前 64 字节是设备的关键信息。Vendor ID 和 Device ID 构成硬件 ID,Interrupt Line 和 BAR 决定驱动如何与设备通信。枚举阶段读到的数据只能满足 PnP 匹配;驱动真正启动后,往往需要再次读取配置空间。原因很简单:总线驱动只暴露必要的子集,而 BAR 地址、扩展能力列表(PCIe Capability)这些细节,需要功能驱动主动查询。

WDM 下读取配置空间的常见做法是构造一个发给总线驱动的 IRP,MinorFunction 设为 IRP_MN_READ_CONFIG,然后 IoCallDriver 把它传下去。下面是一个示例实现:

NTSTATUS ReadPciConfig( PDEVICE_OBJECT fdo, ULONG offset, ULONG length, PVOID buffer) { PDEVICE_OBJECT lowerDevice; PIO_STACK_LOCATION nextStack; KEVENT event; PIRP irp; NTSTATUS status; lowerDevice = IoGetAttachedDeviceReference(fdo); KeInitializeEvent(&event, NotificationEvent, FALSE); irp = IoAllocateIrp(lowerDevice->StackSize, FALSE); if (irp == NULL) { ObDereferenceObject(lowerDevice); return STATUS_INSUFFICIENT_RESOURCES; } irp->IoStatus.Status = STATUS_NOT_SUPPORTED; IoSetCompletionRoutine(irp, OnIrpComplete, &event, TRUE, TRUE, TRUE); nextStack = IoGetNextIrpStackLocation(irp); nextStack->MajorFunction = IRP_MJ_PNP; nextStack->MinorFunction = IRP_MN_READ_CONFIG; nextStack->Parameters.ReadWriteConfig.Offset = offset; nextStack->Parameters.ReadWriteConfig.Length = length; nextStack->Parameters.ReadWriteConfig.Buffer = buffer; status = IoCallDriver(lowerDevice, irp); if (status == STATUS_PENDING) { KeWaitForSingleObject(&event, Executive, KernelMode, FALSE, NULL); status = irp->IoStatus.Status; } ObDereferenceObject(lowerDevice); IoFreeIrp(irp); return status; }

这段代码里,IoGetAttachedDeviceReference 拿到的是 PDO 所在的总线驱动设备对象。IO_STACK_LOCATION 中的 Parameters.ReadWriteConfig 字段描述要读取的配置空间偏移、长度和目标缓冲区。IRP 完成后,由分配它的驱动负责 IoFreeIrp 释放,否则会造成内核内存泄露。

注意:不要在功能驱动里直接用 WRITE_PORT_ULONG 访问 0xCF8/0xCFC 来读配置空间。PCIe 设备使用 ECAM 机制访问配置空间,和传统 PCI 的 I/O 端口方式不同;而且 Windows 已经对总线地址做了抽象,绕开总线驱动容易踩到资源重映射和热插拔之后地址失效的问题。

2.2 PDO、FDO 与设备栈:驱动如何嵌入 PnP 树

WDM 设备模型中,总线驱动创建 PDO,也就是“物理设备对象”。功能驱动在 AddDevice 回调里创建 FDO,并把它挂在 PDO 上面。这串设备对象形成一个栈,上层 IRP 从栈顶进入,经过功能驱动处理后继续向下传给总线驱动。比如读取配置空间的 IRP 就是从 FDO 传给 PDO 的。

NTSTATUS MyAddDevice(PDRIVER_OBJECT driverObject, PDEVICE_OBJECT pdo) { PDEVICE_OBJECT fdo; NTSTATUS status; UNICODE_STRING deviceName; UNICODE_STRING symLink; PDEVICE_EXTENSION ext; RtlInitUnicodeString(&deviceName, L"\\Device\\HelloWDM0"); RtlInitUnicodeString(&symLink, L"\\DosDevices\\HelloWDM0"); status = IoCreateDevice( driverObject, sizeof(DEVICE_EXTENSION), &deviceName, FILE_DEVICE_UNKNOWN, 0, FALSE, &fdo); if (!NT_SUCCESS(status)) { return status; } ext = (PDEVICE_EXTENSION)fdo->DeviceExtension; ext->DeviceObject = fdo; ext->Pdo = pdo; status = IoCreateSymbolicLink(&symLink, &deviceName); if (!NT_SUCCESS(status)) { IoDeleteDevice(fdo); return status; } ext->LowerDeviceObject = IoAttachDeviceToDeviceStack(fdo, pdo); fdo->Flags |= DO_BUFFERED_IO; fdo->Flags &= ~DO_DEVICE_INITIALIZING; return STATUS_SUCCESS; }

IoCreateDevice 的第四个参数 FILE_DEVICE_UNKNOWN 表示设备类型;第七个参数 FALSE 表示非独占设备,允许多个应用同时打开句柄。IoAttachDeviceToDeviceStack 的返回值是下层 PDO 对应设备对象,后续整个驱动生命周期都要靠它向下发送 IRP。创建 FDO 后,必须清除 DO_DEVICE_INITIALIZING 标志,否则设备栈无法完成启动。

2.3 IRP 分发:每一条命令都要有归宿

用户态调用 CreateFile、ReadFile 或 DeviceIoControl 时,I/O 管理器会构造对应 IRP,并调用驱动对象 MajorFunction 表里的分派例程。WDM 驱动至少要处理 Create、Close、DeviceControl 三类非 PnP IRP。PnP 和 Power IRP 如果不能处理,也必须打到下个设备对象,不能直接返回成功。

主功能号用途驱动内常见处理
IRP_MJ_CREATE打开设备记录引用计数,返回 STATUS_SUCCESS
IRP_MJ_CLOSE关闭句柄释放用户态持有的资源
IRP_MJ_DEVICE_CONTROLIOCTL 调用解析参数,操作硬件
IRP_MJ_PNP即插即用事件交给下层或处理 Start/Stop/Remove
IRP_MJ_POWER电源管理交给下层或处理 SetPower

分派函数登记一般放在 DriverEntry 里。设备控制 IRP 使用 Method Buffered I/O 时,输入输出缓冲区都会映射到 Irp->AssociatedIrp.SystemBuffer 中,不需要自己锁定内存。

NTSTATUS MyDispatchDeviceControl(PDEVICE_OBJECT devObj, PIRP irp) { PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(irp); ULONG code = stack->Parameters.DeviceIoControl.IoControlCode; ULONG outLen = stack->Parameters.DeviceIoControl.OutputBufferLength; PVOID buffer = irp->AssociatedIrp.SystemBuffer; NTSTATUS status = STATUS_SUCCESS; ULONG info = 0; switch (code) { case IOCTL_HELLOWDM_QUERY_VERSION: { if (outLen < sizeof(ULONG)) { status = STATUS_BUFFER_TOO_SMALL; break; } *(PULONG)buffer = 1; info = sizeof(ULONG); break; } default: status = STATUS_INVALID_DEVICE_REQUEST; break; } irp->IoStatus.Status = status; irp->IoStatus.Information = info; IoCompleteRequest(irp, IO_NO_INCREMENT); return status; }

IoStatus.Information 表示实际写入系统缓冲的字节数。如果输出缓冲区过小,应返回 STATUS_BUFFER_TOO_SMALL,并把需要的字节放在 Information 里。用户态 DeviceIoControl 的字节返回参数就是引用的这里。

3. HelloWDM 源码工程逐文件拆解:代码文件比 README 更有说服力

这个包把代码以一个可编译的 DriverDev.sln 组织好。查看压缩包可以发现,MyDriver 工程负责内核驱动,Test 工程是用户态测试。熟悉项目结构后,替换设备 ID 和功能逻辑会快很多。

3.1 压缩包里每个文件在干什么

先用一张表把主要文件的作用标出来,避免拿到源码后到处找入口。

文件/目录作用
MyDriver.vcxproj / DriverDev.slnVS/WDK 工程文件,包含编译配置
HelloWDM.cpp / HelloWDM.h驱动入口、分派函数、设备控制逻辑
Ioctls.h用户态与内核共用的 IOCTL 定义
guid.h设备接口或设备实例 GUID 定义
HelloWDM.infINF 安装文件,定义硬件匹配和驱动部署
MyDriver_Check / Debug编译输出或旧 DDK 构建目录
Test / main.cpp / function.cppTest.exe 用户态程序源码

MyDriver.dsp 是旧版 Visual C++ 工程文件,用来兼容老 DDK 环境。现代 WDK 推荐直接用 vcxproj,编译命令更清晰,也不依赖 VC6 那套环境。

3.2 DriverEntry、Unload 与设备扩展:生命周期起点

HelloWDM.cpp 里 DriverEntry 的逻辑很短但缺一不可。首先要给每个 IRP 主功能设置默认处理函数,再覆盖 Create/Close/DeviceControl,最后挂上 AddDevice 和 DriverUnload。

extern "C" NTSTATUS DriverEntry(PDRIVER_OBJECT driverObject, PUNICODE_STRING registryPath) { ULONG i; (void)registryPath; driverObject->DriverUnload = HelloWDMUnload; driverObject->DriverExtension->AddDevice = HelloWDMAddDevice; for (i = 0; i < IRP_MJ_MAXIMUM_FUNCTION; i++) { driverObject->MajorFunction[i] = HelloWDispatchPassDown; } driverObject->MajorFunction[IRP_MJ_CREATE] = HelloWDispatchCreateClose; driverObject->MajorFunction[IRP_MJ_CLOSE] = HelloWDispatchCreateClose; driverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = HelloWDispatchDeviceControl; return STATUS_SUCCESS; }

HelloWDispatchPassDown 是一个把 IRP 原封不动往下传的函数,对 PnP 和 Power IRP 尤其重要。如果不处理而直接返回成功,会破坏 PnP 设备栈的状态,导致设备在枚举后直接停止。DriverUnload 则必须释放 DriverEntry 阶段申请的资源,比如删除设备对象和符号链接。

设备扩展结构体定义在 HelloWDM.h 里,包含 FDO、PDO、下层设备对象、自旋锁或中断对象等。WDM 驱动不能使用全局变量保存设备实例状态,因为同一个驱动可能同时服务多个设备。设备扩展可以理解为“附着在 FDO 上的私有上下文”。

3.3 INF 文件:系统如何匹配到你的驱动

INF 是安装文件,不是编译器产物。Windows 在枚举到 PCI 设备后,会根据硬件 ID 查系统驱动库。INF 里的匹配节指定了这个硬件应该由哪个 .sys 驱动服务。

[Version] Signature = "$WINDOWS NT$" Class = System ClassGuid = {4d36e97d-e325-11ce-bfc1-08002be10318} Provider = %ProviderName% DriverVer = 06/12/2024,1.0.0.0 [Manufacturer] %ProviderName%=DeviceList,NTamd64 [DeviceList.NTamd64] %DeviceDesc%=HelloWDM_DDI, PCI\VEN_1234&DEV_5678&SUBSYS_00000000&REV_01 [HelloWDM_DDI.NT] CopyFiles = HelloWDM_Files [HelloWDM_Files] HelloWDM.sys [HelloWDM_DDI.NT.Services] AddService = HelloWDM, 0x00000002, HelloWDM_Service [HelloWDM_Service] DisplayName = %DeviceDesc% ServiceType = 1 StartType = 3 ErrorControl= 1 ServiceBinary = %12%\HelloWDM.sys

硬件 ID 行中的 PCI\VEN_1234&DEV_5678 就是设备在配置空间里的身份。开发时把硬件 ID 改成自己板卡的 Vendor ID / Device ID。StartType=3 表示由 PnP 管理器按需启动,不能设成 0,否则驱动会进入启动驱动列表。ServiceBinary 的 %12% 是驱动目录的替代路径,实际指向 C:\Windows\System32\drivers。

注意:如果 INF 里的 ClassGuid 和 Class 对不上,设备可能被安装到错误分类,出现“系统设备”下的怪异节点。复制到标准 PCI 设备类时,Class 常设为 System 或 Net,视板卡功能而定。

4. 从源码到可运行驱动:WDK 构建、签名加载与 Test 程序验证

拿到源码并不等于驱动能装进系统。从编译、签名到安装,每一步都可能以蓝屏或者“设备无法启动”收场。先在一个干净环境里练熟流程,再换成真实硬件 ID。

4.1 用命令行构建工程:脱离 Visual Studio 界面

安装 WDK 后,系统里有“开发人员命令提示符”,它已经设好了 INCLUDE 和 LIB 环境变量。直接对 DriverDev.sln 或 MyDriver.vcxproj 执行 msbuild 即可:

msbuild MyDriver.vcxproj /p:Configuration=Debug /p:Platform=x64 /m

/m 允许并行编译,编译速度更快。Debug 还是 Release 会影响优化等级和调试符号生成;内核驱动建议先用 Debug 构建,方便抓崩溃地址和变量值。编译完成后,输出目录里会有 HelloWDM.sys 和 PDB 文件。若工程配置有问题,可以追加 /t:Rebuild 强制重编译,避免旧对象干扰。

如果是从旧 DDK 时代带过来的 .dsp,不用强行转 vcxproj。老方法是用 build.exe + sources/dirs 文件,但新 WDK 已经不鼓励这条路。建议直接把源码导入新工程,DDK 的 build 环境在 Windows 10 之后的驱动开发里已经不是主流。

4.2 测试签名:让 64 位系统接受你的驱动

64 位 Windows 要求内核模块有签名。开发阶段没有 EV 证书,可以启用测试签名模式:

bcdedit /set TESTSIGNING ON

重启后,桌面右下角出现“测试模式”水印。接着用 WDK 提供的 Inf2Cat 和 SignTool 生成目录文件并签名:

Inf2Cat /driver:C:\wdm_driver\x64\Debug /os:10_X64 signtool sign /v /s PrivateCertStore /n WDMTestCert /t http://timestamp.digicert.com C:\wdm_driver\x64\Debug\HelloWDM.cat

Inf2Cat 会扫描 INF 文件中的 CopyFiles 和 AddService,生成对应的 .cat 安全目录文件。signtool 的 /s 参数指定证书存储,/n 指定证书名称。如果没有现成的测试证书,先创建自签名证书,再把它导入“受信任的根证书颁发机构”。这一步不做,设备管理器会提示“数字签名错误”,即使硬件 ID 匹配也装不上驱动。

4.3 Test 程序:从用户态进入内核态

Test 工程里的 main.cpp 打开设备路径 \\.\HelloWDM,这和 INF 安装后创建的符号链接一致。程序通过 DeviceIoControl 把控制码发送给驱动,和驱动内的 IRP_MJ_DEVICE_CONTROL 对应。

#include <windows.h> #include <stdio.h> #include "Ioctls.h" int main() { HANDLE handle = CreateFile( L"\\\\.\\HelloWDM", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL ); if (handle == INVALID_HANDLE_VALUE) { printf("CreateFile failed: %d\n", GetLastError()); return 1; } ULONG version = 0; DWORD bytesReturned = 0; BOOL ok = DeviceIoControl( handle, IOCTL_HELLOWDM_QUERY_VERSION, NULL, 0, &version, sizeof(version), &bytesReturned, NULL ); if (ok) { printf("Driver version: %lu\n", version); } else { printf("DeviceIoControl failed: %d\n", GetLastError()); } CloseHandle(handle); return 0; }

CreateFile 的打开动作会触发 IRP_MJ_CREATE,关闭句柄触发 IRP_MJ_CLOSE。DeviceIoControl 的参数依次是设备句柄、IOCTL 码、输入缓冲区、输入长度、输出缓冲区、输出长度、返回字节数和 OVERLAPPED 结构。最后一个参数传 NULL 表示同步等待,驱动完成 IRP 后才返回。如果驱动里没有把 IoStatus.Information 设置正确,bytesReturned 会返回 0,但调用状态仍然是成功。

4.4 用 DebugView 查看内核日志

驱动内使用 DbgPrint 或 KdPrint 打印的信息不会直接出现在终端。在调试器没连接的情况下,可以用 DebugView 的 System 模式捕获 OutputDebugString 输出。启动 DebugView,勾选 Capture Kernel 和 Enable Verbose Kernel Output,插入硬件后就能看到驱动在 AddDevice 和 Dispatch 里打印的时间点。这个技巧在驱动加载失败时尤其管用,能看到是进入 DriverEntry 还是 AddDevice 崩掉的。

5. 从 HelloWDM 到 PCIe 板卡的实战边界:中断、DMA 和资源验证

HelloWDM 示范了如何枚举设备、接收 IOCTL,但它对真实板卡还有一个缺口:没有中断处理,也没有 DMA 传输。如果目标是网卡、数据采集卡这类需要持续搬运数据的 PCIe 设备,还需要补三块内容。

5.1 中断:从 ISR 到 DPC 的完整链路

WDM 驱动通过 IoConnectInterruptEx 连接中断,中断处理后通常还要插一个 DPC 做延迟处理。因为 ISR 运行在 DIRQL 上,只能做最少的操作,比如读中断状态寄存器、清中断、提交 DPC。如果 ISR 里调用 IoCallDriver 或者操作页面化内存,系统会立刻蓝屏。很多从裸机程序转过来的人喜欢在中断里跑复杂逻辑,这是 WDM 驱动最常见的坑。

5.2 DMA:必须使用 DMA 适配器

PCIe 板卡和主机内存之间的数据传输,不能直接在驱动里写一个 memcpy 到 BAR 地址。正确做法是调用 IoGetDmaAdapter 分配适配器对象,再用 MapTransfer 建立散列/聚集列表。只有这样,总线主控 DMA 才能跨 4GB 边界寻址,也才能被 IOMMU 正确翻译。没有 DMA 适配器而强行操作物理地址,在启用 VT-d 或 ACPI DMAR 的机器上会悄然失败。

5.3 安装后第一时间验证的内容

驱动装完,先别急着跑功能,用设备管理器和 WinDbg 确认三件事:设备状态是否为“此设备工作正常”、中断是否出现在资源里、访问 BAR 地址能否读回设备 ID。WinDbg 连接内核后可以查看设备栈:

命令检查内容
!devobj FDO 地址FDO 标志,设备栈状态
!devstack 设备对象设备对象层次是否正确
!irp 地址IRP 未被完成的执行路径
!pci 100当前 PCI 设备总线资源分配

如果设备对象标志里 DO_DEVICE_INITIALIZING 还没清除,说明 AddDevice 没走到最后。IRP 卡在 IoCallDriver 说明下层总线驱动没有响应,通常和配置空间读写长度不对有关。我在处理一块 PCIe 桥片时遇到过配置空间只能按 4 字节读,否则后面挂死的情况,这类细节只能在调试器里观察。

把 HelloWDM 的 IOCTL 框架保留,替换成寄存器读写和中断+DPC 实现,就是一套可用的 PCIe 板卡驱动雏形。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 3:22:45

Escrcpy:图形界面搞定安卓投屏与多设备管理的免费工具

Escrcpy&#xff1a;图形界面搞定安卓投屏与多设备管理的免费工具 【免费下载链接】escrcpy &#x1f4f1; Display and control your Android device graphically with scrcpy. 项目地址: https://gitcode.com/GitHub_Trending/es/escrcpy Escrcpy 是一款免费的安卓投屏…

作者头像 李华
网站建设 2026/9/14 3:22:23

PSD自动转UI实战:从约定命名到引擎装配的完整自动化管线

做过游戏UI的朋友一定体会过这种绝望&#xff1a;策划提了一版新界面&#xff0c;美术丢过来一张合并好的总成图&#xff0c;没有保留分层PSD&#xff0c;而你需要在Unity里把一个一个按钮、头像、输入框从图上抠出来再重新拼好。我在技术社区泡了几年&#xff0c;关于“PSD自动…

作者头像 李华
网站建设 2026/9/14 3:21:13

微信小程序开发:古天文知识科普系统实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 3:18:29

时序指标流式计算引擎Ants:窗口生命周期与多粒度聚合设计

简介&#xff1a;这是一款面向时序指标数据的通用流式计算引擎框架&#xff0c;来源于博睿宏远十年大数据项目实战沉淀&#xff0c;适合大数据平台开发、运维监控及实时计算场景的技术人员参考。压缩包内共136个文件&#xff0c;以110个Java源码文件为主&#xff0c;覆盖AntsCo…

作者头像 李华