简介:面向需要在 Qt Creator 中进行串口通信与数据可视化的开发者,这份源码包提供了一个可直接运行的示例工程,适合学习串口读取、解析与动态波形绘制的基础流程,也适合作为课程设计或个人练手的起点。工程基于 C++/Qt Widgets 编写,覆盖主窗口界面、串口控制逻辑、动态曲线显示等模块;资源共 7 个文件,以 cpp、h、ui、pro 类型为主,ui 文件负责界面布局,pro 文件管理工程配置,ts 文件提供中文翻译支持,结构清晰,便于初学者对照理解。压缩包整体仅 7 KB,体量轻量,下载后即可快速查看分析。目前已有 569 人学习参考,说明该示例对入门者有一定实用价值。通过阅读源码,可掌握串口参数配置、数据读取与实时曲线刷新等关键实现思路,并据此扩展出符合自身需求的波形监控工具。
1. 串口读取值如何变成动态波形:先把全链路跑通再谈优化
在Qt Creator里做串口数据可视化软件,最核心的需求往往就一句话:把传感器或者某台设备串口上报的数值,变成屏幕上实时滚动的动态波形。这类需求在设备调试、数据采集、上位机开发里出现频率极高,而难点几乎不在"打开串口",也不在"画曲线",而在怎么把"串口读取值"到"动态波形显示"这条管线完整地串起来:帧怎么解析、数据怎么缓存、绘图怎么刷新才能不卡顿、波形怎么滚动才不跳变。
我见过太多人卡在同一个地方:串口Demo能跑,数据也能打印,一旦进入界面绘图阶段就开始翻车,波形要么不动,要么乱跳,要么界面拖一下窗口数据就丢了。这篇笔记我把整条链路的写法、参数和踩坑记录都铺开讲,代码以C++和QCustomPlot为主线,适合新手照抄,也适合熟手对照检查自己的架构选型有没有埋雷。
2. QSerialPort读串口与帧解析:从零碎字节到可绘制数值的完整代码
2.1 串口对象放哪个线程:先定架构再写代码
串口接收数据是异步事件驱动的,QSerialPort在数据到达时会发射readyRead信号。很多初学者图省事,在主窗体的构造函数里直接new一个QSerialPort然后连接readyRead到主窗口槽函数,对小数据量、低频率的简单Demo来说这样确实能跑,但如果设备每秒上报几百上千帧,UI线程既要处理绘图又要处理串口事件,协程调度一旦跟不上,数据就会在缓冲区里积压、丢失。
常见做法是给串口单独开一个工作线程,把QSerialPort对象moveToThread到该线程,线程内只负责读串口和帧解析,拿到完整一帧后通过信号把数据丢给界面线程。代码结构如下:
class SerialWorker : public QObject { Q_OBJECT public: explicit SerialWorker(QObject *parent = nullptr); ~SerialWorker(); public slots: void start(); void stop(); signals: void frameReady(const QByteArray &frame); // 完整一帧,交给上层解析 void errorMessage(const QString &msg); private: QSerialPort *m_serial = nullptr; }; SerialWorker::SerialWorker(QObject *parent) : QObject(parent) { m_serial = new QSerialPort(this); } void SerialWorker::start() { m_serial->setPortName("COM3"); m_serial->setBaudRate(115200); m_serial->setDataBits(QSerialPort::Data8); m_serial->setParity(QSerialPort::NoParity); m_serial->setStopBits(QSerialPort::OneStop); m_serial->setFlowControl(QSerialPort::NoFlowControl); if (!m_serial->open(QIODevice::ReadOnly)) { emit errorMessage(m_serial->errorString()); return; } connect(m_serial, &QSerialPort::readyRead, this, &SerialWorker::handleReadyRead); }这里关键点是QSerialPort::open的第一个参数用QIODevice::ReadOnly,因为可视化软件只需要读,不需要写。BaudRate、DataBits这些参数必须和设备的串口参数严格一致,任何一个不匹配都会导致数据全是乱码或完全收不到。实际项目里,端口号、波特率通常不会写死,而是做成下拉框让用户选,启动时把选中的参数传进来。
工作线程的启动方式,我一般这样接:
QThread *serialThread = new QThread(this); SerialWorker *worker = new SerialWorker(); worker->moveToThread(serialThread); connect(serialThread, &QThread::started, worker, &SerialWorker::start); connect(worker, &SerialWorker::frameReady, this, &MainWindow::handleFrame); // 自动用队列连接,跨线程安全 connect(worker, &SerialWorker::errorMessage, this, &MainWindow::showError); serialThread->start();注意,SerialWorker的父对象不能是this,一旦moveToThread前设置了父对象,整个对象仍会留在原线程,这是新手最容易踩的线程迁移坑。线程负责把收到的原始字节流解析成"完整一帧",再以信号方式发送出去,槽函数handleFrame里再做数值转换和绘图。
提示:readyRead信号不一定对应一帧完整数据,可能在一条数据收到一半时就触发,也可能一次性收到多条数据。帧解析必须自己做缓冲和切帧,不能拿到什么就当一帧用。
2.2 打开串口的完整代码和参数含义
打开串口本身不复杂,但参数错误会以很隐蔽的方式影响波形。一个典型的完整打开函数如下,加上了对端口的可用性判断:
bool SerialWorker::openPort(const QString &portName, qint32 baudRate) { if (m_serial->isOpen()) { m_serial->close(); } m_serial->setPortName(portName); m_serial->setBaudRate(baudRate); m_serial->setDataBits(QSerialPort::Data8); m_serial->setParity(QSerialPort::NoParity); m_serial->setStopBits(QSerialPort::OneStop); m_serial->setFlowControl(QSerialPort::NoFlowControl); m_serial->setReadBufferSize(0); // 0 表示不限制缓冲区大小 return m_serial->open(QIODevice::ReadOnly); }setDataBits、setParity、setStopBits、setFlowControl这四个参数要和设备端保持一致。设备端如果配置的是奇校验,你在上位机配成无校验,收到的一定是满屏乱码,这种问题往往查了半天才发现是校验位不对。setReadBufferSize设置为0的含义是不主动限制接收缓冲区,Windows下默认缓冲区可能不够容纳高速串口数据,不限制能减少数据丢失概率,代价是内存占用略高,对可视化软件完全可接受。
打开成功后,不要忘记做一件事:检查当前的端口错误状态,并清空历史遗留数据。
if (!m_serial->open(QIODevice::ReadOnly)) { emit errorMessage(QString("打开串口失败: %1").arg(m_serial->errorString())); return false; } m_serial->clear(QSerialPort::AllDirections); // 清掉短暂断连残留的数据如果不做clear,串口在程序启动前已经积压了一批数据,打开后第一屏波形会出现一段莫名其妙的历史瞬态,看起来像波形上跳了一下才开始正常显示。这个小细节是我实际调试时吃过的暗亏。
2.3 从字节流里切出有效数据帧:粘包与错位处理
串口数据是字节流,没有明确边界。设备端通常会定义自己的帧协议,最常见的是"帧头+数据+校验"定长帧。我处理过的一个传感器模块协议就是:0xAA 0x55作为帧头,第3字节是采集值,第4字节是低字节校验,校验算法是"第3字节与0xA5异或"。
解析代码要有两个能力:一是把被拆成碎片的帧重新拼起来,二是处理帧头错位。下面是完整解析槽函数:
void SerialWorker::handleReadyRead() { m_buffer.append(m_serial->readAll()); // 先把本次收到的字节全部追加进缓冲 while (m_buffer.size() >= 4) { if (static_cast<quint8>(m_buffer.at(0)) == 0xAA && static_cast<quint8>(m_buffer.at(1)) == 0x55) { QByteArray frame = m_buffer.left(4); quint8 checksum = static_cast<quint8>(frame.at(2)) ^ 0xA5; if (checksum == static_cast<quint8>(frame.at(3))) { emit frameReady(frame); // 校验通过,发完整帧 m_buffer.remove(0, 4); } else { // 帧头对了但校验错,可能数据位错乱,丢掉帧头继续找下一帧 m_buffer.remove(0, 1); } } else { m_buffer.remove(0, 1); // 不是帧头,一位一位往后挪 } } }这里用m_buffer保存未消费的字节,用while循环而不是if判断,是因为一次readAll可能收到多帧数据。每次循环先检查帧头,如果帧头不对就remove一个字节继续找,这能容忍数据流中间夹杂的噪声字节。校验失败时只移除帧头而不是整段丢弃,可以防止一帧错位导致后续所有帧都无法对齐。static_cast 的操作很容易被忽略,但非常关键:QByteArray::at返回的是char,在某些平台可能是带符号的,直接和0xAA比较会被符号扩展成负值而永远判断不了相等。这种细节属于"波形为何不动"的隐形元凶,排查时只靠看数据打印非常难发现。
粘包问题指的是一次读取包含了多帧数据,上面的while循环已经天然解决了;反过来"半包"问题指一帧数据只到了一半,循环会因为m_buffer.size()不足4而退出,等下一批字节继续拼装,这也处理了。
3. 动态波形绘制的核心:数据队列、定时刷新与QCustomPlot选型
3.1 为什么选QCustomPlot而不是QChart
Qt自带的Qt Charts模块功能很全面,折线图、面积图、柱状图都有封装,很多人一开始会自然选它。但做实时高频波形刷新时,我更推荐QCustomPlot,原因是Qt Charts的QLineSeries在数据点数量上去之后,每次更新要重新计算坐标变换和绘图项,批量追加500个点以上时会明显掉帧。QCustomPlot底层用QCPDataContainer做数据存储,追加数据和删除过期数据的开销都控制在很小的范围内,并且提供addData和data()->removeBefore这组接口,非常贴合"滚动波形"这个使用场景。
下面这张表是我实际对比后的选型依据:
| 对比维度 | QCustomPlot | Qt Charts(QLineSeries) |
|---|---|---|
| 追加数据接口 | addData + removeBefore,循环利用存储 | append/remove,大数据量开销偏大 |
| 实时坐标轴自适应 | rescaleValueAxis,参数细粒度可控 | 需要配合QDateTimeAxis手动操作 |
| 重绘控制 | replot支持QueuedReplot合并刷新 | 自动刷新策略不易精细控制 |
| 依赖方式 | 第三方库,源码开放,随pro文件引入 | 官方模块,安装Qt时需勾选 |
如果你的数据速率不高,每秒只有10到20个点,用Qt Charts也足够;如果设备每秒上报100点以上,并且你需要保留10秒以上的窗口波形,QCustomPlot会是更稳健的选择。这个项目的动态波形显示场景,我默认按下述QCustomPlot方案展开。
3.2 用FIFO队列缓存数据和追加绘制曲线
界面线程收到帧之后,不能每收到一帧就立刻replot一次。高频刷新下replot本身有CPU开销,而且串口事件不是均匀到达的,前一秒涌来50帧、后一秒空闲200毫秒是常态。常见做法是维护一个数据队列,帧到达时只做入队和坐标转换,界面上的定时器按固定周期把队列中的数据上屏。
void MainWindow::handleFrame(const QByteArray &frame) { quint8 raw = static_cast<quint8>(frame.at(2)); double voltage = raw * 5.0 / 255.0; // 假设设备输出0-5V,8位ADC m_dataQueue.append(QCPData{m_key, voltage}); // 只保留最近10秒的数据点 m_plot->graph(0)->addData(m_key, voltage); m_plot->graph(0)->data()->removeBefore(m_key - 10.0); m_key += 0.02; // 假设设备每隔20ms一帧 }这里用了QCPData结构,它本质上是一个key-value对,key是时间轴位置,value是波形幅值。addData直接向绘图对象追加数据,removeBefore把10秒之前的数据清掉,这样内存中始终只有窗口范围内的点,绘图刷新时不需要遍历历史数据,这是QCustomPlot滚动波形不卡的关键。
电压换算那行是另一个常见的工程易错点。raw是quint8类型,范围0到255,乘5.0再除以255.0得到一个虚拟电压。如果不先转成double类型,整数除法会直接得到0,波形就永远是一条贴在横轴上的直线。这种"有数据但波形全零"的问题,我在调试某图像处理Demo配套的上位机时踩过,排查了整整一个下午,最后发现是数据类型转换的问题。
3.3 定时器刷新与滚动坐标轴的实现
数据入队只是第一步,真正让波形"动"起来的是坐标轴和重绘策略。坐标轴滚动不能每帧都无条件往右移,否则波形会变成快速闪动而不是平滑滚动。常规做法是:只有当最新key超出当前X轴范围上边界时,才把X轴窗口整体平移一小段距离。
QTimer *refreshTimer = new QTimer(this); refreshTimer->setInterval(30); // 30ms刷新一次 connect(refreshTimer, &QTimer::timeout, this, &MainWindow::refreshPlot); refreshTimer->start();void MainWindow::refreshPlot() { m_plot->graph(0)->rescaleValueAxis(false, true); // Y轴自适应,允许缩小 if (m_key > m_plot->xAxis->range().upper) { m_plot->xAxis->setRange(m_key - 10.0, m_key); // X轴窗口整体平移 } m_plot->replot(QCustomPlot::rpQueuedReplot); }rescaleValueAxis的第一个参数false表示不对X轴做自适应,X轴的窗口由我们自己控制,保证波形始终按时间顺序滚动;第二个参数true只对Y轴生效,表示允许Y轴自动缩小范围。如果不加true,Y轴只会放大不会缩小,波形一个尖峰之后,后面所有幅值都会被压缩成一条直线,这是"波形自己乱跳"的经典原因。replot用QueuedReplot模式,多个replot请求在一个事件循环回合内合并成一次真正重绘,减少屏幕刷新次数,对降低CPU占用效果明显。
刷新间隔30ms是个甜点值:人眼看30ms间隔的连续动画比较流畅,同时给了CPU足够时间完成一次重绘。如果间隔太小到10ms以下,replot耗时可能超过定时器周期,队列里的新增数据会越积越多,最终表现为界面越来越卡、波形逐渐跟不上串口数据到来速度。
4. 波形刷新参数怎么调:三个必调参数和一个隐藏开关
4.1 刷新间隔:20ms还是100ms?取决于数据速率
很多人把定时器刷新间隔固定成10ms,以为越快越流畅,实际往往适得其反。刷新间隔应该以"每个重绘周期内新增的数据点数"为参考:设备每20ms来一帧,30ms刷新一次,每次重绘约1.5个新点,波形连续性好且重绘开销低。如果设备还是20ms一帧,你把刷新间隔改成5ms,意味着重绘频率远超数据到达频率,大多数replot在处理重复数据,白白消耗CPU。
如果设备每秒上报1000帧,推荐把刷新间隔调到50ms甚至100ms,每次重绘合并50到100个新点,波形看起来是一次性跳动的密集波纹,但CPU占用会显著低于高频重绘。具体数值没有绝对标准,需要在真机上用任务管理器观察CPU占用和波形流畅度,找到一个"重绘不排队"的临界间隔。我通常先按30ms起步,再逐步调整。
4.2 数据点上限和窗口时长如何配合
波形显示窗口有两个参数要同时考虑:时间窗口长度W(秒)和数据采样率f(点/秒)。窗口内最大点数N=W×f。如果设备20ms上报一次,即f=50点/秒,要显示10秒波形,N≈500点,这个规模下QCustomPlot任意刷新都没压力。如果窗口要拉到1分钟,点数就到了3000点,此时removeBefore的清理工作也会变重,再用30ms刷新间隔可能吃紧。
我一般把窗口时间做成可选项,默认10秒,并同步把刷新间隔和窗口时长联动:窗口拉到60秒时,刷新间隔自动调整为100ms,否则拖动窗口范围时重绘耗时明显上升。QCustomPlot自带的data接口遍历排序是高效的,但并不意味着可以无限堆点。做工程,取舍才是核心。
4.3 QCustomPlot的图形质量开关与隐藏性能项
QCustomPlot默认开了抗锯齿和高精度坐标轴,在实时刷新下这些效果对性能并不友好。一个隐藏但非常有效的性能开关是关闭所有抗锯齿:
m_plot->setNotAntialiasedElements(QCP::aeAll); m_plot->graph(0)->setLineStyle(QCPGraph::lsLine); m_plot->graph(0)->setPen(QPen(QColor(0, 120, 255), 1.5)); m_plot->xAxis->setBasePen(QPen(QColor(80, 80, 80), 1)); m_plot->yAxis->setBasePen(QPen(QColor(80, 80, 80), 1));setNotAntialiasedElements(QCP::aeAll)表示所有元素都不做抗锯齿,曲线边缘会有一点轻微锯齿感,但在高频刷新场景下肉眼几乎分辨不出来,CPU占用却能降下一截。setLineStyle设置画线方式为普通折线,不要用lsStepCenter这样的阶梯模式,阶梯线绘制计算量大,实时波形也没必要。线宽1.5是折中:太细在深色背景下看不清,太粗会增加绘制开销。
还有一个容易被忽略的参数是图表的layer设置。QCustomPlot默认分layer绘制坐标轴、网格、图形,物理重绘时会有一次合成开销。如果不需要复杂的背景分层,可以合并layer减少合成次数:
m_plot->addGraph(); m_plot->graph(0)->setLayer("main"); m_plot->xAxis->grid()->setLayer("main"); m_plot->yAxis->grid()->setLayer("main");实际效果在不同机器上差异明显,低端核显机器上这个优化能把帧率提升一倍。我自己的习惯是先不开任何优化把功能跑通,确认波形正确之后,再逐项打开性能开关,避免一开始就陷入"显示优化"和"数据解析"的混合排错。
5. 串口波形项目常见问题排查:三条血泪翻车记录
5.1 串口已打开但波形不动:首包数据去了哪
现象:点击打开串口后,程序没有报错,任务管理器里能看到串口有数据接收,但波形区域始终是空白。排查时加了一行qDebug打印收到的原始数据,发现打印窗口确实不停有字节输出,但handleFrame函数根本没有被调用。
原因:帧解析里的帧头判断失败。我写过一次0xAA帧头,代码里却拿char型直接与0xFF比较,因为char在有符号系统上是-86,永远不等于170,整个while循环一直在remove(0,1),把所有数据都当噪声丢掉了。这类问题不看原始数据的十六进制值很难意识到。
解决:在handleReadyRead里把收到的原始字节打印成十六进制,先确认设备发的真实字节到底是0xAA 0x55还是0x55 0xAA,再逐字节核对解析逻辑。加上打印后问题立刻就显形了。
qDebug() << "raw bytes:" << bytes.toHex(' ');注意:toHex(' ')带空格分隔的十六进制字符串,比toHex()直接打印连续字符串更好用,因为可以按字节辨认帧头。
5.2 波形满屏后坐标轴定住,新数据直接被画出右边界
现象:波形刚开始显示正常,几秒后曲线走到屏幕最右侧就停住了,新来的数据点全都堆在右边界外侧,窗口里看不到后续波形。
原因:X轴范围被设置成了固定值,比如range(0, 10),当key超过10时,plot对象并不会自动跟着扩大范围,新数据自然就画到了可视区域外面。很多QCustomPlot入门教程会在初始化时固定坐标轴范围,实时滚动场景下不能沿用这个写法。
解决:改成根据最新key动态平移窗口,做法就是前面refreshPlot里的判断逻辑:只有当key超过当前X轴范围上边界时,setRange到m_key-10到m_key。同时要注意,窗口平移会让横轴标签跳动,如果不希望跳得太频繁,可以只在key超过上边界加一个斜率偏移量,比如超过范围的80%就平移20%的窗口宽度,画面节奏更平滑。
5.3 拖拽窗口时串口数据丢失:UI线程阻塞才是根源
现象:程序运行中拖动主窗口或点击菜单时,波形刷新明显停顿,恢复操作后波形出现缺口,串口缓冲区里的数据丢失。严重时甚至会导致设备端报错。
原因:串口对象一开始放在UI线程,UI线程忙于处理鼠标拖动事件,串口事件得不到响应,QSerialPort内部的缓冲区又不及时读取,数据就溢出了。QSerialPort是事件驱动的,它依赖线程事件循环来派发readyRead,UI线程被阻塞时所有等待的数据都会滞留。
解决:把串口对象和工作逻辑整体迁移到独立线程,这是2.1节架构的核心价值。迁移后UI线程只负责从队列里读数据并定时重绘,串口线程只负责读串口发完整帧,两者互不阻塞。拖拽窗口时即便重绘来不及,串口线程依然能及时消费数据,数据缺口就不会出现。这个坑比较隐蔽,我见过有人靠着加大缓冲区绕过一时的问题,但设备数据速率一提高就复发,真正彻底解决还是要靠线程分离。
6. 波形显示正确性验证:从模拟串口到CSV回放的收尾习惯
波形能动了不代表数据是对的。我吃过一次大亏:某跨平台系统的上位机显示波形趋势正常,但和人工记录值对比后发现每到一个特定值附近就偏差几个点,最后查出来是设备端发送低字节在前、高字节在后,而解析代码按高字节在前处理了。真机调试很难快速定位这类逻辑错位,所以我现在做完波形显示一定先做两步验证:模拟数据源和CSV回放。
第一步,写一个内置的模拟数据源,用定时器按真实帧格式发送正弦波或锯齿波数据,不经过串口直接调用handleFrame,这样能隔离出"绘图显示是否正确"这一个问题。
QTimer *simTimer = new QTimer(this); simTimer->setInterval(20); connect(simTimer, &QTimer::timeout, this, [this]() { static int t = 0; double value = 128.0 + 100.0 * sin(t * 0.1); // 模拟ADC输出 QByteArray frame; frame.append(char(0xAA)); frame.append(char(0x55)); frame.append(static_cast<char>(static_cast<quint8>(value))); frame.append(static_cast<char>(static_cast<quint8>(static_cast<quint8>(value) ^ 0xA5))); handleFrame(frame); ++t; });第二步,把实际接收到的串口数据导出为CSV,同时记录设备端的真实物理量,之后用CSV回放模式重跑程序,对比波形曲线和真值是否一致。导出CSV的代码很短,但能长期复用:
QFile csvFile("serial_log.csv"); if (csvFile.open(QIODevice::WriteOnly | QIODevice::Append)) { QTextStream out(&csvFile); out << m_key << "," << m_lastVoltage << "\n"; csvFile.close(); }我更习惯把这一套做成固定习惯:新写一个串口波形软件,先加模拟源验证绘图链路,再开真机收数据,最后留一个CSV回放入口,给后续排查问题提供可复现路径。这个习惯帮我省掉了太多无助的现场排查时刻,也希望帮到你。
# 回放模式示例:程序启动时带 --replay 参数读取CSV ./serial_plotter --replay serial_log.csv回放模式下从CSV逐行读取key和数值,直接调用appendPoint,不进串口线程,这样任何一个复现场景都可以交给别人一键跑出同样的波形。波形显示这件事,做到"能画"只算入门,做到"能验证、能复现"才算真正可以上设备联调。
本文还有配套的精品资源,点击获取