多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Visual C++枚举USB HID设备:SetupAPI与hid.dll实战

Visual C++枚举USB HID设备:SetupAPI与hid.dll实战 简介面向Visual C开发者的USB编程参考资源专注HID设备检测与信息获取解决USB外设识别、状态监控等实际问题适合设备驱动调试、自动化测试及嵌入式开发场景。压缩包共30个文件以15个.h头文件和3个.cpp源文件为核心涵盖设备描述与API声明并提供Visual Studio工程文件vcproj/sln、导入库lib及界面资源整体仅89KB结构紧凑便于快速定位关键代码。已有145人学习下载。通过完整示例可掌握SetupAPI枚举设备、根据HID类代码筛选目标、CreateFile打开设备、DeviceIoControl与HidD_*函数读取制造商/产品信息等流程同时理解WinUSB、HIDClass、HID报告描述符等关键概念并深入体会设备插拔通知、错误恢复与资源释放的细节为后续驱动开发、底层硬件编程或设备监控工具设计提供可直接借鉴的代码基础。项目同时展示了规范的错误处理与内存管理方式有助于养成严谨的Windows编程习惯。1. Computer_HID_detect.zip在解决什么一次枚举出全部USB HID设备的Visual C工程做USB外设验证、键盘记录、游戏外设配置工具或者只是想知道电脑上插了哪些HID设备的人手里十有八九都缺一个能直接跑起来的枚举程序。Computer_HID_detect.zip 这个 Visual C 工程解决的就是这一件事把当前系统里全部USB HID设备——键盘、鼠标、手柄、条码枪、带按键的HID键盘——的VID、PID、产品字符串一次性列出来而不是去设备管理器里一个个点开属性。它暴露的是Windows为USB编程提供的隐藏接口SetupAPI负责枚举设备接口hid.dll负责读取HID描述符。这套组合是传统设备管理工具里最常见的枚举方案不需要安装额外驱动也不需要写内核代码。2. 原理与选型HID设备的枚举路径为什么偏偏是SetupAPI加hid.dll很多刚接触USB编程的人第一反应是去读USB总线上的设备描述符像写单片机固件一样自己发起控制传输。Windows上这条路基本走不通用户态程序拿不到总线级别的访问权。真正能干活的是Windows已经封装好的设备接口机制。2.1 HID(人机接口设备)到底是什么从设备描述符到设备接口HID人机接口设备Human Interface Device是USB规范里专门为键盘、鼠标、游戏手柄这类交互设备定义的设备类别。它跟普通串口设备最大的区别在于HID设备通过HID报告描述符Report Descriptor来声明自己有哪些输入、输出、功能项而不是靠固定的端点格式。这个报告描述符是HID固件里写死的应用程序想搞清楚设备的行为必须先从设备里把这段描述符读出来。Windows对HID设备的抽象是把每个符合HID类规范的设备接口注册成一个“设备接口”Device Interface并给出一串设备路径Device Path。这串路径长得像这样\\?\HID#VID_046DPID_C539MI_00#81c8d7a2400000#{4d1e55b2-f16f-11cf-88cb-001111000030}。里面能直接看到VID、PID、MI多个接口索引和GUID尾巴。尾巴就是HID设备接口类的GUID枚举的核心就是拿着这个GUID去系统里捞所有挂了这个接口的设备。这里要区分两个概念设备节点和设备接口。设备节点Device Node是驱动栈里的一个物理或功能设备键盘、鼠标这些往往组合在一个复合设备里设备接口则是某个功能驱动暴露给应用层的入口。HID枚举更关注设备接口因为应用层跟HID设备通信靠的就是接口路径拿到路径才能打开设备。2.2 SetupAPI、hid.dll和Raw Input三条路到底选谁这就是Computer_HID_detect这类工程面临的技术选型。Windows上枚举HID设备有几种常见做法我列一个比对表方便后续做选择。枚举方式入口能拿到的信息适用场景缺点SetupAPI hid.dllSetupDiGetClassDevs / HidD_GetAttributes设备路径、VID、PID、版本号、产品/厂商字符串以及Preparsed Data里的Usage通用枚举、设备管理工具需要处理变长缓冲区和设备路径Raw Input APIGetRawInputDeviceList设备句柄、类型键盘/鼠标/HID、RIDI_DEVICEINFO里的VID/PID实时输入事件处理、游戏外设只报告有输入配对的设备不关心未激活设备注册表枚举遍历HKLM\SYSTEM\CurrentControlSet\Enum\HID设备实例ID、硬件ID静态信息查询字段结构不公开维护成本高直接USB控制传输WinUSB / 驱动开发原始描述符固件开发、定制驱动需要管理员权限和WinUSB驱动不适合通用工具SetupAPI加hid.dll的组合胜在覆盖面最全。Raw Input能枚举的设备基本都能在SetupAPI下找到但SetupAPI还能看到Raw Input看不到的设备比如一台只挂载HID接口、没有输入焦点的设备。注册表路径依赖内部结构不同Windows版本有过调整拿来写临时排查脚本可以做成稳定工具不推荐。所以Visual C实现USB编程的HID检测默认组合就是SetupAPI负责枚举路径hid.dll负责读属性。2.3 设备路径是整条枚举链路的核心理解了选型再往下走会发现整个枚举过程实际上就是围绕设备路径展开的。SetupAPI返回给程序的不是VID/PID列表而是一批设备路径程序拿到路径后用CreateFile打开再调用hid.dll里的函数读取设备属性。这跟很多人想的不一样VID、PID不是枚举阶段直接给的是打开设备之后通过HidD_GetAttributes读出来的。为什么Windows不直接返回VID/PID因为设备接口是一个容器同一个物理设备可以暴露多个HID接口每个接口的路径都不一样。键盘鼠标接收器这类复合设备尤其明显一个USB接收器插进去系统里会出现多个HID设备分别对应键盘、鼠标、多媒体控制。只凭VID/PID区分不了同一厂商的多个接口必须依赖设备路径里的MI索引和接口GUID。后续第4章代码里你会看到枚举循环中拿到detail-DevicePath之后才去打开设备就是因为这个原因。3. 工程搭建在Visual C里把最小枚举工程先跑起来很多从别的语言转过来的程序员拿到Computer_HID_detect.zip第一件事是找Main函数结果发现头文件都找不到。Visual C工程玩HID头文件、链接库、字符集三个地方任何一个没配对编译报错能绕晕人。这一章先把地基打好。3.1 新建Visual C控制台应用关掉预编译头干扰我习惯用Visual Studio的“控制台应用”模板建空工程语言选C。这里有个小坑模板默认开“预编译头”新建的工程会要求你必须包含pch.h。做这种小工具完全不需要预编译头直接在项目属性里把“预编译头”改成“不使用”把pch.cpp移除能省掉很多莫名其妙的编译错误。创建好工程后确认项目配置里字符集不是“使用多字节字符集”。新版本Visual Studio默认是Unicode这是合理的。HID的字符串接口全是宽字符WCHARUnicode字符集正好匹配。如果老工程用了多字节也不是不能跑但所有HidD_GetProductString这类调用都要做窄宽转换属于给自己找麻烦。工程创建完成后可以在源文件里先写一个空main函数编译通过一次确认环境没问题再往下加代码。3.2 四个头文件和一个链接库少一个都编译不过编译HID枚举代码需要确保能include进这几个头文件。它们不是C标准库而是Windows SDK自带的路径在C:\Program Files (x86)\Windows Kits\10\Include\下面。需要引入的头文件如下。#include windows.h #include setupapi.h #include hidclass.h #include hidsdi.h #include hidpi.h每个头文件管的事不一样setupapi.h提供设备信息集和设备接口枚举函数hidclass.h定义了HID设备的接口GUID也就是GUID_DEVINTERFACE_HIDhidsdi.h是hid.dll的函数声明比如HidD_GetAttributes、HidD_GetProductStringhidpi.h用于处理HID报告描述符后面识别设备类型时需要用到。光有头文件还不够Visual C链接阶段需要两个导入库最稳的方式是在代码顶部用#pragma comment声明。这样做的理由是即使项目配置里忘了加附加依赖项只要代码里写了这个声明链接器就会自动带上对应的.lib。#pragma comment(lib, setupapi.lib) #pragma comment(lib, hid.lib)setupapi.lib对应SetupAPI这套枚举函数hid.lib对应hid.dll导出函数。这两个库在所有桌面版Windows上都存在不需要额外部署运行库。还有一点setupapi.lib在64位工程理论上会链接到setupapi.dll如果编译出现LNK2019 unresolved external symbol第一反应就是检查这两个lib有没有加全而不是去怀疑代码逻辑。3.3 Unicode和宽字符避免第一眼乱码HID设备返回的产品字符串、厂商字符串、序列号在Windows内部全部是UTF-16编码的宽字符。Visual C里对应的类型是WCHAR或wchar_t用wprintf或OutputDebugStringW才能正确显示。这里有一个Visual C特有的玄学问题wprintf在默认控制台代码页下可能输出不了中文因为stdout的流模式还是ANSI。常见做法是入口处调用setlocale(LC_ALL, )让宽字符输出按当前系统区域设置转换。如果你的代码里不做这个调用枚举出来的中文产品名会变成乱码英文倒是正常。很多人在这一步翻车以为是自己字符串没读对其实是输出函数没配对。另外注意区分sizeof和字符个数。HidD_GetProductString的最后一个参数虽然名字叫BufferLength但语义是字节数不是字符数。传sizeof(product)是对的传wcslen(product)这种就是错的。这一点会在第4章代码里再次强调。3.4 第一次编译通过验证头文件和库都没问题写完这些准备工作可以先用一个最简代码验证环境声明GUID guid GUID_DEVINTERFACE_HID;然后打印这个GUID编译跑一下。这一步能验证头文件路径是否有效、字符集是否配好、lib声明是否生效。如果连这个都编译不过问题基本集中在include路径和预编译头配置上。GUID的值在hidclass.h里有一行定义常见情况下这个值不会变就是{4D1E55B2-F16F-11CF-88CB-001111000030}。不要自己去手写GUID最好是直接引用宏防止系统未来更换GUID导致程序失效。4. 核心实现从SetupAPI枚举到HID属性读取的完整代码这一章把Computer_HID_detect的核心流程拆成四步每一步对应一个Windows API调用组合。我会给出可以直接编译的完整代码并且在代码之后说明每一步的逻辑和参数含义。整个过程不涉及驱动开发和固件修改纯用户态API。4.1 第一步SetupDiGetClassDevs创建设备信息集枚举的入口是SetupDiGetClassDevs。这个函数按指定的设备接口GUID从系统中收集当前存在的所有匹配设备返回一个设备信息集句柄。它的参数含义分别是第一个参数传入guid表示要匹配的接口类GUID第二个参数指定枚举范围通常传NULL表示所有设备第三个参数是枚举标志第四个参数DIGCF_PRESENT | DIGCF_DEVICEINTERFACE非常关键。GUID guid GUID_DEVINTERFACE_HID; HDEVINFO hDevInfo SetupDiGetClassDevs(guid, NULL, NULL, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); if (hDevInfo INVALID_HANDLE_VALUE) { printf(SetupDiGetClassDevs failed, error%lu\n, GetLastError()); return 1; }参数说明DIGCF_PRESENT表示只枚举当前存在的设备不枚举历史残留节点DIGCF_DEVICEINTERFACE表示按设备接口方式枚举而不是按设备节点方式枚举。这两个标志必须同时出现。漏掉DIGCF_PRESENT会出现枚举到已拔出设备的情况漏掉DIGCF_DEVICEINTERFACE则拿到的可能是设备节点信息而不是设备路径后续SetupDiEnumDeviceInterfaces会直接失败。这里有个习惯性坑要提前说很多人会把返回值跟NULL比较实际上该函数返回INVALID_HANDLE_VALUE也就是(HDEVINFO)-1表示失败。判断条件写错的话在成功场景没事失败场景可能导致误判。4.2 第二步SetupDiEnumDeviceInterfaces轮询所有HID接口拿到设备信息集后用SetupDiEnumDeviceInterfaces循环枚举每个HID接口。这个函数的特点是每调用一次返回一个接口继续调用直到返回FALSE且GetLastError等于ERROR_NO_MORE_ITEMS表示枚举完毕。SP_DEVICE_INTERFACE_DATA ifData; ifData.cbSize sizeof(ifData); for (DWORD index 0; SetupDiEnumDeviceInterfaces(hDevInfo, NULL, guid, index, ifData); index) { // 先获取设备接口详情所需的缓冲区大小 DWORD requiredSize 0; SetupDiGetDeviceInterfaceDetail(hDevInfo, ifData, NULL, 0, requiredSize, NULL); }这里的SP_DEVICE_INTERFACE_DATA结构体在调用前必须初始化cbSize否则函数会返回ERROR_INVALID_USER_BUFFER。SetupDiGetDeviceInterfaceDetail第一次调用故意传NULL缓冲区和0长度让它返回requiredSize。这一步经常有人嫌麻烦直接分配一个固定大小的缓冲区比如1024字节绝大部分设备是够用的但遇到带有超长设备路径的设备可能溢出。SetupDiGetDeviceInterfaceDetail这个函数的设计跟GetWindowText那类“先查长度再取数据”的API一个套路。第一次调用返回FALSE并设置ERROR_INSUFFICIENT_BUFFER这不是错误是预期行为。真正需要检查的是requiredSize有没有大于0。4.3 第三步分配缓冲区获取设备路径并用CreateFile打开拿到requiredSize后分配一块内存存放SP_DEVICE_INTERFACE_DETAIL_DATA结构体。这个结构体的末尾是一个可变长度数组DevicePath所以内存大小必须用第一次调用返回的值而不是sizeof这个结构体。PSP_DEVICE_INTERFACE_DETAIL_DATA detail (PSP_DEVICE_INTERFACE_DETAIL_DATA)malloc(requiredSize); if (!detail) { continue; } detail-cbSize sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA); SP_DEVINFO_DATA devInfoData; devInfoData.cbSize sizeof(devInfoData); if (!SetupDiGetDeviceInterfaceDetail(hDevInfo, ifData, detail, requiredSize, NULL, devInfoData)) { free(detail); continue; }这里有一个细节很多人搞不明白detail-cbSize到底应该赋多少正确答案是sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA)不是requiredSize也不是detail分配的总大小。该字段的含义是告诉Windows结构体的固定头部有多大Windows用它来确定DevicePath数组的起始偏移。赋成requiredSize会导致路径解析错位轻则路径带乱码重则CreateFile打不开设备。拿到路径之后CreateFile打开设备。这里要注意共享模式HID设备通常已经被系统驱动打开如果打开时共享模式给0会得到拒绝访问错误。正确写法是共享读和写。HANDLE hDevice CreateFile(detail-DevicePath, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if (hDevice INVALID_HANDLE_VALUE) { free(detail); continue; }GENERIC_READ | GENERIC_WRITE是hid.dll很多函数的要求尤其是HidD_GetPreparsedData只给读权限有时拿不到完整的报告描述符。OPEN_EXISTING表示只能打开已存在的设备。CreateFile的最后一个参数hTemplateFile必须传NULLHID设备不支持模板文件。4.4 第四步HidD_GetAttributes读取VID/PIDHidD_GetProductString读产品名设备打开成功后调用HidD_GetAttributes读取HIDD_ATTRIBUTES结构体里面包含VendorID、ProductID、VersionNumber。这个结构体的Size成员必须提前赋值为sizeof(HIDD_ATTRIBUTES)驱动会校验这个值。HIDD_ATTRIBUTES attr; attr.Size sizeof(attr); if (HidD_GetAttributes(hDevice, attr)) { printf(VID%04X PID%04X Version%u\n, attr.VendorID, attr.ProductID, attr.VersionNumber); } else { printf(HidD_GetAttributes failed, error%lu\n, GetLastError()); } WCHAR product[256] {0}; if (HidD_GetProductString(hDevice, product, sizeof(product))) { wprintf(LProduct: %s\n, product); }产品字符串的缓冲区固定给256个WCHAR够用且不过分。前面强调过HidD_GetProductString的第三个参数是字节数所以写sizeof(product)才正确。如果你写256实际表示256字节只能装128个宽字符虽然够用但不严谨。产品字符串并不是所有设备都提供HID固件里可以不实现这个字符串描述符此时函数返回FALSE程序直接跳过即可不要把它当成枚举失败。最后要释放资源CloseHandle(hDevice)关闭设备句柄free(detail)释放缓冲区。这两步不能少枚举循环跑几十个设备时泄漏的设备句柄会导致后续打开新设备失败。4.5 最终可编译的完整代码把前面四步合在一起就是一个完整的Visual C HID枚举程序。// HID设备枚举列出系统中所有USB HID接口的VID/PID和产品名 #include windows.h #include setupapi.h #include hidclass.h #include hidsdi.h #include stdio.h #include stdlib.h #pragma comment(lib, setupapi.lib) #pragma comment(lib, hid.lib) int main() { setlocale(LC_ALL, ); GUID guid GUID_DEVINTERFACE_HID; HDEVINFO hDevInfo SetupDiGetClassDevs(guid, NULL, NULL, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); if (hDevInfo INVALID_HANDLE_VALUE) { printf(SetupDiGetClassDevs failed: %lu\n, GetLastError()); return 1; } SP_DEVICE_INTERFACE_DATA ifData; ifData.cbSize sizeof(ifData); for (DWORD index 0; SetupDiEnumDeviceInterfaces(hDevInfo, NULL, guid, index, ifData); index) { DWORD requiredSize 0; SetupDiGetDeviceInterfaceDetail(hDevInfo, ifData, NULL, 0, requiredSize, NULL); if (requiredSize 0) { continue; } PSP_DEVICE_INTERFACE_DETAIL_DATA detail (PSP_DEVICE_INTERFACE_DETAIL_DATA)malloc(requiredSize); if (!detail) { continue; } detail-cbSize sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA); SP_DEVINFO_DATA devInfoData; devInfoData.cbSize sizeof(devInfoData); if (!SetupDiGetDeviceInterfaceDetail(hDevInfo, ifData, detail, requiredSize, NULL, devInfoData)) { free(detail); continue; } HANDLE hDevice CreateFile(detail-DevicePath, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if (hDevice INVALID_HANDLE_VALUE) { free(detail); continue; } HIDD_ATTRIBUTES attr; attr.Size sizeof(attr); if (HidD_GetAttributes(hDevice, attr)) { printf(VID%04X PID%04X Version%u\n, attr.VendorID, attr.ProductID, attr.VersionNumber); } WCHAR product[128] {0}; if (HidD_GetProductString(hDevice, product, sizeof(product))) { wprintf(L Product: %s\n, product); } CloseHandle(hDevice); free(detail); } SetupDiDestroyDeviceInfoList(hDevInfo); return 0; }代码逻辑说明外层SetupDiEnumDeviceInterfaces的循环控制所有枚举次数内层每一步都做了缓冲区检查和资源释放。devInfoData的cbSize必须初始化虽然在这个例子里它没有被使用但Windows要求传入前必须设置好。SetupDiDestroyDeviceInfoList用于释放设备信息集不能缺失。整个程序不依赖特定HID设备凡是接入系统的HID接口都会被列出包括键盘、鼠标、触摸板、游戏手柄甚至一些自定义HID设备。5. HID枚举排查手册设备类型识别、5个高频坑与避坑经验枚举代码跑起来之后更麻烦的是拿到一堆VID/PID却不知道它们各自是什么设备。这一章先讲怎么从HID报告描述符里识别设备类型再重点讲实际调试中高频出现的5个坑每条按“现象、原因、解决”展开。5.1 用Usage Page和Usage识别设备类型不要靠VID/PID猜HID协议里设备类型不写在VID/PID里而是由报告描述符里的Usage Page和Usage决定。顶层集合Top-Level Collection会声明自己是什么用途。常见值如下。Usage PageUsage设备类型0x01 Generic Desktop0x02鼠标0x01 Generic Desktop0x06键盘0x01 Generic Desktop0x05游戏手柄0x07 Keyboard0x00~0xFF键盘按键0x0C Consumer0x01消费类控制键注意键盘会出现两次Generic Desktop里的Usage 0x06是键盘顶层的用途声明Usage Page 0x07是按键数组。判断一个HID接口是不是键盘最简单是看HIDP_CAPS结构体里的UsagePage 0x01 Usage 0x06。鼠标同理。要拿到这两个值需要先调用HidD_GetPreparsedData取得解析后的报告描述符再调用HidP_GetCaps读取能力结构。下面是一段独立函数配合第4章代码的中段调用。PHIDP_PREPARSED_DATA preparsed NULL; if (HidD_GetPreparsedData(hDevice, preparsed)) { HIDP_CAPS caps; if (HidP_GetCaps(preparsed, caps) HIDP_STATUS_SUCCESS) { printf(UsagePage%04X Usage%04X\n, caps.UsagePage, caps.Usage); } HidD_FreePreparsedData(preparsed); }HidD_GetPreparsedData会在内部为设备分配报告描述符解析数据使用完必须调用HidD_FreePreparsedData释放否则每次枚举都会泄漏内存。HidP_GetCaps返回的是这个HID接口顶层的用途不是单个按键的用法拿来做类型识别足够了。5.2 坑一HidD_GetProductString返回乱码或者全空现象枚举出来的产品字符串要么是一串乱码要么是空但VID/PID正常。原因通常有两个一是程序字符集没配对产品字符串本身是宽字符却被当成窄字符打印二是HID设备固件里根本没实现产品字符串描述符Windows无法提供这个信息。第二个原因在自制HID设备上特别常见很多开发板固件只写了必需的设备描述符和报告描述符跳过了字符串描述符。解决打印时用wprintf(L%s, product)不要用printf(%s, product)判断HidD_GetProductString返回值返回FALSE时显示“未知设备”而不是继续用未初始化的缓冲区。5.3 坑二SetupDiGetDeviceInterfaceDetail第一次调用必失败被当成真错误现象程序枚举到第一个设备就直接退出日志显示SetupDiGetDeviceInterfaceDetail failed错误码是ERROR_INSUFFICIENT_BUFFER。原因这个API的设计就是先失败一次把需要的缓冲区大小传给调用者很多新手看到FALSE就认为调用失败跳过了后续步骤。解决先判断GetLastError() ERROR_INSUFFICIENT_BUFFER这是正常流程再判断requiredSize大于0后继续分配缓冲区。如果你嫌麻烦分配一个固定的大缓冲区然后直接调用也是可行的省掉第一次调用但需要保证缓冲区足够大Windows 10 22H2上设备路径最长不会超过512字节固定分配1024字节实际上不会出问题。5.4 坑三枚举不到键盘和鼠标但设备管理器里明明有现象运行枚举程序没有键盘鼠标只有几个不认识的HID设备。原因电脑自带的内置键盘、触摸板、USB鼠标在系统里通常挂在一个叫“符合HID标准的用户控制设备”的父节点下它们不直接暴露HID设备接口路径。还有个常见情况笔记本内置键盘走的是ACPI或PS/2接口不是USB HID自然不在这个枚举范围里。解决明确程序的定位Computer_HID_detect枚举的是USB HID设备接口不是所有输入设备。如果要连PS/2键盘也抓到需要枚举GUID_DEVINTERFACE_KEYBOARD和GUID_DEVINTERFACE_MOUSE这类设备类GUID或者改用Raw Input API统一获取所有输入设备。5.5 坑四枚举第二次开始变慢甚至CreateFile失败现象程序第一次运行枚举正常第二次运行能枚举到一部分设备后面开始CreateFile返回ERROR_ACCESS_DENIED。原因上一次运行的程序没有调用CloseHandle或者枚举循环中有一个设备句柄没关导致HID设备被占用。HID设备不像普通文件支持多重独占尤其是键盘鼠标这些已经被系统驱动独占的设备你的程序用非共享方式打开过一次不关闭第二次就打不开。解决代码里保证每个CreateFile成功之后一定配一个CloseHandle排查时可以用进程资源管理器看句柄数也可以用handle.exe查找占用HID的进程。这个话题属于设备管理的常见坑但实际中非常容易碰到。5.6 坑五USB接收器上的无线设备只显示一个父设备现象罗技的Unifying接收器或雷蛇的无线接收器插上后枚举结果只有一个HID接口没有分别列出键盘和鼠标。原因这类接收器内部把自己注册成一个复合HID设备在同一个物理接口下通过不同的Report ID区分键盘鼠标所以系统只分配一个HID接口路径。解决遇到这种设备单靠枚举解决不了需要读取报告描述符里的Report ID和Usage集合逐条解析每个Report ID对应的用途。HidP_GetCaps能给出集合内Usage数量但解析多Report ID逻辑比较复杂如果是做外设检测工具建议在枚举展示时标注“复合HID设备”并提示用户查看设备管理器子项。6. 用Raw Input反向验证枚举结果并处理蓝牙HID的识别差异枚举程序能列出设备只是第一步能不能投入实际使用还得靠验证。我自己的习惯是拿Raw Input API做一次交叉校验两个独立的数据源比对后心里才有底。这一章讲这个验证技巧顺便聊一下蓝牙HID设备在这套流程里的表现差异。6.1 用Raw Input获取设备列表和SetupAPI枚举结果做交叉对照Raw Input API也能枚举HID设备但路径完全不同。关键函数是GetRawInputDeviceList先调用一次获取设备数量再调用一次填充设备列表。这里的设备句柄和枚举设备路径不是一回事但无线键鼠这类设备通过Raw Input能拿到VID/PID与HidD_GetAttributes读到的一致。UINT deviceCount 0; GetRawInputDeviceList(NULL, deviceCount, sizeof(RAWINPUTDEVICELIST)); PRAWINPUTDEVICELIST pList (PRAWINPUTDEVICELIST)malloc( deviceCount * sizeof(RAWINPUTDEVICELIST)); GetRawInputDeviceList(pList, deviceCount, sizeof(RAWINPUTDEVICELIST)); for (UINT i 0; i deviceCount; i) { RID_DEVICE_INFO info; info.cbSize sizeof(info); UINT infoSize sizeof(info); if (GetRawInputDeviceInfo(pList[i].hDevice, RIDI_DEVICEINFO, info, infoSize) ! (UINT)-1) { if (info.dwType RIM_TYPEHID) { printf(RawInput HID: VID%04X PID%04X\n, info.hid.dwVendorId, info.hid.dwProductId); } } } free(pList);逻辑说明GetRawInputDeviceList第一次调用返回数量第二次调用填充列表这和SetupDiGetDeviceInterfaceDetail的两段式调用很像。RID_DEVICE_INFO里的dwType区分设备类型RIM_TYPEHID表示通用HID设备键盘鼠标的dwType是RIM_TYPEKEYBOARD和RIM_TYPEMOUSE。验证时把Raw Input拿到的VID/PID集合和第4章枚举结果做差集如果差集里只有键盘鼠标说明SetupAPI枚举也覆盖了它们只是可能从不同的接口路径暴露。两边都验证过数据才敢用于设备管理工具。6.2 蓝牙HID设备在枚举时的表现蓝牙HID人机接口设备的底层传输走的是蓝牙协议不是USB但Windows驱动层会尽力让应用层无感。实际表现是蓝牙键盘鼠标在SetupDiGetClassDevs的HID GUID下也能被枚举到设备路径里的字符从HID\VID_xxxx变成了BTHENUM\{xxxx}之类的内容。是什么导致了这个差异因为蓝牙HID的驱动暴露了同样的HID接口模拟成HID设备节点所以上层枚举逻辑不需要区分USB还是蓝牙。真正需要注意的地方是蓝牙HID设备经常不会主动报告所有描述符尤其是还没有建立连接的蓝牙设备枚举阶段可能看不到。另外HidD_GetAttributes对蓝牙HID设备的支持不如USB那么稳定个别蓝牙适配器会返回失败。我在实际中遇到过一种情况蓝牙键盘连上后枚举能看到接口但HidD_GetProductString返回空原因就是蓝牙HID配置文件HID Profile没有完整传递字符串描述符。解决方案是不要依赖产品字符串做业务键用VID/PID加设备路径的组合来区分设备。还有一点做蓝牙HID程序时要注意设备配对和连接状态对枚举的影响。拔掉USB接收器枚举列表会立刻变化蓝牙鼠标只是休眠枚举列表里还在但打开设备可能失败。给这类设备做状态判断时务必检查CreateFile是否成功而不是只看枚举结果。6.3 一个能救命的调试习惯先把输出重定向到文件再分析枚举类程序在开发阶段最难受的问题是控制台缓冲区不够设备一多早期打印的信息被冲掉要么就看到半截。我现在的做法是开发时让程序把枚举结果同时写到一个CSV文件里一行一个设备字段包括VID、PID、产品字符串、UsagePage、Usage、设备路径。这样跑一次就能在Excel里排序筛选对比两次枚举结果是否一致。写文件时注意用_wfopen配合宽字符路径或者用ofstream开二进制模式避免中文产品名写进CSV后出现乱码。这里分享一个我踩过的具体教训第一版验证程序把设备和Usage数据全打印在控制台里插上一个带十几个HID接口的模拟器后控制台直接溢出丢失了前半段数据我一度以为枚举循环有漏。改成文件输出后发现16个接口都枚举出来了只是控制台显示不全。调试USB编程永远不要依赖控制台窗口那几千行的缓冲区。整体来看Computer_HID_detect这套方案的边界很清楚它能枚举所有HID接口能拿到VID/PID和产品信息还能通过Usage区分设备类型它不能做的是在设备未激活或已拔出时提供可靠状态也不能绕过系统驱动去和HID固件自由通信。对绝大多数设备管理、外设检测、输入监控工具来说Visual C加SetupAPI加hid.dll这条路已经足够覆盖需求。希望你也能从这套工程里跑出第一份完整的设备清单后面遇到设备识别不准时记得先查Report Descriptor不要猜。本文还有配套的精品资源点击获取
返回列表