Qt 串口通信避坑指南:QSerialPort 的 5 个常见问题

11 阅读6分钟

上个月在公司做一个上位机联调,最离谱的一次是:Qt 这边能发,板子也确实收到;板子回数据,我这边就是一滴都收不到。

我一开始还在怀疑硬件、怀疑 USB 转串口、怀疑线材,搞了半天甚至差点让同事拿示波器……结果最后发现:我把串口对象丢到子线程里了,但 readyRead 相关的事件循环没跑起来

说实话,Qt 的 QSerialPort 用起来不难,但“能跑”和“项目里稳定跑”差别挺大。下面这 5 个坑,基本都是我在项目里真踩过的(而且不止一次)。

0)先给一个我现在最常用的套路:接收缓冲 + 拆包

为什么我先上这个?因为很多人串口一上来就 readAll() 然后直接 QString(data),看起来很顺,一遇到粘包/分包就原地爆炸

我更喜欢把接收分两层:

  • 第一层只负责:把串口来的 bytes 全部塞进 rxBuffer
  • 第二层负责:按协议从 rxBuffer 里“抠”出完整帧

下面这个模板你可以直接抄。

0.1 代码:一个可复用的 SerialSession(含拆包)

为什么这么写:

  • 串口数据到达是“流”,不是“消息”,必须缓冲
  • readyRead 触发时可能只来半包,也可能来好几包
  • 拆包写成函数,后面换协议只改一处
// 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(),于是根本没有事件循环。

为什么会这样:

  • QSerialPortQObject,信号槽依赖事件循环
  • 你把它 moveToThread 了,但线程不跑 event loop,readyRead 这种通知就“没地方发”

1.1 正确姿势:要么主线程用,要么子线程自己跑事件循环

// 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 代码:把参数显式写出来,别靠默认值“赌”

session->open(

    "COM5",

    9600,

    QSerialPort::Data8,

    QSerialPort::EvenParity,

    QSerialPort::OneStop,

    QSerialPort::NoFlowControl

);

结果:

  • 联调时你能一眼对照“协议文档/设备说明书”
  • 不会出现“我以为默认是 NoParity”的玄学问题

3)readAll 读不完整 / 一会儿一会儿的:别把串口当 socket 消息

readyRead 触发时,只代表“现在有数据可读”,不代表“这一帧全到了”。

所以如果你写的是:

auto data = sp.readAll();

process(data); // 直接当一包

那遇到分包就必挂。

3.1 解决方案:永远先 append 到缓冲,再拆包

这就是我在 0)里先给模板的原因。

结果:

  • 粘包/分包都不怕
  • 你可以加超时策略(比如 100ms 内拼不成一帧就丢弃)

4)write() 不是“已经发出去”:写入不全/发太快会踩雷

这个坑经常被忽视:QSerialPort::write() 是异步的,它只是把数据塞进输出缓冲就返回。

当你发得太快,或者对面处理慢,可能出现:

  • write() 返回值 < data.size()(只写进去一部分)
  • 或者你以为发了,结果对面根本没收到完整指令

4.1 代码:发送时至少检查返回值,并等待 bytesWritten

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 我现在的习惯:启动时把可用串口列出来,少靠猜

#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 更新”这一条链路跑通。

如果这篇对你有用,点个赞、收藏一下(真的,串口这玩意你迟早还会回来踩坑)。