
简介面向VC6.0开发者的HID设备读写示例适合刚开始接触Windows系统编程、嵌入式设备驱动或USB人机交互设备通信的读者。示例以对话框程序为骨架完整演示从枚举HID设备、获取设备路径、打开设备句柄到通过DeviceIoControl发送IOCTL_HID_READ_REPORT与IOCTL_HID_WRITE_REPORT实现数据收发的流程其中涵盖了SetupDiGetClassDevs、SetupDiGetDeviceInterfaceDetail、CreateFile等关键API的调用方式并介绍了HID报告描述符的阅读方法帮助理解数据缓冲区与报告格式的对应关系针对键盘、鼠标、自定义HID控制器等常见外设的通信场景均有参考价值。压缩包共24个文件大小仅73KB包含7个.h头文件、3个.cpp源文件、2个.lib依赖库以及DSP、DSW等VC6.0工程文件还附带了可直接运行的HIDRW.exe和ReadMe.txt说明目录清晰可在VC6.0中直接打开编译。已有108人学习下载对于需要快速上手Win32 HID API调用、理解设备枚举与读写机制的初学者来说这是一份可对照实践的轻量级开发模板。 刚接触HID设备开发的时候我其实绕了不少弯路第一次拿到一个自定义HID设备想用VC写个上位机实现数据收发第一反应是翻WinUSB、SetupAPI的文档结果被各种INF文件、驱动签名搞得一头雾水。后来才意识到在Windows平台上做HID设备的应用层读写根本不用碰驱动——系统自带的HID API就是干这个的。这篇东西就是把我实际调通一个VC HID读写例子的完整过程、代码骨架和踩坑记录整理出来给后面做类似事情的朋友做个参考。1. 为什么Windows下做HID读写我最终选了HID API很多人在开始写HID上位机时首先纠结的问题是我该用什么接口和设备通信。如果你的设备是USB HID设备比如自定义的键盘、鼠标、游戏手柄、读卡器、医疗设备、工业控制面板那答案是明确的用系统提供的HID API不要去碰驱动开发。理由并不复杂。Windows对HID设备有内建支持只要设备符合HID协议规范插入USB口后系统会自动加载hidusb.sys驱动应用层只需要通过CreateFile、ReadFile、WriteFile这三个核心API就能完成读写。这等于把最麻烦的驱动签名、驱动安装、内核态调试全部绕开了你只需要关心应用层的业务逻辑。我当时也对比过其他方案实际用下来区别很明显通信方式是否需要驱动开发难度适用场景HID API不需要系统自带低直接调用Win32 API标准HID设备、免驱应用WinUSB需要WinUSB驱动和INF中涉及驱动安装高速批量传输、自定义端点串口虚拟COM设备需支持CDC类低但设备端麻烦蓝牙模块、老旧设备内核驱动需要WDK和签名非常高非标准协议、独占设备对于大多数自定义HID设备的上位机HID API是最省事的路径没有之一。尤其是产品原型阶段你只是想快速验证设备能不能收到我的命令、能不能把数据回传上来HID API从写代码到跑通熟练的话半小时内就能搞定。2. 动手前的三项准备协议认知、工具链、术语清单2.1 先搞清楚HID报告是报文而不是流串口和HID最大的思维差异在这里串口是字节流你发多少收多少边界自己处理而HID是报文模型每次通信的单位是报告报告长度由设备端的报告描述符固定死。比如设备报告描述符里定义了输入报告长度为8字节那么你ReadFile一次必须读8字节WriteFile一次也必须写8字节多写一个字节可能直接被驱动拦掉。这个定长报文的认知越早建立越好后面写缓冲区和处理分包时所有的代码逻辑都是围绕报告长度来设计的。一个常见新手错误是拿串口的思路去拼数据、等数据、拆数据流结果发现设备发来的数据总是一次一包拼包逻辑完全用不上。设备的报告描述符可以通过UsbTreeView或者设备属性里的HID描述符查看。开发阶段我建议直接把设备的输入报告长度、输出报告长度记在代码注释里避免后面忘记。2.2 工具准备清单开发环境这块Visual Studio 2017或2019都行Win10/11 SDK里直接包含了hid.dll的库文件和头文件不需要额外装任何包。但有一个坑要注意ys库里的头文件路径不一样老项目里常见的hidsdi.h可能在新SDK中不在默认包含路径里需要配置一下。我的环境清单供参考Visual Studio 2019C控制台工程Windows SDK 10.0.19041.0链接库hid.lib、setupapi.lib注意setupapi是用来枚举设备的别忘了加不用装任何第三方库纯粹用微软的API就能完成。2.3 核心术语速查动手写代码前有几个名词必须弄清楚因为代码里的每个函数都对应着它们VID/PID厂商ID和产品ID用来识别特定设备。枚举设备时靠这两个ID过滤目标设备。Usage Page / UsageHID协议里的用途页和用途用来描述设备的类型比如Usage Page 0x01通用桌面设备Usage 0x06是键盘。Input Report / Output Report输入报告是设备发给主机的数据输出报告是主机发给设备的数据。HID Device Interface GUID一个固定的GUID即GUID_DEVINTERFACE_HID({4D1E55B2-F16F-11CF-88CB-001111000030})枚举HID设备时用这个GUID。这个GUID建议直接全网搜索后复制到代码里当常量用不要自己去想。3. 代码骨架从枚举设备到读写数据的完整实现下面这段就是我最终调通的代码核心部分。工程是一个控制台程序功能是查找指定VID/PID的HID设备然后向设备写入一帧输出报告再读取设备返回的输入报告。整个流程拆成四个步骤每一步单独讲为什么这么写。3.1 枚举设备并匹配VID/PID第一步是拿到系统里所有HID设备的路径然后根据设备的VID和PID筛选出你要操作的那一个。这里的关键是HidD_GetAttributes函数它能返回设备的VID、PID和版本号用来和你的目标值比对。#include windows.h #include hidsdi.h #include setupapi.h #include stdio.h #pragma comment(lib, hid.lib) #pragma comment(lib, setupapi.lib) #define MY_VID 0x1234 #define MY_PID 0x5678 // 获取指定VID/PID的HID设备路径找不到返回空字符串 std::string FindHidDevicePath(WORD vid, WORD pid) { GUID hidGuid; HidD_GetHidGuid(hidGuid); HDEVINFO devInfo SetupDiGetClassDevs(hidGuid, NULL, NULL, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); if (devInfo INVALID_HANDLE_VALUE) { return ; } std::string resultPath; SP_DEVICE_INTERFACE_DATA deviceInterfaceData { 0 }; deviceInterfaceData.cbSize sizeof(SP_DEVICE_INTERFACE_DATA); for (DWORD i 0;; i) { if (!SetupDiEnumDeviceInterfaces(devInfo, NULL, hidGuid, i, deviceInterfaceData)) { break; // 枚举结束 } DWORD detailSize 0; SetupDiGetDeviceInterfaceDetail(devInfo, deviceInterfaceData, 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, deviceInterfaceData, detailData, detailSize, NULL, NULL)) { HANDLE hDevice CreateFile(detailData-DevicePath, 0, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if (hDevice ! INVALID_HANDLE_VALUE) { HIDD_ATTRIBUTES attributes { 0 }; attributes.Size sizeof(HIDD_ATTRIBUTES); if (HidD_GetAttributes(hDevice, attributes)) { if (attributes.VendorID vid attributes.ProductID pid) { resultPath detailData-DevicePath; CloseHandle(hDevice); free(detailData); break; } } CloseHandle(hDevice); } } free(detailData); } SetupDiDestroyDeviceInfoList(devInfo); return resultPath; }几个细节说明一下。SetupDiGetClassDevs的第一个参数传的是设备接口GUID不是设备类GUID这两者不要混。打开设备用的CreateFile这里传的是0访问权限因为我们只是查询属性不需要读写这样能避免后续真正打开设备时被占用导致失败。通过HidD_GetAttributes拿到的VID/PID是设备硬件层面的标识打印出来时注意大小端。3.2 打开设备并设置读写模式找到设备路径后正式打开设备用于读写。这一步容易踩的坑是共享模式。多个进程同时访问HID设备时如果共享参数配得不好第二个人就打不开了。我的做法是读写的时候都加上FILE_SHARE_READ | FILE_SHARE_WRITE同时用OPEN_EXISTING。打开设备句柄后用HidD_GetPreparsedData获取设备解析数据再用HidP_GetCaps拿到设备的输入报告、输出报告、特性报告的长度。这些长度后面分配缓冲区时要用到。也可以通过HidD_GetInputReport的方式直接读输入报告但那是一次性读取不适合做持续监听日常做数据收发还是用ReadFile配合事件机制更合理。HANDLE OpenHidDevice(const char* devicePath) { HANDLE hDevice CreateFile(devicePath, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, // 异步模式 NULL); if (hDevice INVALID_HANDLE_VALUE) { return INVALID_HANDLE_VALUE; } // 获取报告长度 PHIDP_PREPARSED_DATA preparsedData NULL; if (HidD_GetPreparsedData(hDevice, preparsedData) preparsedData) { HIDP_CAPS caps { 0 }; if (HidP_GetCaps(preparsedData, caps) HIDP_STATUS_SUCCESS) { printf(InputReportLength: %d\n, caps.InputReportByteLength); printf(OutputReportLength: %d\n, caps.OutputReportByteLength); } HidD_FreePreparsedData(preparsedData); } return hDevice; }关于打开设备时用FILE_FLAG_OVERLAPPED这个我强烈建议加上。原因是HID读写如果不加这个标志ReadFile会一直阻塞到读取完成为止一旦设备端没回数据你的界面就会卡死程序看起来像崩溃了一样。用异步模式配合事件对象能实现等数据的时候还能干别的的效果。3.3 写数据向设备下发命令写操作的原理是很直接的WriteFile把输出报告数据发给设备。但是缓冲区分配有要求WriteFile写入的字节数必须等于设备的输出报告长度而且字节流的第一字节是报告IDReport ID。对于不区分报告ID的设备第一个字节填0后面才是你的数据负载。BOOL WriteHidReport(HANDLE hDevice, BYTE* data, DWORD dataLen) { if (dataLen 64) { printf(data too long\n); return FALSE; } BYTE outputReport[65] { 0 }; // 假设输出报告长度是65字节含报告ID // 如果设备没有报告IDReport ID 0 memcpy(outputReport 1, data, dataLen); // 数据从第2字节开始 DWORD bytesWritten 0; OVERLAPPED writeOverlapped { 0 }; writeOverlapped.hEvent CreateEvent(NULL, TRUE, FALSE, NULL); if (!WriteFile(hDevice, outputReport, 65, bytesWritten, writeOverlapped)) { if (GetLastError() ERROR_IO_PENDING) { WaitForSingleObject(writeOverlapped.hEvent, 1000); // 等最多1秒 } else { CloseHandle(writeOverlapped.hEvent); return FALSE; } } CloseHandle(writeOverlapped.hEvent); return TRUE; }注意这里的数组大小。如果设备的输出报告长度含报告ID是65字节那么WriteFile的缓冲区就是65字节。很多网上代码在缓冲区大小上写64或者没有留报告ID的位置结果就是设备端永远收不到你发的前几个数据字节。具体以你设备实际Report Length为准建议先用第3.2节的HidP_GetCaps打印确认。3.4 读数据接收设备上报读数据同理缓冲区的长度是输入报告长度。异步读取的标准做法是创建事件对象发起ReadFile然后WaitForSingleObject等待事件触发。设备有数据上报时事件会被置位接着ReadFile完成数据就到了缓冲区里。BOOL ReadHidReport(HANDLE hDevice, BYTE* buffer, DWORD bufferLen) { OVERLAPPED readOverlapped { 0 }; readOverlapped.hEvent CreateEvent(NULL, TRUE, FALSE, NULL); DWORD bytesRead 0; if (!ReadFile(hDevice, buffer, bufferLen, bytesRead, readOverlapped)) { if (GetLastError() ERROR_IO_PENDING) { DWORD waitResult WaitForSingleObject(readOverlapped.hEvent, 3000); if (waitResult WAIT_OBJECT_0) { GetOverlappedResult(hDevice, readOverlapped, bytesRead, FALSE); } else { // 3秒超时取消读取并退出 CancelIo(hDevice); CloseHandle(readOverlapped.hEvent); return FALSE; } } else { CloseHandle(readOverlapped.hEvent); return FALSE; } } CloseHandle(readOverlapped.hEvent); return TRUE; }到这里一个枚举设备-打开设备-写命令-读响应的最小闭环就跑通了。main函数里思路是这样int main() { std::string path FindHidDevicePath(MY_VID, MY_PID); if (path.empty()) { printf(device not found\n); return 1; } HANDLE hDev OpenHidDevice(path.c_str()); if (hDev INVALID_HANDLE_VALUE) { printf(open failed\n); return 1; } BYTE cmd[] { 0x01, 0x02, 0x03 }; // 自定义命令帧 if (WriteHidReport(hDev, cmd, sizeof(cmd))) { printf(write ok\n); } BYTE inputReport[65] { 0 }; if (ReadHidReport(hDev, inputReport, sizeof(inputReport))) { printf(read: ); for (DWORD i 1; i 8; i) { // 从第2字节开始是有效负载 printf(%02X , inputReport[i]); } printf(\n); } CloseHandle(hDev); return 0; }这里有个个人习惯打印输入报告时跳过第0字节因为第0字节是报告ID无报告ID时固定为0负载数据是从第1字节开始的。4. 实测中容易翻车的四个细节代码跑通很容易但真正对接设备的时候我之前有一次折腾到凌晨2点最后发现是四个细节没注意。写在这里帮后面的人避坑。4.1 第一个坑WriteFile缓冲区多了个报告ID字节我第一次写的时候从网上抄了一段代码缓冲区长度直接用了设备的输出报告长度但字节流是直接从缓冲区头部开始填充数据。结果就是设备收到的每一个命令开头都多了一个0x00导致设备端解析命令的偏移量全部错位返回的数据完全对不上。实际原因就是HID协议要求在发送数据时第一字节必须填报告ID即使是不支持报告ID的单一报告设备也需要填0占位。所以务必要在分配缓冲区时把长度1给报告ID留位置。4.2 第二个坑多个进程同时打开设备导致ReadFile失败调试的时候我开了一个命令行版本的测试工具后来又跑了一次带界面的测试程序结果第二次打开的进程始终收不到数据。排查发现是我在第一个工具里没有关闭设备句柄导致设备被独占。解决方案就是打开设备时共享参数用FILE_SHARE_READ | FILE_SHARE_WRITE。另外一个相关的经验是如果设备插拔后程序没有释放句柄重新枚举时可能会找到同一设备的多个路径一定要在枚举时同时判断VID/PID否则容易绑定到旧路径导致打开失败。4.3 第三个坑超时处理的必要性HID设备有一个特性如果设备从来没发送过数据ReadFile会一直处于挂起状态事件永远不会置位。如果是同步模式整个程序就卡死了。用异步模式后WaitForSingleObject加上超时是很有必要的。我对轮询类设备设置的超时是1000ms对主动上报类设备设置的超时是3000ms。注意超时后一定要调用CancelIo清理挂起的IO操作否则下次ReadFile有可能会复用上一次未完成的OVERLAPPED结构产生不可预期的行为。4.4 第四个坑HidD_GetInputReport 和 ReadFile 是两条路HID设备读取输入报告有两条路径HidD_GetInputReport主动请求设备返回一份输入报告是一次性查询方式ReadFile OVERLAPPED接收设备主动上报的数据是持续监听方式很多刚接触HID的朋友会把HidD_GetInputReport当成读数据函数结果发现设备主动上报的消息怎么都读不到原因是消息是被ReadFile消费的不会自动进入HidD_GetInputReport。所以请求响应型设备比如查询设备电量、查询设备版本号用HidD_GetInputReport适合上报型设备比如键盘、传感器、读卡器必须用ReadFile持续读。我建议在项目中两条路径都写查询用HidD_GetInputReport上报监听用ReadFile各用各的不要混。5. 让代码更抗造的健壮性优化基本读写跑通后真正的考验才开始。设备插拔、系统休眠唤醒、多线程访问这些场景每个都能暴露出一堆问题。如果你要把这个例子用到正式产品里下面这些优化建议值得参考。5.1 热插拔监听的实现思路HID设备的最大特点是即插即用。用户随时可能把USB线拔掉你的程序如果还存着一个设备句柄接下来所有读写都会失败。我现在的做法是用RegisterDeviceNotification注册接口通知当系统广播DBT_DEVICEARRIVAL设备插入和DBT_DEVICEREMOVECOMPLETE设备拔出时上位机程序会自动感知在收到设备拔出的通知时立即关闭句柄并标记设备状态为离线在收到设备插入的通知时重新走一遍枚举-打开流程恢复通信这部分的代码量不大但能极大提升应用的稳定性。5.2 多线程模型设计HID读写不建议在UI线程中直接调用。我之前做的那个带界面工具如果主线程连续ReadFile等待数据窗口拖动都会卡。推荐的结构是一个独立的工作线程负责读数据读到的数据通过自定义消息或回调函数传递给界面层。工作线程的内部逻辑是一个循环while (running) { if (ReadHidReport(hDev, buffer, len)) { // 处理一帧数据 ProcessHidReport(buffer, len); } else { // 超时或错误进入重连逻辑 ReconnectIfNeeded(); } }注意工作线程退出前一定要设置running false并调用CancelIo取消挂起的IO操作否则WaitForSingleObject会永远等下去线程退不出来。5.3 调试阶段的打印与日志开发阶段最痛苦的事就是不知道设备到底有没有收到你的数据。我的调试习惯是在每一个WriteFile和ReadFile前后把缓冲区内容用十六进制打印出来方便对照。void DumpHex(const BYTE* data, DWORD len) { for (DWORD i 0; i len; i) { printf(%02X , data[i]); if ((i 1) % 16 0) printf(\n); } printf(\n); }带上时间戳写日志文件也很重要后面联调时能直接从日志里看出发送和接收的时序关系。这个问题在我自己调试时遇到过设备偶尔丢包不记日志根本找不到原因。5.4 关于读写权限和Windows保护之前在公司有同事遇到过一个问题程序在Win10上运行正常到Win11上报错拒绝访问。最后发现是Windows系统把这个程序识别成了需要管理员权限的操作。虽然大多数HID读写不需要提权但如果你同时打开了系统级的HID设备比如键盘、鼠标Windows有可能会拦截。稳妥的做法是在项目属性-链接器-清单文件里设置requestedExecutionLevel为asInvoker或者直接在main函数里不需要提权就不提权避免用户弹UAC框。6. 我后来还在用的一组调试验证方法代码写完了但写完了能编译通过和设备和上位机真正通了之间还隔着一个验证的过程。这里分享一组我每次拿到新HID设备都会走的调试验证方法按顺序来可以少走很多弯路。第一步是先用系统自带的方式确认设备端是正常的。Windows的设备管理器里把设备属性打开选硬件ID就能看到VID和PID先用这个值和代码里打印出来的比对确认枚举逻辑没匹配错。注意这里很容易踩的坑是设备管理器里的VID显示成小写而代码里你是用大写十六进制写的需要统一。第二步是借助UsbTreeView这个工具查看设备报告描述符。它能直接把HID设备的报告描述符解码成人类可读的结构比如Input Report的长度、Output Report的长度、每个Usage的编号。我之前遇到过一次设备端报告描述符和固件实际发送长度不一致的情况就是通过这个工具发现的。第三步才是跑代码。第一轮只做枚举确认能找到设备路径。第二轮只做写操作用串口或逻辑分析仪如果设备端有调试口确认设备确实收到了数据。第三轮再做读操作。分步验证的好处是问题定位快不会出现写完读不通还不知道是写没写成还是读没读到的困境。这三步做完一般就能确定是上位机代码问题还是设备端固件问题了省下来的调试时间远超这几分钟的操作成本。整个例子到这里该踩的坑和该有的知识大概都覆盖了。直接拿去改改VID和PID对照着你的设备报告长度调整缓冲区就可以用了。本文还有配套的精品资源点击获取