用Visual C++实现USB HID设备枚举:从原理到代码实战

发布时间:2026/10/26 16:52:34
用Visual C++实现USB HID设备枚举:从原理到代码实战

简介:一份基于 Visual C++ 的 USB HID 设备检测工程示例,压缩包内共 30 个文件,大小约 89KB。面向 Windows 平台 C++ 开发者,解决如何借助系统提供的设备管理接口识别并读取已连接 HID 设备信息的问题,可应用于键盘、鼠标、游戏控制器等输入设备的外设管理或监控工具开发。包内以 h 头文件(15 个)、cpp 源文件(3 个)为核心,完整展示了从枚举 USB 设备、比较设备类代码数值是否为十六进制的 0x03 来筛选 HID 类设备,到打开设备句柄、调用相关接口获取设备厂商与产品名称,再到读取 HID 输入报告的整个流程;同时附带 lib 库文件、rc 资源文件和工程配置文件,便于在 Visual C++ 中直接编译调试,并通过清晰的目录结构快速定位各代码模块。已有 145 人学习下载,适合想要系统掌握 Windows USB 编程基础、理解 HID 报告描述符或需要参考完整工程组织方式的开发人员,也可作为设备识别类小工具的起步框架。

1. 用 Visual C++ 复现 Computer_HID_detect:USB HID 设备枚举到底在做什么

Computer_HID_detect.zip 是 Visual C++ 社区里流传很广的一个 USB 编程例程名:它做的事情很单一——枚举系统里所有 HID 设备,把设备路径、VID/PID、厂商字符串和产品字符串读出来,为后续打开设备和读写 HID 报告做准备。凡是用 VC++ 做 USB 外设接入层的开发者,早晚都要过这一关。这篇文章就按这个标题当主线,把 HID 枚举从原理到可编译代码、再到四个高频踩坑点完整过一遍。适合两类人:一类是刚接到 USB 设备接入需求、手里只有一个 MFC 对话框程序的下游软件工程师;另一类是调试 hid 固件、需要确认设备有没有被系统正确枚举的嵌入式开发者。

2. 枚举 HID 的三条技术路线:为什么 SetupAPI + hid.dll 是 VC++ 的首选

2.1 HID 是什么:人机接口设备、报表描述符与 hid 固件

HID 的全称是 Human Interface Device,人机接口设备。USB 键盘、鼠标、触摸板、条码枪、读卡器、游戏手柄,绝大多数都属于这个类别。一个 USB 设备要在系统里被识别成 HID,靠的是设备接口描述符里的 bInterfaceClass 字段等于 3,同时还要在 HID 描述符里声明报表描述符的位置。hid 固件里烧录的就是这套描述符的组合——接口描述符、HID 描述符、报表描述符、字符串描述符,一个都不能少,少一个 Windows 就给你挂个黄叹号或者干脆认成 Unknown Device。

Windows 对 HID 设备有现成的内置驱动,hidclass.sys 和 hidusb.sys 会把底层的 USB 中断传输封装成上层可以读写的 HID 报告。这意味着用户态程序不需要写任何内核驱动,只需要通过 Win32 API 找到设备、打开设备、然后跟设备交换报告数据就行。而"找到设备"这一步,就是 Computer_HID_detect 这类例程存在的全部意义。设备管理器里的"人体学输入设备"和"HID-compliant consumer control device"就是这些设备被系统枚举成功后的样子。

2.2 三条技术路线对比:SetupAPI、Raw Input、注册表快照

在 Visual C++ 里枚举 USB HID 设备,常见做法有三条路,我按实际项目里的使用频率排序。

路线核心 API能拿到什么适合场景复杂度
SetupAPI 枚举设备接口SetupDiGetClassDevs / SetupDiEnumDeviceInterfaces / SetupDiGetDeviceInterfaceDetail设备接口路径、VID/PID、设备实例 ID静态枚举 + 后续打开通信,最通用中等,样板代码多但稳定
Raw InputRegisterRawInputDevice / GetRawInputData设备句柄、设备名、按键数据游戏外设、全局热键、需要原始输入事件的场景低,但拿不到完整设备信息
直接读注册表RegOpenKeyEx 遍历 HKLM\SYSTEM\CurrentControlSet\Enum\HID设备实例键、硬件 ID、驱动信息快速排查、只读场景低,但格式随系统版本变化

