先说结论:实时曲线做不稳,通常不是“图画不出来”,而是 数据进入 UI 的方式不对 ——你把“采样频率”和“刷新频率”绑死了,最后 UI 卡、丢帧、曲线抖。
这篇只解决一件事:ESP32 持续输出传感器数据,Qt 上位机稳定显示实时曲线。
核心设计拆成 4 层(这是工业上位机里最常见、也最稳的结构):
| 层级 | 职责 | 关键约束 | 我更推荐的做法 |
|---|---|---|---|
| 采样层(ESP32) | 读取传感器 / 生成数据点 | 固定采样周期,别让 printf 破坏节奏 | 定时器/任务驱动,帧里带时间戳 |
| 传输层(串口协议) | 把数据“打包成帧” | 串口是字节流,必须可重同步 | 二进制帧 + length + checksum/CRC |
| 接收层(Qt) | 收到 bytes → 还原成点 | 不能假设一次 readyRead 就是一帧 | 接收缓冲 + 拆包(沿用上一篇模板) |
| 展示层(曲线 UI) | 把点渲染成曲线 | 刷新率要节流,窗口要滑动 | UI 30Hz/60Hz 刷新,数据用环形缓冲 |
1. 画实时曲线,先把两个频率分开
实时系统里有两个“天然不同步”的频率:
- 采样频率:传感器每秒产出多少点(例如 200Hz)
- 刷新频率:GUI 每秒重绘多少次(例如 30Hz 或 60Hz)
把它们绑在一起(比如“每收到一帧就 replot”)通常会得到这三种症状:
- CPU 飙高(replot 太频繁)
- 曲线“断断续续”(UI 线程被串口回调拖慢)
- 偶发卡顿(Qt 的事件循环被占满)
关键问题在于:GUI 重绘是昂贵操作。
我的建议(直接拿去用):
| 项目阶段 | 采样频率 | UI 刷新频率 | 备注 |
|---|---|---|---|
| Demo/验证链路 | 50~100Hz | 20~30Hz | 先稳住链路 |
| 正常监控曲线 | 100~500Hz | 30~60Hz | 数据多就做降采样/滑窗 |
| 高频振动/波形 | 1kHz+ | 60Hz | 通常要单独做数据压缩/分段渲染 |
2. 数据帧:别用 CSV,直接上“可扩展的二进制点帧”
上一期用了 AA 55 | len | payload | checksum 的骨架,这里沿用,只是把 payload 结构定下来。
2.1 payload 结构(1 个数据点)
设计意图:
- 让每个点都带时间戳(UI 做横轴才不会漂)
- 支持多通道(温度/压力/电流…)
- 帧足够短(串口带宽更稳)
payload(小端):
| 字段 | 类型 | 说明 |
|---|---|---|
| t_ms | uint32_t | 相对时间戳(毫秒) |
| v0 | float | 通道 0(示例:温度/模拟信号) |
| v1 | float | 通道 1(示例:另一路传感器) |
总计 12 字节。配合帧头/len/ck,整帧也很小。
2.2 ESP32(Arduino)侧:打包并定频发送
设计意图:
- 用固定周期发送(示例 100Hz)
- 先用“合成波形”模拟传感器,链路跑稳后再替换成真实采样
#include <Arduino.h>
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) {
const uint8_t head[3] = {0xAA, 0x55, len};
Serial.write(head, 3);
Serial.write(payload, len);
const uint8_t ck = checksum(payload, len);
Serial.write(&ck, 1);
}
#pragma pack(push, 1)
typedef struct {
uint32_t t_ms;
float v0;
float v1;
} sample_t;
#pragma pack(pop)
static_assert(sizeof(sample_t) == 12, "sample_t size must be 12");
void setup() {
Serial.begin(115200);
delay(200);
}
void loop() {
static uint32_t last = 0;
const uint32_t now = millis();
// 100Hz
if (now - last >= 10) {
last += 10;
// 用两个不同频率的正弦/余弦模拟两路传感器
const float t = now / 1000.0f;
sample_t s;
s.t_ms = now;
s.v0 = 25.0f + 2.0f * sinf(2.0f * 3.1415926f * 1.0f * t);
s.v1 = 1.0f + 0.2f * cosf(2.0f * 3.1415926f * 0.5f * t);
sendFrame((const uint8_t*)&s, (uint8_t)sizeof(s));
}
}
关键点解释:
- 用
Serial.write,不要用Serial.print/println:二进制帧一旦混入\r\n,接收端就会随机校验失败 last += 10而不是last = now:避免 loop 偶发延迟导致采样节奏“漂移”- 这段代码跑起来后,串口会以 100Hz 输出稳定的数据点帧
3. Qt 接收层:拆包后不要直接喂给曲线,先放进环形缓冲
曲线更新应该是“定时拉取”,而不是“收到就立刻重绘”。
3.1 环形缓冲(线程安全的最小实现)
设计意图:
readyRead里只做两件事:append → 拆包 → 写入缓冲- UI 定时器里读缓冲,批量 addData,然后一次 replot
// ring_buffer.h
#pragma once
#include <QVector>
#include <QMutex>
struct Sample {
double t; // seconds
double v0;
double v1;
};
class RingBuffer {
public:
explicit RingBuffer(int capacity = 50000)
: cap_(capacity) {
buf_.reserve(cap_);
}
void push(const Sample& s) {
QMutexLocker lk(&mu_);
if (buf_.size() < cap_) {
buf_.push_back(s);
} else {
buf_[head_] = s;
head_ = (head_ + 1) % cap_;
full_ = true;
}
}
// 取出当前所有样本(按时间顺序),并清空
QVector<Sample> takeAll() {
QMutexLocker lk(&mu_);
QVector<Sample> out;
if (!full_) {
out = buf_;
} else {
out.reserve(cap_);
for (int i = 0; i < cap_; ++i) {
out.push_back(buf_[(head_ + i) % cap_]);
}
}
buf_.clear();
head_ = 0;
full_ = false;
return out;
}
private:
QMutex mu_;
QVector<Sample> buf_;
int cap_ = 0;
int head_ = 0;
bool full_ = false;
};
关键点解释:
- 这不是最高性能实现,但足够稳、足够清晰
- 真实项目里如果你要 1kHz+,建议改成 lock-free 或者“单生产者单消费者队列”
3.2 SerialSession:复用上一篇的拆包,解析 sample_t
设计意图:
- payload 长度必须是 12 字节,否则丢弃(协议自检)
- 把毫秒时间戳换成秒,适合画图
// sample_protocol.h
#pragma once
#include <QtGlobal>
#include <QByteArray>
#pragma pack(push, 1)
struct sample_t {
quint32 t_ms;
float v0;
float v1;
};
#pragma pack(pop)
static_assert(sizeof(sample_t) == 12, "sample_t size must be 12");
inline bool parseSample(const QByteArray& payload, sample_t& out) {
if (payload.size() != (int)sizeof(sample_t)) return false;
memcpy(&out, payload.constData(), sizeof(sample_t));
return true;
}
你可以在 SerialSession::frameReceived(QByteArray payload) 的槽函数里:
sample_t raw;
if (parseSample(payload, raw)) {
Sample s;
s.t = raw.t_ms / 1000.0;
s.v0 = raw.v0;
s.v1 = raw.v1;
ring.push(s);
}
关键点解释:
- 别在这里 replot:接收回调应该保持短小,否则 readyRead 触发频率一高,UI 就开始抖
4. 展示层:QCustomPlot 实时曲线(滑动窗口 + 节流刷新)
Qt Widgets 下做工业曲线,我通常在这三者里选:
| 方案 | 适用场景 | 优点 | 缺点 | 推荐 |
|---|---|---|---|---|
| Qt Charts | 轻量趋势图 | 官方模块、集成简单 | 高频点容易吃 CPU | ✅ 入门/普通趋势 |
| QCustomPlot | 工业趋势图、交互多 | 控制细、生态成熟、示例多 | 需要手动加入源码/库 | ✅ 我更常用 |
| QtGraphs(Qt6.8+) | 新项目/新渲染栈 | 性能潜力大 | API 还在演进 | ⚠️ 评估后再用 |
下面用 QCustomPlot 给出一套“够稳、够清晰”的实时曲线骨架。
4.1 初始化图表
设计意图:
- 两条曲线,两种颜色
- x 轴用时间(秒),y 轴自适应或固定范围
- 打开抗锯齿要慎重(高频时会贵)
// plot_widget.cpp(片段)
#include "qcustomplot.h"
void initPlot(QCustomPlot* p) {
p->addGraph();
p->graph(0)->setName("v0");
p->graph(0)->setPen(QPen(QColor("#3D8BFF"), 2));
p->addGraph();
p->graph(1)->setName("v1");
p->graph(1)->setPen(QPen(QColor("#E06C75"), 2));
p->xAxis->setLabel("t (s)");
p->yAxis->setLabel("value");
p->legend->setVisible(true);
// 交互(按需开)
p->setInteractions(QCP::iRangeDrag | QCP::iRangeZoom);
}
4.2 定时刷新:一次性批量 addData,然后 replot
设计意图:
- 30Hz 刷新(足够顺滑,CPU 压力小)
- 只保留最近 N 秒窗口(例如 10 秒)
- 用
replot(QCustomPlot::rpQueuedReplot)减少重复重绘
#include <QTimer>
static void appendSamples(QCustomPlot* p, const QVector<Sample>& v) {
if (v.isEmpty()) return;
// 批量喂数据(注意:这里用 QVector 提前装好,避免循环里频繁分配)
QVector<double> x; x.reserve(v.size());
QVector<double> y0; y0.reserve(v.size());
QVector<double> y1; y1.reserve(v.size());
for (const auto& s : v) {
x.push_back(s.t);
y0.push_back(s.v0);
y1.push_back(s.v1);
}
p->graph(0)->addData(x, y0);
p->graph(1)->addData(x, y1);
const double tNow = x.back();
const double winSec = 10.0;
p->xAxis->setRange(tNow, winSec, Qt::AlignRight);
// 丢掉窗口外的数据,避免内存一直涨
p->graph(0)->data()->removeBefore(tNow - winSec - 1.0);
p->graph(1)->data()->removeBefore(tNow - winSec - 1.0);
p->replot(QCustomPlot::rpQueuedReplot);
}
void startPlotLoop(QCustomPlot* plot, RingBuffer* ring) {
auto* timer = new QTimer(plot);
timer->setTimerType(Qt::PreciseTimer);
timer->setInterval(33); // 约 30Hz
QObject::connect(timer, &QTimer::timeout, [=]() {
const QVector<Sample> batch = ring->takeAll();
appendSamples(plot, batch);
});
timer->start();
}
关键点解释:
- 批量 addData:比每点 addData + replot 稳定得多
- 滑动窗口 + removeBefore:否则你跑 2 小时,曲线数据会把内存吃掉
- UI 刷新节流:采样 100~500Hz 并不等于你要 500Hz 重绘
5. 常见问题
Q1:曲线“锯齿/卡顿”,但串口数据看起来没问题?
优先检查:你是不是“每帧都 replot”。
推荐做法:
- 数据接收:只 push 到缓冲
- 曲线刷新:固定 30/60Hz
Q2:Qt 侧偶尔出现一段时间没点,像丢数据?
两类原因最常见:
- 串口拆包被打断:payload 长度不匹配、checksum 失败后没有正确重同步
- UI 线程过载:你在
readyRead槽里做了耗时操作(字符串转换/日志刷屏/频繁 setText)
处理建议:
- 拆包失败时:只丢 1 字节并继续找帧头(上一篇模板就是这个策略)
- 任何 UI 更新都放在定时器里批量做
Q3:如何把横轴改成“真实时间”(例如当前时间)?
不推荐。实时趋势图更适合用“相对时间”(t=0 起)。
如果你必须用时间轴:
- ESP32 帧里带 UTC 时间(需要 NTP/RTC)
- Qt 侧用
QDateTimeAxis(Qt Charts 更合适)
Q4:为什么不用 QThread 把串口放后台?
可以,但先满足两个条件:
- 你理解 Qt 事件循环(
readyRead依赖 event loop) - 你能接受线程间数据传递(queue/信号)
很多“子线程更稳”的认知,其实是把 UI 卡顿从 readyRead 里移走了。 这篇给的“接收短小 + UI 定时拉取”结构,在主线程也能跑得很稳。
下一步
下一篇会把这套实时曲线接到一个更“像产品”的链路上:把 ESP32 当作 AI 硬件节点,让它跑通第一句对话(刷固件、串口日志、最小验证路径)。
作者 Felix,工业自动化领域 Qt/C++ 上位机开发,专注嵌入式 + 上位机全栈方案。