用Visual C++实现USB HID设备枚举:从原理到代码实战
发布时间:2026/10/26 16:52:36 锦皓数字建站

简介一份基于 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_detectUSB 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 到 Win11Enum\HID 下面键的组织方式调整过不止一次而且设备拔掉之后注册表里的残留键什么时候清掉是玄学。出了问题连后悔药都没有得等系统自己刷新。我的建议很直接只把注册表当作人工排查手段代码里不要依赖它。2.3 选型理由接口 GUID、设备路径与后续通信的关系做 USB 编程最终目的是打开设备、读写报告。而打开设备的钥匙是设备接口路径Device Interface Path。这个路径长这样\\?\HID#VID_046DPID_C539MI_02#82b6e6b8c00000#{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(VID0x%04X PID0x%04X Version0x%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(LProduct: %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 第一次调用返回 FALSEGetLastError 是 122ERROR_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_xxxxPID_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 枚举阶段就返回 STALLWindows 只能放弃读取退回默认名字。解决程序这边能做的有限。第一不要把产品字符串当作设备识别的依据匹配设备只认 VID/PID/序列号第二HidD_GetProductString 的返回值一定要判空失败时显示默认名别让乱码崩了界面第三如果你是嵌入式那边的人赶紧查固件里字符串描述符的编码iProduct 和 iManufacturer 都要按 UTF-16LE 存长度字节是字符数×22这点改了固件重新烧一次就好了。这属于典型的枚举器显示层查不出问题要往 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 检测方案从能跑推进到敢用。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。