简介:这是一套基于 VC++ 编写的 USB HID 设备检测示例程序,面向需要熟悉 WinUSB、SetupAPI 与 HID 类设备编程的 Windows 开发者,可解决枚举 HID 设备、读取厂商与产品字符串、获取输入报告等常见问题。包内共 30 个文件,压缩后约 89KB,主要包含 15 个头文件、3 个 C++ 源文件、2 个静态库文件以及 VS 工程配置,并含对话框资源与图标,目录结构清晰,便于直接定位核心代码,覆盖从设备枚举、打开句柄到读取报告的完整骨架。已有 145 人学习下载。通过该项目,读者可以掌握 HID 设备检测的基本流程,理解 USB 描述符、HID 报告描述符与 Windows 设备管理 API 的配合方式,包括设备信息获取、输入报告读取、错误处理与设备插拔通知等关键环节。读者还可将源码作为课程设计、毕业设计或设备管理工具开发的起点,结合实际项目快速扩展功能。
1. 用 Visual C++ 直接枚举 HID 设备:这份工程告诉你 USB 编程可以不用写驱动
HID 设备检测是 USB 编程里门槛最低、但水最深的一个方向。键盘、鼠标、游戏手柄、触摸屏、甚至是很多自定义的 hid 固件设备,都走同一个协议——但你真用 Visual C++ 去枚举它们的时候,会发现 SetupAPI 和 HidD 系列函数之间隔着好几层文档没讲透的坑。这份 Computer_HID_detect 工程解决的就是这件事:它用纯 Win32 API 把系统里已连接的 HID 设备逐个枚举出来,读回厂商字符串、产品字符串、设备路径和句柄,并且把操作封装在了一个可直接编译的 MFC 对话框程序里。你打开 Test_HID.sln 编译就能跑,省掉从零搭轮子、翻 MSDN 趟雷的时间。适合正在做 USB 设备管理工具、自动化测试夹具或者硬件调试小工具的开发者,也适合刚接触 Windows USB 编程、想找一个能下断点调试的参考实现的人。
2. HID 编程的基本盘:类驱动、SetupAPI 和 HidD 接口
2.1 为什么 HID 设备不需要自己写驱动
Windows 对 HID 类设备是内置驱动的,这一点是整个方案成立的前提。HID 设备挂在 USB 总线上之后,系统会识别它的接口描述符,如果发现这个接口的类代码是 0x03(HID 类),就自动加载系统自带的 hidserv 服务和 hidclass 驱动,不需要安装任何厂商驱动,也不需要在 INF 文件里做任何手工声明。这个机制意味着你的应用程序可以完全绕开驱动开发,直接通过用户态 API 和设备对话。
这跟 WinUSB 是两条路。WinUSB 是微软提供的通用 USB 驱动接口,适合厂商自定义的、非 HID 协议的设备,比如一个采集卡或者下载器;但 HID 设备不需要 WinUSB,因为系统已经帮你把设备驱动挂好了。你只需要在用户态调用HidD_GetAttributes、HidD_GetProductString这些 API,内核里的 HID 类驱动会替你完成真正的 USB 通信。换句话说,这份工程里你能看到的hidsdi.h和hid.lib,就是用户态和 HID 类驱动之间的桥梁。
2.2 枚举路径:SetupAPI 是入口,HidD 是听诊器
要用代码找到一个 HID 设备,完整路径是:先通过 SetupAPI 枚举出系统中所有符合 HID 类 GUID 的设备,拿到设备路径,然后再用这个路径去CreateFile打开设备,拿到句柄后,所有信息查询都通过HidD_*函数完成。两个阶段的职责完全不同:
- SetupAPI:负责设备发现,核心函数是
SetupDiGetClassDevs、SetupDiEnumDeviceInterfaces和SetupDiGetDeviceInterfaceDetail。它不关心设备具体是什么,只负责把属于某个设备接口类的实例一个个列出来。 - HidD(HID API,声明在
hidsdi.h中,实现在hid.lib里):负责设备信息读取,包括HidD_GetAttributes(拿 VID/PID/版本号)、HidD_GetManufacturerString(拿厂商字符串)、HidD_GetProductString(拿产品名)以及HidD_GetPreparsedData(拿解析过的报告描述符数据)。
值得注意的一个细节是:SetupDiGetClassDevs的第一个参数要传 HID 设备的接口类 GUID{4d1e55b2-f16f-11cf-88cb-001111000030},而不是设备类 GUID。这个值在hidsdi.h里有定义,叫做GUID_DEVINTERFACE_HID。如果你在代码里用的是设备类 GUID,枚举结果要么是空、要么把非 HID 的 USB 设备也掺进来,这是最常见的入门翻车点。
2.3 报告描述符:HID 设备的黑匣子
HID 报告描述符是这份资源里最容易被忽略、但最值得研究的部分。设备描述符告诉你设备是谁(VID/PID),而报告描述符告诉你设备能干什么——它的按钮、轴、LED、传感器分别对应哪些 usage 和 report ID。这份工程里的HidP_GetValueCaps和HidP_GetData调用,就是在解析报告描述符之后,把输入报告里的字段值读出来。
报告描述符本身是嵌套的结构,里面是 TLV 格式的 item。一个典型的鼠标报告描述符会定义两个 usage page(Generic Desktop 和 Button),一个输入报告包含 X/Y 轴和 3~5 个按键。直接读二进制很痛苦,常规做法是调用HidD_GetPreparsedData拿到 preparsed data 句柄,再用HidP_GetCaps拿报告长度上限和 usage 数量,最后用HidP_GetValueCaps展开每个 usage 的详细信息。这是理解 HID 设备行为的关键路径,调试自定义 hid 固件时尤其有用——你会发现固件里报告描述符写错了什么,在HidP_GetValueCaps的返回值里看得一清二楚。
3. 代码骨架拆解:从枚举设备到读回厂商和产品名的完整链路
3.1 枚举 HID 接口:三步拿到设备路径
打开Test_HIDDlg.cpp,程序的核心逻辑都在这个文件里。它在对话框初始化时会调用一个枚举函数,流程是标准的 SetupAPI 三连:获取设备信息集,逐个枚举设备接口,取设备接口详情中的路径。下面是把流程压缩后的关键代码(和工程中实现逻辑一致):
#include <windows.h> #include <setupapi.h> #include <hidsdi.h> #include <devguid.h> #pragma comment(lib, "setupapi.lib") #pragma comment(lib, "hid.lib") // HID 设备的设备接口 GUID,定义在 hidsdi.h 中 static const GUID GUID_DEVINTERFACE_HID = { 0x4d1e55b2, 0xf16f, 0x11cf, { 0x88, 0xcb, 0x00, 0x11, 0x11, 0x00, 0x00, 0x30 } }; void EnumerateHidDevices() { // 1. 获取所有 HID 设备接口的设备信息集 HDEVINFO hDevInfo = SetupDiGetClassDevs( &GUID_DEVINTERFACE_HID, // 设备接口类 GUID,不是设备类 GUID NULL, // 不按枚举器名过滤 NULL, // 不需要父窗口句柄 DIGCF_PRESENT | DIGCF_DEVICEINTERFACE // 只列出当前存在的设备接口 ); if (hDevInfo == INVALID_HANDLE_VALUE) { // 常见原因:GUID 传错,或当前系统无任何 HID 设备 return; } // 2. 逐个枚举设备接口 SP_DEVICE_INTERFACE_DATA ifcData = { 0 }; ifcData.cbSize = sizeof(SP_DEVICE_INTERFACE_DATA); for (DWORD devIdx = 0; ; devIdx++) { if (!SetupDiEnumDeviceInterfaces(hDevInfo, NULL, &GUID_DEVINTERFACE_HID, devIdx, &ifcData)) { break; // ERROR_NO_MORE_ITEMS 表示枚举结束 } // 3. 先拿缓冲区长度,再拿设备路径 DWORD reqSize = 0; SetupDiGetDeviceInterfaceDetail(hDevInfo, &ifcData, NULL, 0, &reqSize, NULL); PS P_DEVICE_INTERFACE_DETAIL_DATA pDetail = (PSP_DEVICE_INTERFACE_DETAIL_DATA)new BYTE[reqSize]; pDetail->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA); if (SetupDiGetDeviceInterfaceDetail(hDevInfo, &ifcData, pDetail, reqSize, &reqSize, NULL)) { // pDetail->DevicePath 就是 CreateFile 需要的设备路径 wchar_t* devicePath = pDetail->DevicePath; // 这里把路径存入列表控件,稍后用于打开设备 } delete[] pDetail; } SetupDiDestroyDeviceInfoList(hDevInfo); }这段代码的逻辑是:先用SetupDiGetClassDevs拿到一个设备信息集的句柄,这个句柄代表“系统里所有 HID 设备接口的集合”;然后用SetupDiEnumDeviceInterfaces在一个 for 循环里按索引取出每个接口;最后用SetupDiGetDeviceInterfaceDetail取设备路径。注意它的调用方式是两段式的,先传 NULL 拿所需缓冲区大小,再分配内存重新调用,这是 SetupAPI 系列函数的经典模式。
参数里值得留意的是DIGCF_PRESENT | DIGCF_DEVICEINTERFACE,前者只列出当前在线设备,后者表示按设备接口枚举而不是按设备节点枚举;缺少任何一个都会导致结果不对或干脆枚举不到任何接口。另外cbSize字段必须初始化为对应结构体的大小,否则 SetupAPI 会返回错误。
3.2 打开设备句柄:CreateFile 的权限和共享模式
拿到设备路径之后,下一步是打开设备获取句柄。工程里用的是CreateFile,注意它不只是用来打开文件的,Windows 的设备对象也用它来获取句柄。打开 HID 设备时重点关注三个参数:
HANDLE hDevice = CreateFile( devicePath, // 从 SetupDiGetDeviceInterfaceDetail 拿到的路径 GENERIC_READ | GENERIC_WRITE, // 通常需要读写权限 FILE_SHARE_READ | FILE_SHARE_WRITE, // 必须允许共享,否则会独占设备 NULL, // 默认安全属性 OPEN_EXISTING, // 设备必须已存在 FILE_ATTRIBUTE_NORMAL, // 普通属性即可 NULL // 模板文件句柄,设备文件不需要 ); if (hDevice == INVALID_HANDLE_VALUE) { // 常见原因:设备路径已失效、设备被其他进程独占 // 或者当前进程没有足够权限访问该设备 }共享模式是这里最容易踩的坑。如果只传 0 而不允许共享,第一次打开成功之后,第二次打开同一个设备会直接失败,因为句柄把设备锁死了。这在做设备管理工具的时候尤其麻烦——你可能在后台枚举设备做监控,用户又开了另一个工具去访问同一个 HID 设备,两边就互相打架了。
打开句柄之后,Test_HIDDlg.cpp里就能调用各种HidD_*函数了。获取厂商字符串要注意缓冲区类型,HidD_GetManufacturerString需要的是PVOID指向的宽字符缓冲区,缓冲区大小按字节数传,但返回值是wchar_t字符串。如果你把缓冲区按char数组传,读出来的字符串会是一堆乱码的宽字符。
3.3 读厂商和产品字符串:两个 API 搞定
wchar_t manufacturer[256] = { 0 }; wchar_t product[256] = { 0 }; wchar_t serial[256] = { 0 }; // 读厂商字符串,缓冲区按字节数计算,256 个 wchar_t 是 512 字节 if (HidD_GetManufacturerString(hDevice, manufacturer, sizeof(manufacturer))) { // manufacturer 里就是设备厂商,比如 "Microsoft" } if (HidD_GetProductString(hDevice, product, sizeof(product))) { // product 里就是产品名,比如 "Microsoft® 2.4GHz Receiver" } if (HidD_GetSerialNumberString(hDevice, serial, sizeof(serial))) { // serial 是序列号,不是所有 HID 设备都有 }这三个函数的行为比较直白:打开设备句柄后,HID 类驱动会向设备发起标准的 HID 描述符请求,设备返回字符串描述符,系统转成宽字符传给调用者。注意sizeof(manufacturer)传的是字节数,函数内部按wchar_t逐字拷贝。如果你的 HID 设备固件里根本没有实现字符串描述符,这些函数会返回 FALSE,不要硬读缓冲区内容。
工程里还调用了HidD_GetAttributes拿VID/PID/Version,这是判断设备身份最可靠的手段。厂商字符串和产品名理论上可以由固件随意改,但 VID 和 PID 在设备描述符里定义,不会乱变。实际做设备识别时,我一般把 VID/PID 组合作为主键,字符串信息只作为展示用途。
4. 读取报告描述符里的数据:HidP_GetValueCaps和HidP_GetData的配合使用
4.1 获取 preparsed data:打开报告描述符的第一把钥匙
要读取 HID 设备发送的输入报告,不能直接读原始字节流,必须先调用HidD_GetPreparsedData让系统解析设备的报告描述符。这个函数返回一个PHIDP_PREPARSED_DATA类型的句柄,内部是解析过的报告描述符树。后续所有HidP_*函数都依赖这个句柄来理解报告里各个字段的偏移、大小和含义。
PHIDP_PREPARSED_DATA preparsedData = NULL; if (HidD_GetPreparsedData(hDevice, &preparsedData)) { // 解析成功,preparsedData 可传给 HidP_GetCaps 和 HidP_GetValueCaps // 使用完毕后必须调用 HidD_FreePreparsedData 释放 } else { // 失败往往意味着设备刚被拔出,或设备返回了非法的报告描述符 }容易忽略的是释放操作。preparsedData是系统分配的内存块,不调用HidD_FreePreparsedData会导致每次打开设备都泄漏一份。在反复枚举、刷新设备列表的循环里,这个泄漏会越来越明显,实测跑一晚上内存能涨好几百 MB。
拿到 preparsed data 之后,下一步用HidP_GetCaps获取设备的顶层能力集合:
HIDP_CAPS caps = { 0 }; NTSTATUS status = HidP_GetCaps(preparsedData, &caps); // caps.UsagePage 和 caps.Usage 字段标识设备类型 // caps.InputReportByteLength 表示输入报告最大长度 // caps.NumberLinkCollectionNodes 表示顶层集合数量HidP_GetCaps返回的是NTSTATUS,不是 BOOL。判断是否成功要检查status == HIDP_STATUS_SUCCESS,如果直接用if (HidP_GetCaps(...))判断,你会发现成功时返回值是 0,被当成 FALSE 处理了。这是 HID API 和普通 Win32 API 的一个典型区别,也是很多人在这一步翻车的原因。
4.2 枚举所有 Value Caps:看设备到底报告了哪些数据
HidP_GetValueCaps会展开报告描述符里的所有数值字段。所谓“数值字段”,指的是那些报告具体数值的 usage,比如鼠标的 X/Y 轴、摇杆的 X/Y/Z 轴、传感器的温度/湿度。每个字段对应一个HIDP_VALUE_CAPS结构体,里面有详细的 usage page、usage ID、report ID、bit 偏移和位宽。
// 先拿需要的缓冲区大小 HIDP_VALUE_CAPS* valueCaps = NULL; USHORT valueCapsLength = 0; NTSTATUS status = HidP_GetValueCaps( HidP_Input, // 输入报告:设备发给主机 preparsedData, NULL, &valueCapsLength ); if (status == HIDP_STATUS_SUCCESS && valueCapsLength > 0) { valueCaps = new HIDP_VALUE_CAPS[valueCapsLength]; status = HidP_GetValueCaps(HidP_Input, preparsedData, valueCaps, &valueCapsLength); // 遍历 valueCaps 里的每一项,取出 UsagePage/Usage/ReportID/BitPosition } delete[] valueCaps;注意HidP_GetValueCaps的参数顺序,缓冲区长度是 in/out 参数,第一次传 NULL 进去会把需要的数组元素个数写回valueCapsLength。这里有两个细节值得说:
- 数组元素个数不是字节数,分配时用
new HIDP_VALUE_CAPS[valueCapsLength],不要乘sizeof(HIDP_VALUE_CAPS)后再分配字节数组。 - 有些低成本的 hid 固件会为了省空间,把一个 usage 定义成了数组或者用逻辑最小值/最大值来压缩数据范围,这些都会体现在
HIDP_VALUE_CAPS的LogicalMin/LogicalMax/BitSize字段上。固件和上位机对不上数据时,这些字段就是排查的起点。
4.3 从输入报告里解出具体值
拿到HIDP_VALUE_CAPS之后,要读取某一时刻的输入数据,整个调用链是:ReadFile读原始报告 →HidP_GetData按 report ID 解析数据 →HidP_GetUsageValue取出具体的值。工程里读取的流程大致是:
BYTE inputReport[64] = { 0 }; DWORD bytesRead = 0; // ReadFile 读一次输入报告,阻塞直到设备有数据 if (ReadFile(hDevice, inputReport, caps.InputReportByteLength, &bytesRead, NULL)) { // 解析报告里的第一个 input value ULONG value = 0; HIDP_DATA dataList[1] = { 0 }; dataList[0].ReportID = inputReport[0]; // 报告第一个字节是 report ID NTSTATUS status = HidP_GetData( HidP_Input, // 数据来自输入报告 preparsedData, // 之前拿到的解析句柄 dataList, // 输出数据数组 &dataListLength, // 数组长度,输入输出参数 preparsedData, // 设备句柄参数 inputReport, // ReadFile 读到的原始报告 caps.InputReportByteLength ); if (status == HIDP_STATUS_SUCCESS) { // dataList[0].RawValue 就是解析出来的值 ULONG rawValue = dataList[0].RawValue; // 结合 valueCaps 里的 LogicalMin/LogicalMax 换算成实际物理量 } }这里最容易出错的地方是dataListLength的初始值。它是一个 in/out 参数,传入时表示数组能容纳多少项,返回时表示实际填写了多少项。如果你传入 0 或者比实际需要的少,函数会返回HIDP_STATUS_BUFFER_TOO_SMALL,不会帮你自动扩容。实际做的时候,我习惯先把dataListLength设成HIDP_MAX_DATA_ITEMS或者从HidP_GetCaps里读到的 usage 数量上限,保证缓冲区足够大。
RawValue是原始值,要做一次线性变换才能真正对应到物理意义。比如一个摇杆轴,逻辑最小值是 0、最大值是 255,那么物理位置百分比就是RawValue * 100 / 255。如果固件里把同样的轴定义成了 16 位,那么RawValue的范围就是 0~65535,上位机按字节数硬解就要出错——这也是很多人在读 HID 摇杆数据时数值对不上的根源。
5. 避坑指南:HID 开发中四个高频问题
5.1 枚举结果总是缺设备或者根本为空
现象:程序跑起来之后,设备列表里只有一个鼠标,或者干脆一项都没有,但设备管理器里明明挂着好几个 HID 设备。
原因:第一种情况是SetupDiGetClassDevs的参数传错了,把DIGCF_DEVICEINTERFACE漏了,或者传的是GUID_DEVCLASS_HID而不是GUID_DEVINTERFACE_HID。第二种情况是程序以普通用户权限运行,某些设备接口详情需要管理员权限才能读取到完整路径,尤其是系统级保护设备。
解决:先检查 GUID 是不是{4d1e55b2-f16f-11cf-88cb-001111000030};然后把程序清单文件里的requestedExecutionLevel设为requireAdministrator,重新编译后再试。用管理员权限打开 Visual Studio 调试也能规避权限问题,但这只适合开发期,部署时要用清单文件而不是依赖手动右键管理员运行。
5.2HidD_GetProductString返回 FALSE,但设备明明连接正常
现象:设备路径枚举到了,CreateFile也成功了,一调HidD_GetProductString就返回 FALSE,GetLastError是ERROR_INVALID_HANDLE或ERROR_INVALID_PARAMETER。
原因:最常见的是设备固件根本没实现字符串描述符。HID 规范不强制要求设备提供 iManufacturer 和 iProduct 字符串,很多裸机 demo 代码和低成本 hid 固件直接跳过了这两个描述符。此时HidD_GetProductString自然失败。另外,如果CreateFile时只用了GENERIC_READ,某些设备对读权限的字符串查询会拒绝返回。
解决:先用HidD_GetAttributes拿 VID/PID,这个不依赖字符串描述符;如果拿到的 VID/PID 符合预期,就说明设备没问题。字符串查询失败时直接置空字符串,不要阻断整个枚举逻辑。实在需要字符串,就找固件工程师确认设备描述符里有没有实现字符串索引,没有的话改固件重新烧录。
5.3 输入报告里有数据,但HidP_GetData解析出来的值全为零
现象:ReadFile能读到 8 个字节的输入报告,但HidP_GetData返回成功,RawValue却全是 0,或者和实际按键/轴位置对不上。
原因:HIDP_DATA结构体里的ReportID字段没有正确匹配。对于带 report ID 的设备,输入报告的第一个字节就是 report ID,HidP_GetData内部靠这个 ID 来查找对应的 usage 集合。如果你把dataList[0].ReportID设成了 0 而设备实际用的是 1,解析就会拿到错误的数据或者是全零;对于不带 report ID 的设备,这个字段必须保持 0,不能瞎设。
解决:打印inputReport[0]的实际值,如果设备启用了 report ID,第一个字节通常是一个非零值,把它赋给dataList[0].ReportID。同时检查HIDP_VALUE_CAPS里的ReportID字段,确保和实际报告头一致。还有个细节:某些设备会动态切换报告 ID,这种情况下要按报告 ID 分别维护一套HIDP_DATA缓冲区,不能共用一个。
5.4 设备拔出时的崩溃和句柄错误,防不胜防
现象:程序在运行,拔掉 USB HID 设备,界面上还在刷新数据,结果要么崩溃、要么弹出一堆INVALID_HANDLE_VALUE或者ERROR_DEVICE_REMOVED的错误。
原因:设备拔出后,之前拿到的设备句柄和 preparsed data 句柄都成了悬垂资源。继续用ReadFile读数据会直接失败,继续调用HidD_*函数会访问已经释放的内核对象,严重时崩溃。很多初版代码都不做设备移除通知,只在打开设备那瞬间检查一次句柄是否有效。
解决:在对话框初始化时用RegisterDeviceNotification注册GUID_DEVINTERFACE_HID的通知,监听WM_DEVICECHANGE消息。收到DBT_DEVICEREMOVECOMPLETE时,把所有句柄关闭、preparsed data 释放,并从列表控件里删掉对应项。这个机制工程源码里已经有雏形,用的是 MFC 的OnDeviceChange重载,没有写具体业务逻辑的话需要自己补。从那以后我每次做 HID 工具都会把设备热插拔处理放在开发第一天就写进去,而不是最后补——补的时候调试成本远高于一开始就写好。
6. 把这套代码改成本机 HID 设备实时监控器:加过滤和热插拔显示
工程本身能枚举并展示所有 HID 设备,但如果只是弹一个列表,那它还停留在“教学示例”的层面。真正要把它变成能日常使用的工具,至少要做三件事:按 VID/PID 过滤目标设备、用设备通知实时刷列表、把厂商/产品字符串包装成容错显示。下面给出一个可直接替换的监控器核心实现思路,所有代码都基于这份工程已有的头文件和 API。
首先是按 VID/PID 过滤。在枚举循环里调用HidD_GetAttributes拿到HIDD_ATTRIBUTES结构体,然后判断VendorID和ProductID:
HIDD_ATTRIBUTES attrib = { 0 }; attrib.Size = sizeof(HIDD_ATTRIBUTES); if (HidD_GetAttributes(hDevice, &attrib)) { // 只保留目标设备的 VID/PID const USHORT targetVid = 0x1234; const USHORT targetPid = 0x5678; if (attrib.VendorID != targetVid || attrib.ProductID != targetPid) { CloseHandle(hDevice); continue; // 不感兴趣的设备,直接跳过 } }HIDD_ATTRIBUTES的Size字段必须提前填好sizeof(HIDD_ATTRIBUTES),否则HidD_GetAttributes返回 FALSE。这个结构体返回的VersionNumber对应的是设备的 bcdDevice 字段,可以用来区分同一型号的不同硬件版本——比如某个 hid 固件把版本从 1.0 升到 1.1,但 VID/PID 不变,上位机可以用版本号做差异化逻辑。
接着是设备通知。要求窗口类在注册时不带CS_HREDRAW | CS_VREDRAW的对话框里重载OnDeviceChange,代码大致是:
afx_msg LRESULT CTest_HIDDlg::OnDeviceChange(WPARAM wParam, LPARAM lParam) { PDEV_BROADCAST_HDR dbh = (PDEV_BROADCAST_HDR)lParam; if (dbh->dbch_devicetype == DBT_DEVTYP_DEVICEINTERFACE) { PDEV_BROADCAST_DEVICEINTERFACE pDevIf = (PDEV_BROADCAST_DEVICEINTERFACE)dbh; if (pDevIf->dbcc_classguid == GUID_DEVINTERFACE_HID) { if (wParam == DBT_DEVICEARRIVAL) { // 设备插入:重新执行一次枚举,把新设备加入列表 EnumerateHidDevices(); } else if (wParam == DBT_DEVICEREMOVECOMPLETE) { // 设备拔出:按设备路径清理列表项和相关句柄 RemoveDeviceByPath(pDevIf->dbcc_name); } } } return 0; }dbcc_name是设备接口路径,和枚举时拿到的DevicePath是同一个字符串,可以用它作为列表项的唯一标识。注意DBT_DEVICEREMOVECOMPLETE是设备完全移除后才发的通知,此时再访问句柄已经晚了;如果你之前的代码没有在收到通知时同步清理句柄,那读取线程里依然会撞上ERROR_DEVICE_REMOVED。安全做法是维护一个std::map<std::wstring, HANDLE>,读取线程在每次ReadFile前检查句柄是否还在这个表里,不在就直接退出。
最后是字符串容错显示。因为很多 HID 设备的固件不实现字符串描述符,HidD_GetManufacturerString失败时直接显示 VID/PID 更实用:
wchar_t displayName[256] = { 0 }; if (!HidD_GetProductString(hDevice, displayName, sizeof(displayName))) { // 字符串描述符不可用时,退回 VID/PID 格式 swprintf_s(displayName, L"HID Device (VID:%04X PID:%04X)", attrib.VendorID, attrib.ProductID); }这几个字段组合起来,能解决一个很实际的问题:你在调试那个自定义 hid 固件时,设备管理器和各种 USB 查看工具都只能显示“HID-compliant device”这种含糊名字,而这个程序能直接显示你从固件里写进去的 VID/PID 和产品名,一眼就能确认当前枚举到的是不是你正在调试的那台设备。
这套代码的完整骨架在Test_HIDDlg.cpp和Test_HIDDlg.h里都能找到,你需要做的是把事件分发和过滤条件补进去。顺着这个改法做完之后,这个工具就能从“演示枚举器”变成一个真正能盯设备的监控面板。从那以后我每次拿到一台不知道叫什么名字的 USB 外设,都会先跑一遍这个程序看它的实际报告结构和字符串能力,再决定上层应用怎么做解析。希望帮到你。
本文还有配套的精品资源,点击获取