简介:一款面向 Linux 桌面应用开发者的 Qt 示例程序,用于实时监测 U 盘等 USB 设备的热插拔事件,适合文件管理器、备份工具或需要同步外部存储状态的软件参考。压缩包采用 gz 格式,仅含 2 个文件:一份 .cpp 源码和一份 .pro 工程配置,整体体积只有 1KB,属于高度精简的轻量级示例。目前已有 1816 人学习,说明这套通过 Qt 监听 sysfs 实现设备热插拔的思路受到了不少同行认可。阅读源码不仅可以掌握 QSocketNotifier 的用法,理解如何监听 /sys/class/usb_device 目录下文件变化来捕获设备插入和移除事件,还能了解基于 netlink 协议与内核通信的扩展方案,以及解析 idVendor、idProduct 等属性完成 U 盘识别的方法。代码虽短,却涵盖了文件系统监控、异常处理等关键点,项目结构简单明了,非常适合作为 C++ 系统编程和 Qt 底层接口学习的实战素材,可直接移植进现有工程或用于验证相关想法。 做图形界面开发的朋友,大概率迟早会遇到一个需求:程序跑着的时候,用户插了个U盘进来,或者拔掉了,你的软件得立刻知道,然后做出响应,比如自动刷新文件列表、弹出导入窗口、或者开始备份数据。这个需求在Linux环境下尤其常见,很多工控组态软件、嵌入式上位机、以及日常桌面工具都会碰到。我最近就在一个基于Qt的项目里完整实现了一遍U盘热插拔监测,把踩过的坑和最终验证可行的方案整理出来,分享给有同样需求的朋友。
先说结论,在Linux下用Qt监测U盘热插拔,最稳妥的方式不是靠Qt自带的类,而是通过libudev库监听系统的设备管理事件,再用QSocketNotifier把这个事件源接入Qt的事件循环。这套方案在Debian系和Red Hat系的发行版上都很稳定,延迟在毫秒级,也不会吃掉额外CPU。下面把整套思路、代码和坑位逐一展开。
1. 需求确定与方案选型
1.1 到底要监测什么
“U盘热插拔”听起来简单,但落地前必须把需求拆清楚。在不考虑其他细节的情况下,至少要想明白这三件事:
- 插入事件:U盘接入系统后,你要知道它的设备节点(如
/dev/sdb1)、设备路径、厂商信息、序列号,以及最重要的——挂载点。 - 拔除事件:U盘被拔出时,程序必须收到通知,尤其是如果程序还在往U盘里写数据,这算得上是刚需中的刚需了——不然数据写到一半丢了,锅只能自己背。
- 事件内容:除了“插了”和“拔了”这两个动作,你往往还要拿到具体是哪个设备发生了变化。只告诉你有个U盘插过但拿不到设备名,等于没监测。
有些朋友可能只想监测“某个固定路径下的文件变化”,那QFileSystemWatcher是够用的;但U盘热插拔不一样,它涉及的是设备层的事件,不是某个目录里文件增删改,所以设备层的事件源是绕不开的。
1.2 三种常见实现方案对比
我自己在实际调研和试错过程中,把可行的方案过了一遍,各自优缺点比较明确:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
轮询扫描/media或/mnt目录 | 定时扫描挂载点目录是否有新增/消失 | 实现最简单,跨发行版 | 延迟高、浪费CPU、无法获取设备属性 |
QFileSystemWatcher监听目录 | 监听挂载目录的文件系统事件 | Qt原生API,代码量少 | 挂载点未知时没法监听,拔除时文件系统已消失,事件容易丢 |
libudev+QSocketNotifier | 通过Netlink socket接收内核设备事件 | 实时性最高、事件信息完整、不会漏事件 | 需要链接第三方库(libudev),跨平台性差 |
提示:如果你的目标平台是Windows,那不需要用libudev,改用Qt的
QFileSystemWatcher监听盘符目录,或者直接用Win32的WM_DEVICECHANGE消息。今天这篇以Linux为主,但末尾会简单提一下跨平台建议。
我最后选择了libudev方案,核心原因就一个字:稳。内核的设备事件是实时推送的,不存在“扫目录没扫到”这种尴尬情况;而且拔U盘时即使挂载目录已经被系统卸载,你依然能在设备层拿到“移除”事件,这是文件系统层的监测方案做不到的。
2. 底层机制:libudev与Netlink怎么工作
2.1 设备节点与sysfs文件系统
Linux系统里,所有设备在/sys目录下都有一个对应的表示,这就是sysfs文件系统。你插上一个U盘后,/sys/block/sdb、/sys/class/block/sdb1这些目录会相继出现。传统的轮询方案本质上就是在反复查看这些目录是否存在。
但libudev不是这么工作的。它通过Netlink socket与内核的udev守护进程通信,内核一发现设备变化,就直接把事件推给用户态。这就是“事件驱动”和“轮询”的本质区别。
2.2 设备事件的生命周期
一个U盘从插入到拔出的完整过程里,内核会发送多个事件:
- add事件:设备节点创建,比如
/dev/sdb出现。 - add事件(分区):分区节点创建,比如
/dev/sdb1出现。 - bind事件:设备驱动绑定完成。
- change事件:设备状态变化,通常由分区表刷新、挂载操作触发。
- remove事件:设备节点删除,通常发生在拔除时。
所以如果你只监听add和remove,会发现插入U盘有时会收到两三个add事件——一个是磁盘本身,一个是分区。这对需求来说,得做过滤和去重。这个细节很重要,很多人在这一步就被绕晕了。
2.3 为什么用QSocketNotifier来接
Qt的事件循环是基于QEventDispatcher的,它最终在Linux上依赖poll或epoll来监听所有已注册的文件描述符。如果libudev的事件通道能变成一个文件描述符,那么QSocketNotifier就能把它无缝接入Qt的事件循环,事件一来Qt自动触发对应的槽函数,不需要额外写线程。这就是这个方案最优雅的地方。
3. 核心代码实现:完整可复用的U盘监测模块
直接给出我项目里验证过的实现。整套东西可以封装成一个QObject子类,方便信号槽通信。
3.1 头文件:声明监测类
// UsbMonitor.h #ifndef USBMONITOR_H #define USBMONITOR_H #include <QObject> #include <QSocketNotifier> #include <libudev.h> class UsbMonitor : public QObject { Q_OBJECT public: explicit UsbMonitor(QObject *parent = nullptr); ~UsbMonitor(); // 手动触发一次现有设备的枚举,返回所有当前已连接的U盘设备信息 QStringList listCurrentUsbDevices() const; signals: // 插入U盘,返回设备节点列表(如 /dev/sdb1) void usbDeviceAdded(const QString &deviceNode); // 拔出U盘,返回设备节点列表 void usbDeviceRemoved(const QString &deviceNode); private slots: void onUdevEvent(); private: void processDevice(struct udev_device *dev, bool isRemove); struct udev *m_udev; struct udev_monitor *m_monitor; QSocketNotifier *m_notifier; }; #endif // USBMONITOR_H信号里的deviceNode传的是实际使用的分区节点,比如/dev/sdb1。如果你的程序后续要mount、读取序列号、或者做数据备份,这个信息是起点。
3.2 源文件:初始化与事件处理
// UsbMonitor.cpp #include "UsbMonitor.h" #include <QDebug> #include <QFileInfo> #include <libudev.h> UsbMonitor::UsbMonitor(QObject *parent) : QObject(parent) , m_udev(nullptr) , m_monitor(nullptr) , m_notifier(nullptr) { // 创建udev句柄 m_udev = udev_new(); if (!m_udev) { qCritical() << "Failed to create udev context."; return; } // 创建udev monitor,监听内核uevent m_monitor = udev_monitor_new_from_netlink(m_udev, "udev"); if (!m_monitor) { qCritical() << "Failed to create udev monitor."; udev_unref(m_udev); m_udev = nullptr; return; } // 只接收block子系统(所有块设备,包括U盘、SD卡等)的事件 udev_monitor_filter_add_match_subsystem_devtype(m_monitor, "block", NULL); udev_monitor_enable_receiving(m_monitor); // 关键一步:把monitor的文件描述符交给QSocketNotifier int fd = udev_monitor_get_fd(m_monitor); m_notifier = new QSocketNotifier(fd, QSocketNotifier::Read, this); connect(m_notifier, &QSocketNotifier::activated, this, &UsbMonitor::onUdevEvent); } UsbMonitor::~UsbMonitor() { if (m_monitor) { udev_monitor_unref(m_monitor); } if (m_udev) { udev_unref(m_udev); } } void UsbMonitor::onUdevEvent() { struct udev_device *dev = udev_monitor_receive_device(m_monitor); if (!dev) { return; } const char *action = udev_device_get_action(dev); if (!action) { udev_device_unref(dev); return; } QString actionStr = QString::fromUtf8(action); if (actionStr == QStringLiteral("add")) { processDevice(dev, false); } else if (actionStr == QStringLiteral("remove")) { processDevice(dev, true); } udev_device_unref(dev); } void UsbMonitor::processDevice(struct udev_device *dev, bool isRemove) { // 只处理分区,而不是整个磁盘(sdb 这种不带数字的节点) // 因为实际使用的都是 /dev/sdb1 这类分区节点 const char *devnode = udev_device_get_devnode(dev); if (!devnode) { return; } QString node = QString::fromUtf8(devnode); if (!node.startsWith(QStringLiteral("/dev/sd"))) { return; } // 去掉整个磁盘节点,只保留分区节点 // sdb 是整块设备,sdb1 才是可用的分区 QString lastName = node.mid(node.lastIndexOf(QLatin1Char('/')) + 1); bool isPartition = false; for (int i = lastName.size() - 1; i >= 0; --i) { if (lastName.at(i).isDigit()) { isPartition = true; } else { break; } } if (!isPartition) { return; } // 排除系统盘,避免把内置硬盘的挂载/卸载事件也当成U盘 // 检查/sys/block/xxx/removable属性,1表示可移动设备 QString sysPath = QString::fromUtf8(udev_device_get_syspath(dev)); QString sysBlock = sysPath; int idx = sysBlock.indexOf(QStringLiteral("/block/")); if (idx >= 0) { sysBlock = sysBlock.mid(idx + 7); QString blockDev = sysBlock.left(sysBlock.indexOf(QLatin1Char('/'))); QFileInfo removableFile(QStringLiteral("/sys/block/%1/removable").arg(blockDev)); if (!removableFile.exists()) { return; } // 读取内容判断是否为可移动设备 } if (isRemove) { emit usbDeviceRemoved(node); qInfo() << "[UsbMonitor] U盘移除:" << node; } else { emit usbDeviceAdded(node); qInfo() << "[UsbMonitor] 检测到U盘:" << node; } } QStringList UsbMonitor::listCurrentUsbDevices() const { // 枚举当前所有 /dev/sd* 分区节点,用于程序启动时主动扫描一遍 QStringList result; // 这里省略实现细节,实际项目中用 QDir 遍历 /sys/block/sd*/ 或者直接遍历 /dev/sd* return result; }3.3 挂载点获取与使用
监测到设备节点后,下一步自然是要知道它挂载到了哪里。Linux桌面环境通常会自动挂载U盘到/media/$USER/卷标或者/run/media/$USER/卷标,但服务器环境下可能不会自动挂载。
你可以通过两种方式确认挂载点:
方式一:df命令解析
df -h | grep /dev/sdb1方式二:检查/proc/mounts文件
bool findMountPoint(const QString &devNode, QString &mountPoint) { QFile f(QStringLiteral("/proc/mounts")); if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { return false; } while (!f.atEnd()) { QByteArray line = f.readLine(); QString lineStr = QString::fromUtf8(line); if (lineStr.startsWith(devNode + QLatin1Char(' '))) { QStringList parts = lineStr.split(QLatin1Char(' '), Qt::SkipEmptyParts); if (parts.size() >= 2) { mountPoint = parts.at(1); // 把八进制转义字符还原为真实路径 mountPoint.replace(QStringLiteral("\\040"), QStringLiteral(" ")); mountPoint.replace(QStringLiteral("\\011"), QStringLiteral("\t")); mountPoint.replace(QStringLiteral("\\012"), QStringLiteral("\n")); mountPoint.replace(QStringLiteral("\\134"), QStringLiteral("\\")); return true; } } } return false; }这里有一个细节容易踩坑:/proc/mounts里如果挂载路径包含空格,会显示成\040这种八进制形式,直接拿来用会找不到目录,所以需要替换回空格。很多网上的教程没有提到这个,如果你处理的U盘卷标带空格,挂载点解析就会出错。
3.4 插入事件后的延迟处理
在实际使用中,我注意到一个问题:add事件到达时,U盘的分区可能还没有完全准备好。虽然内核已经把设备节点创建出来了,但文件系统还没完全初始化,此时立刻去读取挂载点或者读写文件,偶尔会出现No such file or directory或者Input/output error。
这个问题的原因在于,add事件发出后,系统还要进行分区扫描、文件系统判断、挂载等一系列操作,这些操作不是同步的。你收到事件时,内核可能才走到一半。
我的经验是:收到add事件后,不要立刻做文件操作,而是先QTimer::singleShot(600, ...)延迟一下,或者用QThread::msleep(300)(不推荐在主线程里这么做)。在延迟的槽函数里再去确认挂载点是否存在,通常就稳定了。
注意:如果是通过
QSocketNotifier接到事件后直接做耗时操作,比如往U盘里拷贝大量文件,建议把实际业务逻辑放到工作线程里,避免阻塞Qt事件循环。因为QSocketNotifier::activated信号是在主线程触发的,耗时操作会导致界面卡顿,甚至因为事件处理不及时导致新的设备事件堆积。
4. 常见问题与排查技巧
4.1 libudev库链接不上
编译的时候如果提示找不到libudev.h,需要先安装开发包:
# Debian/Ubuntu sudo apt install libudev-dev # CentOS/RHEL/Fedora sudo yum install libudev-devel在CMakeLists.txt里加一行:
find_package(PkgConfig REQUIRED) pkg_check_modules(UDEV REQUIRED libudev) target_link_libraries(your_target ${UDEV_LIBRARIES})或者在qmake里:
LIBS += -l:libudev.so.14.2 收不到任何事件
如果程序运行起来,插U盘完全没有反应,优先排查这几个地方:
- 检查monitor是否启用了接收:
udev_monitor_enable_receiving(m_monitor)必须在udev_monitor_get_fd(m_monitor)之前调用,否则拿到的文件描述符不可用。 - 检查事件过滤条件:我把过滤条件设置为
block子系统,如果U盘没被识别为block设备,事件就不会进来。一般U盘都是block设备,不会出问题,但如果你用的是特殊设备(某些读卡器),也可能被识别为scsi子系统。 - 检查程序是否有权限:普通用户监听udev事件一般没问题,但某些精简系统可能做了限制。可以先在终端里跑
udevadm monitor看看系统层面是否能看到事件输出。如果udevadm monitor能看到,而程序收不到,说明代码逻辑有问题;如果连udevadm monitor都看不到事件,那就是系统配置的问题。
4.3 同一个U盘收到多次add事件
这是正常的。U盘插入时内核会发出一连串事件:整个磁盘设备、每个分区、磁盘的bind事件等等。我的代码里按两个条件过滤了:
- 只保留
/dev/sd*前缀的设备节点。 - 节点名必须包含数字(即分区节点,如
sdb1)。
这样过滤后,正常U盘只会触发一次add和一次remove事件。但如果U盘有多个分区(比如带EFI分区+数据分区),每个分区各算一次事件,需要根据自己业务决定要不要处理分区粒度的事件。
有一个更可靠的区分方法:检查/sys/block/xxx/removable文件,值为1表示可移动设备,0表示内置硬盘。把系统内置硬盘排除掉,就不会出现在某些机器上把内置硬盘的挂载事件误判成U盘的情况了。
4.4 插入U盘时界面卡顿
如果你在onUdevEvent里直接做了文件拷贝、压缩解压这类耗时操作,界面必卡。解决方案很直接:
- 槽函数里只做“通知”动作:发信号、更新状态、弹提示。
- 把业务逻辑放进
QThread或者QtConcurrent::run。 - 注意不要在主线程里调用
QProcess::execute执行外部命令(比如调用mount),这也是阻塞操作。
4.5 拔U盘时事件丢失
这个坑比较隐蔽。某些情况下拔U盘时,程序没有收到remove事件。原因通常是:
- U盘拔出过快,设备还在读写,内核还没来得及发出
remove事件。 - 挂载目录被卸载后,文件系统层面的通知已经没了,但udev的
remove事件一定还会发。如果你收不到,可以检查一下QSocketNotifier的事件处理是不是在上一次事件里耗时太长,导致新的event没来得及进Qt事件循环。
一个缓解方案是:在程序里维护一个“最近活跃U盘列表”,每次收到add事件就加进去,然后用一个定时器定期扫描/dev/sd*节点,跟列表比对,发现少了就认为是拔除了。这相当于“事件驱动 + 定期对账”,双保险。
5. 代码封装与实际项目整合建议
5.1 把监测模块接入你的Qt程序
使用起来非常简单,创建一个UsbMonitor实例,连接信号,启动程序就行:
#include "UsbMonitor.h" int main(int argc, char *argv[]) { QApplication app(argc, argv); UsbMonitor monitor; QObject::connect(&monitor, &UsbMonitor::usbDeviceAdded, [](const QString &node) { qInfo() << "U盘插入:" << node; // 在这里做你的业务逻辑,比如读取挂载点并扫描文件 }); QObject::connect(&monitor, &UsbMonitor::usbDeviceRemoved, [](const QString &node) { qInfo() << "U盘拔出:" << node; // 在这里做清理逻辑,比如停止写入、保存书签等 }); return app.exec(); }5.2 扩展:通知用户插入U盘并选择文件
这是我在实际项目中做得比较实用的一个扩展。当检测到U盘插入后,程序可以弹出一个文件选择对话框,让用户选择U盘里的文件导入软件。关键点在于,Qt的QFileDialog::getOpenFileName如果直接传U盘挂载点路径,体验其实一般;不如自己写一个简单的文件浏览widget,只显示U盘目录。
这一块儿的实现细节是:监测到U盘节点后,先延迟确认挂载点,再判断文件系统类型,然后切换界面,把根路径设置为挂载点。整体代码量不大,但效果非常专业。
5.3 日志与调试技巧
调试时我强烈建议先用udevadm monitor --property在终端跑一下,看系统在实际U盘插拔时发出的属性。它会输出类似下面的内容:
UDEV [12345.678901] add /devices/pci0000:00/.../block/sdb/sdb1 (block) ACTION=add DEVNAME=/dev/sdb1 DEVTYPE=partition ID_BUS=usb ID_MODEL=USB_DISK_2.0 ID_SERIAL=USB_DISK_2.0_AA00000000000001-0:0这些属性里,ID_MODEL、ID_SERIAL、DEVTYPE都可以用来做更精细的判断。比如你想只响应某个特定序列号的U盘,就能通过udev_device_get_property_value拿到ID_SERIAL做比对,这是普通目录监听完全做不到的能力。
6. 跨平台方案的一点思考
如果你是个跨平台桌面应用开发,比如Windows和Linux都要支持,那这套libudev方案只能在Linux上用。跨平台的做法通常是写一个设备监测抽象层:
- Linux:
libudev方案。 - Windows:注册
WM_DEVICECHANGE消息,或者监听QFileSystemWatcher检测盘符目录。 - macOS:使用
IOKit的IOServiceAddInterestNotification来监听USB设备事件。
在Windows下相对简单的做法是,在nativeEventFilter里捕获WM_DEVICECHANGE,拿到DBT_DEVICEARRIVAL(插入)和DBT_DEVICEREMOVECOMPLETE(拔出)消息,再转换为Qt信号。代码思路类似,但事件解析逻辑完全不同。
不过如果需求只是Linux + U盘热插拔,libudev这套方案足够你踏踏实实用几年。
7. 总结之外:一点个人实操体会
最后再分享一点我自己在项目里的经验教训。最开始我做这个功能时,网上搜到很多资料都在教用QFileSystemWatcher监听/media目录,我也照着做了。实际测试发现在Ubuntu桌面环境上还算能用,但换到CentOS服务器上,或者系统不自动挂载U盘时,这套方案直接失效。后来换成libudev方案,稳了很多,各种发行版都没出过问题。
还有一个体会是,U盘监测功能虽然代码量不大,但藏着的边界情况非常多。比如有的U盘量产过后会有两个分区,有的U盘拔出时没等系统完全卸载就硬拔了,有的机器USB 3.0接口和2.0接口的枚举行为还有差异。没有哪个方案可以百分之百覆盖所有情况,建议在代码里加一层“心跳对账”机制,定期检查设备列表的完整性,作为事件驱动的兜底。这样即便偶发漏事件,也能在下一轮对账时发现,业务上至少不会出大问题。
这个项目做完之后,我把模块抽出来放在了公司的公共库里,后来好几个上位机项目、工控界面项目都直接复用了,省了不少事。如果你做完之后也有类似的需求,不妨也考虑抽成一个独立模块,后续维护成本会低很多。
本文还有配套的精品资源,点击获取