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_;
};
关键点
readyRead只做 append:不要在这个 slot 里做任何解析以外的事情(比如更新 UI)extractFrames()用 while 循环 :一次readAll()可能包含多帧,必须全部提取- 帧头跨界处理 :当缓冲区没找到帧头时,保留最后 1 字节(防止
0xAA在上次末尾、0x55在下次开头的情况) - 校验失败只丢弃当前帧:不清空整个缓冲区,后续数据可能仍然有效
最小 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 完全收不到?
检查三件事:
- 波特率是否一致(两端都是 115200)
- 串口是否被占用(Arduino IDE 的串口监视器会占用端口)
- USB 线是否是数据线(部分 Type-C 线只能充电)
Q: 收到的数据是对的,但偶尔会"丢帧"?
大概率是 Qt 侧 UI 更新太频繁导致事件循环阻塞。把 frameReceived 的处理逻辑保持轻量,或者用 QTimer 做定时刷新而不是每帧都更新 UI。
Q: 想传 float / int16 等数据类型?
直接放进 payload 即可。发送端用 memcpy 写入,接收端用 memcpy 读出。注意两端的字节序要一致(ESP32 和 x86 都是小端,通常没问题)。
下一步
本文建立的是最小通信骨架。下一篇将在此基础上实现 ESP32 采集传感器数据 + Qt 实时曲线显示,包括 QCustomPlot 集成、刷新率控制和断线重连机制。
作者 Felix,工业自动化领域 Qt/C++ 上位机开发,专注嵌入式 + 上位机全栈方案。