ESP32 + Qt 串口通信(二):实时曲线显示传感器数据(刷新率、缓冲与性能)

5 阅读8分钟

先说结论:实时曲线做不稳,通常不是“图画不出来”,而是 数据进入 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~100Hz20~30Hz先稳住链路
正常监控曲线100~500Hz30~60Hz数据多就做降采样/滑窗
高频振动/波形1kHz+60Hz通常要单独做数据压缩/分段渲染

2. 数据帧:别用 CSV,直接上“可扩展的二进制点帧”

上一期用了 AA 55 | len | payload | checksum 的骨架,这里沿用,只是把 payload 结构定下来。

2.1 payload 结构(1 个数据点)

设计意图:

  • 让每个点都带时间戳(UI 做横轴才不会漂)
  • 支持多通道(温度/压力/电流…)
  • 帧足够短(串口带宽更稳)

payload(小端):

字段类型说明
t_msuint32_t相对时间戳(毫秒)
v0float通道 0(示例:温度/模拟信号)
v1float通道 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 侧偶尔出现一段时间没点,像丢数据?

两类原因最常见:

  1. 串口拆包被打断:payload 长度不匹配、checksum 失败后没有正确重同步
  2. 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++ 上位机开发,专注嵌入式 + 上位机全栈方案。