ESP32 + Qt 串口通信(一):从协议设计到双向数据链路

Qt 上位机和 ESP32 之间的串口通信,核心不是 API 调用,而是协议设计缓冲区管理


为什么不能直接 readAll 当一包?

先说结论:串口是字节流,不是消息流。

一次 readyRead 信号触发时,readAll() 拿到的数据可能是:

  • 半帧(一帧被操作系统拆成两次送达)
  • 多帧粘在一起(ESP32 连续发了几包,Qt 一次性收到)
  • 甚至半帧 + 一个完整帧 + 另一个半帧
情况 你收到的内容 结果
正常一帧 AA 55 04 42 45 41 54 8C ✅ 正好是一个 BEAT
粘包 AA 55 04 ...8C AA 55 04 ...8C ⚠️ 两帧粘在一起
分包 第一次 AA 55 04 42,第二次 45 41 54 8C ⚠️ 一帧被拆成两次

所以正确做法只有一个:接收缓冲 + 协议拆包。没有例外。


协议设计:一个朴素但够用的二进制帧

在项目早期,我更推荐直接用二进制帧格式,而不是 ASCII + 逗号分隔。原因是:

方案 优点 缺点
ASCII("TEMP:25.3\n" 肉眼可读,调试方便 换行符歧义、无法传二进制、解析效率低
二进制帧 结构明确、可传任意数据、易扩展 CRC 调试需要十六进制工具

本文使用的帧格式:

bash 复制代码
┌──────┬──────┬──────┬───────────┬──────────┐

│ 0xAA │ 0x55 │ len  │ payload   │ checksum │

│ 帧头  │ 帧头  │ 1字节 │ len 字节   │ 1字节     │

└──────┴──────┴──────┴───────────┴──────────┘
  • 帧头 0xAA 0x55:两字节同步字,用于定位帧起始
  • len:payload 长度(0-255)
  • payload:实际数据
  • checksum :payload 所有字节累加 & 0xFF

这不是最完善的协议(生产环境建议用 CRC16),但作为开发阶段的通信骨架完全够用。


ESP32 侧:Arduino 实现

ESP32 上电后做两件事:每 500ms 发一个心跳包(payload = "BEAT"),收到 Qt 的数据包后回一个 ACK。

发包函数

设计意图:将帧头、长度、payload、校验按顺序写入串口。使用 Serial.write() 而不是 Serial.print()------后者会做格式化转换,不适合二进制协议。

bash 复制代码
static uint8_t checksum(const uint8_t* p, uint8_t n) {

    uint16_t sum = 0;

    for (uint8_t i = 0; i < n; ++i) sum += p[i];

    return (uint8_t)(sum & 0xFF);

}



static void sendFrame(const uint8_t* payload, uint8_t len) {

    uint8_t head[3] = {0xAA, 0x55, len};

    Serial.write(head, 3);

    Serial.write(payload, len);

    uint8_t ck = checksum(payload, len);

    Serial.write(&ck, 1);

}

关键点 :绝对不要用 Serial.println() 发二进制数据。它会自动追加 \r\n,导致帧尾多出两个字节,接收端校验必然失败。

收包 + 拆包

设计意图:和 Qt 侧一样的逻辑------先把串口数据 append 到缓冲区,再从缓冲区里按协议提取完整帧。

bash 复制代码
static uint8_t rx[256];

static size_t  rxLen = 0;



static bool tryExtractFrame(uint8_t* outPayload, uint8_t* outLen) {

    // 1. 找帧头 0xAA 0x55

    size_t i = 0;

    while (i + 1 < rxLen) {

        if (rx[i] == 0xAA && rx[i + 1] == 0x55) break;

        i++;

    }

    if (i > 0) { memmove(rx, rx + i, rxLen - i); rxLen -= i; }

    if (rxLen < 3) return false;



    // 2. 检查是否收齐

    uint8_t len = rx[2];

    size_t frameSize = 2 + 1 + len + 1;

    if (rxLen < frameSize) return false;



    // 3. 校验

    const uint8_t* payload = rx + 3;

    if (checksum(payload, len) != rx[3 + len]) {

        memmove(rx, rx + 1, rxLen - 1);

        rxLen -= 1;

        return false;

    }



    memcpy(outPayload, payload, len);

    *outLen = len;

    memmove(rx, rx + frameSize, rxLen - frameSize);

    rxLen -= frameSize;

    return true;

}

主循环

bash 复制代码
void setup() {

    Serial.begin(115200);

    delay(200);

    const char* hello = "ESP32-HELLO";

    sendFrame((const uint8_t*)hello, (uint8_t)strlen(hello));

}



void loop() {

    // 心跳:每 500ms 发 BEAT

    static uint32_t lastBeat = 0;

    if (millis() - lastBeat > 500) {

        lastBeat = millis();

        sendFrame((const uint8_t*)"BEAT", 4);

    }



    // 接收:append 到缓冲

    while (Serial.available() > 0) {

        int c = Serial.read();

        if (c >= 0 && rxLen < sizeof(rx)) rx[rxLen++] = (uint8_t)c;

    }



    // 拆包:收到命令就回 ACK

    uint8_t payload[128];

    uint8_t len = 0;

    if (tryExtractFrame(payload, &len)) {

        uint8_t out[128];

        memcpy(out, "ACK:", 4);

        size_t n = 4 + min((size_t)len, sizeof(out) - 4);

        memcpy(out + 4, payload, n - 4);

        sendFrame(out, (uint8_t)n);

    }

}

烧录后,ESP32 会持续发送 BEAT 心跳,收到任何合法帧都会回复 ACK: + 原始 payload。


Qt 侧:SerialSession 封装

设计思路

把串口通信封装成一个 SerialSession 类,职责单一:

  • 管理串口的打开/关闭
  • 接收数据 → append 缓冲 → 协议拆包 → 发出信号
  • 提供 sendPayload() 方法发送数据帧

UI 层只需要 connect frameReceived 信号即可,不接触任何串口细节。

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(&port_, &QSerialPort::readyRead,

                this, &SerialSession::onReadyRead);

        connect(&port_, &QSerialPort::errorOccurred,

                this, &SerialSession::onError);

    }



    bool open(const QString& name, int baud = 115200) {

        if (port_.isOpen()) port_.close();

        port_.setPortName(name);

        port_.setBaudRate(baud);

        port_.setDataBits(QSerialPort::Data8);

        port_.setParity(QSerialPort::NoParity);

        port_.setStopBits(QSerialPort::OneStop);

        port_.setFlowControl(QSerialPort::NoFlowControl);

        port_.setReadBufferSize(64 * 1024);

        return port_.open(QIODevice::ReadWrite);

    }



    void close() { port_.close(); }

    bool isOpen() const { return port_.isOpen(); }



    void sendPayload(const QByteArray& payload) {

        if (!port_.isOpen()) return;

        QByteArray frame;

        frame.append(char(0xAA));

        frame.append(char(0x55));

        frame.append(char(payload.size() & 0xFF));

        frame.append(payload);

        quint8 sum = 0;

        for (auto c : payload) sum += quint8(c);

        frame.append(char(sum & 0xFF));

        port_.write(frame);

    }



