简介:这是一份已适配到QT5环境的USB设备检测项目,基于QDeviceWatcher类实现对USB设备插拔的实时监控,面向需要开发硬件交互、设备管理或动态外设响应的Qt5开发者。压缩包共72个文件,包含C++源码与头文件、Qt工程配置、Windows与Linux编译产物及说明文档,整体约1.18MB。项目源自wang-bin-qdevicewatcher-2018.1.30-2052编译成功版本,清晰演示了如何引入QDeviceWatcher头文件、创建监控对象并添加设备路径,通过setDeviceTypes设置监听USB设备类型,连接deviceAdded和deviceRemoved信号,并在槽函数中调用devices()方法枚举当前设备,同时给出设备拔出后的资源清理思路。代码还提供跨平台兼容实践,从代码结构到信号槽机制均有清晰注释,便于理解设备热插拔事件在Qt5中的完整处理流程,可直接参考或集成到自己的项目中。已有1385人学习下载,适合中高级Qt开发者快速掌握USB热插拔检测的方法。
1. 项目定位:一个不用轮询就能感知 USB 插拔的 Qt5 模块
有一次我给某控制台程序加外设管理功能,第一个版本用 QTimer 每两秒轮询一次盘符列表,结果既不实时又耗电,插上设备要等好几秒才能反应过来。后来换了一个思路:直接用 Qt5 的nativeEventFilter捕获 Windows 的系统设备广播消息,配合 SetupAPI 枚举设备属性,十几次提交之后就稳定跑起来了。这套监听和解析逻辑独立成类之后,现在能拿到设备的 VID、PID、序列号、设备路径和友好名称,插入和拔出都能毫秒级感知。这个项目就是把这套完整实现拆给你看,包括消息注册、事件分发、设备信息解析、常见坑位以及二次改造的方向。适合要给工控机做 U 盘审计、给上位机加设备白名单校验、或者做自动化测试平台需要自动感知外设插拔的开发者。你不需要引入额外的框架依赖,Qt 5 的 Widgets 模块和 Windows SDK 自带的能力就够用。
2. 技术选型与项目结构:为什么监听系统消息而不是轮询
2.1 三种方案的取舍
在确定选 Windows 系统消息机制之前,我简单对比过三条技术路线。
| 方案 | 实时性 | 信息量 | 平台局限 | 适用场景 |
|---|---|---|---|---|
QTimer 轮询QStorageInfo | 秒级延迟 | 只有挂载点和容量 | 跨平台 | 定时刷新盘符列表,不追求即时响应 |
WM_DEVICECHANGE+ SetupAPI | 毫秒级 | VID/PID、序列号、设备路径、友好名称 | Windows | 实时插拔监测、设备审计、白名单管控 |
| libusb / hidapi 热插拔回调 | 事件级 | USB 层级详情 | 跨平台,但需要引入 C 库依赖 | 深度访问 USB 端点、需要跨平台 |
从可控性和信息量两个维度来看,Windows 内核本来就会在设备变化时向顶层窗口广播WM_DEVICECHANGE,Qt5 的QAbstractNativeEventFilter可以让我们在消息进 Qt 事件循环之前先拦截一层,开销极小。SetupAPI 则是 Windows 设备管理的标准 API,能拿到比盘符更底层的硬件信息。所以这个项目最终定了组合:RegisterDeviceNotificationW注册通知 +nativeEventFilter接收消息 +SetupDI系列函数枚举设备属性。
2.2 目录结构一览
工程文件只有四个源文件,适合直接嵌进现有 Qt 项目里改造:
USBMonitorDemo/ ├── USBMonitorDemo.pro ├── main.cpp ├── usbmonitor.h ├── usbmonitor.cpp └── usbdeviceinfo.h其中usbdeviceinfo.h只放一个结构体,用来承载一次设备插拔的所有静态信息。usbmonitor就是核心类,它同时继承QObject和QAbstractNativeEventFilter,对外只暴露两个信号deviceArrived和deviceRemoved。界面层只需要连接这两个信号,不需要关心消息解析细节。
2.3 跨平台留的口子
虽然当前实现是 Windows 专用,但类接口在设计上没有绑定平台概念。UsbDeviceInfo里的字段都是硬件级信息,Linux 下用libudev或 Android 下用USB_HOST都可以映射到同一套结构体。我处理跨平台的标准做法是:在usbmonitor.h头文件顶部用#ifdef Q_OS_WIN包住 Windows 原生成员声明,非 Windows 环境直接返回空实现,这样代码在 Qt5 支持的其他平台上也能编译通过,只是收不到事件而已。
3. 核心模块拆解:从事件注册到 VID/PID 解析的完整链路
3.1 设备通知注册:这一步不做,后面全白搭
WM_DEVICECHANGE虽然系统会广播,但应用程序默认不会主动收到设备接口级别的变化通知,必须先用RegisterDeviceNotificationW注册感兴趣的设备类型。这里最常犯的一个错误是传了空句柄,导致收不到任何消息。
void UsbMonitor::registerNotification(HWND hwnd) { DEV_BROADCAST_DEVICEINTERFACE_W filter = {}; filter.dbcc_size = sizeof(filter); filter.dbcc_devicetype = DBT_DEVTYP_DEVICEINTERFACE; // 只监听 USB 设备接口,避免鼠标移动也触发回调 filter.dbcc_classguid = GUID_DEVINTERFACE_USB_DEVICE; m_notificationHandle = RegisterDeviceNotificationW( hwnd, reinterpret_cast<LPVOID>(&filter), DEVICE_NOTIFY_WINDOW_HANDLE); if (m_notificationHandle == nullptr) { qWarning() << "RegisterDeviceNotification failed:" << GetLastError(); } }dbcc_size必须正确设置,否则注册直接失败,GetLastError会返回 87。DEVICE_NOTIFY_WINDOW_HANDLE表示消息投递到指定窗口;如果选DEVICE_NOTIFY_SERVICE_HANDLE则是投递给服务控制管理器。这里的GUID_DEVINTERFACE_USB_DEVICE只匹配 USB 设备节点,如果连 USB 转串口、USB 网卡也想统一监听,可以把过滤条件改为GUID_DEVINTERFACE_ANY,代价是消息量会大不少,需要自己在回调里过滤设备类。
3.2 nativeEventFilter 里的消息分发
Qt 的事件循环在收到原生消息后会先调用我们重写的nativeEventFilter,返回true表示消息已被处理,不再继续传给 Qt 层的QWidget::nativeEvent。判断消息类型时要注意 Qt5 和 Qt6 的eventType字符串不一样,Qt5 是"windows_generic_MSG",用错了直接收不到任何回调。
bool UsbMonitor::nativeEventFilter(const QByteArray &eventType, void *message, long *result) { Q_UNUSED(result); if (eventType != "windows_generic_MSG") { return false; } MSG *msg = static_cast<MSG *>(message); if (msg->message == WM_DEVICECHANGE) { handleDeviceChange(msg->wParam, msg->lParam); } return false; // 不拦截,让消息继续走完 Qt 的默认流程 }这里必须返回false,因为我们只是想旁路监听,而不是截断消息。真正的事件解析放在handleDeviceChange里,按wParam的值区分插入和拔出。DBT_DEVICEARRIVAL表示设备已经就绪,DBT_DEVICEREMOVECOMPLETE表示设备已经被安全移除,这两个值才是我们关心的核心事件。lParam指向一个DEV_BROADCAST_HDR结构,需要先判断dbch_devicetype是不是DBT_DEVTYP_DEVICEINTERFACE,防止把逻辑设备广播也当成 USB 设备。
3.3 用 SetupAPI 从设备路径里挖出硬件身份
拿到DEV_BROADCAST_DEVICEINTERFACE_W结构里的dbcc_name之后,那就是设备的完整接口路径。这个路径形如\\?\usb#vid_1234&pid_5678#serial#...,里面已经带了 VID/PID,但直接字符串截取不够严谨,正规做法是用SetupDiGetDeviceRegistryProperty查询设备节点属性。
UsbDeviceInfo UsbMonitor::fetchDeviceInfo(const QString &devicePath) { UsbDeviceInfo info; info.devicePath = devicePath; // 从接口路径打开设备信息集 HDEVINFO devInfoSet = SetupDiGetClassDevsW( nullptr, nullptr, nullptr, DIGCF_ALLCLASSES | DIGCF_DEVICEINTERFACE); SP_DEVICE_INTERFACE_DATA interfaceData = {}; interfaceData.cbSize = sizeof(interfaceData); // 这里简化了遍历逻辑,完整版本需要 SetupDiEnumDeviceInterfaces 循环 if (SetupDiOpenDeviceInterfaceW(devInfoSet, reinterpret_cast<LPCWSTR>(devicePath.utf16()), 0, &interfaceData)) { SP_DEVINFO_DATA devInfoData = {}; devInfoData.cbSize = sizeof(devInfoData); if (SetupDiGetDeviceInterfaceDetailW(devInfoSet, &interfaceData, nullptr, 0, nullptr, &devInfoData)) { // 第一次调用故意失败,只用来拿 devInfoData } // 读友好名称 WCHAR buffer[256] = {}; SetupDiGetDeviceRegistryPropertyW(devInfoSet, &devInfoData, SPDRP_FRIENDLYNAME, nullptr, reinterpret_cast<PBYTE>(buffer), sizeof(buffer), nullptr); info.name = QString::fromWCharArray(buffer); // 读硬件 ID,格式为 USB\VID_1234&PID_5678 WCHAR hwId[256] = {}; SetupDiGetDeviceRegistryPropertyW(devInfoSet, &devInfoData, SPDRP_HARDWAREID, nullptr, reinterpret_cast<PBYTE>(hwId), sizeof(hwId), nullptr); QString hw = QString::fromWCharArray(hwId); // 用正则提取 VID/PID,比手写字符截取更抗格式变化 QRegularExpression re(R"(VID_([0-9A-F]{4})&PID_([0-9A-F]{4}))"); auto match = re.match(hw); if (match.hasMatch()) { info.vid = match.captured(1).toUShort(nullptr, 16); info.pid = match.captured(2).toUShort(nullptr, 16); } } SetupDiDestroyDeviceInfoSet(devInfoSet); return info; }这段代码的核心逻辑是先通过接口路径关联到设备信息集,再拿设备节点SP_DEVINFO_DATA去系统缓存里查注册表属性。SPDRP_FRIENDLYNAME给的是“便携设备”这类用户友好名称,SPDRP_HARDWAREID给的是硬件厂商写入的 ID,两个都读是因为有些设备没有写友好名称。正则提取VID_和PID_是双保险,即使 SetupAPI 调用异常,也能从路径里兜底解析。
4. 常见问题与避坑:这些坑位我基本都踩过一遍
4.1 收不到任何设备变化消息
现象:插入 U 盘时nativeEventFilter完全没响应,断点打不上,qDebug 也没有输出。 原因:最常见的是忘记调用RegisterDeviceNotificationW,或者传入的窗口句柄来自已经销毁的部件。Qt 的winId()在窗口显示前可能还没创建底层窗口,拿到的是 0。 解决:用QWidget::winId()前先确保窗口已经 show 过;或者干脆创建一个隐藏的QWindow挂在成员变量上,把生命周期和监听器绑定在一起。每次程序启动后,先插拔一次设备,确认收到消息再继续开发后续逻辑。
4.2 插入一个设备,收到五六次重复事件
现象:插一个 U 盘,deviceArrived触发了多次,界面被刷没了。 原因:WM_DEVICECHANGE本身就会广播多轮,包括设备到达、设备节点改变、卷到达等多个子消息。如果把整个DBT_DEVICEARRIVAL当作最终状态直接抛出去,又没有去重,就会出现重复。 解决:在handleDeviceChange里按wParam精确判断,只对DBT_DEVICEARRIVAL和DBT_DEVICEREMOVECOMPLETE做业务处理,其他子消息忽略。另外一个经验做法是保存最近一次设备的路径哈希,两秒内相同哈希不重复触发。
4.3 在 nativeEventFilter 里做耗时查询,界面卡成黑匣子
现象:设备插上后整个程序卡顿 1 秒以上,界面无响应,鼠标转圈。 原因:nativeEventFilter是同步回调,直接在里面调 SetupAPI 的完整枚举流程,等于把耗时操作挤在了事件分发的主线程上。 解决:收到事件后先用QMetaObject::invokeMethod(this, "handleDevice", Qt::QueuedConnection, ...)抛到主线程任务队列,再在槽函数里做fetchDeviceInfo。如果设备查询更重,比如要读取 USB 配置描述符,就放到QtConcurrent::run或线程池里,完成后通过信号回到界面线程。
4.4 MinGW 和 MSVC 下结构体大小不一致导致解析乱码
现象:在 MSVC 编译环境下一切正常,切到 MinGW 后dbcc_name读出来是乱码。 原因:DEV_BROADCAST_DEVICEINTERFACE_W结构体的内存对齐方式在不同工具链下有差异,直接用reinterpret_cast去强制转换lParam,在 MinGW 下对不齐,某些成员偏移就错了。 解决:不要直接用结构指针去解引用,而是手动做偏移计算。从DEV_BROADCAST_HDR头部拿到结构大小后,再用memcpy把后续字节按顺序拷到本地变量,或者干脆统一用 MSVC 工具链跑这个项目。就这个场景而言,稳定压倒一切,MSVC 更契合 Windows 设备 API 的内存布局。
5. 构建与运行:从新建工程到插拔验证
5.1 工程配置:链接 Windows 设备管理库
项目使用 qmake 管理,.pro文件里最核心的就是链接系统库。setupapi.lib提供设备信息集的 API,user32.lib提供窗口消息注册,advapi32.lib提供注册表操作支持。
QT += core gui widgets TARGET = USBMonitorDemo TEMPLATE = app SOURCES += \ main.cpp \ usbmonitor.cpp HEADERS += \ usbmonitor.h \ usbdeviceinfo.h win32 { LIBS += -lsetupapi -luser32 -ladvapi32 DEFINES += _WIN32_WINNT=0x0601 }_WIN32_WINNT=0x0601表示目标平台最低是 Windows 7,这样会启用较新版本的设备 API 定义。如果这行不写,部分 SDK 头文件会保留旧的函数签名,RegisterDeviceNotificationW可能与后来的原型冲突,导致编译报错。整个工程不依赖第三方库,这是它嵌到老项目里成本比较低的关键原因。
5.2 主程序与信号连接
main.cpp只负责创建监听器、连接信号、然后把事件打印出来。演示程序不画界面,跑起来直接在控制台和 qDebug 里看输出。
#include <QApplication> #include <QDebug> #include "usbmonitor.h" #include "usbdeviceinfo.h" int main(int argc, char *argv[]) { QApplication app(argc, argv); UsbMonitor monitor; QObject::connect(&monitor, &UsbMonitor::deviceArrived, [](const UsbDeviceInfo &info) { qDebug() << "设备接入:" << info.name << QString("VID=%1").arg(info.vid, 4, 16, QChar('0')) << QString("PID=%1").arg(info.pid, 4, 16, QChar('0')); }); QObject::connect(&monitor, &UsbMonitor::deviceRemoved, [](const UsbDeviceInfo &info) { qDebug() << "设备拔出:" << QString("VID=%1").arg(info.vid, 4, 16, QChar('0')); }); return app.exec(); }信号连接用了 Qt5 的新语法,编译期就检查信号与槽函数签名是否匹配,比字符串连接方式更早暴露错误。注意UsbMonitor虽然在栈上创建,但它是QObject,必须在QApplication之后实例化,否则事件系统还没初始化,注册设备通知时拿不到消息循环。
5.3 编译运行与调试技巧
构建时使用 Qt 自带的命令行环境:
qmake USBMonitorDemo.pro nmake # MSVC 环境 # 或者 mingw32-make # MinGW 环境运行编译出的 exe,插入任意 USB 设备,观察日志输出。如果插 U 盘没反应,可以先在设备管理器里右键“扫描检测硬件改动”,这个动作也会触发WM_DEVICECHANGE,可以帮我们区分“消息链路断了”和“设备本身不兼容”两类问题。
调试时有一个比较省力的技巧:把QVariant或QByteArray形式的原始消息结构体先打印一部分字段,确认dbch_devicetype和dbcc_name是不是空,定位到底问题出在注册阶段还是解析阶段。由于事件触发时机不可控,建议打印时加上时间戳,这样能看出事件回调的先后顺序,排查乱序问题会轻松很多。
6. 二次开发:把监听器扩展成设备管控模块
到了这一步,核心的“插拔感知”能力已经打通了,剩下的是怎么把信息用起来。最常见的改造方向是白名单管控:插上设备后先比对vid/pid是否在许可列表里,不在列表内就直接在界面弹警告,甚至可以通过策略阻止资源管理器访问该盘符。这些逻辑都可以在deviceArrived信号对应的槽里三到五行代码以内完成。
另一个值得改造的点是对“弹出前拦截”的处理。WM_DEVICECHANGE还有DBT_DEVICEQUERYREMOVE和DBT_DEVICEQUERYREMOVEFAILED两个子事件,前者在系统尝试弹出设备之前触发,允许我们拒绝弹出。我一般会在用户正在拷贝文件时向系统返回BROADCAST_QUERY_DENY,让“安全删除硬件”暂时失败,等文件操作结束再允许弹出。这比单纯监听插拔更能贴合真实工作流场景。
如果要把这套机制做得更健壮,我还会加上一层基于QSettings的持久化记录,把每次插入设备的序列号和系统时间写到本地文件,形成一个轻量级的资产审计日志。序列号字段在UsbDeviceInfo里已经预留了serialNumber,目前解析逻辑没有填这个值,二次开发时只需要从设备实例路径的U盘段里提取即可,从路径格式来看,序列号位于最后一个#之后,用QString::section就能干净地切出来。
从那以后我每次新增一个设备类型检测,都会强制走一遍完整的插拔测试用例,先确认事件到达,再确认字段解析,最后做异常处理,顺序错一步就容易陷入“消息收到了但拿不到信息”的尴尬。这套流程虽然朴素,但确实能兜住九成以上设备兼容性问题,希望帮到你。
本文还有配套的精品资源,点击获取