简介:这是一份面向Windows 10、基于Qt5与hidapi编写的轻量级USB调试助手源码包,适合有一定C++与Qt基础、需要调试HID协议USB设备的开发者参考。程序围绕设备枚举、数据收发、通信错误捕获与界面交互展开,由主窗口类衔接UI事件与业务逻辑,可直观选择设备、发送命令并查看响应,是理解Qt5界面开发与hidapi设备访问配合方式的实践样本。压缩包共19个文件,约1004KB,以h头文件与cpp源文件为主,辅以ui界面描述、pro与pri工程配置、readme说明及x64/x86两套dll、lib、pdb库文件,可直接编译运行。目前已有558人学习下载,读者可借此掌握HID设备VID/PID识别、数据读写与异常处理的基本流程,也能参照其工程组织与第三方库引入方式,搭建自己的调试工具。
1. USB 调试助手如果只会开串口,遇到 HID 设备就会卡在第一步
USB 调试助手如果只会开串口,遇到 HID 设备就会卡在第一步:Qt5 的 QSerialPort 枚举不到它,因为 HID 走的是 hidclass,不是 COM 口。这份基于 Qt5 和 hidapi 的 USB 调试助手,专门补 Windows 10 下 HID 设备的枚举、打开、报告收发和原始字节查看。它适合手里已经有 HID 协议、需要快速验证 VID/PID、接口号和报告格式的嵌入式/上位机开发者;不适合把它当串口助手用的人。简单说,它解决的是“设备插上了,但不知道数据长什么样”的问题。工程本身不追求大而全,重点是把 USB HID 调试里最容易翻车的几个参数摊开给你看。
2. 把 hidapi 接进 Qt5 工程:Windows 10 下的编译链路与依赖边界
2.1 为什么不用 QSerialPort,也不急着上 libusb
HID 设备在 Windows 10 下由系统自带的 hidclass 驱动接管,插上就能枚举,不需要替换驱动。Qt5 的 QSerialPort 只能看到 COM 口,HID 设备不会出现在串口列表里,所以拿串口助手去调 HID,第一步就会空列表。libusb 更底层,能力也更强,但在 Windows 下通常要配合 WinUSB 或 libusbK 替换驱动,对只想读报告、写报告的场景来说太重,换完驱动还可能影响设备原本的功能。hidapi 的位置刚好在中间:它封装了 Windows 的 HID API,枚举、打开、读写、feature report 都有现成函数,编译也只需要一个 hid.c 和 setupapi.lib。这个选型不是追求“最底层”,而是追求“不换驱动就能把数据跑通”。我一般会先用 hidapi 把报告格式确认下来,确实需要控制传输或更底层描述符时再考虑 libusb。
2.2 工程目录和编译入口:把 hid.c 直接编进目标
拿到工程后,先别急着点运行。HID 工程最常见的翻车不是代码逻辑,而是 hidapi 的编译方式不对。比较稳的做法是把 hidapi 的 Windows 后端源码直接放进工程,而不是链接一个来路不明的预编译 lib。目录可以按下面这样放:
UsbHidAssistant/ UsbHidAssistant.pro main.cpp mainwindow.h mainwindow.cpp hidworker.h hidworker.cpp third_party/hidapi/hidapi/hidapi.h third_party/hidapi/windows/hid.cqmake 的 .pro 文件按这个顺序写:
QT += core gui widgets CONFIG += c++11 TARGET = UsbHidAssistant TEMPLATE = app INCLUDEPATH += $$PWD/third_party/hidapi/hidapi SOURCES += main.cpp \ mainwindow.cpp \ hidworker.cpp \ third_party/hidapi/windows/hid.c LIBS += -lsetupapi这段配置做三件事:把 hidapi.h 所在目录加入头文件搜索路径;把 hid.c 作为当前工程的源文件一起编译;在 Windows 下链接 setupapi。参数上,INCLUDEPATH必须指向hidapi.h的直接父目录,代码里才能写#include <hidapi/hidapi.h>;LIBS += -lsetupapi不能漏,否则枚举和打开设备时会报一堆 unresolved external symbol。把 hid.c 编进工程的好处是 Debug/Release 运行库跟主工程一致,不会出现预编译 lib 的 CRT 不匹配。代价是编译时间略长,但比查 LNK2038 省事。
如果习惯 CMake,等价写法是:
cmake_minimum_required(VERSION 3.16) project(UsbHidAssistant LANGUAGES CXX) set(CMAKE_CXX_STANDARD 11) set(CMAKE_AUTOMOC ON) find_package(Qt5 COMPONENTS Widgets REQUIRED) add_executable(UsbHidAssistant main.cpp mainwindow.cpp hidworker.cpp third_party/hidapi/windows/hid.c ) target_include_directories(UsbHidAssistant PRIVATE third_party/hidapi/hidapi) target_link_libraries(UsbHidAssistant PRIVATE Qt5::Widgets setupapi)CMAKE_AUTOMOC必须打开,因为窗口类里有 Q_OBJECT;setupapi是 Windows 的库名,不要加.lib后缀。qmake 和 CMake 选一个就行,不要在同一套源码里混着生成,否则中间文件会互相覆盖。编译时我一般用 Qt5.15 加 MSVC2019 64 位,MinGW 也能编,但 hidapi 的 Windows 后端在 MinGW 下对宽字符和 setupapi 的链接参数更敏感,新手先用 MSVC 少踩一层坑。
2.3 初始化与退出:hid_init 只做一次,hid_exit 别乱调
hidapi 在进程里需要一个初始化动作。简单做法是在 main 里放一个栈对象,让它的构造和析构覆盖整个程序生命周期:
#include <hidapi/hidapi.h> #include <stdexcept> class HidRuntime { public: HidRuntime() { if (hid_init() != 0) { throw std::runtime_error("hid_init failed"); } } ~HidRuntime() { hid_exit(); } HidRuntime(const HidRuntime&) = delete; HidRuntime& operator=(const HidRuntime&) = delete; };这段代码把hid_init和hid_exit包在 RAII 对象里。hid_init返回 0 表示成功,非 0 时直接抛异常,不要继续往下走;hid_exit在析构时调用一次,释放 hidapi 的全局资源。参数上没有什么可调的,关键是不要在每次打开设备时重复hid_init,也不要在读线程还在跑的时候调hid_exit。有的后端在重复初始化时不会立刻报错,但枚举结果会变得不稳定,这种问题很像玄学,最后查出来往往是初始化/退出顺序乱了。我的习惯是:整个进程只允许一个 HidRuntime 实例,放在 main 函数栈上,其他类只拿设备句柄,不碰全局初始化。
2.4 Windows 10 下的字符集与路径参数
hidapi 里有两套字符串参数:hid_open要wchar_t*的厂商名/产品名/序列号,hid_open_path要char*的设备路径。枚举返回的path是char*,在 Windows 10 下是设备接口路径,通常只含 ASCII,用QString::fromUtf8接住,打开时再用path.toUtf8().constData()传回去。manufacturer_string和product_string是wchar_t*,用QString::fromWCharArray转,别拿fromUtf8硬套,否则设备名会变成乱码。路径不要自己拼,也不要缓存到配置文件里长期用,设备换一个 USB 口路径就可能变。这条规则在后面的热插拔章节还会用到,先记住:路径只信枚举返回值。
3. 枚举到打开:VID/PID、接口号和设备路径的取舍
3.1 hid_enumerate 的过滤参数:0 不是错误
枚举是调试助手的第一屏。下面这段代码把设备列表拉到表格里:
void MainWindow::refreshDeviceList() { ui->deviceTable->setRowCount(0); hid_device_info *list = hid_enumerate(0x0, 0x0); for (hid_device_info *cur = list; cur != nullptr; cur = cur->next) { const int row = ui->deviceTable->rowCount(); ui->deviceTable->insertRow(row); ui->deviceTable->setItem(row, 0, new QTableWidgetItem( QString("%1:%2") .arg(cur->vendor_id, 4, 16, QChar('0')) .arg(cur->product_id, 4, 16, QChar('0')).toUpper())); ui->deviceTable->setItem(row, 1, new QTableWidgetItem( cur->product_string ? QString::fromWCharArray(cur->product_string) : QString())); ui->deviceTable->setItem(row, 2, new QTableWidgetItem( QString::number(cur->interface_number))); ui->deviceTable->setItem(row, 3, new QTableWidgetItem( QString::fromUtf8(cur->path))); } hid_free_enumeration(list); }hid_enumerate(vendor_id, product_id)的两个参数为 0 表示不过滤,不是错误值。想只看某个设备,可以传0x1234, 0x5678这样的十六进制。返回的是链表,必须用hid_free_enumeration释放,否则每刷新一次就漏一点内存,刷几十次后列表会变慢。interface_number在复合设备上才有区分意义,简单设备可能是 -1,不要拿它当唯一标识。path是 Windows 分配的设备接口路径,打开设备时最可靠。表格里把 VID/PID 统一显示成四位大写十六进制,是为了跟设备管理器里的硬件 ID 对得上;路径列留完整,不要截断,排查多接口设备时这一列比产品名有用。
3.2 多接口设备怎么选:VID/PID 相同不代表同一个口
一个 HID 设备枚举出多条记录很常见,尤其是复合设备:键盘接口、鼠标接口、厂商自定义接口共用同一个 VID/PID,产品名也可能一样,只有interface_number、usage_page、usage不同。如果代码直接取链表第一条,很可能打开的是键盘接口,写进去的报告自然没人处理。常见做法是先按usage_page过滤,再让用户从列表里选路径。示例判断函数:
bool isTargetInterface(const hid_device_info *info) { // 0xFF00 只是示例,实际值看设备报告描述符 return info->usage_page == 0xFF00; }usage_page和usage来自 HID 报告描述符,比interface_number更稳定,因为接口号可能随枚举顺序变化。参数0xFF00是厂商自定义页的常见示例,不能照抄,得看固件里报告描述符怎么写的。如果设备厂商没填 usage,退回按interface_number过滤,再不行就让用户手动选path。我的习惯是:列表里同时显示 VID/PID、interface_number、usage_page、usage 和 path,调试阶段不替用户做选择,等协议确认后再加“只显示目标接口”的过滤开关。这样多接口设备不会因为“看起来一样”而打开错口。
3.3 打开与关闭:hid_open_path 的返回值要立刻检查
打开设备用路径版本,不用 VID/PID 版本,因为后者遇到多接口会打开第一个匹配项,控制权不在你手里。代码:
bool HidWorker::openByPath(const QString &path) { close(); m_dev = hid_open_path(path.toUtf8().constData()); if (m_dev == nullptr) { const wchar_t *err = hid_error(nullptr); emit errorText(QString::fromWCharArray(err ? err : L"hid_open_path failed")); return false; } return true; } void HidWorker::close() { if (m_dev) { hid_close(m_dev); m_dev = nullptr; } }hid_open_path失败返回 NULL,必须立刻停手,不能继续hid_write或hid_read_timeout。hid_error(nullptr)取全局错误,hid_error(m_dev)取某个设备的错误;打开失败时还没有设备句柄,只能用前者。关闭顺序要注意:如果读线程还在跑,先停线程再hid_close,否则读线程可能拿着已经释放的句柄继续读,表现就是概率性崩溃。参数上,path必须来自枚举结果,不要手工拼\\?\hid#...这种字符串,Windows 的接口路径格式不是给应用层拼的。
3.4 热插拔后的重新枚举
设备拔掉后,原来的path可能失效,继续用旧句柄读写会返回错误。简单做法是定时枚举,比较路径集合有没有变化:
void MainWindow::onRefreshTimer() { const QStringList now = collectHidPaths(); if (now != m_lastPaths) { m_lastPaths = now; refreshDeviceList(); } }collectHidPaths内部就是hid_enumerate一遍,把path转成 QStringList 后释放链表。定时器设 2 秒足够,不要设 100 毫秒,枚举本身有系统调用开销,刷太快会让界面发涩。发现路径消失时,先把读线程停掉,再关闭句柄,最后刷新列表;发现新路径时只刷新列表,不要自动打开,自动打开在多设备环境里容易开错。热插拔这块没有太多玄学,关键是别缓存路径、别跨线程关句柄。
4. 报告收发:缓冲长度、报告 ID 与超时参数怎么定
4.1 输出报告的第一个字节:报告 ID 不是数据
HID 写报告最容易翻车的地方是首字节。下面这段把纯数据和报告 ID 拼成输出报告:
QByteArray HidWorker::makeOutputReport(const QByteArray &payload, quint8 reportId) { QByteArray report; report.reserve(payload.size() + 1); report.append(static_cast<char>(reportId)); report.append(payload); return report; } bool HidWorker::writeReport(const QByteArray &payload, quint8 reportId) { if (m_dev == nullptr) { emit errorText("device not open"); return false; } const QByteArray report = makeOutputReport(payload, reportId); const int written = hid_write(m_dev, reinterpret_cast<const unsigned char *>(report.constData()), report.size()); if (written < 0) { emit errorText(QString::fromWCharArray(hid_error(m_dev))); return false; } return written == report.size(); }hid_write的长度包含报告 ID,不是纯数据长度。设备使用编号报告时,首字节填报告 ID;设备不使用编号报告时,首字节填 0。很多人把报告 ID 当数据发,hid_write返回成功,设备却不动,因为设备把首字节当成了报告 ID,后面的数据整体错位。参数上,payload只放协议数据,reportId从设备报告描述符或协议文档里查,不要猜。写入成功只代表驱动接收了缓冲区,不代表设备处理了,所以日志里要把“发送长度”和“报告 ID”一起打出来,方便对照。
注意:
hid_write的返回值等于report.size()才算完整写入,小于这个值要当异常处理。
4.2 输入报告的读取:缓冲区给足,超时给短
读报告放在独立循环里,缓冲区给足,超时给短:
void HidWorker::readLoop() { unsigned char buffer[1024]; while (!m_stop.loadAcquire()) { const int n = hid_read_timeout(m_dev, buffer, sizeof(buffer), 50); if (n < 0) { emit errorText(QString::fromWCharArray(hid_error(m_dev))); break; } if (n == 0) { continue; } emit reportReceived(QByteArray( reinterpret_cast<const char *>(buffer), n)); } }缓冲区 1024 字节大于常见 HID 报告长度,避免驱动写越界;hid_read_timeout的 50 毫秒是等待上限,不是设备速率,返回 0 表示这段时间没有数据,继续循环即可;返回负数才是错误,通常意味着设备被拔出或句柄失效。接收到的数据是否包含报告 ID 取决于设备和后端,不要假设首字节一定是数据,日志里先按原始字节显示,确认协议后再做解析。参数上,超时不要设 -1,-1 会一直阻塞,停线程时只能强杀;也不要设 0,0 会变成忙轮询,CPU 占用会上去。50 毫秒到 100 毫秒之间比较稳。
4.3 阻塞、非阻塞和线程边界
hid_set_nonblocking(dev, 0)是阻塞模式,但hid_read_timeout仍然带超时;hid_set_nonblocking(dev, 1)后hid_read立即返回,适合自己控制轮询节奏。调试助手一般用阻塞加超时,代码简单,也不会把 CPU 拉满。读循环必须放在工作线程,不能放 UI 线程。线程启动和停止可以这样写:
void HidWorker::startRead() { m_stop.storeRelease(0); m_thread = std::thread(&HidWorker::readLoop, this); } void HidWorker::stopRead() { m_stop.storeRelease(1); if (m_thread.joinable()) { m_thread.join(); } }m_stop用QAtomicInt或std::atomic<int>,storeRelease和loadAcquire是内存序,避免编译器把循环里的读写重排到标志判断之外。join之前不要hid_close,否则读线程可能访问已释放句柄。参数上,停止标志用 0/1 就够,不需要复杂状态机。线程边界这条是血泪经验:只要读循环在
本文还有配套的精品资源,点击获取