上个月在公司做一个上位机联调,最离谱的一次是:Qt 这边能发,板子也确实收到;板子回数据,我这边就是一滴都收不到。
我一开始还在怀疑硬件、怀疑 USB 转串口、怀疑线材,搞了半天甚至差点让同事拿示波器......结果最后发现:我把串口对象丢到子线程里了,但 readyRead 相关的事件循环没跑起来。
说实话,Qt 的 QSerialPort 用起来不难,但"能跑"和"项目里稳定跑"差别挺大。下面这 5 个坑,基本都是我在项目里真踩过的(而且不止一次)。
0)先给一个我现在最常用的套路:接收缓冲 + 拆包
为什么我先上这个?因为很多人串口一上来就 readAll() 然后直接 QString(data),看起来很顺,一遇到粘包/分包就原地爆炸。
我更喜欢把接收分两层:
- 第一层只负责:把串口来的 bytes 全部塞进
rxBuffer - 第二层负责:按协议从
rxBuffer里"抠"出完整帧
下面这个模板你可以直接抄。
0.1 代码:一个可复用的 SerialSession(含拆包)
为什么这么写:
- 串口数据到达是"流",不是"消息",必须缓冲
readyRead触发时可能只来半包,也可能来好几包- 拆包写成函数,后面换协议只改一处
bash
// serial_session.h
#pragma once
#include <QObject>
#include <QSerialPort>
#include <QByteArray>
class SerialSession : public QObject {
Q_OBJECT
public:
explicit SerialSession(QObject* parent = nullptr)
: QObject(parent) {
connect(&sp_, &QSerialPort::readyRead,
this, &SerialSession::onReadyRead);
connect(&sp_, &QSerialPort::errorOccurred,
this, &SerialSession::onError);
}
bool open(const QString& portName,
int baud = 115200,
QSerialPort::DataBits dataBits = QSerialPort::Data8,
QSerialPort::Parity parity = QSerialPort::NoParity,
QSerialPort::StopBits stopBits = QSerialPort::OneStop,
QSerialPort::FlowControl flow = QSerialPort::NoFlowControl) {
if (sp_.isOpen()) sp_.close();
sp_.setPortName(portName);
sp_.setBaudRate(baud);
sp_.setDataBits(dataBits);
sp_.setParity(parity);
sp_.setStopBits(stopBits);
sp_.setFlowControl(flow);
// 读缓冲不要太小,避免频繁触发;也别太大导致延迟(按场景调)
sp_.setReadBufferSize(64 * 1024);
const bool ok = sp_.open(QIODevice::ReadWrite);
return ok;
}
void close() { sp_.close(); }
signals:
void frameReceived(QByteArray frame);
void log(QString text);
private slots:
void onReadyRead() {
rxBuffer_.append(sp_.readAll());
tryExtractFrames();
}
void onError(QSerialPort::SerialPortError e) {
if (e == QSerialPort::NoError) return;
emit log(QString("serial error: %1").arg(sp_.errorString()));
}
private:
// 示例协议:AA 55 | len(1) | payload(len) | checksum(1)
// checksum = (payload 累加和) & 0xFF(很土,但够演示)
void tryExtractFrames() {
while (true) {
// 找帧头
const QByteArray header("\xAA\x55", 2);
int idx = rxBuffer_.indexOf(header);
if (idx < 0) {
// 没找到,保留最后 1 字节,防止帧头跨界
if (rxBuffer_.size() > 1) rxBuffer_ = rxBuffer_.right(1);
return;
}
if (idx > 0) rxBuffer_.remove(0, idx); // 丢弃垃圾数据
if (rxBuffer_.size() < 2 + 1) return; // header + len
const quint8 len = static_cast<quint8>(rxBuffer_[2]);
const int frameSize = 2 + 1 + len + 1;
if (rxBuffer_.size() < frameSize) return; // 半包,等下次
const QByteArray frame = rxBuffer_.left(frameSize);
rxBuffer_.remove(0, frameSize);
const QByteArray payload = frame.mid(3, len);
quint8 sum = 0;
for (auto c : payload) sum += static_cast<quint8>(c);
const quint8 ck = static_cast<quint8>(frame.back());
if ((sum & 0xFF) != ck) {
emit log("checksum mismatch, drop 1 byte and resync");
// 简单粗暴重同步:丢 1 个字节再找帧头
if (!rxBuffer_.isEmpty()) rxBuffer_.remove(0, 1);
continue;
}
emit frameReceived(frame);
}
}
QSerialPort sp_;
QByteArray rxBuffer_;
};
写完会得到什么结果:
- 你不再纠结"为啥这次 readAll 只有 7 个字节"
- 协议层能稳定拿到完整帧(或者明确知道校验失败)
- 出问题时你能打印
rxBuffer_,定位速度快很多
下面 5 个坑,基本都能用这个模板兜住一半。
1)readyRead 不触发:你以为是串口坏了,其实是线程/事件循环
我踩过最蠢的一次:把 QSerialPort new 在子线程里,但线程里没有 exec(),于是根本没有事件循环。
为什么会这样:
QSerialPort是QObject,信号槽依赖事件循环- 你把它 moveToThread 了,但线程不跑 event loop,
readyRead这种通知就"没地方发"
1.1 正确姿势:要么主线程用,要么子线程自己跑事件循环
bash
// worker_thread.cpp(示意)
QThread* t = new QThread;
auto* session = new SerialSession;
session->moveToThread(t);
connect(t, &QThread::started, [=](){
session->open("COM3", 115200);
});
connect(t, &QThread::finished, session, &QObject::deleteLater);
t->start();
// 关键:不要自己写一个 while(true) 的阻塞循环把线程卡死
// QThread 默认 run() 会调用 exec(),所以 started 后 event loop 是存在的
结果:
readyRead会稳定触发- UI 不会卡死
⚠️ 踩坑提示:如果你在子线程里写了一个"自旋 while(true) { ... }",那你等于亲手把事件循环掐死了。
2)串口能打开但收不到:参数不匹配(尤其是校验位/停止位)
这类问题最折腾:open() 返回 true,你还以为万事大吉,结果对面发了半天你一个字节都没有。
我个人的经验:
- 波特率大家都记得配
- 校验位(Parity)/停止位(StopBits) 很容易忽略
- 工业设备里
EvenParity/TwoStop还挺常见
2.1 代码:把参数显式写出来,别靠默认值"赌"
bash
session->open(
"COM5",
9600,
QSerialPort::Data8,
QSerialPort::EvenParity,
QSerialPort::OneStop,
QSerialPort::NoFlowControl
);
结果:
- 联调时你能一眼对照"协议文档/设备说明书"
- 不会出现"我以为默认是 NoParity"的玄学问题
3)readAll 读不完整 / 一会儿一会儿的:别把串口当 socket 消息
readyRead 触发时,只代表"现在有数据可读",不代表"这一帧全到了"。
所以如果你写的是:
bash
auto data = sp.readAll();
process(data); // 直接当一包
那遇到分包就必挂。
3.1 解决方案:永远先 append 到缓冲,再拆包
这就是我在 0)里先给模板的原因。
结果:
- 粘包/分包都不怕
- 你可以加超时策略(比如 100ms 内拼不成一帧就丢弃)
4)write() 不是"已经发出去":写入不全/发太快会踩雷
这个坑经常被忽视:QSerialPort::write() 是异步的,它只是把数据塞进输出缓冲就返回。
当你发得太快,或者对面处理慢,可能出现:
write()返回值 < data.size()(只写进去一部分)- 或者你以为发了,结果对面根本没收到完整指令
4.1 代码:发送时至少检查返回值,并等待 bytesWritten
bash
QByteArray cmd = ...;
qint64 n = sp.write(cmd);
if (n < 0) {
qWarning() << "write failed:" << sp.errorString();
} else if (n < cmd.size()) {
qWarning() << "partial write:" << n << "/" << cmd.size();
}
// 简单粗暴:阻塞等一下(不建议在 UI 线程长时间用)
if (!sp.waitForBytesWritten(50)) {
qWarning() << "waitForBytesWritten timeout";
}
结果:
- 你至少能知道问题在"没写进去"还是"对面不回"
我个人更喜欢的做法:用一个发送队列 + bytesWritten 事件驱动(更丝滑),但那得写一套状态机,篇幅就超了,后面有机会单开一篇。
5)端口被占用/权限问题:最常见,但最容易被忽略
你以为你代码写错了,实际上是:
- Windows:串口被串口助手占着(你忘了关)
- Linux:
/dev/ttyUSB0权限不够 - 设备管理器里端口号变了(插到另一个 USB 口,COM 号飘了)
5.1 我现在的习惯:启动时把可用串口列出来,少靠猜
bash
#include <QSerialPortInfo>
for (const auto& info : QSerialPortInfo::availablePorts()) {
qDebug() << info.portName()
<< info.description()
<< info.manufacturer();
}
结果:
- 现场联调速度会快很多
- 你能一眼看出"是不是插上了""是不是被系统识别了"
⚠️ 真实踩雷:我曾经把串口助手开着就跑程序,跑了 20 分钟一直以为是协议问题......后来同事一句"你串口助手关了吗?"给我整沉默了。
小结(这篇你只要记住这几条)
readyRead不触发,先别骂 Qt:检查线程/事件循环有没有跑- 串口参数别靠默认值赌,尤其是 parity/stop bits
- 串口是"字节流",永远:append 缓冲 → 拆包
write()不等于"发出去了",至少检查返回值/必要时等 bytesWritten- 端口占用/权限/COM 号飘了,这些低级问题反而最耗时间
下一篇我准备写《ESP32 + Qt 串口通信(一):让上位机和单片机对话》------把上面的拆包模板直接用在 ESP32 上,顺便把"发指令→回包→UI 更新"这一条链路跑通。
如果这篇对你有用,点个赞、收藏一下(真的,串口这玩意你迟早还会回来踩坑)。