简介:一份面向VC++6.0环境下HID(人机交互设备)通信开发的实例代码包,适合嵌入式系统、设备驱动及底层硬件编程学习者参考。示例围绕设备枚举、接口匹配、打开句柄、读写报告及资源释放的完整流程展开,重点演示SetupDiGetClassDevs、CreateFile、DeviceIoControl等Win32 API在HID设备操作中的实际用法。压缩包共24个文件,约73KB,以h头文件、cpp源文件、lib库文件为主,附带dsp/dsw工程文件与可运行的exe,便于直接编译调试和对照学习。目前已有109人学习下载,适合希望快速上手Windows下HID读写、理解报告描述符解析的开发人员。通过阅读源码与运行示例,可掌握HID设备从枚举到关闭的完整调用链,并为后续驱动开发或嵌入式交互设计提供可直接改写的模板。
1. 一个被忽略的通信入口:VC6.0里直读HID设备能做什么
接手过一个老项目,用 VC6.0 维护一款 MFC 工具,要给某厂商的 USB 自定义按键设备写配套的上位机。厂商很抠门,驱动没给,协议文档只有两页。第一反应觉得又要碰 USB 驱动开发,结果查了一圈发现 HID 设备在系统里是免驱的,用户态用 CreateFile、ReadFile、WriteFile 就能完成读写。这个 VC++ HID 读写实例解决的就是这个场景:在 VC6.0 不死守新框架的前提下,用系统自带的 API 把 HID 设备的数据完整读出来、写进去。适合两类人,一类是维护老工程但被迫接新硬件的开发者,一类是想搞明白 USB HID 应用层通信原理、需要一份可直接编译参考代码的入门者。
2. 技术架构与工程结构:HID免驱背后的API调用链
2.1 HID通信的核心:报告不是流,是消息
HID 协议里,设备数据不叫“字节流”,叫“报告”。输入报告是设备发给主机的状态数据,比如按键值、传感器读数;输出报告是主机发给设备的命令,比如设置 LED、触发动作。每条报告有固定长度,长度由固件里的报告描述符决定。这个“固定长度消息”的模型,决定了应用层代码的写法。
系统对 HID 的处理是分层的:总线驱动识别到设备描述符里的 HID 类代码后,自动挂载通用 HID 驱动,应用层通过系统提供的 API 和驱动对话。这才是“免驱”的真正含义——不是不需要驱动,而是驱动由系统带好了,你不需要自己写 .sys 文件。这套 API 分成两类:一类是 HidD_ 开头的 HID 专项接口,另一类是 Win32 通用的文件读写接口。第一类走控制端点,适合低频配置操作;第二类走中断端点,适合持续的数据收发。这个例子里两边都用到了,新手上手时最容易把两者混着用,后面会专门说。
2.2 工程目录与模块划分:一个最小可编译的HID读写Demo
这个实例按 VC6.0 老工程的惯例组织文件,模块边界很干净,功能划分是:枚举、打开、读写、界面。目录结构大致如下:
| 文件 | 职责 |
|---|---|
| hid_rw.h | 定义设备路径、报告长度、打开/读写接口声明 |
| hid_rw.c | 枚举 HID 设备、打开句柄、ReadFile/WriteFile 实现 |
| main.cpp | 演示入口,枚举设备后选择目标设备,循环读写并打印 |
| ReadMe.txt | 编译步骤与依赖说明 |
| hid_rw.dsp / hid_rw.dsw | VC6.0 工程文件,双击 .dsw 打开即可 |
代码总量不大,但把要点都浓缩进去了。工程文件保留 VC6.0 的 .dsw 格式,意味着里面没掺 CMake、没掺新 SDK 依赖,拿来编译的路径最短。main.cpp 里把枚举、打开、读写分成三个独立函数,方便后续单独替换逻辑。
2.3 枚举与路径:HID的“电话簿”是麻烦根源
HID 设备在系统里不是一个简单文件名,而是一长串设备路径,格式类似\\?\hid#vid_xxxx&pid_xxxx#内部标识#类GUID。要打开设备,得先枚举出这个路径。枚举用的是 SetupAPI 设备信息接口,流程固定在五步:
- 调用 HidD_GetHidGuid 拿到 HID 设备类 GUID
- 调用 SetupDiGetClassDevs 获取设备信息集
- 循环调用 SetupDiEnumDeviceInterfaces 逐个取出设备接口
- 调用 SetupDiGetDeviceInterfaceDetail 取出具体设备路径
- 用 HIDD_ATTRIBUTES 结构拿到 VID/PID,过滤出目标设备
枚举阶段是 HID 开发里黑匣子味道最重的一块,经常看到设备明明插上了,程序就是“找不到”。核心原因往往是没做 VID/PID 过滤,把鼠标、键盘这类本机 HID 设备也枚举进来了,取到第一个路径就去打开,结果打开的是鼠标驱动,后面读写自然全乱套。这个实例里过滤逻辑写得很干净:
HIDD_ATTRIBUTES attrib; attrib.Size = sizeof(HIDD_ATTRIBUTES); if (HidD_GetAttributes(hidHandle, &attrib)) { if (attrib.VendorID == 目标VID && attrib.ProductID == 目标PID) { // 找到目标设备,复制路径后退出循环 } }HidD_GetAttributes 在拿到句柄后调用,返回的 VendorID 和 ProductID 正是设备描述符里的厂商 ID 和产品 ID。这里有个实用建议:枚举循环里不要只比较一次 VID/PID,应该把所有候选设备路径和对应的 VID/PID 打印出来,肉眼确认后再过滤。第一次做 HID 开发时,我靠这个习惯省掉了很多无效 debug。
3. 核心代码拆解:从打开设备到读写报告的完整链路
3.1 打开设备:CreateFile的打开姿势有讲究
枚举到设备路径后,用 CreateFile 打开。这一步看起来和打开普通文件一样,实际有几个参数容易翻车——共享模式必须同时带上读和写,否则会被系统里已打开该设备的其他句柄挡住。HID 设备在同一时刻可能被驱动、其他应用同时访问,共享模式小了会拿到拒绝访问的错误码。
HANDLE hDevice = CreateFile(lpDevicePath, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if (hDevice != INVALID_HANDLE_VALUE) { // 打开成功,可以用 HidD_GetAttributes 二次确认设备身份 }第一个参数是枚举阶段拿到的设备路径,第二个参数指定读写权限,第三个参数是关键——共享模式。第四个参数传 NULL 表示默认安全属性,OPEN_EXISTING 因为设备必须已存在,最后两个参数是文件属性和模板文件,HID 设备都用不到,传 0 和 NULL 即可。打开成功后我一般会立刻调用 HidD_GetAttributes 再核对一次 VID/PID,原因是枚举阶段拿到的路径可能因为设备重复枚举出现错位,多一次确认少一次返工。
3.2 读取输入报告:ReadFile 与中断传输的关系
读数据用 ReadFile,缓冲区第一个字节必须预留给报告 ID。HID 协议规定,如果设备没有使用报告 ID,缓冲区首字节填 0;如果固件用到了报告 ID,首字节要填实际的 ID 值。缓冲区大小等于报告长度加 1,少一个字节系统会直接返回参数错误。
BYTE inBuffer[MAX_REPORT_LEN + 1]; DWORD bytesRead = 0; // 首字节是报告 ID,设备没启用报告 ID 时固定填 0 inBuffer[0] = 0; if (ReadFile(hDevice, inBuffer, MAX_REPORT_LEN + 1, &bytesRead, NULL)) { // bytesRead = 实际读取字节数,数据从 inBuffer[1] 开始 printf("收到 %lu 字节: ", bytesRead); for (DWORD i = 1; i < bytesRead; i++) { printf("%02X ", inBuffer[i]); } }第一次做 HID 读操作最容易犯的错,是直接拿报告长度当缓冲区大小。ReadFile 在 HID 上要求长度必须等于“报告长度 + 1 字节报告 ID”,因为系统要把首字节的报告 ID 一并塞进缓冲区。bytesRead 返回的也是完整长度,解析数据时记得跳过 inBuffer[0]。这个读操作走的是中断端点,适合持续读取实时数据,比如自定义按键、传感器值。如果只是想一次读取设备当前状态,也可以用 HidD_GetInputReport,它走控制端点,速度慢但不会阻塞线程,适合低频查询。两个 API 在同一个句柄上混用没有问题,但注意不要在同一时刻并发读,否则报告会乱。
3.3 写输出报告:WriteFile 的缓冲区构造规则
写设备用 WriteFile,缓冲区的构造规则和读完全对称:首字节是报告 ID,后面跟着输出报告数据。很多自定义 HID 设备的输出报告就是一条命令,比如第一个字节是命令字、后续字节是参数。写操作同样要预留报告 ID 字节。
BYTE outBuffer[MAX_REPORT_LEN + 1]; DWORD bytesWritten = 0; // 首字节是报告 ID,本实例设备未启用,固定填 0 outBuffer[0] = 0; // 从位置 1 开始填充输出报告数据 outBuffer[1] = 0x01; // 命令字:读取状态 outBuffer[2] = 0x00; // 参数 outBuffer[3] = 0x00; // 参数 if (WriteFile(hDevice, outBuffer, MAX_REPORT_LEN + 1, &bytesWritten, NULL)) { // bytesWritten 是实际写入字节数 }这里有个细节值得注意:WriteFile 的第三个参数不是真实数据长度,而是固定缓冲区总长度。HID 协议要求一次写入长度等于输出报告长度,系统会把这个缓冲区整体包装成一条报告发给设备。如果写短了,驱动会填充或直接报错。所以我的习惯是,无论实际命令多短,都按 MAX_REPORT_LEN + 1 的长度写,多出来的字节填 0。和读一样,写也有另一个选择:HidD_SetOutputReport,它走控制端点,主要用于固件只实现了控制端点输出的设备。大部分厂商设备用 WriteFile 就够了,速度更快。
3.4 读线程与退出:别让阻塞式ReadFile卡死界面
HID 读操作默认是阻塞的,设备没数据时 ReadFile 一直挂起。放在 UI 线程里,界面直接假死;放在工作线程里,程序退出时线程收不回来。实例里的处理方式是开一个独立读线程,循环等待数据,同时用事件对象控制退出。
// 读线程入口 DWORD WINAPI ReadThreadProc(LPVOID lpParam) { BYTE buffer[MAX_REPORT_LEN + 1]; DWORD bytesRead = 0; while (WaitForSingleObject(hExitEvent, 0) == WAIT_TIMEOUT) { if (ReadFile(hDevice, buffer, MAX_REPORT_LEN + 1, &bytesRead, NULL)) { // 将数据投递给主界面逻辑处理 } else { // 读取失败,可能是设备被拔出,退出线程 break; } } return 0; } // 主程序退出时 SetEvent(hExitEvent); // 通知线程退出 WaitForSingleObject(hThread, 2000); // 等线程跑完 CloseHandle(hThread);WaitForSingleObject 在循环开头检查退出事件,没触发就继续读。这个模式比用 Sleep 轮询干净得多。但有个隐患:如果 ReadFile 正阻塞在一半,设备被拔出,ReadFile 可能不会立刻返回,线程就会卡死在阻塞调用里。稳妥做法是维护一个全局句柄列表,在收到设备拔出通知时先调用 CancelIo 取消挂起的读操作,再关闭句柄。这个话题在下一章的避坑记录里详细展开,这也是整个 HID 开发里我最常被问到的点。
4. 编译运行与避坑:VC6.0下 HID 开发的血泪记录
4.1 环境配置与编译步骤
VC6.0 是 1998 年的编译器,自带工具链里没有 HID 相关的头文件和库文件。这是编译阶段最大的坎。hidsdi.h、hidpi.h 这些头文件以及 hid.lib、setupapi.lib 库文件,原本是跟 Driver Kit 绑定的。早期做这个项目时,我先确认本机装了 DDK,然后在 VC6.0 的工程设置里把 DDK 的 include 和 lib 目录加进了搜索路径。
如果手头没有 DDK 也不想装,可以只拷贝四个文件到工程目录:hidsdi.h、hidpi.h、hid.lib、setupapi.lib。在源码里用 pragma comment 显式链接库,VC6.0 编译器比较老,对新型 SDK 结构不敏感,这四个文件放本地就能编过。
#pragma comment(lib, "hid.lib") #pragma comment(lib, "setupapi.lib") #include <windows.h> #include <setupapi.h> #include <hidsdi.h>编译顺序就三步:打开 .dsw 工程文件,确认 include 和 lib 路径正确,F7 编译。生成后插上 USB 设备运行,程序会打印枚举到的所有 HID 设备路径和对应的 VID/PID,选到目标设备后进入读写循环。如果这一步打印不出任何设备路径,先检查设备是不是真的枚举成了 HID 设备,用系统自带的“设备管理器”看一眼设备类名称。设备显示在“人体学输入设备”类别下,说明枚举正常;显示在其他类别下,比如“未知设备”,说明固件描述符有问题,代码层面没救,得找硬件方改固件。
4.2 避坑记录:我踩过的四个 HID 开发陷阱
陷阱一:设备路径枚举出一大堆,打开第一个返回拒绝访问
现象:程序打印出了十几个 HID 设备路径,没有做过滤直接取第一个,CreateFile 返回拒绝访问错误码。
原因:系统里鼠标、键盘、触摸板都是 HID 设备,某些系统设备被驱动独占,没有给应用层留出共享访问权限。取到这些设备的路径去打开,自然会撞防火墙。
解决:枚举阶段必须用 VID/PID 过滤目标设备,拿到路径后再和设备的实际 VID/PID 匹配。宁可枚举时全部打印出来人工核对,也不要盲目打开第一个。这个坑排查成本低,但第一次做的人基本都躲不掉。
陷阱二:ReadFile 返回参数无效,缓冲区长度被忽略
现象:句柄打开成功,但一调用 ReadFile 就返回 FALSE,GetLastError 是参数错误。缓冲区大小明明设置了,为什么还说参数无效?
原因:HID 读缓冲区要求报告长度加 1 字节的报告 ID 前缀。很多设备报告描述符里定义输入报告长度是 64 字节,实际缓冲区要传 65。传了 64,系统认为缓冲区装不下完整报告,直接拒绝。
解决:读和写的缓冲区长度统一用 MAX_REPORT_LEN + 1,首字节显式置 0。如果换了设备,先通过 HidD_GetAttributes 或者报告描述符接口确认实际报告长度,再调整这个宏。
陷阱三:设备拔出后读线程卡死,程序退不干净
现象:程序运行中拔出 USB 设备,界面没反应,结束进程时任务管理器里还能看到残留线程。
原因:ReadFile 在阻塞等待数据时,设备物理断开,驱动没有立即把挂起的读请求打回,读线程就一直挂在那里。主线程哪怕调用了 WaitForSingleObject 等线程退出,也永远等不到。
解决:在程序里处理设备拔出通知,收到拔出事件后立刻对设备句柄调用 CancelIo,让挂起的 ReadFile 返回失败,线程检测到失败后退出循环。没有通知机制时,给线程设置一个较短的超时轮询,配合事件退出,是退而求其次的做法。
陷阱四:VC6.0 编译找不到 hidsdi.h,翻了一晚上 SDK
现象:编译报错Cannot open include file: 'hidsdi.h',链接阶段又报一堆unresolved external symbol __imp_SetupDiGetClassDevs。
原因:VC6.0 自带的库目录里没有 HID API 的头文件和导入库,这些文件被放在驱动开发工具链里,需要单独配置。
解决:把 hidsdi.h、hidpi.h、hid.lib、setupapi.lib 四个文件拷入工程目录,在源码文件顶部直接#include "hidsdi.h",并用 pragma comment 指定链接库。这个方法比配置各种依赖路径可靠,换电脑重新打开工程也不会丢配置。
5. 二次开发改造:把裸 API 封装成 HID 通信模块
5.1 设计一个可复用的 CHidDevice 类
跑通读写只是第一步,真正接到项目里,需要把这套裸 API 封装起来。我的做法是做一个 CHidDevice 类,接口设计不多不少,正好覆盖项目里最常用的场景:
| 接口 | 作用 |
|---|---|
| Open(VID, PID) | 枚举并打开指定设备 |
| Close() | 关闭句柄并释放资源 |
| ReadReport() | 阻塞读取一条输入报告 |
| WriteReport() | 写一条输出报告 |
| IsOpened() | 判断当前是否持有有效句柄 |
核心用 C++ 类封装设备句柄,构造函数里初始化成员,析构函数里确保 Close 被调用。这样设备的打开和释放就绑定了对象生命周期,不会再出现句柄泄漏。原来是散落一地的全局函数,改成类之后,项目里其他模块直接维护一个 CHidDevice 成员变量就能接入。
5.2 和 MFC 界面集成:用自定义消息把数据送进对话框
这个实例的 main.cpp 是控制台演示,接到 MFC 项目时,需要把读线程的数据送到对话框更新界面。直接在读线程里操作控件更新是不行的,界面控件只能在 UI 线程操作,所以要用 PostMessage 投递自定义消息。
#define WM_HID_DATA (WM_APP + 100) // 读线程里投递消息 PostMessage(hMainWnd, WM_HID_DATA, bytesRead, (LPARAM)bufferCopy); // 对话框里响应消息 ON_MESSAGE(WM_HID_DATA, OnHidData)设备插拔对应的系统通知也值得一接:收到设备拔出消息时调用 CHidDevice 的 Close,收到设备插入消息时重新 Open。这个兜底逻辑补上之后,工具就不会在用户中途拔掉设备时崩溃。多次改过之后我的习惯是,所有 HID 相关工程都强制走一遍“打印路径→核对 VID/PID→最小读写→再接界面”的顺序,顺序乱了必出玄学问题。
这个项目本身不复杂,胜在代码路径清晰。拿走示例源码之后,建议先不改逻辑,原样编译跑通一次,确认电脑上能枚举出目标设备,再开始往自己的工程里迁移。把 HID 设备的报告模型吃透,后面做自定义 HID 设备的上位机就顺畅多了,希望帮到你。
本文还有配套的精品资源,点击获取