简介:这是一份面向Windows 10平台、基于Qt5与hidapi库编写的USB调试助手源码工程,适合嵌入式开发、USB HID设备驱动调试及上位机开发初学者参考。项目以Qt5构建图形界面并处理事件,借助hidapi跨平台接口访问HID设备,实现设备枚举、VID/PID信息读取、数据收发与错误处理等核心功能,可帮助开发者快速搭建支持HID协议的USB设备测试环境。压缩包共19个文件,约1004KB,包含5个h头文件、4个cpp源文件、2个lib静态库、2个dll动态库、2个pdb调试符号,以及pro工程配置、ui界面描述和readme说明等,覆盖从界面设计到库依赖的完整结构。目前已有554人学习下载。读者可从中获取Qt工程组织方式、hidapi在Windows下的集成方法、mingw编译配置思路及设备通信排错经验,适合作为USB调试工具二次开发的基础模板。
1. 从抓包到发数据:一个能跑在 Win10 上的 USB 调试助手长什么样
手上有个自定义 HID 设备,厂商只给了一份模糊的协议文档,Windows 设备管理器里能看到它,但想发一条命令过去、看看它回什么,翻遍系统自带工具都找不到趁手的。总线抓包工具能看到枚举过程,却没法主动构造报文;芯片原厂的调试软件又只认自家芯片。这种时候,一个能枚举设备、打开端点、手动收发原始报文的 USB 调试助手,就是刚需。
这份资源是基于 Qt5 加 hidapi 写的 USB 调试助手源码,跑在 Windows 10 上,界面用 Qt Widgets 搭,底层通过 hidapi 的 C 接口访问 HID 设备。它解决的不是驱动开发问题,而是「设备已经能被系统识别,我要跟它对话」这个环节。适合做嵌入式固件、HID 外设、工控采集模块的工程师,也适合刚接触 USB 通信、想找一个能编译能改的起点项目的人。整套代码不依赖额外商业库,qmake 或 CMake 都能拉起来。
2. 环境搭建与工程结构:Qt5 和 hidapi 怎么接上
2.1 为什么是 Qt5 + hidapi 这个组合
选型这件事,先想清楚边界。Windows 上访问 HID 设备,原生路径是 SetupAPI 加 HidD_ 系列函数,能拿到设备路径、打开句柄、发 Feature Report,但代码量大,枚举设备要处理设备接口类 GUID,新手容易在设备信息集遍历那一步卡住。另一个方向是 libusb,能力更底层,能碰非 HID 类设备,但 Windows 上要额外装驱动(WinUSB 或 libusbK),对已经用系统 HID 驱动的设备反而多此一举。
hidapi 的价值在于把上面那套 SetupAPI 调用封成了几个函数:hid_enumerate枚举、hid_open_path打开、hid_write发、hid_read_timeout收。它跨平台,Linux 和 macOS 上同一套调用逻辑,源码量小,读一遍就能明白底层干了什么。Qt5 负责的是界面和事件循环,把设备列表、收发日志、十六进制输入这些交互做出来。两者之间用一层薄封装衔接,不引入额外抽象。
常见做法是:hidapi 在 Windows 上编译成静态库或直接把hid.c加进工程,Qt 这边用extern "C"包含头文件。这样不依赖 DLL 分发,编译出来的 exe 拷到别的 Win10 机器上也能跑。
2.2 工程目录与依赖放置
一个能直接编译的工程,目录结构建议这样组织:
usb_debug_assistant/ ├── usb_debug_assistant.pro # qmake 工程文件 ├── src/ │ ├── main.cpp │ ├── mainwindow.cpp │ ├── mainwindow.h │ ├── mainwindow.ui │ ├── hiddevice.cpp # hidapi 封装层 │ └── hiddevice.h ├── third_party/ │ └── hidapi/ │ ├── hidapi.h │ └── windows/ │ └── hid.c └── README.md.pro文件里关键几行:
QT += core gui widgets CONFIG += c++11 INCLUDEPATH += $$PWD/third_party/hidapi SOURCES += \ src/main.cpp \ src/mainwindow.cpp \ src/hiddevice.cpp \ third_party/hidapi/windows/hid.c HEADERS += \ src/mainwindow.h \ src/hiddevice.h # Windows 下 hidapi 需要链接的库 win32: LIBS += -lsetupapi -lhidINCLUDEPATH指向 hidapi 头文件所在目录,SOURCES里把hid.c直接编进来,省去单独编译静态库的步骤。win32: LIBS那行是必须的,setupapi提供设备信息集遍历,hid提供 HID 相关的导出函数,漏掉会在链接阶段报一堆未解析符号。
提示:hidapi 的
hid.c在 Windows 目录下,不要错拿 Linux 目录那份,否则#include <windows.h>会直接报错。
2.3 编译与首次运行验证
用 Qt Creator 打开.pro,选 Desktop Qt 5.x MinGW 或 MSVC 套件,直接构建。命令行方式:
# 假设 Qt 安装在默认路径,MinGW 套件 qmake usb_debug_assistant.pro mingw32-make -j4编译通过后运行,界面应该出现设备列表区域和收发区。如果列表为空,先确认目标设备已经插上且设备管理器里显示为「人体学输入设备」下的 HID 设备。hidapi 的hid_enumerate(0x0, 0x0)会枚举所有 HID 设备,参数传 0 表示不按 VID/PID 过滤。
首次运行建议先拿一个标准 HID 设备试,比如带自定义 Report 的键盘或游戏手柄,确认枚举和打开流程通了,再换自己的目标设备。这一步能排除掉「代码没问题但设备本身枚举失败」的干扰。
3. 设备枚举与打开:hid_enumerate 返回的链表怎么用
3.1 枚举逻辑与设备信息提取
hidapi 的枚举函数返回一个struct hid_device_info *链表,每个节点包含设备路径、VID、PID、厂商字符串、产品字符串、序列号、用法页和用法值。封装层要做的是遍历链表,把关心的字段取出来填进 Qt 的列表控件。
// hiddevice.cpp 片段 #include "hiddevice.h" #include "hidapi.h" QList<HidDeviceInfo> HidDevice::enumerate(quint16 vid, quint16 pid) { QList<HidDeviceInfo> result; struct hid_device_info *devs = hid_enumerate(vid, pid); struct hid_device_info *cur = devs; while (cur) { HidDeviceInfo info; info.path = QString::fromWCharArray(cur->path); info.vendorId = cur->vendor_id; info.productId = cur->product_id; info.manufacturer = QString::fromWCharArray(cur->manufacturer_string); info.product = QString::fromWCharArray(cur->product_string); info.serial = QString::fromWCharArray(cur->serial_number); info.usagePage = cur->usage_page; info.usage = cur->usage; result.append(info); cur = cur->next; } hid_free_enumeration(devs); // 必须释放,否则每次枚举都泄漏 return result; }逻辑说明:hid_enumerate内部在 Windows 上走的是 SetupAPI 的设备接口遍历,拿到所有 HID 类设备的路径,再逐个打开读取属性。path是后续hid_open_path要用的唯一标识,VID/PID 用于过滤,字符串字段可能为空(设备没提供),代码里要容忍空指针。
参数说明:vid和pid传 0 表示不过滤。如果只想看某个厂商的设备,传具体值能大幅缩短枚举时间,也避免列表里混入鼠标键盘。
3.2 打开设备与句柄管理
枚举只是拿到路径,真正通信要打开设备拿到句柄。hid_open_path返回一个hid_device *,后续读写都靠它。
bool HidDevice::open(const QString &path) { if (m_handle) { hid_close(m_handle); m_handle = nullptr; } m_handle = hid_open_path(path.toLocal8Bit().constData()); if (!m_handle) { m_lastError = QString::fromWCharArray(hid_error(nullptr)); return false; } return true; } void HidDevice::close() { if (m_handle) { hid_close(m_handle); m_handle = nullptr; } }path在 Qt 里是 QString,转成const char *时用toLocal8Bit而不是toUtf8,因为 Windows 上设备路径是 ANSI 编码的窄字符串,用 UTF-8 会打开失败。这是实际调试中很容易翻车的一个点,现象是枚举能看到设备,但一打开就返回空句柄。
句柄管理上,打开新设备前先关旧的,避免句柄泄漏。hid_close之后句柄失效,不能再读写。如果程序退出时没关,系统会在进程结束时回收,但长时间运行的工具还是显式关掉稳妥。
3.3 设备热插拔的检测思路
HID 设备拔掉再插上,路径可能变,句柄会失效。一个调试助手如果只能枚举一次,用起来会很难受。常见做法是用 QTimer 定时重新枚举,对比设备列表变化,或者监听 Windows 的WM_DEVICECHANGE消息。
定时枚举实现简单,代价是每次枚举有开销。间隔设 2 到 3 秒比较合适,太短浪费 CPU,太长插拔后要等。对比时用path作为唯一键,路径相同视为同一设备。如果当前打开的设备路径从列表里消失,就自动关闭句柄并在日志区提示设备已断开。
void MainWindow::onRefreshTimer() { QList<HidDeviceInfo> current = HidDevice::enumerate(0x0, 0x0); // 与 m_lastList 对比,更新界面 // 若 m_openedPath 不在 current 中,调用 close 并提示 m_lastList = current; }这套逻辑不复杂,但能显著提升工具在真实调试场景下的可用性。设备反复插拔是常态,手动点刷新容易漏掉状态。
4. 收发报文与十六进制处理:hid_write 和 hid_read_timeout 的配合
4.1 发送 Feature Report 与 Output Report 的区别
HID 设备通信分几条通道:Control 传输走 Feature Report,Interrupt 传输走 Input/Output Report。hidapi 的hid_write在 Windows 上默认走的是 Output Report,也就是中断输出端点。如果要发 Feature Report,得用hid_send_feature_report。
这个区别不搞清楚,会出现「数据发出去了但设备没反应」的情况。判断方法看设备描述符:如果设备声明了 Output Report,用hid_write;如果只有 Feature Report,用hid_send_feature_report。很多自定义 HID 设备两者都有,具体用哪个看固件怎么实现的。
int HidDevice::sendOutputReport(const QByteArray &data) { if (!m_handle) return -1; // hid_write 要求缓冲区第一个字节是 Report ID // 如果设备 Report ID 为 0,首字节填 0 return hid_write(m_handle, reinterpret_cast<const unsigned char *>(data.constData()), data.size()); } int HidDevice::sendFeatureReport(const QByteArray &data) { if (!m_handle) return -1; return hid_send_feature_report(m_handle, reinterpret_cast<const unsigned char *>(data.constData()), data.size()); }参数说明:data的第一个字节必须是 Report ID。如果设备只用 Report ID 0,首字节填 0x00,后面跟实际数据。data.size()是包含 Report ID 在内的总长度。返回值是实际发送的字节数,失败返回 -1,可以用hid_error取错误信息。
4.2 接收数据与超时设置
接收用hid_read_timeout,它带一个毫秒级超时参数。传 0 是非阻塞立即返回,传 -1 是无限阻塞,传具体值则最多等那么久。
void HidDevice::readLoop() { unsigned char buf[64]; while (m_running) { int res = hid_read_timeout(m_handle, buf, sizeof(buf), 100); if (res > 0) { QByteArray data(reinterpret_cast<char *>(buf), res); emit dataReceived(data); // 通过信号发到界面线程 } else if (res < 0) { emit errorOccurred(QString::fromWCharArray(hid_error(m_handle))); break; } // res == 0 表示超时,继续循环 } }逻辑说明:读循环放在独立线程里,避免阻塞 Qt 界面线程。hid_read_timeout返回正数是收到的字节数,0 是超时,负数是错误。缓冲区大小按设备的最大 Input Report 长度设,常见 64 字节,不够会截断。
参数说明:超时设 100 毫秒是个折中,既能及时响应数据,又不会让循环空转太频繁。如果设备数据量大,可以调小;如果只是偶尔上报,调大减少 CPU 占用。
注意:
hid_read_timeout在设备被拔掉后可能返回 -1,此时要退出循环并通知界面,否则线程会一直空转。
4.3 十六进制输入解析与日志显示
界面上用户输入的是十六进制字符串,比如01 02 AB FF,要转成 QByteArray 再发。解析时容忍空格、逗号、大小写,非法字符要提示。
QByteArray MainWindow::parseHexInput(const QString &text, bool *ok) { QByteArray result; QString cleaned = text; cleaned.remove(QRegularExpression("[\\s,]+")); // 去掉空格和逗号 if (cleaned.length() % 2 != 0) { *ok = false; return result; } for (int i = 0; i < cleaned.length(); i += 2) { bool byteOk = false; int value = cleaned.mid(i, 2).toInt(&byteOk, 16); if (!byteOk) { *ok = false; return result; } result.append(static_cast<char>(value)); } *ok = true; return result; }日志区显示收发数据时,统一格式化成大写十六进制、每字节两位、空格分隔,前面加时间戳和方向标记。这样回看日志时能快速定位。接收数据用QPlainTextEdit追加,设置最大块数防止内存无限增长。
5. 避坑与排查:那些让调试助手「看起来能用但实际不能用」的问题
5.1 枚举到设备但打开失败
现象:设备列表里能看到目标设备,点打开返回失败,hid_error提示「无法打开设备」或类似信息。
原因:最常见的是路径编码问题。QString::toUtf8()在 Windows 上对设备路径会出问题,因为 hidapi 期望的是本地 ANSI 编码。另一个原因是设备被其他进程独占打开,HID 设备默认是独占访问,如果厂商调试工具还开着,这边就打不开。
解决:路径转换改用toLocal8Bit()。确认没有其他程序占用设备,任务管理器里检查有没有残留进程。如果设备支持共享模式,需要在打开时设置,但 hidapi 默认不提供这个选项,得改底层CreateFile的共享标志。
5.2 发送成功但设备无响应
现象:hid_write返回的字节数等于发送长度,看起来成功了,但设备没有任何回复。
原因:Report ID 不对。很多自定义 HID 设备在固件里定义了非零 Report ID,发送时首字节必须填对应的 ID,填 0 会被设备忽略。另一个可能是发错了通道,该用 Feature Report 的用了 Output Report。
解决:查设备描述符确认 Report ID 和 Report 类型。用总线抓包工具对比厂商工具发送的报文,看首字节是什么。如果设备只支持 Feature Report,换hid_send_feature_report。
5.3 接收线程收不到数据
现象:发送正常,设备也确实在回复(抓包能看到),但界面日志里没有接收记录。
原因:读循环的缓冲区大小小于实际 Input Report 长度,导致hid_read_timeout返回错误或截断。另一个可能是读循环根本没启动,或者启动在了错误的句柄上。
解决:把缓冲区设成 64 或 128 字节,覆盖大多数 HID 设备。确认读线程在hid_open_path成功之后才启动,句柄关闭前先停线程。在hid_read_timeout返回负值时打印hid_error,不要静默忽略。
5.4 程序退出后设备仍被占用
现象:关闭调试助手后,再打开厂商工具提示设备被占用,要拔插一次才能恢复。
原因:hid_close没有在所有退出路径上被调用。比如窗口关闭时只关了界面,没触发设备关闭;或者读线程还在阻塞,句柄没释放。
解决:在MainWindow::closeEvent里显式调用close(),先停读线程再关句柄。用 RAII 思路封装一个HidDeviceGuard,析构时自动关闭。调试阶段可以在hid_close后加日志确认执行到了。
5.5 换一台 Win10 机器就编译不过
现象:在自己机器上编译正常,拷到同事机器上报找不到hidapi.h或链接错误。
原因:.pro里用了绝对路径,或者依赖了本机特有的 Qt 套件路径。hidapi 的hid.c没一起拷过去。
解决:.pro里用$$PWD相对路径。把third_party/hidapi整个目录纳入版本管理,不要靠本机安装。确认目标机器装了对应版本的 Qt 和编译器,MinGW 和 MSVC 的库不通用。
6. 进阶技巧:把调试助手变成可复用的协议验证工具
基础收发跑通之后,这个工具的价值可以再往上提一层。我一般会加两个东西:报文模板和定时发送。
报文模板就是把常用命令存成列表,每条带名称和十六进制内容,点一下直接填充到发送框。实现上用QListWidget加一个QHash<QString, QByteArray>,模板存成 JSON 文件放在 exe 同目录,换设备时改 JSON 就行,不用重新编译。
// 模板加载 QFile file("templates.json"); if (file.open(QIODevice::ReadOnly)) { QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); QJsonObject obj = doc.object(); for (auto it = obj.begin(); it != obj.end(); ++it) { m_templates.insert(it.key(), it.value().toString()); } }定时发送用于周期性轮询设备状态。用QTimer,间隔可调,发送内容从当前发送框取。注意定时发送和手动发送要互斥,避免同时写同一个句柄导致数据交错。加一个QMutex在sendOutputReport外面,或者干脆在定时器触发时禁用发送按钮。
验证方法上,我习惯用「回环测试」:如果设备固件支持,发一条命令让它原样返回,对比发送和接收的十六进制是否一致。这能一次性验证发送通道、接收通道和 Report ID 配置是否正确。没有回环功能的话,就发一条已知会改变设备状态的命令(比如点亮 LED),观察物理现象。
还有一个实用技巧是日志导出。把收发记录带时间戳存成 CSV,出问题时发给固件同事,比截图靠谱。实现上用QFile追加写,字段包括时间、方向、长度、十六进制内容。注意日志文件要按天分割或限制大小,长时间跑调试容易撑爆磁盘。
从那以后我每次拿到新的 HID 设备,都先用这个助手做三件事:枚举确认 VID/PID 和 Report 长度、发一条 Feature Report 读设备信息、开回环验证收发通道。这三步走完,再写正式的上位机代码,能省掉大量「以为是代码问题其实是配置问题」的排查时间。希望帮到你。
本文还有配套的精品资源,点击获取