简介: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 构造之后执行一次 qRegisterMetaType<SerialFrame>("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()); }参数按常用的 8N1(8 数据位、无校验、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,等重新插拔后再 open |
| FramingError | 波特率设置错误,线路干扰大 | 核对设备实际波特率,换短线 |
| 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/COM4;Linux 一行命令就够:
socat -d -d pty,raw,echo=0 pty,raw,echo=0 # 输出类似 /dev/pts/3 和 /dev/pts/4,两端互通把 Qt 程序打开 /dev/pts/3,另一端用 Python 模拟设备:写入一段带帧头的原始数据,确认 Qt 解析出 frameReady;再让 Qt 回发数据,Python 这边收一把 hex:
import serial s = serial.Serial('/dev/pts/4', 115200, timeout=1) 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,等 100~200ms 再发握手。这个延时放进 worker 线程要用 QTimer::singleShot,不能占住事件循环。排查这类问题时我常用的习惯是:在 openPort、handleReadyRead、handleBytesWritten 入口各打印一行 QThread::currentThreadId() 和时间戳,日志一对照,线程有没有迁移错、延时发生在哪一段立刻就能看出来。把线程 id 打日志这条习惯保持住,Qt 多线程串口的坑能少踩一半。
本文还有配套的精品资源,点击获取