简介:面向Windows 10下需要调试HID类USB设备的开发者与学习者,这份基于Qt5与hidapi的简易USB调试助手给出了可参考的完整工程。它围绕设备枚举、数据读写、通信错误处理与图形界面交互展开,适合具备一定C++与Qt基础、希望理解USB HID通信流程并快速搭建调试工具的人群。压缩包共19个文件,约1004KB,以头文件、C++源文件、界面描述、工程配置和说明文档为主,并附带x64与x86的库文件及调试符号,便于在MinGW/Qt环境中编译运行与定位问题。已有558人学习,可作为课程设计、工具原型或自用调试助手的起步模板;通过阅读主窗口逻辑、程序入口与hidapi调用,能掌握设备选取、命令发送、响应查看及异常提示的实现思路。工程结构简洁,便于按通信逻辑、界面控制与库依赖分层理解,也方便替换VID/PID或继续扩展HID报告解析等功能。
1. 插上设备就想看数据:Qt5 加 hidapi 这条路的取舍
做外设联调的人多半遇过这种场景:手上一个 USB 设备,驱动装好、系统也认了,可你想发一条自定义报文看它怎么回,翻遍系统自带工具都找不到入口。设备管理器只会说“这个设备正常工作”,不会说它收到什么、回了什么。我最早拿串口助手凑合,碰上纯 HID 设备就彻底没辙——HID 没有 COM 号,想打开只能走 Windows 10 的 HID 接口集。
这份基于 Qt5 和 hidapi 的 USB 调试助手,就是把这层门槛抹平:枚举、打开、收发十六进制报文、落日志,界面由 Qt5 Widgets 撑起来,底层靠 hidapi 屏蔽掉 SetupAPI 那堆样板代码。它适合两类人:要快速验证 HID 设备固件行为的嵌入式从业者,和想在 Windows 10 上摸清 USB 通信链路的学生。
2. 三件套搭起来:Qt5 套件、hidapi 后端与 qmake 工程
动手之前先把三件事定死,后面九成的编译问题都能提前躲开:用哪套 Qt5 编译器、hidapi 走哪个后端、工程文件怎么把两者缝起来。这三件事里任何一件选错,你都能在链接阶段收获一屏看不懂的报错。
2.1 MinGW 还是 MSVC,先看你要不要跟厂商 SDK 混编
Qt5 官方安装器里,同一个小版本通常同时提供 mingw 和 msvc 两套预编译套件。这俩在纯 Qt 项目里差别不大,但牵扯到 C 语言写的第三方库就有讲究了。hidapi 的 Windows 实现是标准 C,两个编译器都能吃下,所以决定权不在 hidapi,在你的上下游。
我一般这样判断:如果项目只做上位机调试、不需要引入任何厂商给的.lib静态库,选 MinGW 64-bit,部署时windeployqt一把梭,不用操心运行库分发。反过来,如果设备厂商给了 MSVC 编译的 SDK,或者你后面要把调试工具的数据喂给别的 Windows 原生库,那从一开始就选 msvc2019 64-bit,省得中间来回换套件重编。混编 MinGW 和 MSVC 产物是新手最容易掉进去的坑,症状是链接期一堆undefined reference或者运行时莫名崩溃,排查成本极高。
安装时还有个细节:安装器里的 “MinGW” 组件和 “Qt 5.15.x MinGW 64-bit” 套件是两回事,前者是编译器本体,后者是编译好的 Qt 库,两个都要勾。只勾前者,Qt Creator 里能看到套件却编不过。
2.2 hidapi 在 Windows 10 上只有一条后端路可走
很多人第一次翻 hidapi 源码会被目录结构绕晕:linux/、mac/、windows/各有一套实现。在 Linux 上它走的是内核hidraw节点,在 macOS 上走 IOKit,而在 Windows 10 上它没有第二条路,只能通过windows/hid.c,内部先用 SetupAPI 系列函数枚举设备接口,再用hid.dll导出的HidD_GetAttributes、HidD_SetFeature、ReadFile、WriteFile这一组 API 完成实际收发。
这意味着两件事。第一,你不需要装任何驱动,Windows 自带的 HID 类驱动就够用,这也是 HID 设备相比需要装驱动的自定义 USB 设备的最大便利。第二,链接时必须把系统库带上,setupapi负责枚举,hid负责属性查询和读写,缺一个就是链接错误。我见过有人只加了-lhid,然后在hid_enumerate那里炸掉,报的是一串SetupDiGetClassDevsW未定义。
工程里摆放 hidapi 的方式我推荐直接把源码纳入版本管理,而不是去构建一个 DLL。理由很实际:调试工具这种小项目,分发时少一个 DLL 就少一类“在我机器上好好的”事故。目录结构照下面这样铺:
UsbHidTool/ ├── UsbHidTool.pro ├── main.cpp ├── mainwindow.h ├── mainwindow.cpp ├── mainwindow.ui └── third_party/ └── hidapi/ ├── hidapi.h └── windows/ └── hid.chidapi.h放在上层,hid.c放在windows/下,是因为hid.c内部#include "hidapi.h"用的是相对路径,你把两个文件摊平到同一层也能编,但后续如果想同时保留其他平台的实现就会乱。保持和上游仓库一致的目录结构,升级源码时直接整目录替换,不用记改过哪些地方。
2.3 .pro 里那几行配置,决定了你能不能编过
qmake 的工程文件看着简单,但把 C 源文件和 C++ 源文件混在一起时,有几个配置项必须显式写上。下面是我在用的版本,逐行都有说法:
QT += core gui widgets TARGET = UsbHidTool TEMPLATE = app CONFIG += c++11 # 关掉 windows.h 带进来的 min/max 宏,避免和 Qt 的模板冲突 DEFINES += NOMINMAX INCLUDEPATH += $$PWD/third_party/hidapi SOURCES += \ main.cpp \ mainwindow.cpp \ third_party/hidapi/windows/hid.c HEADERS += \ mainwindow.h \ third_party/hidapi/hidapi.h FORMS += mainwindow.ui # 枚举走 setupapi,属性查询和收发走 hid win32: LIBS += -lsetupapi -lhidDEFINES += NOMINMAX这行不是可选项。windows.h里定义min和max两个宏,一旦你的 C++ 代码里出现std::min或者 Qt 容器相关的模板实例化,预处理器会把它们替换掉,编译器报的错往往是expected primary-expression before ')' token这种完全指不到病灶的句子。加上这行之后,hid.c里如果确实需要min/max,它自己会用__min/__max或者条件编译绕过去,不冲突。
LIBS那行在 MSVC 套件下同样有效,qmake 会自动翻译成setupapi.lib和hid.lib。如果你用的是 MSVC 并且在链接阶段还报HidD_GetAttributes未定义,先检查是不是名称修饰问题——hid.c是 C 文件,qmake 会用 C 编译器处理,导出符号不会带 C++ 修饰,正常情况下不该出问题。
配好之后跑一个最小可验证程序,先别急着画界面,能枚举出设备就说明三件套缝对了:
#include <QApplication> #include <QDebug> #include <QString> #include "third_party/hidapi/hidapi.h" int main(int argc, char *argv[]) { QApplication app(argc, argv); // 必须最先调用,内部会做 Winsock/SetupAPI 的惰性初始化 if (hid_init() != 0) { qWarning() << "hid_init failed"; return 1; } // 两个参数都传 0 表示不过滤,枚举当前系统所有 HID 接口 struct hid_device_info *list = hid_enumerate(0x0, 0x0); if (!list) { qDebug() << "no hid device found"; } for (struct hid_device_info *d = list; d != nullptr; d = d->next) { qDebug() << "VID:PID =" << QString::number(d->vendor_id, 16) << ":" << QString::number(d->product_id, 16); // Windows 后端下这两个字段是 wchar_t*,其他平台可能是 NULL qDebug() << " product =" << QString::fromWCharArray(d->product_string ? d->product_string : L""); qDebug() << " mfr =" << QString::fromWCharArray(d->manufacturer_string ? d->manufacturer_string : L""); // path 在 Windows 后端是窄字符串,按本地编码转,别用 fromUtf8 qDebug() << " path =" << QString::fromLocal8Bit(d->path); qDebug() << " usage =" << QString::number(d->usage_page, 16) << "/" << QString::number(d->usage, 16); } // 链表是 hidapi 内部申请的,必须还回去 hid_free_enumeration(list); return 0; }这段代码要盯三个点。hid_init()放在最前面,虽然新版 hidapi 有惰性初始化的兜底,但显式调用能让初始化失败在第一时间暴露。hid_enumerate的两个参数是unsigned short,传0x0表示通配,传具体 VID/PID 就只返回匹配项。最后hid_free_enumeration不能漏,漏了在长时间轮询的程序里就是稳定的内存增长。
还有一个跨平台写法上的陷阱:product_string、manufacturer_string、serial_number这三个字段的类型随平台变,Windows 后端是wchar_t*,Linux 后端是char*。如果你打算让这份代码将来在别的系统上也编得过,得用#ifdef _WIN32分叉,或者干脆在枚举结果里只保留path和 VID/PID,其他字段按需取。我在第一版里图省事直接qDebug() << d->product_string,在 MinGW 下编过了,换到别的环境立刻暴露类型不匹配。
3. 枚举到打开:结构体九个字段怎么读,句柄怎么拿稳
枚举出来只是“看见”,真正要发数据得拿到一个hid_device*句柄。这一步的失败率比想象中高,因为 HID 设备往往不是一个接口,键盘、鼠标、厂商自定义功能可能全挤在同一个物理设备上,选错接口就是能打开、能写、但设备毫无反应。
3.1 hid_device_info 里真正有用的只有四个字段
struct hid_device_info一共九个字段,逐一看过去容易信息过载,实际筛选设备时按优先级排是这样的:
| 字段 | 类型 | 在筛选中的作用 |
|---|---|---|
vendor_id/product_id | unsigned short | 第一道筛子,锁定自家设备,别去动系统的键鼠接口 |
usage_page/usage | unsigned short | 第二道筛子,区分同一物理设备上的不同功能接口 |
interface_number | int | 复合设备里定位具体接口,Windows 后端有时为 -1 |
path | char* | 唯一标识,hid_open_path只认它 |
serial_number | 宽/窄字符串 | 同型号多台设备时用来区分个体 |
release_number | unsigned short | 固件版本号,排查“同一型号不同批次行为不一致”时有用 |
manufacturer_string/product_string | 宽/窄字符串 | 只用于界面展示,不要拿来做匹配 |
usage_page和usage这两个字段值得单独说。HID 规范里usage_page表示功能大类,0x01是通用桌面(键盘、鼠标都在这里),0x0C是消费类控制(多媒体键),0xFF00到0xFFFF是厂商自定义区间。很多自定义 HID 设备会把数据接口的usage_page设成0xFF00,把配置接口设成0x0001。如果你的筛选逻辑只按 VID/PID 走,很可能打开的是那个“配置接口”,写进去的数据被设备固件直接丢弃,你会以为是通信协议有问题,查半天。
3.2 先拷 path 再释放链表,顺序反了就是悬空指针
这是我在这个项目上翻得最狠的一次车。最初版本的写法是:遍历链表找到目标节点,把节点指针存下来,然后hid_free_enumeration,再拿存下来的节点去取path调hid_open_path。单设备测试时一切正常,因为释放后的内存还没被覆写;上了循环枚举之后,程序开始偶发打不开设备,重试几次又能开,典型的 use-after-free。
正确顺序是把需要的信息在释放前拷出来,链表本身只当临时快照用:
// 目标:找出 VID=0x1234、PID=0x5678 且 usage_page 为厂商自定义页的接口 static hid_device *openTargetDevice() { hid_device *dev = nullptr; QByteArray pathCopy; // 必须活到 hid_open_path 调用之后 struct hid_device_info *list = hid_enumerate(0x1234, 0x5678); for (struct hid_device_info *d = list; d != nullptr; d = d->next) { if (d->usage_page != 0xFF00) continue; // 跳过配置接口,只要数据接口 pathCopy = QByteArray(d->path); // 深拷贝,脱离链表生命周期 break; } hid_free_enumeration(list); // 链表到这里就没用了 if (pathCopy.isEmpty()) { qWarning() << "target interface not found"; return nullptr; } dev = hid_open_path(pathCopy.constData()); if (!dev) { // 打开失败时 hid_error 需要有效句柄,这里只能给通用信息 qWarning() << "hid_open_path failed for" << pathCopy; } return dev; }QByteArray(d->path)这个构造会按strlen拷贝内容,是深拷贝,不共享缓冲区,所以链表释放后依然有效。如果图省事写成const char *p = d->path;,那就是纯裸指针,后面所有诡异现象都能从这一行找到源头。
另一个常被忽略的点是hid_open_path和hid_open的区别。hid_open(vid, pid, serial)看起来更省事,但它内部只挑“第一个匹配 VID/PID 的接口”,在多接口设备上你无法控制它挑哪个。这个助手我全程只用hid_open_path,把筛选逻辑显式写在代码里,出问题时日志能直接打出打开了哪条 path,排查路径清晰得多。
3.3 热插拔:轮询够用,设备通知留到后面再上
调试工具最影响手感的地方就是插拔响应。设备拔了界面还显示在线,插回去还得手动点刷新,用两次就不想用了。Windows 上做 HID 热插拔有两条路:一条是注册WM_DEVICECHANGE窗口消息配合RegisterDeviceNotification,响应快、开销低,但需要拿到窗口句柄,在 Qt 里要挂一个QAbstractNativeEventFilter,代码量不小;另一条就是定时重新枚举,比对设备集合变化。
我一般先用轮询把功能跑通,等交互定型了再考虑换通知机制。原因很实在:轮询逻辑二十行写完,出错也容易定位,而且枚举本身在毫秒级,一秒一次对 CPU 的影响可以忽略。代码长这样:
void MainWindow::onPollTimer() { QSet<QString> current; struct hid_device_info *list = hid_enumerate(0x0, 0x0); for (struct hid_device_info *d = list; d != nullptr; d = d->next) { // 用 path 做主键,VID/PID 相同但接口不同的设备不会被误判成同一个 current.insert(QString::fromLocal8Bit(d->path)); } hid_free_enumeration(list); if (current == m_lastDeviceSet) return; // 集合没变,什么都不做 m_lastDeviceSet = current; refreshDeviceList(); // 重建界面上的设备下拉框 // 当前打开的设备如果已经不在集合里,说明被拔了 if (!m_currentPath.isEmpty() && !current.contains(m_currentPath)) { closeCurrentDevice(); } }定时器的间隔设 1000 毫秒比较稳妥。有人图快设成 50 毫秒,结果枚举走的是 SetupAPI,每次都要遍历设备信息集,在设备多的机器上 CPU 占用能肉眼可见地往上走。500 毫秒是个折中值,插拔手感已经很快了,开销也还能接受。
还有一个细节:QSet<QString>比对的是完整 path 字符串,而 Windows 上同一个设备的 path 在不同次枚举中是否稳定?实测是稳定的,它由设备实例 ID 派生,只要设备没重新安装驱动就不会变。但如果你的设备每次插拔都会被系统分配新的实例 ID(某些复合设备会这样),那基于 path 的比对就会误报“拔了又插”,这时得改用serial_number做键。这个坑我在一个带序列号的样机上踩过,表现是插着不动,界面每秒刷新一次设备列表,闪得没法看。
4. 收发链路:报告 ID 占位、超时读与线程边界
到这一步设备已经能打开了,剩下的就是把字节送出去、把回包收回来。HID 的收发和串口、TCP 最大的区别在于它有一套“报告”概念,缓冲区长度、首字节含义、读写超时都有硬性约定,不按约定来就是数据错位或者永久阻塞。
4.1 hid_write 的第一个字节永远不是你的数据
这是 hidapi 上最容易翻车、也最容易被文档一句话带过的地方:hid_write的data参数第一个字节必须是报告 ID。如果设备只用了一个报告(绝大多数简单 HID 设备都是这样),这个字节填0x00占位即可,真正的载荷从第二个字节开始。
我见过太多人写成这样:
// 错误写法:直接把载荷从 buf[0] 开始填 unsigned char buf[64]; memcpy(buf, payload.constData(), payload.size()); hid_write(dev, buf, payload.size());现象是设备收到了数据但解析全错,逻辑分析仪抓出来一看,每个字段都往前串了一位。原因就是hid_write把buf[0]当成了报告 ID,真正的第一个数据字节被当成了 ID 的一部分。
正确写法要同时处理占位和补齐两件事:
bool sendReport(hid_device *dev, const QByteArray &payload) { // HID 输出报告常见长度是 64 字节,具体值要查设备的报告描述符 const int kReportSize = 64; if (payload.size() > kReportSize) { qWarning() << "payload too long:" << payload.size(); return false; } unsigned char buf[kReportSize + 1]; memset(buf, 0, sizeof(buf)); buf[0] = 0x00; // 报告 ID 占位 memcpy(buf + 1, payload.constData(), payload.size()); // 长度参数要含报告 ID 字节,并且补齐到完整报告长度 int written = hid_write(dev, buf, kReportSize + 1); if (written < 0) { qWarning() << "hid_write failed:" << QString::fromWCharArray(hid_error(dev)); return false; } return true; }三个参数各有讲究。kReportSize要按设备实际的输出报告长度来,查报告描述符里OUTPUT项的REPORT_COUNT乘以REPORT_SIZE再除以 8。估算错了会怎样?短了设备收不全,长了部分固件会直接丢包。buf[0] = 0x00是按“单报告设备”处理的,如果你面对的设备确实用了编号报告(描述符里有REPORT_ID项),这里要填真实的 ID,比如0x02。hid_write的第三个参数要含报告 ID 那一个字节,也就是kReportSize + 1,少写一个字节的后果是最后一个数据字节被截掉,而且不会有任何报错。
4.2 读循环放进 QThread,停止标志用原子变量
hid_read是阻塞的,直接在 UI 线程里调,界面立刻假死。hid_read_timeout带超时,稍好一些,但循环放在主线程里依然会拖慢事件循环。标准做法是起一个QThread子类,run()里跑读循环。
class HidReader : public QThread { Q_OBJECT public: explicit HidReader(hid_device *dev, QObject *parent = nullptr) : QThread(parent), m_dev(dev) {} void stop() { m_stop.store(true); } signals: void dataReceived(const QByteArray &data); void readFailed(const QString &reason); protected: void run() override { unsigned char buf[256]; while (!m_stop.load()) { // 超时设 100ms,兼顾响应速度和 CPU 占用 int n = hid_read_timeout(m_dev, buf, sizeof(buf), 100); if (n < 0) { emit readFailed(QString::fromWCharArray(hid_error(m_dev))); break; // 出错直接退出循环,不要硬撑 } if (n > 0) { // 拷贝构造,跨线程传递时 Qt 会自动做深拷贝 emit dataReceived(QByteArray(reinterpret_cast<char *>(buf), n)); } } } private: hid_device *m_dev = nullptr; std::atomic<bool> m_stop{false}; };m_stop用std::atomic<bool>而不是普通bool,是因为它同时被 UI 线程写、被读线程读,普通 bool 在这种场景下编译器有权做寄存器缓存优化,导致stop()调用后循环迟迟不退出。这是血泪经验,症状是点“关闭设备”按钮后界面卡住两三秒,甚至偶尔卡死。
emit dataReceived(...)这个信号连接到主线程的槽时,Qt 默认走Qt::QueuedConnection,参数里的QByteArray会被复制一份放进事件队列,所以栈上的buf出了作用域也不影响。但要注意如果槽函数在主线程里做重活(比如逐字节追加到QTextEdit),高频率数据会把事件队列撑爆,界面越来越卡。我的做法是在槽里做节流:攒够 32 条或者隔 50 毫秒批量刷一次界面。
hid_read_timeout的第四个参数是毫秒。设太小(比如 10)会让循环空转,CPU 上去;设太大(比如 1000)会让stop()之后最长要等一秒才退出。100 毫秒是我试下来比较平衡的值。
4.3 十六进制视图:两位补零、大写、每行十六字节
调试工具的界面价值全在“能不能一眼看出问题”。十进制显示对协议调试毫无意义,必须十六进制。格式化函数本身很短,但几个参数不写对就会出现0x5这种一位数,跟后面的字节粘在一起看花眼:
QString toHexDump(const QByteArray &data) { QString out; out.reserve(data.size() * 3 + data.size() / 16); for (int i = 0; i < data.size(); ++i) { // 2 表示最小宽度,16 表示十六进制,QChar('0') 是补位字符 out += QString("%1 ").arg(static_cast<quint8>(data.at(i)), 2, 16, QChar('0')).toUpper(); if ((i + 1) % 16 == 0) out += '\n'; // 每 16 字节换行,方便和协议文档对照 } return out; }static_cast<quint8>这步不能省。QByteArray::at返回char,在多数平台上char是有符号的,值超过0x7F会被当成负数,arg打出来带负号或者变成一长串 F。这个坑在只看 ASCII 范围数据时完全发现不了,一旦设备回包里出现0x80以上的校验和,界面就开始显示FFFFFFFFFFFFFF80之类的东西。
日志落盘建议直接用QFile以追加模式写纯文本,每行前面加时间戳:
void MainWindow::appendLog(const QString &dir, const QByteArray &data) { QFile f(m_logPath); if (!f.open(QIODevice::Append | QIODevice::Text)) return; QString line = QString("[%1] %2 %3\n") .arg(QDateTime::currentDateTime().toString("HH:mm:ss.zzz")) .arg(dir) .arg(toHexDump(data).trimmed()); f.write(line.toUtf8()); f.close(); // 每条都开关一次,防止异常退出丢日志 }每条都开关文件看着低效,但对调试场景是对的:程序崩溃或者被强制结束时,缓冲里的内容不会丢。真要追求吞吐,再改成常开加定时 flush。
5. 避坑排查:Windows 10 上最容易翻车的五个现场
下面五条都是我在不同机器、不同设备上真实撞过的,按出现频率排。每条都按“看到什么、为什么、怎么改”写,照着对号入座基本能省掉半天。
5.1 枚举列表里明明有设备,hid_open_path 却返回空指针
现象:hid_enumerate打出来的日志里目标 VID/PID 和 path 都在,紧接着调hid_open_path拿到 NULL,hid_error也给不出有效信息。
原因有两个层面。一是 path 的指针在hid_free_enumeration之后已经失效(3.2 节讲过),看起来“有设备”其实读的是被释放的内存。二是设备确实被系统独占——键盘、鼠标这类标准 HID 设备会被系统 HID 类驱动接管,用户态程序能枚举到但打不开。这两类现象一模一样,得靠日志区分:如果同一段代码在别的设备上正常、只在这个设备上失败,基本就是独占问题。
解决:先确认 path 是深拷贝出来的;确认无误后打印usage_page,如果落在0x01(通用桌面)且usage是0x06(键盘)或0x02(鼠标),那这个接口本来就不该去开,老老实实找厂商自定义的那个接口。实在需要访问键鼠接口,只能走过滤驱动那条路,不是这个工具的范围。
5.2 设备收到数据但解析全乱,每个字段往前串一位
现象:用逻辑分析仪或者设备端日志看,收到的字节序列和你发出去的完全对不上,但长度是对的,像是整体位移了一个字节。
原因:hid_write的第一个字节被当成了报告 ID。你的载荷从buf[0]开始填,实际发出去的是“载荷第一个字节当 ID,载荷其余部分当数据”,设备侧就整体错位。
解决:buf[0]固定占位,载荷从buf[1]开始,长度参数加一。改完之后建议在界面上把“实际发送的完整字节(含报告 ID)”也打出来,很多人就是靠这一行日志第一次看明白报告 ID 到底占在哪。
5.3 拔掉设备的瞬间程序崩溃,或者关闭按钮点不动
现象:热插拔一根线,程序直接闪退,崩溃栈指向hid_read_timeout或者hid_close;或者点“关闭设备”之后界面卡住几秒。
原因:读线程还在hid_read_timeout里阻塞,主线程已经把hid_device*给hid_close掉了。句柄释放后读线程再拿它去调 API,就是访问已释放资源。另一种情况是关闭时只调了hid_close却没等线程退出,线程随后拿到-1返回值继续循环,越界访问。
解决:关闭流程固定成三步,顺序不能换。先m_reader->stop()置标志,再m_reader->wait(2000)等线程真正退出,最后才hid_close(dev)。wait的超时要给够,因为读循环最长会阻塞一个读取超时周期(4.2 节设的 100 毫秒),给 2000 毫秒足够。如果wait返回 false,说明线程卡住了,这时候不要强行terminate(),先打日志,因为强杀线程会留下未释放的句柄。
5.4 设备名里的中文变成问号,或者 path 里的盘符样式乱码
现象:设备管理器里产品名是中文,助手界面上显示成一串问号或者方块。
原因:product_string和manufacturer_string在 Windows 后端是wchar_t*,用QString::fromUtf8转必然乱码;而path在 Windows 后端是char*,内容是宽字符经wcstombs按当前 locale 转出来的窄串,用fromUtf8同样会错。两个字段的编码方向相反,这是最容易搞混的地方。
解决:宽字符字段一律QString::fromWCharArray,并且判空;path一律QString::fromLocal8Bit。写个封装函数把这层差异包起来,别在界面代码里到处散着转换调用。跨平台项目还要再加一层#ifdef,因为 Linux 后端的这几个字段全是char*。
5.5 在自己机器上好好的,拷到同事电脑上双击没反应
现象:Qt Creator 里跑得好好的,把release目录拷给别人,双击一闪而过,或者报缺少某个 DLL。
原因:动态链接的 Qt 库和 MinGW 运行时没跟着走。UsbHidTool.exe只是个壳,真正干活的Qt5Core.dll、Qt5Widgets.dll、libgcc_s_seh-1.dll、libstdc++-6.dll、libwinpthread-1.dll都在 Qt 安装目录里。
解决:用 Qt 自带的部署工具,在release目录下开命令行执行:
windeployqt --release --no-translations UsbHidTool.exe--no-translations能砍掉一堆用不上的语言包,体积能小不少。跑完之后目录里会多出platforms/、styles/等子目录,打包时必须整个目录一起给,只拷 exe 和几个 DLL 是不够的——缺platforms/qwindows.dll的典型症状就是双击后弹窗说“could not find or load the Qt platform plugin windows”。
6. 进阶用法:把收发记录变成可重放的报文流水线
工具用熟之后,真正省时间的不是手动一条条发,而是把调试过程中试出来的有效报文存下来,下次接同型号设备直接重放。我在助手加了一个很轻的功能:界面上把当前发送框的内容连同间隔时间存成 CSV,每行一条,字段就三列——名称、十六进制字节、延时毫秒。格式长这样:
名称,十六进制,延时ms 唤醒,01 00 00 00,50 查询版本,02 00 00 00,20 开始采样,03 0A 00 00,100重放逻辑用一个QTimer单次触发串起来,每条发完等延时再发下一条,中途出错就停:
void ReplayEngine::step() { if (m_index >= m_steps.size()) { emit finished(); return; } const Step &s = m_steps.at(m_index); QByteArray payload = QByteArray::fromHex(s.hex.toLatin1()); if (!sendReport(m_dev, payload)) { emit failed(m_index, s.name); return; // 出错即停,别继续往下发把设备状态搞乱 } ++m_index; m_timer.start(s.delayMs); // 单次定时,触发后回到 step() }QByteArray::fromHex对格式很宽容,"01000000"和"01 00 00 00"都能解析,空格会被忽略,所以 CSV 里写成带空格的十六进制不影响。m_timer必须设成setSingleShot(true),否则会周期触发,变成并发发包。
验证这套重放是否可信,我用两个笨办法。一是回环对照:找一台能自发自收的样机(把 OUT 报告直接映射到 IN 报告),重放一遍,比对收到的字节和 CSV 里的字节是否逐字节一致。二是已知设备对照:拿一个协议公开的通用 HID 设备,用系统自带工具发一条已知报文,再用助手发同样的报文,两次设备行为要完全一致。这两步走完,基本可以确信助手的收发链路没有偏移和截断问题。
还有一个我后来才加上的习惯:每次接一个没见过的新设备,先不急着写发送逻辑,而是把枚举结果里的usage_page、usage、interface_number和报告描述符里的输入输出报告长度全部打印出来,存成一份文本丢进项目目录。同一型号不同批次的固件,这几个值偶尔会变,等出问题再回头翻,手里有没有这份基线完全是两种排查效率。
从那以后我每次给新设备写第一条发送代码之前,都强制先把枚举信息和一个空报文回环跑一遍,确认链路通了再动协议层。这个习惯让我少熬了好几个通宵。希望帮到你。
本文还有配套的精品资源,点击获取