ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Windows HID设备通讯示例程序:从设备枚举到报告收发的完整实战

Windows HID设备通讯示例程序:从设备枚举到报告收发的完整实战 简介面向Windows平台VC开发者的HID设备通信示例程序完整演示了从枚举HID设备、打开设备句柄、通过DeviceIoControl读写输入/输出报告到解析HID报告描述符、注册设备事件通知并正确关闭句柄的闭环流程适合初、中级开发者学习USB HID通信原理也可作为工控、仪器仪表、游戏外设等项目中的通讯测试参考。资源共74个文件、约25.27MB除了可直接运行的exe还包含C源码、头文件、Visual Studio工程文件、HID通讯说明文档以及lib/obj等编译中间产物和自动备份脚本便于直接查看代码逻辑、对照调试或二次开发。已有619人学习浏览包内工程配合说明文档可帮助理解HidD_GetPreparsedData、HidP_GetCaps、RegisterDeviceNotification等关键API的实际用法减少外设接入、报告格式配置方面的踩坑适合作为Windows HID编程入门的配套示例。1. Windows下HID设备通讯示例程序解决的是哪一类问题标题里的“HID示例程序”本质是一份能在vc里直接编译运行的模板枚举设备、打开句柄、收发报告。HID是Windows上少数不需要额外驱动包的设备类型键鼠是最大的设备群体但真正需要翻源码找示例的往往是医用脚踏开关、扫码枪、自定义按键板这类非标HID设备。要把Windows下的HID通讯从头搭通得处理设备接口GUID、SetupAPI两段式取路径、报告ID加一这些琐碎细节示例程序的价值就是把整个链路串成最小可跑工程。看不到那份源码也没关系下面按最常见的实现思路把每一步API还原并给出可复制的代码和参数。Windows的HID协议栈从XP到11行为基本一致示例程序涉及的hid.dll、SetupAPI、报告缓冲三块到今天依然是HID设备通讯的全部控制点。按“原理—实现—踩坑—验证”的顺序展开最后给的排错流程可以直接抄进自己的上位机代码里。适合的读者是上位机开发、固件验证和测试自动化工程师。不管目标设备走USB还是蓝牙信道Windows暴露给应用的接口都是同一套只是底层传输驱动不同这也是HID设备通讯示例程序跨场景通用的原因。2. HID通讯原理Windows HID协议栈与设备枚举机制2.1 免驱的本质是报告描述符HID设备能做到免驱核心不在驱动而在报告描述符。设备上电后固件用一组描述符告诉主机我有输入报告8字节输出报告4字节第一个字节是按钮状态。Windows的hidparse.sys负责解析这组描述符并缓存之后应用层不需要知道设备内部怎么实现只需要按报告结构读写。这个设计对厂商的吸引力在于不用签驱动、不用写安装包、即插即用代价是报告格式表达能力有限复杂协议只能在vendor-defined用法页里再包一层。在Windows设备管理器里这类设备显示为HID-compliant device。如果设备报告描述符写错系统直接把它识别成未知USB设备应用层任何代码都救不回来。所以排查HID通讯问题时不要上来就翻代码先看设备枚举是否正常这个习惯能省很多时间。2.2 用户态能接触到的协议栈入口应用层能直接调用的主要是hid.dll和SetupAPI。hid.dll导出HidD_GetHidGuid、HidD_GetAttributes、HidD_GetPreparsedData这些函数SetupAPI负责设备接口枚举。一个HID示例程序里最常见的初始化动作就两行GUID hidGuid; HidD_GetHidGuid(hidGuid); // 拿到HID设备接口类GUIDHidD_GetHidGuid返回的是Windows内部注册的HID设备接口GUID常见值就是{4d1e55b2-f16f-11cf-88cb-001111000030}。代码里尽量不要硬编码这个值调用API获取更稳省得在不同Windows版本上出偏差。这个GUID是后续SetupAPI所有调用入口的基础。2.3 枚举链路SetupAPI两段式取路径拿到GUID之后枚举流程是固定套路SetupDiGetClassDevs打开设备信息集SetupDiEnumDeviceInterfaces逐个取接口SetupDiGetDeviceInterfaceDetail取设备路径CreateFile打开。其中第三步是犯错重灾区。SetupDiGetDeviceInterfaceDetail需要调用两次。第一次传空缓冲区让系统把需要的长度写回第二次分配好内存再来真正的路径才填进去。DWORD needed 0; SetupDiGetDeviceInterfaceDetailW(devInfo, ifData, NULL, 0, needed, NULL); PSP_DEVICE_INTERFACE_DETAIL_DATA_W detail (PSP_DEVICE_INTERFACE_DETAIL_DATA_W)malloc(needed); detail-cbSize sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W); SetupDiGetDeviceInterfaceDetailW(devInfo, ifData, detail, needed, NULL, NULL);两段式不是多余动作Windows需要从系统缓冲区分配合适内存直接给固定长度地址在部分系统上会返回ERROR_INVALID_USER_BUFFER。detail-cbSize必须赋成结构体大小否则API无法判断你用的哪个结构版本。有些示例程序用固定数组直接接收detail比如char detail[1024]在系统位数和结构对齐不同的机器上会随机失败正规代码都会保留两段式调用。这一节先把“HID设备没有盘符必须走设备接口取路径”这个逻辑建立起来。下一章给出完整的枚举循环。2.4 两组通讯函数中断端点与控制端点设备路径打开后收发报告有两条路线。用ReadFile/WriteFile走的是中断端点和鼠标键盘上报数据的通道相同用HidD_GetInputReport、HidD_SetOutputReport、HidD_GetFeature、HidD_SetFeature走的是控制端点。二者区别如下API通道典型场景注意点ReadFile中断IN设备主动上报持续监听需要处理阻塞和超时WriteFile中断OUT高频下发命令设备必须实现OUT端点HidD_GetInputReport控制IN主机主动查询当前状态有些固件不响应控制传输HidD_SetOutputReport控制OUT低频设置输出与WriteFile不是一回事HidD_GetFeature控制传输读取特性报告报告ID独立编号HidD_SetFeature控制传输设置特性报告常用于参数配置选择哪个端点要看设备的报告描述符。很多定制HID设备只实现了中断IN和中断OUT也有些低成本设备只实现了Feature报告。示例程序通常只会用通其中一条拿到新设备时先确认固件支持哪种再决定要不要改写收发逻辑。3. vc下HID设备通讯的核心实现从枚举到报告收发3.1 最小工程配置头文件、库与GUID初始化在Visual Studio里新建一个控制台工程先加四样东西。对应关系如下文件作用hidsdi.hHidD_* 函数声明setupapi.hSetupDi* 枚举函数声明hid.libhid.dll导入库setupapi.libSetupAPI导入库#include windows.h #include hidsdi.h #include setupapi.h #pragma comment(lib, hid.lib) #pragma comment(lib, setupapi.lib)这两条pragma comment会把hid.lib和setupapi.lib交给链接器省得每次在工程配置里手写加依赖。如果编译通过但链接报LNK2019先检查这两个库如果是GUID重复定义通常是其他头文件里#define了initguid需要调整包含顺序。代码里用HidD_GetHidGuid取GUID不自己定义GUID就不会有这类问题。3.2 枚举HID设备路径的两段式调用完整枚举代码GUID hidGuid; HidD_GetHidGuid(hidGuid); HDEVINFO devInfo SetupDiGetClassDevs(hidGuid, NULL, NULL, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); if (devInfo INVALID_HANDLE_VALUE) return; SP_DEVICE_INTERFACE_DATA ifData; ifData.cbSize sizeof(ifData); for (DWORD idx 0; SetupDiEnumDeviceInterfaces( devInfo, NULL, hidGuid, idx, ifData); idx) { DWORD detailSize 0; SetupDiGetDeviceInterfaceDetailW(devInfo, ifData, NULL, 0, detailSize, NULL); PSP_DEVICE_INTERFACE_DETAIL_DATA_W detail (PSP_DEVICE_INTERFACE_DETAIL_DATA_W)malloc(detailSize); detail-cbSize sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W); SP_DEVINFO_DATA devInfoData; devInfoData.cbSize sizeof(devInfoData); if (SetupDiGetDeviceInterfaceDetailW(devInfo, ifData, detail, detailSize, NULL, devInfoData)) { wprintf(LHID path: %s\n, detail-DevicePath); } free(detail); } SetupDiDestroyDeviceInfoList(devInfo);代码逻辑上注意三点。HidD_GetHidGuid获取接口GUIDfor循环用SetupDiEnumDeviceInterfaces从0开始递增索引返回FALSE时表示枚举结束两段式调用确保detail内存足够。detail-DevicePath是宽字符在需要支持中文路径的工程里直接用%ls打印。循环结束后调用SetupDiDestroyDeviceInfoList释放设备信息集不释放会一直占着系统资源。参数说明DIGCF_PRESENT只枚举当前在线的设备DIGCF_DEVICEINTERFACE枚举设备接口。少了后者抓到的是设备而非接口拿不到DevicePath。二者通常必须一起出现。3.3 根据VID/PID过滤并打开设备系统里的HID设备不只有键鼠触摸板、音量键、甚至部分电源管理设备都会注册为HID接口。所以枚举代码里一定要加过滤。最简单做法是匹配路径里的VID/PID子串if (wcsstr(detail-DevicePath, LVID_1234) NULL) continue; if (wcsstr(detail-DevicePath, LPID_5678) NULL) continue;匹配到之后打开设备HANDLE hDev CreateFile(detail-DevicePath, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if (hDev ! INVALID_HANDLE_VALUE) { HIDD_ATTRIBUTES attr; attr.Size sizeof(attr); if (HidD_GetAttributes(hDev, attr)) { // 这里再核对attr.VendorID和attr.ProductID做二次校验 } }CreateFile的三个关键参数访问权限给GENERIC_READ|GENERIC_WRITE确保输入输出都能操作共享模式给FILE_SHARE_READ|FILE_SHARE_WRITE避免占住设备不让别的工具读创建方式固定OPEN_EXISTING因为设备路径必须已存在。打开成功后用HidD_GetAttributes读到的VID/PID是硬件层实际上报的值比路径字符串更可信所以建议再校验一次。3.4 输入报告和输出报告的收发假设设备报告长度为64字节应用缓冲区就应该是65字节多的1字节用来放报告ID。BYTE outReport[65] { 0 }; outReport[0] 0x00; // 无Report ID时固定填0 outReport[1] 0x12; // 数据从第2字节开始 DWORD written 0; if (!WriteFile(hDev, outReport, sizeof(outReport), written, NULL)) { DWORD err GetLastError(); // 87 ERROR_INVALID_PARAMETER多半是长度不对 // 31 ERROR_GEN_FAILURE设备没有OUT端点 } BYTE inReport[65] { 0 }; DWORD readLen 0; if (ReadFile(hDev, inReport, sizeof(inReport), readLen, NULL)) { for (DWORD i 1; i readLen; i) printf(%02X , inReport[i]); }WriteFile的第三参数是缓冲区大小不是报告数据长度所以必须填包含报告ID占位的完整大小。ReadFile返回的readLen是实际有效字节数也包含报告ID字节循环从1开始跳过它。两个API出错时先看GetLastError再对照报告描述符里的长度就能定位大部分问题。4. 实战中绕开HID通讯的四个坑报告ID、超时、热插拔与句柄占用4.1 报告ID缓冲区第一个字节不是数据带Report ID的设备缓冲区长度是“数据长度1”且首字节是报告ID。无报告ID时首字节固定为0。这个规律对输入、输出、特性报告都一样改动固件后最容易在这里翻车。对应关系如下报告定义应用缓冲区长度有效数据位置无Report ID数据8字节9字节buf[0]0buf[1..8]为数据Report ID0x03数据8字节9字节buf[0]0x03buf[1..8]为数据Report ID0x07数据16字节17字节buf[0]0x07buf[1..16]为数据长度不要硬编码。打开设备后调用HidD_GetPreparsedData和HidP_GetCaps读取报告长度PHIDP_PREPARSED_DATA prep NULL; HIDP_CAPS caps; if (HidD_GetPreparsedData(hDev, prep)) { if (HidP_GetCaps(prep, caps) HIDP_STATUS_SUCCESS) { // caps.InputReportByteLength 已包含报告ID字节 // caps.OutputReportByteLength 同理 } HidD_FreePreparsedData(prep); }HidD_GetPreparsedData返回的是解析后的缓冲区指针用完必须调用HidD_FreePreparsedData释放。caps里的InputReportByteLength和OutputReportByteLength就是WriteFile/ReadFile应该用的缓冲区长度直接拿去做数组大小或动态分配都行。4.2 阻塞读与超时FILE_FLAG_OVERLAPPED CancelIoEx同步ReadFile在设备不上报时会一直阻塞生产环境不能用这种写法。常见做法是CreateFile时加FILE_FLAG_OVERLAPPED再用WaitForSingleObject控制等待时间。HANDLE hDev CreateFile(path, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL);读取循环OVERLAPPED ov { 0 }; ov.hEvent CreateEvent(NULL, TRUE, FALSE, NULL); BYTE buf[65] { 0 }; DWORD readLen 0; if (!ReadFile(hDev, buf, sizeof(buf), readLen, ov)) { DWORD err GetLastError(); if (err ERROR_IO_PENDING) { DWORD wait WaitForSingleObject(ov.hEvent, 1000); if (wait WAIT_TIMEOUT) { CancelIoEx(hDev, ov); WaitForSingleObject(ov.hEvent, INFINITE); } else { GetOverlappedResult(hDev, ov, readLen, FALSE); } } }OVERLAPPED里的事件对象在IO完成时被置为有信号WaitForSingleObject返回后就能从GetOverlappedResult里取实际读取字节数。超时后必须调用CancelIoEx撤回未完成IO再等事件有信号不然缓冲区可能被后续操作改写。设备拔出时ReadFile可能返回ERROR_GEN_FAILURE或ERROR_DEVICE_REMOVED这两类错误不要重试直接关闭句柄走重枚举。注意CancelIoEx取消的是指定句柄上等待的IO不是杀死线程。需要退出读线程时先关闭句柄让IO失败再等待线程结束避免TerminateThread留下内核锁。4.3 热插拔RegisterDeviceNotification与重新枚举设备随时可能被拔掉程序要能感知。窗口程序用RegisterDeviceNotification注册HID设备接口消息收到WM_DEVICECHANGE后重新枚举。控制台程序没有窗口需要先CreateWindow建一个隐藏窗口再用同一组API。DEV_BROADCAST_DEVICEINTERFACE filter; ZeroMemory(filter, sizeof(filter)); filter.dbcc_size sizeof(filter); filter.dbcc_devicetype DBT_DEVTYP_DEVICEINTERFACE; filter.dbcc_classguid hidGuid; HDEVNOTIFY hNotify RegisterDeviceNotification( hWnd, filter, DEVICE_NOTIFY_WINDOW_HANDLE);窗口过程里处理case WM_DEVICECHANGE: if (wParam DBT_DEVICEARRIVAL) { PostMessage(hWnd, WM_APP_REOPEN_DEVICE, 0, 0); } else if (wParam DBT_DEVICEREMOVECOMPLETE) { PostMessage(hWnd, WM_APP_CLOSE_DEVICE, 0, 0); } break;收到DBT_DEVICEARRIVAL后不要立刻打开设备系统还没完成设备接口注册常见做法是延迟100到200毫秒重试。设备拔出后路径会变程序不能继续使用旧的DevicePath必须重新枚举。把“枚举—打开—建读线程”封装成一个函数插入、启动、重连都调用同一个入口逻辑就清晰了。4.4 句柄占用与共享模式CreateFile的共享参数决定了多个进程是否能同时打开同一设备。HID类驱动本身允许多客户端共享所以共享参数写FILE_SHARE_READ|FILE_SHARE_WRITE是安全的选择。如果返回ERROR_ACCESS_DENIED先检查三点前一个句柄是否已经关闭是否有调试工具在占用设备以及固件是否把设备配置成独占模式。还有一种不明显的泄漏枚举循环里每匹配到一个路径就CreateFile一次打开后没匹配到目标设备时直接continue忘记CloseHandle。多个句柄同时开着最后一次打开自己失败。示例程序里这种问题尤其多因为代码短看起来没问题实际跑久了资源就耗尽。建议所有打开路径都走同一个函数成功返回前先释放临时句柄。5. HID通讯示例程序落地验证三分钟定位HID通讯故障5.1 先确认设备在设备管理器中是HID类排查HID通讯问题顺序不能反。设备管理器→人体学输入设备如果设备出现在列表里且显示“HID-compliant device”说明报告描述符被主机接受问题缩小到应用层。如果是黄叹号或“未知USB设备”问题在USB描述符和协议层先修固件。右键属性→详细信息→硬件ID能看到VID和PID拿它和代码里的过滤条件比对确认程序找的就是这个设备。5.2 错误码地图运行示例程序时把GetLastError都打出来结合这张表快速定位GetLastError()出现环节排查方向ERROR_FILE_NOT_FOUND(2)枚举/打开设备路径已失效需要重新枚举ERROR_ACCESS_DENIED(5)打开共享参数不足或设备被独占ERROR_INVALID_HANDLE(6)读写句柄已关闭检查生命周期ERROR_INVALID_PARAMETER(87)读写缓冲区长度没包含报告ID字节ERROR_GEN_FAILURE(31)读写设备不支持当前端点改用控制传输APIERROR_DEVICE_REMOVED(1617)读写设备被拔出关闭句柄重枚举这六类错误覆盖了HID通讯开发里九成现场其它错误码一般都能从MSDN查到但答案最终都会落回报告长度和设备端点这两个根因。5.3 自研最小收发工具做验证把示例程序改造成命令行小工具日常验证非常高效。用法是传入VID、PID和一组十六进制字节程序发一次再读一次hidtool.exe 0x1234 0x5678 55 AA 00 0F核心发送逻辑用sscanf解析参数for (int i 0; i argCount; i) { unsigned int v; sscanf(argv[i], %x, v); outBuf[outLen] (BYTE)v; } WriteFile(hDev, outBuf, outLen 1, written, NULL);固件端把收到的原始字节也打印出来两边对不上时再看总线抓包工具抓到的USB URB。应用层缓冲区与抓包字节不一致差在报告ID的1字节一致但设备不动说明报告内容解析或命令逻辑有问题。这轮排完Windows下HID设备通讯的调用链就算完全吃透了。本文还有配套的精品资源点击获取
返回列表