signals:

    void frameReceived(QByteArray payload);

    void error(QString text);



private slots:

    void onReadyRead() {

        buf_.append(port_.readAll());

        extractFrames();

    }



    void onError(QSerialPort::SerialPortError e) {

        if (e != QSerialPort::NoError)

            emit error(port_.errorString());

    }



private:

    void extractFrames() {

        static const QByteArray HEADER("\xAA\x55", 2);

        while (true) {

            int idx = buf_.indexOf(HEADER);

            if (idx < 0) { buf_ = buf_.right(1); return; }

            if (idx > 0) buf_.remove(0, idx);

            if (buf_.size() < 3) return;



            quint8 len = quint8(buf_[2]);

            int frameSize = 2 + 1 + len + 1;

            if (buf_.size() < frameSize) return;



            QByteArray payload = buf_.mid(3, len);

            quint8 sum = 0;

            for (auto c : payload) sum += quint8(c);

            quint8 ck = quint8(buf_[frameSize - 1]);



            buf_.remove(0, frameSize);



            if ((sum & 0xFF) != ck) continue; // 校验失败,丢弃

            emit frameReceived(payload);

        }

    }



    QSerialPort port_;

    QByteArray buf_;

};

关键点

  1. readyRead 只做 append:不要在这个 slot 里做任何解析以外的事情(比如更新 UI)
  2. extractFrames() 用 while 循环 :一次 readAll() 可能包含多帧,必须全部提取
  3. 帧头跨界处理 :当缓冲区没找到帧头时,保留最后 1 字节(防止 0xAA 在上次末尾、0x55 在下次开头的情况)
  4. 校验失败只丢弃当前帧:不清空整个缓冲区,后续数据可能仍然有效

最小 UI 示例

bash 复制代码
// main.cpp

