ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

VC++ HID设备读写实战:免驱通信从枚举到联调避坑指南

2026/9/8 1:42:23 拓冰建站 浏览量
VC++ HID设备读写实战:免驱通信从枚举到联调避坑指南 简介VC6.0环境下的HID设备读写编程示例面向Windows平台从事设备驱动、嵌入式开发或需与USB人机交互设备通信的工程师与学习者。示例通过Win32 API与HID API演示了设备枚举、接口选择、句柄打开、报告读写及释放资源的完整流程可帮助在VC6环境中快速实现HID通讯。压缩包共24个文件以头文件.h、C源码.cpp、静态库.lib及VC6工程文件.dsp/.dsw为主并附可直接运行的exe代码结构与工程组织清晰便于对照修改。整个资源包仅73KB轻量精炼适合按需学习。目前已有108人学习下载对理解HID报告机制和Windows设备IO操作具备实用参考价值。 很多人上来就写串口但串口要驱动、要配置、还怕线接错。真正做设备和上位机联调时HIDHuman Interface Device反而被严重低估了。我这里说的HID不是单指键盘鼠标而是通用的HID自定义设备——比如STM32枚举成一个HID设备通过中断端点跟PC交换几十个字节的控制信息这种场景用HID简直舒服得不行。这篇文章就是用VC写一个完整的HID读写例子覆盖设备枚举、打开、写报告、读报告、资源释放以及联调时最容易翻车的几个坑。适合正在写上位机的工程师、玩STM32裸机或者RTOS但不想折腾驱动的朋友参考。我会直接给代码和解释不说废话也不绕弯子。1. 为什么我推荐HID做设备通信1.1 HID设备的天然优势HID最大的卖点就是免驱。Windows、macOS、Linux、Android都内置了HID协议栈设备插上去系统自动识别不需要安装任何第三方驱动。这一点对产品交付来说太关键了用户拿回去插上就能用不用去设备管理器里手动更新驱动。另一个优势是即插即用和热插拔。HID设备拔掉再插上系统能很快重新枚举应用程序只需要处理设备重连的逻辑就行。相比于串口设备掉了就没了HID的容错性要好很多。从协议层面上讲HID设备和主机之间通过“报告”交换数据报告分为输入报告、输出报告和特征报告。输入报告是设备往主机发数据输出报告是主机往设备发数据特征报告则是双向的配置类数据。VC里主要操作的就是输入报告的读取和输出报告的写入对应到Win32 API就是ReadFile和WriteFile理解起来非常直接。1.2 HID的适用边界和选型建议HID也不是万能的它用的是USB中断传输全速设备每个包最大64字节高速设备最大1024字节但实际批量传输速率远不如Bulk端点。所以HID适合小数据量、低延时、控制类的通信比如按键状态、开关量、编码器读数传感器数据温度、湿度、IMU姿态角配置参数读写PID参数、阈值、校准值自定义命令下发设备启动、复位、切换模式如果你要传大文件、传音视频流、跑几Mbps以上的数据HID就别想了老老实实用USB CDC虚拟串口或者WinUSB。我做选型时会用一个简单的对比表方案驱动数据量实时性复杂度HID无需安装小每包几十字节高中断传输低CDC虚拟串口Windows自带usbser.sys中等一般低WinUSB需要INF或WinUSB驱动大Bulk传输高高我个人的经验是如果产品只需要交换控制命令、小状态量、低频率传感器数据HID绝对是最省事的选择。因为CDC串口虽然看起来通用但在一些精简版系统上、或者用户机器上被其他软件占用了串口号麻烦事一堆。HID是完全独立的不存在串口被占用的问题。2. 环境准备和第一步枚举HID设备2.1 Visual Studio工程怎么建我用的开发环境是Visual Studio 2017其实VS2015、VS2019、VS2022都没区别。新建一个Windows控制台应用程序或者Windows桌面应用程序都行核心在于链接两个库hid.lib和setupapi.lib。如果你是手动建的空项目在属性页里设置一下C/C - 常规 - 附加包含目录确保能找到hidsdi.h和setupapi.h链接器 - 输入 - 附加依赖项加上hid.lib和setupapi.lib项目属性 - 常规 - 字符集建议用Unicode我一般在代码里直接写#pragma comment(lib, hid.lib)和#pragma comment(lib, setupapi.lib)这样换机器编译不用重新配置工程属性省事不少。如果你用VS2017新建的是Windows窗体应用也不影响通信逻辑放在后台线程里跑界面用控件触发读写就行。2.2 枚举设备的完整代码框架HID设备的枚举绕不开SetupAPI流程是固定的先用HidD_GetHidGuid拿到HID设备类的GUID再用SetupDiGetClassDevs枚举设备接口接着用SetupDiEnumDeviceInterfaces循环拿设备接口最后用SetupDiGetDeviceInterfaceDetail取到设备路径。拿到路径之后CreateFile打开就万事俱备了。这段代码我直接贴出来带注释可以直接用#include windows.h #include hidsdi.h #include setupapi.h #include stdio.h #include malloc.h #pragma comment(lib, hid.lib) #pragma comment(lib, setupapi.lib) void EnumHidDevices() { GUID guidHID; HidD_GetHidGuid(guidHID); HDEVINFO devInfo SetupDiGetClassDevs( guidHID, NULL, NULL, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE ); if (devInfo INVALID_HANDLE_VALUE) { printf(SetupDiGetClassDevs failed: %d\n, GetLastError()); return; } SP_DEVICE_INTERFACE_DATA interfaceData; interfaceData.cbSize sizeof(SP_DEVICE_INTERFACE_DATA); for (DWORD i 0; SetupDiEnumDeviceInterfaces(devInfo, NULL, guidHID, i, interfaceData); i) { // 第一次调用获取所需缓冲区大小 DWORD detailSize 0; SetupDiGetDeviceInterfaceDetail(devInfo, interfaceData, NULL, 0, detailSize, NULL); PSP_DEVICE_INTERFACE_DETAIL_DATA detailData (PSP_DEVICE_INTERFACE_DETAIL_DATA)malloc(detailSize); detailData-cbSize sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA); if (SetupDiGetDeviceInterfaceDetail(devInfo, interfaceData, detailData, detailSize, NULL, NULL)) { printf(Device Path: %ws\n, detailData-DevicePath); } free(detailData); } SetupDiDestroyDeviceInfoList(devInfo); }注意detailData-cbSize在32位和64位系统上赋值方式不一样官方要求设置成sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA)即可我在Win10 x64和Win11 x64上都实测过这样写没问题。如果你直接用固定大小的数组去接收DevicePath碰上路径长一点的情况会报ERROR_INSUFFICIENT_BUFFER所以稳妥起见都用动态分配。2.3 过滤目标设备多设备场景怎么办枚举出来的不一定是你想要的设备。一个USB复合设备在系统里会出现多个HID节点比如带触摸板的键盘在设备管理器里能看到好几个HID键盘和HID鼠标设备。热词里提到的“设备管理器有两个hid keyboard”就是典型情况这是正常的不用慌。要在代码里过滤出目标设备标准做法是打开设备后读取HIDD_ATTRIBUTES然后判断VID和PIDHANDLE device CreateFile(detailData-DevicePath, 0, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if (device ! INVALID_HANDLE_VALUE) { HIDD_ATTRIBUTES attrib; attrib.Size sizeof(HIDD_ATTRIBUTES); if (HidD_GetAttributes(device, attrib)) { printf(VID%04X PID%04X Version%04X\n, attrib.VendorID, attrib.ProductID, attrib.VersionNumber); // 这里判断是不是你的设备 if (attrib.VendorID 0x1234 attrib.ProductID 0x5678) { // 找到目标设备保存DevicePath后面打开用 } } CloseHandle(device); }还有一种更进阶的过滤方式用HidP_GetCaps判断设备用途页和用途。比如厂商自定义的HID设备通常使用UsagePage 0xFF00Usage 0x0001。这种过滤方式不依赖VID/PID比较适合同一厂家多种产品共用一个上位机的场景。3. 打开设备、读写报告、关闭句柄3.1 打开设备CreateFile的关键参数找到目标设备的DevicePath之后就可以正式CreateFile了。这里有几个参数特别容易踩坑。HANDLE deviceHandle CreateFile( devicePath, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL ); if (deviceHandle INVALID_HANDLE_VALUE) { printf(CreateFile failed: %d\n, GetLastError()); return; }第一个坑是dwDesiredAccess如果设备要发数据又要收数据必须同时指定GENERIC_READ和GENERIC_WRITE不能只写一个。有些设备固件只开放一个方向那就要根据实际需求调整。第二个坑是dwShareMode一定要带FILE_SHARE_READ和FILE_SHARE_WRITE。很多人偷懒传0结果自己打开之后想再开一次或者调试器里重复打开直接返回ACCESS_DENIED。Windows对HID设备的共享模式很严格不带共享标志就相当于独占后续就没法操作了。第三个坑是dwCreationDisposition固定是OPEN_EXISTING。HID设备不可能由你创建所以这个参数别动。3.2 写报告向设备发送命令的完整流程写报告之前先要拿到设备的报告长度。这一步需要HidD_GetPreparsedData拿到解析过的报告数据然后HidP_GetCaps拿HIDP_CAPS结构体。HIDP_CAPS里有InputReportByteLength和OutputReportByteLength单位是字节都包含报告ID这一个字节。PHIDP_PREPARSED_DATA preparsedData NULL; HIDP_CAPS caps; if (HidD_GetPreparsedData(deviceHandle, preparsedData)) { if (HidP_GetCaps(preparsedData, caps) HIDP_STATUS_SUCCESS) { printf(InputReportByteLength %d\n, caps.InputReportByteLength); printf(OutputReportByteLength %d\n, caps.OutputReportByteLength); } HidD_FreePreparsedData(preparsedData); }之后写报告就简单了。假设OutputReportByteLength 65那么发送缓冲区就分配65个字节第一个字节放报告ID如果设备没有使用报告ID就填0。后面的字节就是你实际要发送的数据。BYTE outputReport[65] {0}; outputReport[0] 0x00; // 报告ID无ID写0 outputReport[1] 0x01; // 命令字开灯 outputReport[2] 0xFF; // 参数 DWORD bytesWritten 0; BOOL result WriteFile(deviceHandle, outputReport, caps.OutputReportByteLength, bytesWritten, NULL); if (result) { printf(Write success, bytes%lu\n, bytesWritten); } else { printf(WriteFile failed: %d\n, GetLastError()); }这里有一个非常重要的细节一次WriteFile必须写满OutputReportByteLength个字节不能只写你自己定义的有效数据长度。比如OutputReportByteLength65你就必须写65个字节哪怕后面全是0。如果写少了Windows直接报ERROR_INVALID_PARAMETER。我在新手阶段在这里卡过很久后来才搞明白HID报告是定长块传输设备端在中断端点等着的就是一整个报告包。3.3 读报告同步和异步两种实现读报告和写报告对称用ReadFile读写InputReportByteLength个字节。如果设备有数据上报比如传感器周期性采数同步ReadFile就能用。但要注意如果设备一直不上报数据ReadFile会一直阻塞界面线程直接卡死。所以实际项目里我强烈建议用异步模式或者放在工作线程里。最简单的异步写法是结合OVERLAPPED和事件对象BYTE inputReport[65] {0}; DWORD bytesRead 0; OVERLAPPED overlap; memset(overlap, 0, sizeof(overlap)); overlap.hEvent CreateEvent(NULL, TRUE, FALSE, NULL); BOOL result ReadFile(deviceHandle, inputReport, caps.InputReportByteLength, bytesRead, overlap); if (!result GetLastError() ERROR_IO_PENDING) { DWORD waitResult WaitForSingleObject(overlap.hEvent, 2000); // 2秒超时 if (waitResult WAIT_OBJECT_0) { GetOverlappedResult(deviceHandle, overlap, bytesRead, FALSE); // inputReport[0] 是报告ID后续字节是设备数据 } else { // 超时了取消读操作 CancelIo(deviceHandle); } } CloseHandle(overlap.hEvent);注意读回来的数据里第一个字节依然是报告ID没有ID就填0真正的数据从下标1开始。如果你发现读回来的数据整体错位了一个字节多半就是忘了跳过报告ID。3.4 关闭设备与释放资源用完了一定要CloseHandle否则句柄泄漏在长时间运行的上位机程序里就是个隐患。建议在程序退出和切换设备时统一处理if (deviceHandle ! INVALID_HANDLE_VALUE) { CloseHandle(deviceHandle); deviceHandle INVALID_HANDLE_VALUE; }如果之前调用过HidD_GetPreparsedData记得HidD_FreePreparsedData释放掉。这些释放逻辑最好写在同一个清理函数里别在多个分支里各关一遍容易漏。4. 联调时最容易翻车的几个问题4.1 枚举不到设备怎么排查枚举不到设备先别急着改代码按照下面几个思路排查程序是否以管理员权限运行。有些环境会限制SetupAPI的访问权限右键以管理员身份运行就正常了。设备是否被其他程序独占。如果设备已经被别的手柄控制软件或者其他上位机打开你这边枚举到的路径可能打不开或者压根枚举不出来。你把VID和PID写死了确认一下设备真实的值别用网上随便抄的数字。设备是否处于挂起状态。USB设备在总线休眠后可能枚举失败拔插一次试试。拔插之后如果程序还在跑应该重新枚举而不是复用缓存的DevicePath。我当时调试的一个板卡就是因为固件里USB描述符配置有问题导致Windows认不出HID设备设备管理器里显示未知设备枚举自然就是空的。后来用USB抓包工具看了描述符发现HID报告描述符长度字段配错了改掉之后立刻就好了。这个经验和VC无关但经常是真正的原因。4.2 WriteFile总是失败或写不进去WriteFile失败最常见的原因就是报告长度不对。我前面强调过一次必须写满OutputReportByteLength个字节。第二个常见原因是报告ID写错了设备没用报告ID时你要写0用了报告ID时你要写那个ID值绝对不能从1开始乱猜。还有第三个原因就是设备端根本没开中断OUT端点。有些精简的HID设备固件只开了IN端点只上报数据不收主机数据。这时候你WriteFile返回失败GetLastError可能是个比较模糊的ERROR_GEN_FAILURE。排查办法就是换一个已知OK的HID设备测试或者用USB分析工具确认端点方向。另外如果你打开了设备但没有先调用HidD_GetPreparsedData有些HID设备驱动在接收WriteFile之前需要主机先获取报告描述符否则会拒绝写入。别问我怎么知道的都是拿时间换来的教训。所以写之前一定先执行一次HidD_GetPreparsedData和HidP_GetCaps。4.3 ReadFile一直阻塞读不到数据同步ReadFile阻塞的原因90%是因为设备端根本没有上报数据。HID读操作是在等中断端点来数据设备如果不调用USB发送函数主机这边就只能干等。你先用设备端固件确认是否周期上报数据比如用USB抓包工具看看中断IN传输里有没有包。如果是异步ReadFile超时之后记得CancelIo否则上一次的读请求还挂在驱动里下次再发起ReadFile就可能出现奇奇怪怪的错乱。我在处理设备热拔插时就遇到过拔掉设备之后CancelIo返回成功但重叠事件始终不触发最后只能关闭句柄重新枚举。还有一种情况你读到的总是0x00 0x00 0x00。这不一定是你代码错了也可能是设备固件把全零报告当成了有效数据。建议在固件里设计一个固定帧头比如0xAA 0x55开头上位机判断前两个字节再加校验和能过滤掉大部分无效数据。4.4 USB拔插之后程序崩溃HID设备拔掉之后你已经打开的句柄就失效了。这时候再调用ReadFile或者WriteFile通常会返回失败GetLastError得到ERROR_DEVICE_REMOVED之类。但如果你用异步操作挂着的OVERLAPPED事件可能不会触发这时候程序会一直等下去看起来像卡死。处理思路有两个监听WM_DEVICECHANGE消息在设备拔出时主动关闭句柄、清理资源在后台线程里定时做设备存在性检测检测到句柄无效就重新枚举我实际项目里用的是第二种方案简单粗暴一个定时器每2秒调用一次HidD_GetAttributes失败就认为设备掉了然后重新枚举。这样省去了窗口消息处理的复杂度控制台程序也能用。5. 个人经验总结与一个万能调试模板做HID上位机这段时间我最大的感受是这套代码其实不难就是琐碎。枚举、打开、拿报告长度、读写、释放每一步都有对应的API但很多人卡在细节上。尤其是报告ID这个概念不知道的话真能把人逼疯。后来我养成一个习惯拿到一个新设备先写一个20行的枚举小工具把每个HID设备的VID、PID、产品字符串、InputReportByteLength、OutputReportByteLength全部打印出来确认参数之后再写业务逻辑。这个小工具到现在还在用每次接手新硬件都能省半天时间。如果你配合STM32做HID设备联调建议先用一个回环测试PC发一个0xA5设备收到之后原样返回一个0xA5。上位机这边WriteFile之后马上ReadFile如果能收到一样的字节说明链路是通的再逐步增加协议字段。不要一上来就搞复杂的协议不然两边都出错你根本不知道问题在哪一端。最后再分享一个小技巧调试HID设备时Windows自带的设备管理器只能看到设备状态看不到报告数据。你可以用抓包工具直接看USB总线上的中断传输内容是我排查报告长度和报告ID问题时的终极武器。数据包一眼就能看出设备上报的是65字节还是64字节、第一个字节是不是报告ID比自己瞎猜快太多了。本文还有配套的精品资源点击获取