Raw Input 这条路的典型问题是:它本质上是为"处理输入事件"设计的,不是为"枚举设备"设计的。你要等到设备真的产生输入事件,才能在 WM_INPUT 里通过 GetRawInputData 拿到 RIDI_DEVICENAME。做静态清单、做设备的接入检测,用它就很别扭。

注册表那条路看着是捷径,读起来确实快,但坑在键值格式不稳定。Vista 到 Win11,Enum\HID 下面键的组织方式调整过不止一次,而且设备拔掉之后注册表里的残留键什么时候清掉是玄学。出了问题连后悔药都没有,得等系统自己刷新。我的建议很直接:只把注册表当作人工排查手段,代码里不要依赖它。

2.3 选型理由:接口 GUID、设备路径与后续通信的关系

做 USB 编程,最终目的是打开设备、读写报告。而打开设备的钥匙,是设备接口路径(Device Interface Path)。这个路径长这样:

\\?\HID#VID_046D&PID_C539&MI_02#8&2b6e6b8c&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}

注意最后那一段花括号里的 GUID,就是 GUID_DEVINTERFACE_HID。它告诉系统"这是 HID 类的设备接口"。SetupAPI 枚举 HID 设备时,就是按这个 GUID 过滤设备接口,然后通过 SetupDiGetDeviceInterfaceDetail 把完整路径掏出来。有了这个路径,CreateFile 才能打开设备,HidD_GetAttributes 才能读属性,ReadFile/WriteFile 才能收发 HID 报告。整条链路是通的。

Raw Input 拿到的设备名虽然也能打开,但格式是设备实例路径,不是接口路径,而且它不保证代表 HID 类设备。如果设备用的是自定义 HID 报表、不是标准键盘鼠标,Raw Input 甚至根本不会把设备列进来。所以 Computer_HID_detect 这类例程选 SetupAPI + hid.dll 是合理的,这是三条路里唯一一条能同时满足"静态枚举全部 HID 设备"+"拿到可打开的接口路径"的方案。

3. 复现 Computer_HID_detect 的核心:一个能编译的 HID 枚举器

3.1 工程准备:头文件、链接库与 VS 工程配置

我一般用 Visual Studio 建一个空的 Win32 控制台工程,Unicode 字符集,然后直接写。需要的头文件有 SetupAPI 和 HID API 两组,链接库对应 setupapi.lib 和 hid.lib。用 #pragma comment 写在源文件里,省得再去工程设置里翻配置。

#include <windows.h> #include <setupapi.h> #include <hidsdi.h> #include <hidguid.h> // 提供 GUID_DEVINTERFACE_HID #include <devguid.h> // 提供设备类 GUID,备用 #include <stdio.h> #pragma comment(lib, "setupapi.lib") #pragma comment(lib, "hid.lib")

逻辑说明:setupapi.h 声明了设备信息集的枚举函数,hidsdi.h 声明了 HID 属性读取函数,hidguid.h 里的 GUID_DEVINTERFACE_HID 是枚举 HID 设备接口的过滤条件。注意 hidguid.h 不是所有老 VS 版本都默认带,VC6 时代要装 Platform SDK 才行,新版 VS2015 往上都内置了。hid.lib 在部分旧 SDK 里存在兼容问题,老项目里有人用 LoadLibrary("hid.dll") 动态调用,这是那个时代的血泪经验;新项目直接用链接库就行。

参数说明:这里没有需要调整的运行参数,但工程要保证"字符集"设置和代码一致。我上面代码用的是 TCHAR 风格,如果你建工程时选了多字节字符集,DevicePath 就会以 char 数组返回;如果选了 Unicode,则是 wchar_t 数组。建议统一用 Unicode,后面打印调试信息时不要混用。

