
简介Qt多线程读写串口是嵌入式、工控和上位机开发中很常见的需求。这份资源提供了一个名为ThreadTool2的完整示例程序面向初学Qt串口通信或多线程编程的开发者能够帮助理解串口数据收发与界面响应之间的协调方式。压缩包共13个文件大小仅9KB主体是5个C源文件、4个头文件另外包含一个UI界面文件、工程文件和工程配置文件目录简洁适合直接打开工程对照学习。程序从创建QThread子类并重写run()方法入手到初始化QSerialPort参数端口、波特率、数据位、校验位、停止位再到调用read和write读写数据为保障线程安全引入了互斥锁并通过信号与槽把串口事件交给主线程更新界面最后还处理了线程退出与资源回收基本覆盖了串口工具类软件的核心设计方法。目前已有179人学习下载对想解决串口读写卡顿、数据并发访问或界面卡死等问题的开发者来说是一份轻量实用的参考。1. 多线程读写串口Qt 里最容易翻车的三个地方把 QSerialPort 直接丢进 QThread然后发现数据丢帧、界面卡死、退出时崩溃——这是 Qt 多线程串口最典型的三个翻车现场。标题里这个例子之所以常被搜是因为串口设备几乎全是异步的设备随时上报界面随时下发两边都不能等。常见做法是拆一个工作线程让 QSerialPort 在后台事件循环里跑界面只通过信号槽收发数据。文章按我写串口调试助手时的路子从线程亲和性讲到帧解析最后给出一套能直接改用的最小代码。对刚接触 Qt 串口的人跟着把线程模型理顺比抄一百个网上残缺例子都管用。2. 为什么串口读写必须拆出工作线程QSerialPort 的线程归属与信号槽边界2.1 QSerialPort 不是线程安全的事件默认都投递到创建它的线程每个 QObject 都有线程亲和性thread affinity。QSerialPort 在哪个线程创建它的 readyRead、bytesWritten、errorOccurred 这些信号就从哪个线程发出。大部分初学者是在 MainWindow 的构造函数里 new QSerialPort于是 readAll 全在主线程执行等阻塞式 waitForReadyRead 一出现界面就冻住了因为主线程的事件循环被卡死连重绘都没机会跑。更关键的是 QSerialPort 自己没有线程安全保证。同一个 port 实例一个线程在 write另一个线程在 readAll轻则丢数据重则直接崩溃。所以读写分离成两个线程看起来高级实际上要另加一把 QMutex 保护所有调用收益不大坑不少。我一般用一个工作线程统一处理接收和发送事件驱动天然串行不需要锁。2.2 跨线程的信号槽排队连接和元类型注册Qt 跨线程通信的标准做法是信号槽。默认 AutoConnection 在 emit 时判断发送方和接收方不在同一线程就退化成 QueuedConnection——把参数打包成一个事件投递到接收方的事件循环。这里有个隐藏条件参数的元类型必须先注册否则排队连接会直接失败控制台报 Cannot queue arguments。连接类型行为适用场景AutoConnection同线程直连跨线程排队默认选项别乱改DirectConnection发送线程里同步调用槽同线程强制同步或故意要阻塞发送方QueuedConnection投递事件发送方不等待跨线程传数据标准选择BlockingQueuedConnection投递并阻塞发送方直到槽返回关停前同步清理慎用注册自定义类型的固定写法#include QMetaType struct SerialFrame { quint8 cmd; QByteArray payload; }; Q_DECLARE_METATYPE(SerialFrame) // 在 main() 里QApplication 构造之后执行一次 qRegisterMetaTypeSerialFrame(SerialFrame);排队连接要把参数拷贝到事件对象里拷贝动作依赖 QMetaType 的默认构造和析构QByteArray、QString、int 这些内置类型已经注册过只有自定义结构体才需要手动注册。实际项目里如果只在串口线程内部解析帧、只往界面传 QByteArray这一步可以省但加上能避免协议升级后莫名其妙地报连接失败。2.3 事件驱动单线程比轮询和双线程都稳三种常见方案对比第一种在主线程开 QTimer 每 10ms 读一次界面稍一耗时就会漏数据第二种单独起读线程加 waitForReadyRead 阻塞写操作又得另外加锁第三种就是本篇要写的 worker 线程方案读和写都在同一个事件循环里排队执行。串口 115200 波特率换算下来约 11.5KB/s这个吞吐量对事件驱动绰绰有余瓶颈根本不在 CPU而在帧解析和界面刷新。把读和写放在同一个线程既保序又免锁代价只是代码上多绕一次信号槽。提示只要槽函数在 worker 线程里执行所有阻塞型串口 API 都不能用需要延时一律用 QTimer::singleShot。3. 最小可用的串口工作线程实现Worker 类与线程生命周期3.1 正确姿势是 moveToThread不是继承 QThread初学最常见的错法是从 QThread 派生一个类重写 run()在里面 new QSerialPort 并写死读循环。这个写法看起来在线程里干活但 run() 结束后线程直接销毁串口对象的析构时机、close 的清理顺序都很难控制。Qt 官方推荐的反而是 QObject 工作线程模式用普通 QObject 子类承载串口把整个对象 moveToThread 到 QThread 的事件循环里。方案事件循环串口清理结论继承 QThread 重写 run()要自己实现循环析构时机难控不推荐工作线程 moveToThread由 exec() 提供deleteLater 自动回收推荐关键区别在于事件循环。moveToThread 之后worker 的公有槽函数会按排队连接被投递到工作线程由 exec() 循环逐个执行。不需要手动管理循环open、close、write 全是事件驱动的天然和 readyRead 的顺序一致。3.2 SerialWorker 类骨架、启动与 openPort 参数worker 头文件里 QSerialPort 直接作为成员构造时把 this 传给它让它成为子对象class SerialWorker : public QObject { Q_OBJECT public: explicit SerialWorker(QObject *parent nullptr) : QObject(parent), m_port(this) {} public slots: void openPort(const QString portName, qint32 baudRate); void writeData(const QByteArray data); void closePort(); signals: void opened(bool ok, const QString msg); void frameReady(const QByteArray frame); void errorOccurred(const QString msg); void txIdle(); private slots: void handleReadyRead(); void handleBytesWritten(qint64 bytes); void handleError(QSerialPort::SerialPortError err); private: QSerialPort m_port; QByteArray m_rxBuffer; qint64 m_pendingBytes 0; };m_port 是 worker 的子对象moveToThread 会把子对象一并迁移这正是要的效果。MainWindow 里这样启动m_thread new QThread(this); m_worker new SerialWorker(); // 先连接信号再 moveToThread connect(m_thread, QThread::finished, m_worker, QObject::deleteLater); connect(this, MainWindow::openRequested, m_worker, SerialWorker::openPort); connect(this, MainWindow::sendRequested, m_worker, SerialWorker::writeData); connect(m_worker, SerialWorker::frameReady, this, MainWindow::onFrameReady); connect(m_worker, SerialWorker::errorOccurred, this, MainWindow::onWorkerError); m_worker-moveToThread(m_thread); m_thread-start();这段代码的顺序有讲究AutoConnection 是在 emit 那一刻判断线程归属的所以连接先于迁移或后于迁移都行但先连接后迁移可以保证线程一启动信号就不漏。m_thread-finished 连到 worker 的 deleteLater是 Qt 文档里的标准清理链意思是线程事件循环结束worker 由自己清理自己。openPort 的典型实现void SerialWorker::openPort(const QString portName, qint32 baudRate) { if (m_port.isOpen()) m_port.close(); m_port.setPortName(portName); m_port.setBaudRate(baudRate); m_port.setDataBits(QSerialPort::Data8); m_port.setParity(QSerialPort::NoParity); m_port.setStopBits(QSerialPort::OneStop); m_port.setFlowControl(QSerialPort::NoFlowControl); if (!m_port.open(QIODevice::ReadWrite)) { emit opened(false, m_port.errorString()); return; } m_rxBuffer.clear(); m_pendingBytes 0; emit opened(true, QString()); }参数按常用的 8N18 数据位、无校验、1 停止位配置。无流控是默认选择因为多数 USB 转串口设备CH340、FTDI 方案出厂就支持硬件流控但很多国产设备实际没接 RTS/CTS 线开了反而卡住收发真要用硬件流控得先确认对方设备引脚接了再开。3.3 退出顺序先同步关串口再 quit再 wait粗暴做法是窗口关闭时直接 m_thread-quit() 然后等析构结果往往是串口还开着、驱动句柄没释放第二次打开同一端口报 PermissionError。收尾代码void MainWindow::closeEvent(QCloseEvent *event) { // 1. 断开 UI 通向 worker 的信号避免退出期间再投递命令 disconnect(this, nullptr, m_worker, nullptr); // 2. 同步等待 worker 把串口关完阻塞在主线程 QMetaObject::invokeMethod(m_worker, closePort, Qt::BlockingQueuedConnection); // 3. 结束事件循环等线程真正退出 m_thread-quit(); m_thread-wait(); QMainWindow::closeEvent(event); }invokeMethod 用了 BlockingQueuedConnection主线程会等到 closePort 执行完才继续这样才能保证后面 quit 时 worker 里没有残存的读写动作。代价是如果 worker 内部在等一个永不返回的阻塞调用主线程会一起卡死。关闭流程里最隐蔽的死锁就藏在这worker 的关闭路径里误用了 waitForBytesWritten界面关不掉任务管理器里进程还挂着。4. 串口读写逻辑落到代码帧解析、写队列与超时处理4.1 写入端用 write 返回值加 bytesWritten 判断写完了QSerialPort::write 不会立刻把字节发出去它只是复制进 Qt 内部的发送缓冲区立刻返回成功写入的字节数。真正落到底层设备靠的是 bytesWritten 信号。所以判断两条命令之间要不要等不能看 write 的返回值要看 bytesWritten 是否把待发字节清零void SerialWorker::writeData(const QByteArray data) { if (data.isEmpty()) return; qint64 n m_port.write(data); if (n 0) { emit errorOccurred(m_port.errorString()); return; } m_pendingBytes n; // 记下已进入发送缓冲的字节数 } void SerialWorker::handleBytesWritten(qint64 bytes) { m_pendingBytes - bytes; if (m_pendingBytes 0) { m_pendingBytes 0; emit txIdle(); // 发完了界面可以放心发下一条 } }这套写法的逻辑是界面只管发串口线程自己记账pending 归零后由 txIdle 通知界面。比 flush 或 waitForBytesWritten 可靠因为 flush 只是申请立即发送wait 是阻塞轮询都会干扰事件循环。需要串行发送多条命令的协议比如先发握手再发读寄存器就在收到 txIdle 后发下一条而不是 sleep 硬等。4.2 读取端readyRead 里做滑动窗口拆帧设备一次上报的数据量不定readyRead 每次通知的字节数也不定必须自己攒缓冲、按帧头找边界。下面是一个常见的 AA 55 帧格式void SerialWorker::handleReadyRead() { m_rxBuffer.append(m_port.readAll()); // 一次全读走防止残留 int pos 0; while (true) { if (pos 4 m_rxBuffer.size()) break; const uchar *p (const uchar *)m_rxBuffer.constData(); if (p[pos] ! 0xAA || p[pos 1] ! 0x55) { pos; // 逐个字节滑动找帧头 continue; } int len p[pos 2]; int frameSize 3 len 1; if (pos frameSize m_rxBuffer.size()) break; // 帧没收全等下一次 readyRead quint8 sum 0; for (int i pos; i pos 3 len; i) sum ^ p[i]; if (sum ! p[pos 3 len]) { pos; // 校验不过错帧跳掉 continue; } QByteArray frame m_rxBuffer.mid(pos, frameSize); m_rxBuffer.remove(0, pos frameSize); emit frameReady(frame); pos 0; } if (pos 0) m_rxBuffer.remove(0, pos); // 清掉已扫过的无效字节 }解析策略的核心是扫到哪删到哪。每轮只用 readAll 取一次之后全部在本地 QByteArray 上操作不依赖 bytesAvailable 的猜测。帧头校验失败时前进一个字节而不是丢弃整包这是对付设备上电时发乱码的关键。注意 break 条件短缺时必须保留缓冲等下一批数据不能为了省内存把没凑满的帧清掉。另一个细节是每次循环重新取 constData 指针因为 remove 会触发 QByteArray 的内部拷贝缓存旧指针在 detach 后会指向失效内存。4.3 错误分支errorOccurred 不是日志是状态机QSerialPort 的错误信号要区分对待不能统一弹个框就完事。错误枚举典型触发场景处置建议DeviceNotFoundError设备没插上CH340/FTDI 驱动没识别提示用户检查驱动重新枚举串口列表PermissionError被串口调试助手等其他程序占用提示关闭占用程序或让用户换 COM 口ResourceError运行中 USB 线被拔立刻 close等重新插拔后再 openFramingError波特率设置错误线路干扰大核对设备实际波特率换短线TimeoutError混用了 waitForReadyRead 阻塞函数代码层就该消除事件驱动模式不会出现对应的槽实现void SerialWorker::handleError(QSerialPort::SerialPortError err) { if (err QSerialPort::NoError) return; if (err QSerialPort::ResourceError) { m_port.close(); // USB 被拔必须关掉句柄否则重新 open 无效 emit errorOccurred(QStringLiteral(串口设备不可用请重新插拔)); return; } if (err QSerialPort::TimeoutError) return; emit errorOccurred(m_port.errorString()); }这个实现里最容易被忽略的是 ResourceError 分支。很多 Qt 串口程序在设备热拔后点重连没反应就是因为没走 close 就把 open 又调了一遍Windows 上旧句柄还占着设备open 必然报 PermissionError。另一个经验open 之后对 DTR/RTS 的复位时机也要放在 worker 线程里用 QTimer::singleShot 做几十毫秒延时不要在 open 后面直接接 write否则会碰到下面的串口烧写时序问题。5. 实战验证与高频踩坑从虚拟串口对测到串口烧写失败排查5.1 用虚拟串口对做闭环验证没有真机也能验证多线程串口代码Windows 用 com0com 生成一对互联的 COM3/COM4Linux 一行命令就够socat -d -d pty,raw,echo0 pty,raw,echo0 # 输出类似 /dev/pts/3 和 /dev/pts/4两端互通把 Qt 程序打开 /dev/pts/3另一端用 Python 模拟设备写入一段带帧头的原始数据确认 Qt 解析出 frameReady再让 Qt 回发数据Python 这边收一把 heximport serial s serial.Serial(/dev/pts/4, 115200, timeout1) s.write(b\xAA\x55\x02\x11\x22\x33) resp s.read(64) print(resp.hex())验证时重点看两个时机连续快速写入几百条小帧Qt 侧是否丢帧界面频繁点击发送时主线程是否还跟手。前者查解析逻辑后者查是否有串口操作不小心跑回了主线程。5.2 串口烧写失败多半不是波特率的锅很多用 Qt 写上位机的人把 STM32 或 ESP 烧写失败归咎于波特率实际常见原因是 bootloader 窗口期被 open 后的默认 DTR/RTS 状态消耗掉了。芯片上电后要检测特定引脚组合才进入下载模式正确流程是 open 之后按芯片手册先拉 DTR/RTS等 100200ms 再发握手。这个延时放进 worker 线程要用 QTimer::singleShot不能占住事件循环。排查这类问题时我常用的习惯是在 openPort、handleReadyRead、handleBytesWritten 入口各打印一行 QThread::currentThreadId() 和时间戳日志一对照线程有没有迁移错、延时发生在哪一段立刻就能看出来。把线程 id 打日志这条习惯保持住Qt 多线程串口的坑能少踩一半。本文还有配套的精品资源点击获取