#include <QApplication>

#include <QWidget>

#include <QVBoxLayout>

#include <QComboBox>

#include <QPushButton>

#include <QLineEdit>

#include <QLabel>

#include <QSerialPortInfo>

#include "serial_session.h"



int main(int argc, char* argv[]) {

    QApplication app(argc, argv);

    SerialSession session;



    QWidget w;

    auto* lay = new QVBoxLayout(&w);

    auto* ports = new QComboBox;

    for (auto& info : QSerialPortInfo::availablePorts())

        ports->addItem(info.portName());



    auto* btn = new QPushButton("打开串口");

    auto* input = new QLineEdit;

    input->setPlaceholderText("输入要发送的字符串,如 PING");

    auto* sendBtn = new QPushButton("发送");

    auto* label = new QLabel("等待连接...");



    lay->addWidget(ports);

    lay->addWidget(btn);

    lay->addWidget(input);

    lay->addWidget(sendBtn);

    lay->addWidget(label);



    QObject::connect(&session, &SerialSession::frameReceived,

        [&](const QByteArray& p) {

            label->setText(QString("← %1").arg(QString::fromUtf8(p)));

        });



    QObject::connect(btn, &QPushButton::clicked, [&]() {

        if (!session.isOpen()) {

            if (session.open(ports->currentText())) btn->setText("关闭串口");

            else label->setText("打开失败:检查端口占用或驱动");

        } else {

            session.close();

            btn->setText("打开串口");

        }

    });



    QObject::connect(sendBtn, &QPushButton::clicked, [&]() {

        session.sendPayload(input->text().toUtf8());

    });



    w.resize(400, 240);

    w.show();

    return app.exec();

}

运行后:打开串口即可看到 ESP32 的 BEAT 心跳持续到达;发送 PING,会收到 ACK:PING。链路闭环确认完成。


调试技巧:十六进制输出

用二进制协议调试时,串口监视器显示的"乱码"是正常的------它在尝试把 0xAA 0x55 当 UTF-8 渲染。

在 Qt 侧加一行即可看到原始字节:

bash 复制代码
qDebug() << "rx hex:" << payload.toHex(' ');

// 输出:rx hex: "42 45 41 54"  → 即 ASCII "BEAT"

这比任何串口助手都直观。


常见问题

Q: ESP32 在发数据,但 Qt 完全收不到?

检查三件事:

  1. 波特率是否一致(两端都是 115200)
  2. 串口是否被占用(Arduino IDE 的串口监视器会占用端口)
  3. USB 线是否是数据线(部分 Type-C 线只能充电)

Q: 收到的数据是对的,但偶尔会"丢帧"?

大概率是 Qt 侧 UI 更新太频繁导致事件循环阻塞。把 frameReceived 的处理逻辑保持轻量,或者用 QTimer 做定时刷新而不是每帧都更新 UI。

Q: 想传 float / int16 等数据类型?

直接放进 payload 即可。发送端用 memcpy 写入,接收端用 memcpy 读出。注意两端的字节序要一致(ESP32 和 x86 都是小端,通常没问题)。


下一步

本文建立的是最小通信骨架。下一篇将在此基础上实现 ESP32 采集传感器数据 + Qt 实时曲线显示,包括 QCustomPlot 集成、刷新率控制和断线重连机制。


作者 Felix,工业自动化领域 Qt/C++ 上位机开发,专注嵌入式 + 上位机全栈方案。

相关推荐
用户377515412762 小时前
用 AR 眼镜打造你的办公助手,使用 Unity 开发到 Rokid 部署全记录
后端
小码哥_常2 小时前
Spring Boot文件访问安全:筑牢数据防线,让漏洞无处遁形
后端
初次攀爬者2 小时前
BIO、NIO 和 AIO 基础介绍
后端
小码哥_常2 小时前
告别繁琐try - catch!打造全局异常拦截的魔法城堡
后端
Hoffer_2 小时前
MySQL 强制索引:USE/FORCE INDEX 用法与避坑
后端·mysql
Hoffer_2 小时前
MySQL 索引核心操作:CREATE/DROP/SHOW
后端·mysql
神奇小汤圆2 小时前
拒绝写重复代码,试试这套开源的 SpringBoot 组件,效率翻倍~
后端
哈密瓜的眉毛美2 小时前
零基础学Java|第八篇:面向对象编程的类与对象(基础)
后端
神奇小汤圆2 小时前
架构师手记:彻底终结 Kafka 丢消息与重复消费的“核武器”
后端