VC6.0下HID设备读写:用户态API实现USB通信完整示例

发布时间:2026/10/19 7:43:42
VC6.0下HID设备读写:用户态API实现USB通信完整示例 简介这是来自VC6.0环境下HID设备读写的一份完整示例工程面向嵌入式系统开发者、驱动开发初学者以及需要与USB人机交互设备通信的Windows程序员。工程围绕设备枚举、打开、报告读写与关闭等核心流程展示了如何使用SetupDi设备信息API枚举HID接口通过CreateFile打开设备句柄并借助DeviceIoControl配合IOCTL_HID_READ_REPORT和IOCTL_HID_WRITE_REPORT进行数据收发同时涉及HID报告描述符的解析方法完整呈现了设备信息集获取、接口遍历、路径解析等关键步骤。压缩包共24个文件包括7个头文件、3个C源文件、2个库文件以及VC6工程文件和可执行程序整体仅73KB代码量适中适合精读。目前已有109人学习下载。通过阅读源码和运行示例可以直观掌握HID设备从识别到数据交换的调用链并能复用其中的设备打开、报告读写、错误处理等代码框架作为自研调试工具或底层驱动开发的基础参考对理解Windows设备模型也很有帮助值得动手实践。1. HID读写示例不写内核驱动也能和 USB 设备对话拿到一份 HID 读写示例代码第一件该确认的事是它能不能在我这台 VC6.0 的老开发机上真正跑起来并且把设备里的字节读出来。这个例子做到了。它演示的是一条完整的 HID 通信链路枚举设备、打开句柄、解析报告描述符、读写输入输出报告全程只调 Windows 用户态 API不写内核驱动也不依赖任何第三方库。如果你手头有一个 USB HID 设备比如某自定义开发板、HID 协议加密狗、甚至是一个改过固件的鼠标想做上位机读状态、下发指令或者只是想用最干净的方式验证设备描述符这份代码就够用了。整个工程基于 VC6.0MFC 对话框结构界面和逻辑分得比较清楚拿过来改改就能接到自己的项目里。2. 项目架构解析HID 从设备到应用层的完整链路2.1 Windows HID 体系为什么应用层能直接读写HID 设备在 Windows 里是一类特殊的即插即用设备系统自带的 hidclass.sys 承担了协议解析工作它把 USB 总线上的 HID 报告转换为若干个设备接口。开发人员不需要接触 USB 传输层的同步传输、中断传输细节只需要通过三组 API 操作。第一组是 SetupAPI。它负责枚举设备信息入口是 SetupDiGetClassDevs传入 HID 设备的 GUID可以通过 HidD_GetHidGuid 获取就能拿到全部 HID 设备的设备信息集。之后用 SetupDiEnumDeviceInterfaces 遍历再用 SetupDiGetDeviceInterfaceDetail 获取每条设备的接口路径这个路径就是后面 CreateFile 的入参。第二组是 hidsdi.dll 中的 HidD_ 系列函数。HidD_GetAttributes 可以读取 VID、PID 和版本号HidD_GetPreparsedData 会把设备的报告描述符解析成一份预处理数据随后用 HidP_GetCaps 从这个预处理数据里取出输入报告的最大长度、输出报告的最大长度、报告 ID 个数等关键参数。第三组是大家最熟悉的文件操作接口。设备也是一个文件句柄输入报告用 ReadFile 读取输出报告用 WriteFile 下发。这套设计的排查思路非常清晰枚举不到设备先查 SetupAPI 调用拿到了句柄但读写失败问题基本出在报告长度或设备占用上。2.2 示例工程的模块划分和文件清单示例工程是一个标准 VC6 的 MFC 对话框程序。界面布局很直观左边一个设备列表右边是打开设备、关闭设备、读取报告、写入报告四个按钮中间一块编辑框用来滚动显示日志信息。工程里真正干活的业务逻辑封装在 CHidDevice 类中界面代码只负责把用户操作转发给它。整个工程的文件结构可以这样看文件职责HidDemo.dsp / HidDemo.dswVC6.0 工程文件和工作区文件HidDemo.h / HidDemo.cpp应用程序类和初始化逻辑HidDemoDlg.h / HidDemoDlg.cpp对话框界面按钮响应和列表填充HidDevice.h / HidDevice.cppHID 设备操作封装核心代码StdAfx.h / StdAfx.cppMFC 预编译头包含常用系统头文件Resource.h / res\HidDemo.rc界面资源定义控件 ID 和布局CHidDevice 类的对外接口设计得比较精简总共五个方法。下面是它的头文件内容class CHidDevice { public: // 枚举系统里所有 HID 设备填充到指定的列表框 BOOL EnumerateDeviceList(CListBox listBox); // 根据列表框中的索引打开对应的设备并解析报告参数 BOOL OpenDevice(int nIndex); // 关闭当前设备句柄释放预处理缓存 void CloseDevice(); // 读取一条输入报告数据放入 buffer长度不超过 length BOOL ReadReport(BYTE* buffer, DWORD length); // 发送一条输出报告长度取 buffer 前 length 字节 BOOL WriteReport(BYTE* buffer, DWORD length); private: HANDLE m_hDevice; // 打开后的设备句柄 CString m_strDevicePath; // 当前设备的接口路径 PHIDP_PREPARSED_DATA m_pPreparsedData; // 预处理数据缓存 HIDP_CAPS m_Caps; // 设备能力含报告长度 };封装上有几个细节值得借鉴。第一m_pPreparsedData 在打开设备时获取在关闭设备时用 HidD_FreePreparsedData 释放防止句柄泄漏第二列表框里存的是 VID_PID 文本但真正要打开的是设备路径所以列表项的 ItemData 存的是设备列表中的真实索引避免在 UI 层暴露路径字符串第三所有读写函数第一行都会判断 m_hDevice 是否有效无效直接返回 FALSE把错误收敛在类内部。2.3 VC6.0 工程配置头文件、库文件和项目选项VC6.0 自带的是上世纪的老 SDK很多 HID 相关的头文件不在里面。第一次编译时最常见的报错是找不到 hidsdi.h或者链接阶段提示 HidD_GetAttributes 是 unresolved external symbol。这两个问题都是同一个根源缺少新版本的 Platform SDK。解决办法分两步。第一步安装一个兼容的 Platform SDK 或 Windows SDK安装时选择自定义并保留 include 和 lib 即可。第二步用 Tools - Options - Directories 配置 include 路径和 lib 路径确保目录列表里 SDK 的 Include 和 Lib 排在 VC6 默认目录之前。链接库方面工程里要引用 setupapi.lib 和 hid.lib。直接在 Project Settings - Link - Object/library modules 里加上这两个库名就行注意中间用空格分隔。如果不想改全局目录配置也可以把这两个 .lib 文件复制到工程目录下在代码里用 pragma 注释引入#pragma comment(lib, setupapi.lib) #pragma comment(lib, hid.lib)另外还有一个容易被忽视的坑VC6.0 的编译器是 C98 时代的东西不支持 std::thread、auto、lambda 这类新语法。网上搜到的许多 HID 示例代码都是 VS2013 以上版本写的直接拖到 VC6 里编译会报一堆语法错误。我一般只参考这些新代码的思路但实现必须回退到老式 C 风格或者最基本的 C 风格这也是为什么这份例子值得留存的原因——它是真正能在 VC6.0 里编译通过的版本。3. 核心代码拆解从枚举到数据收发的完整流程3.1 设备枚举SetupAPI 的两次调用技巧枚举 HID 设备的第一个动作是获取设备类别 GUID。HidD_GetHidGuid 比起直接定义 GUID_DEVINTERFACE_HID 更稳妥因为操作系统内部的实际 GUID 可能随版本调整用系统 API 拿到的永远是正确的值。BOOL CHidDevice::EnumerateDeviceList(CListBox listBox) { GUID guidHID; HidD_GetHidGuid(guidHID); // 获取系统当前所有 Hid 设备的设备信息集 HDEVINFO hDevInfo SetupDiGetClassDevs(guidHID, NULL, NULL, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); if (hDevInfo INVALID_HANDLE_VALUE) return FALSE; SP_DEVICE_INTERFACE_DATA interfaceData; interfaceData.cbSize sizeof(SP_DEVICE_INTERFACE_DATA); DWORD dwIndex 0; listBox.ResetContent(); while (SetupDiEnumDeviceInterfaces(hDevInfo, NULL, guidHID, dwIndex, interfaceData)) { // 第一次调用获取设备接口详情所需的缓冲区大小 DWORD detailSize 0; SetupDiGetDeviceInterfaceDetail(hDevInfo, interfaceData, NULL, 0, detailSize, NULL); if (detailSize 0) { dwIndex; continue; } // 申请动态内存保存接口详情数据 PSP_DEVICE_INTERFACE_DETAIL_DATA pDetail (PSP_DEVICE_INTERFACE_DETAIL_DATA)new BYTE[detailSize]; pDetail-cbSize sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA); // 第二次调用真正获取设备接口路径 if (SetupDiGetDeviceInterfaceDetail(hDevInfo, interfaceData, pDetail, detailSize, NULL, NULL)) { // 这里拿到了一个形如 \\?\hid#vid_xxxxpid_xxxx#... 的路径 HANDLE hTemp CreateFile(pDetail-DevicePath, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if (hTemp ! INVALID_HANDLE_VALUE) { HIDD_ATTRIBUTES attr; attr.Size sizeof(HIDD_ATTRIBUTES); if (HidD_GetAttributes(hTemp, attr)) { CString strItem; strItem.Format(_T(VID_%04X | PID_%04X | Ver_%04X), attr.VendorID, attr.ProductID, attr.VersionNumber); int nPos listBox.AddString(strItem); listBox.SetItemData(nPos, dwIndex); } CloseHandle(hTemp); } } delete[] pDetail; dwIndex; } SetupDiDestroyDeviceInfoList(hDevInfo); return TRUE; }这段代码的关键在 SetupDiGetDeviceInterfaceDetail 的两次调用。第一次传 NULL 地址和 0 长度函数会返回失败并设置字节数为真实需要的布局大小第二次申请了内存后pDetail 的结构体头必须是设备接口详情数据结构的真实大小这个 cbSize 字段赋值错了会导致第二次调用直接返回 ERROR_INVALID_PARAMETER。还有一个参数是父设备信息这里传 NULL 即可。CreateFile 用 GENERIC_READ | GENERIC_WRITE 打开是为了后续读写都顺畅如果只读设备的输入报告也可以只给 GENERIC_READ。但共享模式建议保持 FILE_SHARE_READ | FILE_SHARE_WRITE因为很多 HID 设备不允许两个程序同时占用这一步做不好后面打开会撞上 ERROR_ACCESS_DENIED。3.2 打开设备获取报告长度和预处理数据设备列表里双击一条记录界面会调用 OpenDevice。打开操作除了拿到句柄还要解析两个关键参数输入报告长度和输出报告长度。HID 设备的报告长度不是随便猜的必须从报告描述符里解析出来否则读写时数据长度不对轻则读多读少重则触发设备异常。BOOL CHidDevice::OpenDevice(int nIndex) { // nIndex 对应枚举时保存的 dwIndex GUID guidHID; HidD_GetHidGuid(guidHID); HDEVINFO hDevInfo SetupDiGetClassDevs(guidHID, NULL, NULL, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); SP_DEVICE_INTERFACE_DATA interfaceData; interfaceData.cbSize sizeof(SP_DEVICE_INTERFACE_DATA); // 按索引重新走到目标设备取出路径 if (!SetupDiEnumDeviceInterfaces(hDevInfo, NULL, guidHID, nIndex, interfaceData)) { SetupDiDestroyDeviceInfoList(hDevInfo); return FALSE; } DWORD detailSize 0; SetupDiGetDeviceInterfaceDetail(hDevInfo, interfaceData, NULL, 0, detailSize, NULL); PSP_DEVICE_INTERFACE_DETAIL_DATA pDetail (PSP_DEVICE_INTERFACE_DETAIL_DATA)new BYTE[detailSize]; pDetail-cbSize sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA); SetupDiGetDeviceInterfaceDetail(hDevInfo, interfaceData, pDetail, detailSize, NULL, NULL); m_strDevicePath pDetail-DevicePath; delete[] pDetail; SetupDiDestroyDeviceInfoList(hDevInfo); // 打开设备文件句柄 m_hDevice CreateFile(m_strDevicePath, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if (m_hDevice INVALID_HANDLE_VALUE) return FALSE; // 解析设备能力报告长度、报告ID列表、使用页等 PHIDP_PREPARSED_DATA preparsedData NULL; if (!HidD_GetPreparsedData(m_hDevice, preparsedData)) { CloseHandle(m_hDevice); m_hDevice INVALID_HANDLE_VALUE; return FALSE; } m_pPreparsedData preparsedData; // 输入报告最大长度、输出报告最大长度写入 m_Caps HidP_GetCaps(m_pPreparsedData, m_Caps); return TRUE; }HidD_GetPreparsedData 分配了内部缓存保存设备报告描述符解析后的结果。这个缓存要保留到关闭设备时才用 HidD_FreePreparsedData 释放中间可以随时调用 HidP_GetCaps 获取能力参数。m_Caps.InputReportByteLength 和 m_Caps.OutputReportByteLength 两个字段在读写时会派上用场。这里有一个处理细节路径字符串 m_strDevicePath 在 OpenDevice 里被重新获取而不是从列表里的 ItemData 取是为了防止用户在打开操作前重新枚举了设备导致索引错位。虽然例子很简单但这种防御性的取法在反复插拔设备时很管用。3.3 输出报告WriteFile 和 HidD_SetOutputReport 怎么选有了句柄和报告长度发送数据就简单了。最常见的写法是直接拿 WriteFile 往设备句柄写字节流。对于大多数 HID 设备WriteFile 走的是输出报告请求会被 HID 类驱动转发给设备固件。BOOL CHidDevice::WriteReport(BYTE* buffer, DWORD length) { if (m_hDevice INVALID_HANDLE_VALUE) return FALSE; // 长度不能超过输出报告的最大长度 if (length m_Caps.OutputReportByteLength) return FALSE; DWORD bytesWritten 0; // buffer[0] 必须是报告ID没有报告ID的设备填 0x00 BOOL bResult WriteFile(m_hDevice, buffer, length, bytesWritten, NULL); // 有时候 WriteFile 没报告错误但字节数不对也要当失败处理 if (!bResult || bytesWritten ! length) return FALSE; return TRUE; }WriteFile 的第一个参数是设备句柄第二、三个参数是数据缓冲区和字节数第四个返回实际写入字节数后两个在同步模式下传 NULL。如果 createfile 时使用了 FILE_FLAG_OVERLAPPED这里就要传入 OVERLAPPED 结构体指针并自己处理 GetOverlappedResult这套异步写法适合读写耗时的低频设备但会让示例复杂不少。还有一条路是 HidD_SetOutputReport。它和 WriteFile 的底层通道不同走的是 HID 专用的控制请求更接近设备固件里报告解析的那条路径。有些设备对 WriteFile 无响应但对 HidD_SetOutputReport 正常反过来也有。我一般默认用 WriteFile如果联调时发现设备不动作就换 HidD_SetOutputReport 试试这个切换成本很小代码里两个函数都有体现。3.4 输入报告读取ReadFile 与缓冲区的字节对齐读输入报告的方法和写基本对称同样用 ReadFile。设备主动上报的输入报告会进入系统缓冲区ReadFile 每次读取一条完整的报告。如果设备没有新数据而句柄是同步模式ReadFile 会一直阻塞所以实际项目里通常放到工作线程里循环读取。BOOL CHidDevice::ReadReport(BYTE* buffer, DWORD length) { if (m_hDevice INVALID_HANDLE_VALUE) return FALSE; DWORD bytesRead 0; // 同步读取读不到数据会一直等 BOOL bResult ReadFile(m_hDevice, buffer, length, bytesRead, NULL); if (!bResult || bytesRead 0) return FALSE; // 如果设备有报告IDbytesRead 会包含报告ID字节 return TRUE; }调用方传入的 length 最好直接用 m_Caps.InputReportByteLength不要自己拍脑袋写 64。HID 协议里报告长度由描述符决定真实长度可能只有 4 字节或者 8 字节多读会多出无效数据少读又会截断报告。缓冲区大小可以开 65 字节因为带报告 ID 的场景最多是 64 字节数据加 1 字节 ID。还有一点要专门提拿到数据的第一个字节往往不是有效载荷而是报告 ID。对于不带报告 ID 的设备这个字节是 0x00。解析数据时要么跳过第一字节要么根据实际设备的报告 ID 判断这个在踩坑章节里会再展开。4. 避坑指南VC6 下 HID 开发的高频故障与解决记录4.1 编译环境找不到 hidsdi.h 和 hid.lib现象新建工程后写第一行 HidD_GetHidGuid编译直接报 C1083 打不开 hidsdi.h屏蔽头文件错误后链接阶段又报 LNK2001 unresolved external symbol HidD_GetAttributes。原因VC6.0 自带的 Windows SDK 版本非常老HID 支持是 Windows 2000 之后才逐步完善的老 SDK 里根本没有 hidsdi.h也没有对应的 lib 导入库。解决安装 Platform SDK 并在 VC6 的 include 和 lib 目录配置里把新 SDK 路径放到最前面。改完重启 VC6清理中间文件重新编译。如果不想全局改就直接在工程设置里加 setupapi.lib 和 hid.lib 两个链接库名并确认头文件搜索路径。4.2 设备枚举cbSize 设置错误导致接口详情获取失败现象SetupDiGetDeviceInterfaceDetail 第二次调用返回 FALSEGetLastError 是 ERROR_INVALID_PARAMETER设备列表一片空白。原因SP_DEVICE_INTERFACE_DETAIL_DATA 的 cbSize 字段在 32 位和 64 位系统上值不同。在 VC6 这种 32 位环境下sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA) 返回 4是正确的但如果你把代码稍加改动在 64 位编译器下重新编译这个值会变成 8。有些老代码直接把这个结构整体清零cbSize 没有赋值在部分 Windows 版本上也会报参数错误。解决每次调用前显式赋值 cbSize sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA)不要直接 memset 整个结构。并且确保申请 detailSize 后再填充数据顺序不能反。第一次调用和第二次调用之间不要复用同一个缓冲区老老实实先取大小再分配内存。4.3 打开设备失败文件共享模式不对现象CreateFile 返回 INVALID_HANDLE_VALUEGetLastError 有时是 ERROR_ACCESS_DENIED有时是 ERROR_SHARING_VIOLATION。同一个设备换个 USB 口又正常了。原因HID 设备在 Windows 里同一时刻只允许一个句柄以读写模式打开严格的共享行为由设备固件和驱动共同决定。如果之前某个进程没有正常关闭句柄就退出设备名空间里会残留占用再次打开就冲突了。解决CreateFile 的 dwShareMode 参数不要给 0用 FILE_SHARE_READ | FILE_SHARE_WRITE。另外在清理代码里保证 CloseDevice 一定被调用尤其程序退出时要关掉句柄并释放预处理缓存。如果调试时遇到这种残留占用直接拔插一次 USB 设备就能恢复。4.4 报告长度与实际不符导致数据错乱现象读回来的数据总是多几个字节或者少几个字节WriteFile 发送后设备没有反应但换成设备自带的上位机却一切正常。原因最常见的是直接把缓冲区固定为 64 字节。实际上报告长度由描述符决定有的设备输入报告只有 8 字节输出报告 6 字节多传的长度会被设备固件按报告长度截断或忽略少传则直接返回错误数据错乱是必然的。解决以 HidP_GetCaps 返回的 InputReportByteLength 和 OutputReportByteLength 为准每次读写都按这个长度传递。同时预留报告 ID 字节如果设备描述符里报告 ID 个数大于 0buffer[0] 必须是 0x00 或无报告 ID 设备的实际 ID 值数据从 buffer[1] 开始才是有效内容。5. 二次开发把 HID 示例改成你自己的设备上位机拿到这份示例第一步自然是跑通。跑通的标志是三件事设备列表里能看到你的设备、能打开、读写不报错。做到之后再往自己的项目方向改造我通常按三条线来改。第一条线是过滤设备。办公电脑上插着鼠标、键盘、音量旋钮等一堆 HID 设备直接全部列出来很容易选错对象。改造方式是在 CHidDevice 里加一个成员变量 m_wVid 和 m_wPid在枚举时比较 attr.VendorID 和 attr.ProductID不符合的直接跳过。这种过滤比在界面上让用户猜要靠谱得多特别是在产线调试的时候少点很多鼠标动作。第二条线是封装复用。CHidDevice 这个类本来就不依赖对话框控件把它抽出来放到独立工程里去掉 CListBox 的参数型改成返回数组或者回调就能变成一个纯业务类。我一般会再加两个方法一个 StartReadThread 启动后台工作线程循环读输入报告另一个设置回调函数指针让数据主动送到业务模块。这样一来界面线程不用阻塞在 ReadFile 上数据的实时性也有保障。第三条线是超时和容错。同步模式 ReadFile 在设备拔掉时会一直阻塞这是最需要改的。常见的做法是把 CreateFile 的 dwFlagsAndAttributes 改为 FILE_FLAG_OVERLAPPED然后配合 WaitForSingleObject 等待读写完成超时时间按设备情况设在 200 到 1000 毫秒之间。拔掉 USB 线后读写会立刻返回失败这时候只需要关句柄、释放预处理数据、重新枚举就能实现自动恢复连接。在做这套改造时有一个教训我印象很深第一次尝试给 CHidDevice 加异步读线程时关闭设备没有等线程退出就直接释放句柄程序一退出就崩。后来所有关闭流程都强制先停止线程、再关句柄、再释放缓存顺序一步不改。从那以后我每次新增一个设备方法都会先走一遍完整的打开、读写、插拔、关闭流程再合入代码。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询