简介:面向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_046D&PID_C539&MI_00#8&1c8d7a24&0&0000#{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.dll | SetupDiGetClassDevs / HidD_GetAttributes | 设备路径、VID、PID、版本号、产品/厂商字符串,以及Preparsed Data里的Usage | 通用枚举、设备管理工具 | 需要处理变长缓冲区和设备路径 |
| Raw Input API | GetRawInputDeviceList | 设备句柄、类型(键盘/鼠标/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的字符串接口全是宽字符(WCHAR),Unicode字符集正好匹配。如果老工程用了多字节,也不是不能跑,但所有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_HID;hidsdi.h是hid.dll的函数声明,比如HidD_GetAttributes、HidD_GetProductString;hidpi.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必须传NULL,HID设备不支持模板文件。
4.4 第四步:HidD_GetAttributes读取VID/PID,HidD_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(L"Product: %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 Page | Usage | 设备类型 |
|---|---|---|
| 0x01 Generic Desktop | 0x02 | 鼠标 |
| 0x01 Generic Desktop | 0x06 | 键盘 |
| 0x01 Generic Desktop | 0x05 | 游戏手柄 |
| 0x07 Keyboard | 0x00~0xFF | 键盘按键 |
| 0x0C Consumer | 0x01 | 消费类控制键 |
注意键盘会出现两次: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,不要猜。
本文还有配套的精品资源,点击获取