
简介一款面向 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代码量少挂载点未知时没法监听拔除时文件系统已消失事件容易丢libudevQSocketNotifier通过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) ACTIONadd DEVNAME/dev/sdb1 DEVTYPEpartition ID_BUSusb ID_MODELUSB_DISK_2.0 ID_SERIALUSB_DISK_2.0_AA00000000000001-0:0这些属性里ID_MODEL、ID_SERIAL、DEVTYPE都可以用来做更精细的判断。比如你想只响应某个特定序列号的U盘就能通过udev_device_get_property_value拿到ID_SERIAL做比对这是普通目录监听完全做不到的能力。6. 跨平台方案的一点思考如果你是个跨平台桌面应用开发比如Windows和Linux都要支持那这套libudev方案只能在Linux上用。跨平台的做法通常是写一个设备监测抽象层Linuxlibudev方案。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接口的枚举行为还有差异。没有哪个方案可以百分之百覆盖所有情况建议在代码里加一层“心跳对账”机制定期检查设备列表的完整性作为事件驱动的兜底。这样即便偶发漏事件也能在下一轮对账时发现业务上至少不会出大问题。这个项目做完之后我把模块抽出来放在了公司的公共库里后来好几个上位机项目、工控界面项目都直接复用了省了不少事。如果你做完之后也有类似的需求不妨也考虑抽成一个独立模块后续维护成本会低很多。本文还有配套的精品资源点击获取