3.2 创建设备信息集:SetupDiGetClassDevs 过滤接口类

枚举的第一步是让系统给出一份"当前存在的设备接口清单"。SetupDiGetClassDevs 负责创建这个设备信息集,它返回一个句柄指向内存中的一个快照。

HDEVINFO hDevInfo = SetupDiGetClassDevs( &GUID_DEVINTERFACE_HID, // 只想看 HID 接口 NULL, // 不按设备实例 ID 过滤 NULL, // 不用 hwnd DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); if (hDevInfo == INVALID_HANDLE_VALUE) { printf("SetupDiGetClassDevs failed: %lu\n", GetLastError()); return 1; }

逻辑说明:GUID_DEVINTERFACE_HID 是设备接口类 GUID,不是设备类 GUID,这是最容易搞混的点。设备类对应的是键盘、鼠标这类"设备类型",接口类对应的是"通信入口"。我们要的是接口,所以必须传 GUID_DEVINTERFACE_HID,后面还必须带 DIGCF_DEVICEINTERFACE 标志,否则函数会把 GUID 当作设备类去查,什么都查不到。

参数说明:DIGCF_PRESENT 表示只要当前在线(已枚举成功)的设备,不带的系统会连掉线的幽灵设备一起列出来,插入 USB Hub 但没供电的设备会在里面出现。如果你做的是故障诊断工具,想看到底哪些端口曾经挂过设备,可以临时去掉 DIGCF_PRESENT,对比一下清单差异。这个函数返回的 HDEVINFO 是一个黑匣子句柄,操作完必须用 SetupDiDestroyDeviceInfoList 释放,否则每次调用都泄漏一个快照。

3.3 遍历设备接口:SetupDiEnumDeviceInterfaces 与两段式缓冲获取

有了设备信息集,接下来就是循环遍历。SetupDiEnumDeviceInterfaces 按索引一个一个往外掏设备接口,每掏出一个,就要用 SetupDiGetDeviceInterfaceDetail 拿它的详细数据,重点是设备接口路径。

SP_DEVICE_INTERFACE_DATA ifData = { 0 }; ifData.cbSize = sizeof(SP_DEVICE_INTERFACE_DATA); for (DWORD i = 0; ; i++) { if (!SetupDiEnumDeviceInterfaces(hDevInfo, NULL, &GUID_DEVINTERFACE_HID, i, &ifData)) { if (GetLastError() == ERROR_NO_MORE_ITEMS) break; // 正常枚举结束 printf("EnumDeviceInterfaces error at %lu: %lu\n", i, GetLastError()); break; } DWORD detailSize = 0; // 第一次调用:传入 NULL,让函数返回所需缓冲区大小 SetupDiGetDeviceInterfaceDetail(hDevInfo, &ifData, NULL, 0, &detailSize, NULL); if (GetLastError() != ERROR_INSUFFICIENT_BUFFER) continue; PSP_DEVICE_INTERFACE_DETAIL_DATA pDetail = (PSP_DEVICE_INTERFACE_DETAIL_DATA)malloc(detailSize); pDetail->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA); if (SetupDiGetDeviceInterfaceDetail(hDevInfo, &ifData, pDetail, detailSize, NULL, NULL)) { // pDetail->DevicePath 就是完整的设备接口路径 printf("DevicePath: %s\n", pDetail->DevicePath); } free(pDetail); } SetupDiDestroyDeviceInfoList(hDevInfo);

逻辑说明:这里有个关键流程叫"两段式调用"。第一次 SetupDiGetDeviceInterfaceDetail 故意传入空缓冲,函数必然返回 FALSE,但会把真正需要的缓冲区字节数写到 detailSize 里,GetLastError 返回 ERROR_INSUFFICIENT_BUFFER。新手经常在这里踩坑,以为是自己代码写错了,其实这是 API 的设计——设备路径长度不定,必须按返回的大小分配。分完内存再调第二次,才能拿到路径。

参数说明:SP_DEVICE_INTERFACE_DETAIL_DATA 结构体第一个成员是 cbSize,调用前必须初始化为 sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA),不初始化会导致后续输出乱码或者直接返回 ERROR_INVALID_USER_BUFFER。另外注意,这个结构体的 DevicePath 成员是 TCHAR 数组,它的实际长度就是第一次调用算出来的 detailSize 减去结构体头部的偏移,所以必须用 malloc 按 detailSize 分配整块内存,不能只给一个固定大小的栈数组。

3.4 打开设备读取属性:HidD_GetAttributes 与 VID/PID 显示

拿到设备路径之后,Computer_HID_detect 这类例程的下一步就是打开设备并读取属性,把 VID、PID 和版本号打出来,让用户确认"这是我要的那个设备"。

HANDLE hDev = CreateFile( pDetail->DevicePath, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, // 必须共享,否则独占 NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, // 异步模式,后续读写不掉链子 NULL); if (hDev != INVALID_HANDLE_VALUE) { HIDD_ATTRIBUTES attrs = { 0 }; attrs.Size = sizeof(HIDD_ATTRIBUTES); // 这个成员不初始化会调用失败 if (HidD_GetAttributes(hDev, &attrs)) { printf("VID=0x%04X PID=0x%04X Version=0x%04X\n", attrs.VendorID, attrs.ProductID, attrs.VersionNumber); } else { printf("HidD_GetAttributes failed: %lu\n", GetLastError()); } wchar_t productStr[256] = { 0 }; if (HidD_GetProductString(hDev, productStr, sizeof(productStr))) { wprintf(L"Product: %s\n", productStr); } CloseHandle(hDev); }

逻辑说明:CreateFile 是打开设备接口的标准入口,打开成功后,hid.dll 提供两个常用的属性读取函数。HidD_GetAttributes 返回一个 HIDD_ATTRIBUTES 结构,里面有 VendorID、ProductID、VersionNumber,这三个值直接来自设备描述符和 hid 固件里烧录的版本号。HidD_GetProductString 返回产品字符串,这个字符串来自固件里的 iProduct 字符串描述符,以 UTF-16 编码返回,所以打印要用 wprintf。

参数说明:CreateFile 的共享模式这里最容易出错。HID 设备允许多个客户端同时打开,所以必须传 FILE_SHARE_READ | FILE_SHARE_WRITE。只传一个或者不传,只要系统里其他程序(比如鼠标驱动)已经占用设备,你的调用就会返回 ERROR_SHARING_VIOLATION。FILE_FLAG_OVERLAPPED 建议在这里就加上,后面要想用 ReadFile 异步读取报表数据时不需要改代码重开设备。还有一点,HIDD_ATTRIBUTES 的 Size 成员是输入参数,不先赋值,HidD_GetAttributes 会直接返回 FALSE,这是 hid.dll 有名的黑匣子行为,文档里写得很含蓄,实际开发中坑了不少人。

提示:枚举器如果只是做设备识别,不想碰数据通道,CreateFile 的 dwDesiredAccess 可以传 0。这样不需要设备驱动授予读写权限,也能拿到句柄调用 HidD_GetAttributes,适合对付那些被驱动独占的设备。

4. HID 枚举排查实录:四个 USB 编程里躲不开的坑

4.1 第一次调用返回 FALSE?先看 ERROR_INSUFFICIENT_BUFFER

现象:照着教程敲完代码,SetupDiGetDeviceInterfaceDetail 第一次调用返回 FALSE,GetLastError 是 122(ERROR_INSUFFICIENT_BUFFER),程序直接 break,一个设备都列不出来。这是 Computer_HID_detect 这类例程被问得最多的问题,论坛里能翻出几十页老帖。

原因:前面说过,这个 API 就是两段式设计。第一次调用传 NULL 缓冲,API 故意失败,目的只是让系统告诉你"这个结构体需要多大空间"。你把 FAIL 当成异常直接退出,等于主动放弃了枚举机会。很多人还把 detailSize 硬编码成 256 或者 sizeof 一个结构体,设备路径稍微长一点就截断,路径尾部的 GUID 不完整,后面 CreateFile 一定打不开。

解决:不要拿第一次调用的返回值做判断依据,判断条件改成 GetLastError() == ERROR_INSUFFICIENT_BUFFER 才算有效返回。然后严格用返回的 detailSize 分配内存,第二次调用时传入这个大小。分配用 malloc 或 new[] 都行,注意用完释放。如果你想把这段封装成函数,我一般会把"两次调用"封装成一个 GetDeviceInterfaceDetail 辅助函数,入参是 HDEVINFO 和 SP_DEVICE_INTERFACE_DATA,出参是路径字符串,调用方完全不用关心缓冲细节。

4.2 CreateFile 打不开设备:共享模式与权限的边界

现象:枚举路径全部打印正常,VID/PID 也能在设备管理器里对上,但 CreateFile 返回无效句柄,GetLastError 是 5(拒绝访问)或者 32(共享冲突)。尤其插着 USB 鼠标、键盘这类设备时,必现。

原因:两个层面。第一,共享冲突——你打开设备时没传 FILE_SHARE_READ | FILE_SHARE_WRITE,而系统里的 hidclass.sys 或其他上层驱动已经占用了设备,Windows 文件系统层的共享检查不通过。第二,权限问题——有些 HID 设备(比如银行读卡器、某些带加密芯片的采集卡)在固件或驱动里做了独占声明,只允许一个客户端拥有读写通道,普通枚举器直接撞墙。

解决:先把共享标志补齐重试。如果还是拒绝访问,换 dwDesiredAccess = 0 再打开。共享标志不改,dwDesiredAccess 传 GENERIC_READ 还是 GENERIC_WRITE 都会失败,传 0 反而能拿到句柄——因为 0 表示"我不想要任何访问权限,只想要句柄本身",设备驱动对这类请求通常放行。拿到句柄之后,HidD_GetAttributes 依然能调,足够做设备识别。这个方法不是万能后悔药,遇到驱动里强制排他的设备还是没辙,但九成枚举器场景够用了。

4.3 蓝牙 HID(人机接口设备)不出现:蓝牙枚举器与路径差异

现象:代码在 USB 键盘鼠标上跑得稳稳的,换了一套蓝牙键鼠,枚举结果里就是找不到设备。设备管理器里明明是正常的,蓝牙设置页里也显示已连接,但程序就是列不出来。或者说,能列出来,但设备路径前缀长得完全不同。

原因:蓝牙 HID(人机接口设备)走的是 BTHENUM 或 BTHLEUM 枚举器,设备接口路径以前缀\\?\BTHENUM#...或\\?\BTHLEUM#...开头,而不是 USB HID 的\\?\HID#VID_xxxx&PID_xxxx#...。很多例程为了过滤掉非 HID 的杂设备,会在代码里加一句"路径以 HID# 开头才处理"的判断。这个判断把 USB HID 留下了,顺便把蓝牙 HID 也误杀了。另外,蓝牙设备在未配对或处于低功耗待机时,系统不会暴露它的 HID 接口,枚举结果里自然就看不到。

解决:不要按路径前缀过滤设备。正确做法是按 VID/PID 过滤——枚举时不加字符串判断,把所有 GUID_DEVINTERFACE_HID 接口都列出来,之后再用你自己的设备清单去匹配。如果确实想区分 USB 和蓝牙,判断BTHENUM或BTHLEUM前缀即可,但不要把这个判断当作"是否合法 HID 设备"的标准。调试蓝牙 HID 时,要先把设备调整到正常工作状态再枚举,设备刚唤醒时接口可能还没注册完毕,稍等几百毫秒重新枚举一次是常见做法。

4.4 hid 固件字符串描述符乱码:枚举器能读但显示不对

现象:VID/PID 读出来完全正常,但产品名是空的,或者是一堆乱码,甚至中文变成几个问号。有些设备更极端,HidD_GetProductString 直接失败,设备管理器里显示"USB 输入设备"这种泛称。

原因:这是 hid 固件的问题。产品字符串取自设备固件里的 iProduct 字符串描述符,这个描述符在 USB 规范里要求以 UTF-16LE 编码。很多嵌入式工程师用串口工具或调试器烧固件时,字符串表里直接放了 GBK 或 ASCII 字节,没做 UTF-16 转换,Windows 去读的时候按 UTF-16 解析,自然全是乱码。还有的固件压根没实现字符串描述符,USB 枚举阶段就返回 STALL,Windows 只能放弃读取,退回默认名字。

解决:程序这边能做的有限。第一,不要把产品字符串当作设备识别的依据,匹配设备只认 VID/PID/序列号;第二,HidD_GetProductString 的返回值一定要判空,失败时显示默认名,别让乱码崩了界面;第三,如果你是嵌入式那边的人,赶紧查固件里字符串描述符的编码,iProduct 和 iManufacturer 都要按 UTF-16LE 存,长度字节是"字符数×2+2",这点改了固件重新烧一次就好了。这属于典型的"枚举器显示层查不出问题,要往 hid 固件里找原因"的场景,我遇到太多次了。

5. 从静态枚举到热插拔监听:WM_DEVICECHANGE 的验证技巧

Computer_HID_detect 做到能枚举,只是第一步。实际项目里,设备什么时候插上、什么时候拔掉,才是 USB 编程真正要伺候的需求。Windows 提供了 WM_DEVICECHANGE 消息,窗口程序可以收到设备变化通知。配合第 3 章的枚举函数,就能做成"设备一变,立刻重新枚举"的效果。

DEV_BROADCAST_DEVICEINTERFACE filter = { 0 }; filter.dbcc_size = sizeof(DEV_BROADCAST_DEVICEINTERFACE); filter.dbcc_devicetype = DBT_DEVTYP_DEVICEINTERFACE; filter.dbcc_classguid = GUID_DEVINTERFACE_HID; HDEVNOTIFY hNotify = RegisterDeviceNotification(hWnd, &filter, DEVICE_NOTIFY_WINDOW_HANDLE); // 在窗口过程里: case WM_DEVICECHANGE: if (wParam == DBT_DEVNODES_CHANGED) { EnumerateHidDevices(); // 重新执行第 3 章的枚举流程 } break;

逻辑说明:RegisterDeviceNotification 告诉系统"这个窗口对 HID 设备接口的事件感兴趣"。当任何 HID 设备插入或拔出时,系统会向该窗口发送 WM_DEVICECHANGE。DBT_DEVNODES_CHANGED 是一个粗粒度通知,只要设备节点有变化就触发,不区分是插入还是拔出,也不需要解析 lParam,适合做重新枚举的触发器。要精确区分哪个设备插拔,就得解析 lParam 里的 DEV_BROADCAST_DEVICEINTERFACE,用 dbcc_name 和上一次枚举的 DevicePath 做 diff。

参数说明:DEVICE_NOTIFY_WINDOW_HANDLE 表示消息走窗口过程,如果你的程序没有窗口(比如控制台或 Windows 服务),要改用 DEVICE_NOTIFY_SERVICE_HANDLE 并按服务事件循环处理,控制台程序直接收不到这个通知。hNotify 不需要时用 UnregisterDeviceNotification 释放,否则窗口销毁后系统还留着注册信息,可能造成回调泄漏。

我自己的习惯是:枚举函数里每次打印设备路径,插拔触发后先和上一次的清单做 diff,确认是不是我关注的那台设备,确认之后才发打开指令。别偷懒把枚举和打开写在同一个触发回调里,热插拔瞬间设备驱动可能还没就绪,立刻打开容易撞上图 4.2 里的共享冲突。等 200 毫秒再开,成功率会高很多。按这个思路把枚举器做成监听器,再配合 VID/PID 过滤和驱动就绪延时,你就能把这个 HID 检测方案从"能跑"推进到"敢用"。希望帮到你。

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