news 2026/9/9 2:43:09

Qt实时曲线图实战:QCustomPlot+kissfft串口数据可视化与性能调优

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Qt实时曲线图实战:QCustomPlot+kissfft串口数据可视化与性能调优

简介:面向QT与C++开发者的曲线图制作实战资源,适合需要掌握QT绘图机制、模型/视图架构及自定义图形项的中级开发者。资源围绕曲线图完整实现展开,覆盖QPainter绘图、QGraphicsView/Scene场景搭建、QGraphicsPathItem与QPainterPath路径构建,以及线性插值、贝塞尔/样条插值和平滑算法等关键点,并引入鼠标键盘事件处理以支持缩放拖拽交互,代码按数据处理、曲线绘制与事件逻辑拆分,便于维护和二次扩展。压缩包共261个文件,其中以240个idx索引/缓存文件为主,另有cpp、h、ui、pro等工程源码,obj、makefile、pdb与debug/release配置等编译辅助文件,整体仅1.81MB,结构紧凑。已有689人学习下载,适合在真实工程中对照练习QT图形绘制与交互设计。 直接开篇说结论:在Qt项目里做实时曲线图,如果还在用QPainter一个个画点、或者费劲折腾QChart的性能问题,这篇文章就是给你看的。我从一个实际的数据采集项目出发,完整走了一遍"串口取数 -> 时域波形绘制 -> FFT变换到频域谱 -> 性能调优 -> windeployqt打包分发"的全流程,把QCustomPlot和kissfft怎么配合、采样率怎么定、为什么你会遇到"绘制卡顿""频谱对不上""打包后闪退"这些坑,一次说清楚。

1. 方案选型:为什么是QCustomPlot而不是QChart

先说结论:如果项目里只需要二维曲线、波形、柱状图这些常规图表,QCustomPlot几乎是最省心的选择。我在做这个数据采集项目之前,也试过QChart,它作为Qt官方图表模块,功能确实全面,但在"实时高频刷新"这个场景下表现并不理想。

1.1 两者在实时绘制场景的真实差距

QChart底层走的是Qt Charts图形场景架构,每一次append新数据点都会触发场景重绘,当数据量积累到几千个点、每秒刷新30帧以上时,CPU占用会明显上升,UI线程时不时掉帧。而QCustomPlot直接操作QCPGraph的数据容器,配合QCPAxis的数据范围更新,用replot()做局部重绘,性能要好一截。

我实测的一个参考数据:在Release模式下,向图表里追加10000个数据点并每秒刷新20次,QCustomPlot在我那台i5-9400F的机器上CPU占用约12%到18%,QChart在同场景下能到30%以上,而且拖动缩放时明显感觉粘滞。

1.2 QCustomPlot的许可证和集成成本

有人担心QCustomPlot的License问题,其实它采用MIT许可证,商用完全没问题。集成方式也极简:只需要把qcustomplot.hqcustomplot.cpp两个文件加进工程,配置好C++11标准即可。相比起装QChart模块还要处理.pro文件里QT += charts的依赖关系和版本兼容,QCustomPlot几乎没有集成门槛。

注意:QCustomPlot不是纯头文件库,它是需要编译的。所以项目里如果要release给客户,记得把QCustomPlot相关代码一起编进exe或dll,别漏了。

1.3 老版本和Qt版本兼容性问题

我项目里用的是Qt 5.15.2 + MSVC2019组合,QCustomPlot的官方Release版本基本都能直接用。但如果你在用MinGW编译,建议从官网下载匹配的编译版本,或者直接用源码重新编译一次,否则偶尔会遇到__imp___ZN...这类符号找不到的链接错误,本质就是编译链不一致导致的。

2. 数据从哪来:串口采集与一帧数据的解析流程

曲线图要想"有数据可画",第一步是把数据源打通。我这边的项目是通过串口从下位机读取传感器波形数据,这一节就完整说下串口部分的设计逻辑。

2.1 串口通信的基础配置

使用QSerialPort类,需要先了解设备的波特率、数据位、停止位、校验位。下位机上报的协议是典型的二进制帧格式:

QSerialPort serial; serial.setPortName("COM3"); serial.setBaudRate(QSerialPort::BaudRate::Baud115200); serial.setDataBits(QSerialPort::DataBits::Data8); serial.setStopBits(QSerialPort::StopBits::OneStop); serial.setParity(QSerialPort::Parity::NoParity); serial.setFlowControl(QSerialPort::FlowControl::NoFlowControl); if (!serial.open(QIODevice::ReadWrite)) { qDebug() << "串口打开失败:" << serial.errorString(); return false; } connect(&serial, &QSerialPort::readyRead, this, &DataReceiver::onReadyRead);

这里有几个容易踩的坑:

  • 如果下位机波特率不标准(比如19200、256000),QSerialPort里没有对应枚举值,需要写成serial.setBaudRate(19200);它会自动转为qint32处理。
  • 打开串口后要做错误处理,比如串口被其他程序占用时会返回PermissionDeniedError
  • Windows下串口号要带COM前缀,Linux下是/dev/ttyUSB0/dev/ttyS0这种绝对路径。

2.2 拆帧与粘包处理的实现思路

串口数据是字节流,不存在"一次readyRead就是完整一帧"的说法。我的做法是维护一个QByteArray buffer,每次收到数据就append进去,然后按帧头、帧长度做循环解析。

比如协议格式是:

帧头 0xAA 0x55 | 数据长度(2字节) | 通道号(1字节) | 采样点数据(N个uint16) | CRC(2字节) | 帧尾 0x0D 0x0A

代码如下:

void DataReceiver::onReadyRead() { buffer.append(serial.readAll()); while (buffer.size() >= 6) { // 至少帧头+长度+帧尾 if (isFrameHeader(buffer)) { int frameLen = getFrameLength(buffer); // 解析完整帧长度 if (buffer.size() < frameLen) { break; // 数据不完整,等下一次readyRead } QByteArray frame = buffer.left(frameLen); buffer.remove(0, frameLen); handleFrame(frame); } else { buffer.remove(0, 1); // 丢垃圾字节,找回帧头 } } }

2.3 为什么我建议把解析和绘制拆成两个线程

串口数据解析、FFT计算、界面刷新如果全塞在主线程里,UI容易卡。实际项目中我是这样设计的:

  • 串口线程:负责读串口、拆帧、解析得到double数组。
  • 工作线程:负责做FFT、数学计算。
  • 主线程:定时器每20ms到50ms从缓冲队列里取最新数据更新曲线。

用Qt信号槽连接时,queued connection会自动跨线程投递,不用手动加锁(前提是别在线程里直接操作QCustomPlot对象,UI对象必须在主线程创建和访问)。

3. 时域波形图与频域图:从漫无目的画图到数据有意义

曲线图到底要呈现什么?我项目里直接复用同一套数据,同时做两幅图:一幅显示时域波形(横轴时间、纵轴幅值),另一幅显示FFT频域谱(横轴频率、纵轴能量)。这也是热搜词里"qt时域图转换为频域图"想解决的事情。

3.1 时域图核心操作:QCustomPlot的绑定与刷新

初始化时域图:

// 在初始化函数里 customPlot->addGraph(); // 添加一条曲线 customPlot->graph(0)->setPen(QPen(QColor(0, 120, 255))); customPlot->xAxis->setLabel(QStringLiteral("时间 (ms)")); customPlot->yAxis->setLabel(QStringLiteral("幅值")); customPlot->xAxis->setRange(0, 1000); // 显示最近1000ms的数据窗口 customPlot->yAxis->setRange(-2048, 2048); // 根据传感器量程

数据更新时,我采用的是"滚动窗口"模式,即只保留最近N个点:

void RealTimePlot::updateWaveform(const QVector<double>& sampleData) { // 每来一批数据,追加到时序序列尾部,并移除头部旧数据 timeData.append(sampleData); if (timeData.size() > maxPoints) { timeData.remove(0, timeData.size() - maxPoints); } QVector<double> keys(timeData.size()); for (int i = 0; i < timeData.size(); ++i) { keys[i] = i * sampleIntervalMs; // 采样间隔计算时间轴 } customPlot->graph(0)->setData(keys, timeData); customPlot->xAxis->setRange(keys.first(), keys.last()); customPlot->replot(QCustomPlot::rpQueuedReplot); }

有一个优化点值得提一下:replot(QCustomPlot::rpQueuedReplot)会把重绘请求排队,避免在极短时间内大量重绘导致CPU空转。如果追求极限性能,可以再用QTimer定时器合并刷新,比如20ms刷一次,效果更平滑。

3.2 为什么不能直接把时域数据扔给FFT:kissfft的输入输出规则

FFT的输入是复数数组,但对实数信号来说,虚部填0即可。接着需要确定窗口大小——FFT点数通常是2的幂次,如512、1024、2048。

我项目里用的是kissfft这个轻量级FFT库。它只有一个头文件和一个源文件,集成方式和QCustomPlot一样简单,不依赖额外库,很适合嵌入式桌面项目。

// 初始化kissfft int nfft = 1024; kiss_fft_cfg cfg = kiss_fft_alloc(nfft, 0, nullptr, nullptr); std::vector<kiss_fft_cpx> fin(nfft); std::vector<kiss_fft_cpx> fout(nfft); // 填充时域数据:取最近nfft个采样点,加窗函数 for (int i = 0; i < nfft; ++i) { int idx = timeData.size() - nfft + i; if (idx >= 0) { double windowed = timeData[idx] * hannWindow[i]; fin[i].r = windowed; fin[i].i = 0.0; } } kiss_fft(cfg, fin.data(), fout.data()); // 计算幅值谱 for (int i = 0; i < nfft / 2; ++i) { double mag = sqrt(fout[i].r * fout[i].r + fout[i].i * fout[i].i) * 2.0 / nfft; freqData.append(mag); }

这里有个新手非常容易犯的错:直接用timeData的最后N个点去做FFT,但实际采集频率不稳定,导致频谱泄漏和栅栏效应。我通常先做一个采样率估计,再结合窗函数(汉宁窗)进行平滑,绘制出来的频域谱才准确。

3.3 频率轴的计算与物理意义

频率轴每个bin对应的实际频率是:

freqResolution = sampleRate / nfft

假设采样率是1000Hznfft=1024,则频率分辨率为约0.9766Hz,第i个点的频率为i * freqResolution。所以频率轴代码:

QVector<double> freqAxis(nfft / 2); double freqRes = sampleRate / nfft; for (int i = 0; i < nfft / 2; ++i) { freqAxis[i] = i * freqRes; }

如果你发现频谱的峰值位置和信号实际频率对不上,先检查freqRes算得对不对,再检查采样率是否准确。我调试时遇到过下位机实际是998Hz而代码里写死1000Hz,峰值偏了大概2Hz,肉眼看不出来,但导致后续幅度谱分析的阈值判断偏差了。

3.4 加窗函数到底加不加

做FFT时,如果直接截断信号,会造成频谱泄漏,导致一个单频信号的能量泄露到多个频点,表现为主峰旁边出现裙边。汉宁窗是最常用的通用窗:

std::vector<double> hannWindow(nfft); for (int i = 0; i < nfft; ++i) { hannWindow[i] = 0.5 * (1.0 - cos(2.0 * M_PI * i / (nfft - 1))); }

加了窗之后,频率分辨率会有轻微下降,但幅值准确度更高、旁瓣更低。如果项目关注的是幅值检测,用汉宁窗;如果更关注频率分辨率,可以用矩形窗(不加窗),但要接受频谱泄漏的风险。

4. 性能调优:从拖动卡顿到丝滑刷新的三条关键路径

实时曲线最怕的就是"越画越卡"。我调优过程中验证了三条路径,按性价比排序分别是:批量更新数据、限制绘制点数量、开启OpenGL加速。

4.1 用setData批量替换,而不是addData逐点追加

QCPGraphaddData接口虽然方便,但每次调用都会维护内部数据结构。当点的数量达到万级,逐点addData的性能会极速下降。正确姿势是:

// 一次性传送整个数组,而不是循环调用addData customPlot->graph(0)->setData(keys, values); customPlot->rescaleAxes(false); customPlot->replot();

setData直接整体替换数据容器,内部会做一次高效的内存搬运,比逐点追加至少快一个数量级。

4.2 限制可视区域的数据点数

绘制2万个点时,很多点其实已经重叠到看不清了。为了视觉清晰度和渲染性能,我设置一个可视窗口内最多显示1万个点,超过就做降采样。最简单的降采样方式就是平均抽样:

int step = std::ceil(double(totalSize) / maxDisplayPoints); QVector<double> downSampledKeys, downSampledValues; for (int i = 0; i < totalSize; i += step) { downSampledKeys.append(keys[i]); downSampledValues.append(values[i]); }

这在观感上相当于对数据做了一次"箱式滤波",对于波形轮廓显示完全足够。

4.3 OpenGL加速和抗锯齿的取舍

QCustomPlot从2.0版本开始支持OpenGL绘制。启用方式是:

customPlot->setOpenGl(true);

启用之后,画高密度曲线的FPS有明显提升,但也要注意两个副作用:

  • OpenGL在高DPI缩放下文字渲染可能模糊,需要额外设置。
  • 部分驱动不兼容,会出现黑屏或闪烁,需要在程序里做fallback。

我自己的实测:不开OpenGL,绘制12000个点、每秒刷新30次时,CPU约为25%;开启OpenGL后降到8%左右。但如果功能机上显卡驱动老旧,开了反而崩溃。稳妥做法是做一个设置项,默认开,出问题时允许关闭。

时刻记住一个原则:优先减少绘制数据量,其次再考虑硬件加速。数据量降到几千个点以后,开不开OpenGL差别没那么大,维护成本却能省很多。

5. 打包发布:windeployqt的正确用法与常见崩溃排查

项目做完了,最后要交付成一个可以双击运行的exe。这一节讲的是我打包时踩过的三个真实坑,每一件都让人印象深刻。

5.1 windeployqt基本步骤

在Qt安装目录的对应工具链bin目录下运行:

cd /path/to/your/build/release /path/to/Qt/5.15.2/msvc2019/bin/windeployqt.exe MyApp.exe

它会自动复制Qt运行库、platform插件、编译链相关的dll到exe同目录。比如Qt5Core.dllQt5Gui.dllQt5Widgets.dll,以及关键的platforms/qwindows.dll

注意:不要在Debug目录用windeployqt部署,会有Debug版本的dll依赖,分发后表现异常甚至崩溃。

5.2 "Windows no Qt platform plugin could be initialized" 错误的真实原因

很多人部署完,双击exe直接弹出这个错误,然后应用闪退。这句话的意思是Qt没找到platforms/qwindows.dll

有几个常见原因:

  • platforms目录放错位置:它必须在exe同级目录下、名为platforms的文件夹内。有些时候windeployqtOut-of-Date,不会自动创建,就需要手动检查。
  • 路径含中文:某些Qt版本对非ASCII路径支持不完善,把项目文件放在纯英文路径下能避免问题。
  • VC运行库缺失:MSVC编译的Qt程序依赖vcruntime140.dllmsvcp140.dll等,目标机器上没装VC++ Redistributable也会出现类似崩溃。

验证方式很简单:在exe同目录下打开cmd,运行set PATH=你的Qt\bin;%PATH%再启动exe。如果这样能起来,说明是缺dll或插件路径问题,按上面逐条排查。

5.3 打包后无法读取相对路径资源

很多曲线图程序会把配置文件、主题样式放在exe同目录下。打包后如果直接双击exe通常没事,但如果通过快捷方式启动,工作目录可能不在exe目录,导致相对路径失效。

我统一用一套工具函数获取exe真实路径:

QString getAppDir() { return QCoreApplication::applicationDirPath(); } QString getResourcePath(const QString& fileName) { return QDir(getAppDir()).filePath(fileName); }

所有读配置、读图标、保存日志的操作,必须基于applicationDirPath()拼出来的绝对路径,千万别依赖QDir::current()

5.4 调试崩溃的终极大法:用Qt Creator的Release构建再跑一遍

如果发布的exe崩溃但在Qt Creator里却正常,通常是因为调试和发布的环境差异。我的经验是:在Qt Creator里切到Release构建模式,直接运行MyApp.exe,配合qDebug输出,能很快定位是缺少dll,还是代码中有未定义行为。

另外,如果代码里用了assert或某些依赖Debug环境下才产生的行为,Release下被优化掉后行为会变,这类问题往往只能靠日志逐步缩小范围。

6. 进阶思考:多个通道、波形缩放和坐标轴联动

做完基础的曲线图之后,还可以往这几个方向加功能,让项目更完整。这些都是我在实际项目里反复打磨过的点。

6.1 多通道数据同图显示

传感器可能不止一路输出,代码里只需为每个通道创建独立的QCPGraph

QCPGraph* graph1 = customPlot->addGraph(); QCPGraph* graph2 = customPlot->addGraph(); graph1->setPen(QPen(QColor(220, 60, 60))); graph2->setPen(QPen(QColor(60, 160, 60)));

分别setData后统一replot()。图例用QCPLegend展示:

customPlot->legend->setVisible(true); graph1->setName(QStringLiteral("通道1")); graph2->setName(QStringLiteral("通道2"));

6.2 鼠标框选缩放

QCustomPlotsetInteractions里启用iRangeDrag | iRangeZoom之后,就可以天然支持鼠标拖拽平移和滚轮缩放,这个特性对查看波形细节非常管用。

customPlot->setInteractions(QCP::iRangeDrag | QCP::iRangeZoom);

如果你的需求是"框选某个区间放大查看",就需要用QCPSelectionRect或自己实现一个RubberBand。我最终采用的是双图联动方案:上方大图显示全貌,下方小图显示缩放范围,鼠标拖动下方小图的选区和上方大图的显示范围同步变化。

6.3 坐标轴游标和数值读取

要做一个跟随鼠标移动读取曲线的数值功能,可以通过QCPGraph::coordsToPixelsQCustomPlot::pixelsToCoords完成坐标换算:

// 在主窗体的mouseMove事件里 double xVal = customPlot->xAxis->pixelToCoord(event->pos().x()); double yVal = customPlot->yAxis->pixelToCoord(event->pos().y()); QCPGraphDataContainter::const_iterator it = graph->data()->findBegin(xVal); // 通过迭代器找到最近的采样点并显示其坐标

这套实现不难,但对用户体验提升非常明显,调试数据时能"点到哪读到哪",比对着图表肉眼看准得多。

7. 踩坑记录:我花了两天才解决的三个问题

最后分享三个我在"曲线图制作"过程中花了大时间排查的问题,每个都是真实经历,希望对你有帮助。

7.1 问题一:绘制的曲线像"锯齿"而不是光滑曲线

现象:数据点没问题,但波形显示出来后棱角分明,特别是放大后很丑。原因:没有启用抗锯齿,也没有设置点的线宽。解决

customPlot->graph(0)->setAntialiasedFill(false); customPlot->graph(0)->setAntialiased(true); customPlot->graph(0)->setLineStyle(QCPGraph::lsLine); QPen pen; pen.setWidthF(1.5); customPlot->graph(0)->setPen(pen);

另外检查是不是绘制了点形状(setScatterStyle),默认散射点也会让曲线显得"碎"。

7.2 问题二:频域图幅度和理论值对不上

现象:一个幅度1.0的正弦波,FFT后主峰幅度却不是1.0,偏大或偏小。原因:没有正确归一化,也没有处理窗函数带来的幅度衰减。汉宁窗会让主峰幅度降低约一半。解决:做窗函数补偿。计算幅值时,除以窗系数平均值coherent gain(汉宁窗约为0.5),即:

double amplitude = (fout[i].r * fout[i].r + fout[i].i * fout[i].i); double magnitude = std::sqrt(amplitude) * 2.0 / (nfft * coherentGain);

7.3 问题三:程序运行1小时后内存持续上涨

现象:长时间运行,占用内存越来越高。原因:曲线数据无限增长,timeData一直append,没有清理旧数据。解决:给timeData设一个最大长度,超过就移除头部。再配合定时器主动setData,确保内存恒定。我项目里最大点数设置的是10万,单个double是8字节,算下来才800KB一个通道,完全可控。

if (timeData.size() > maxPoints) { timeData.remove(0, timeData.size() - maxPoints); }

如果还想更省,可以在每个通道内部单独维护历史数据,而不是把所有通道数据混在一个大向量里。

根据我个人的项目经验,Qt曲线图这块内容想要做扎实,简化后的技术栈就是:QCustomPlot画图、kissfft做频域转换、QSerialPort取实时数据、windeployqt打包分发。把这四样吃透,绝大多数桌面端数据可视化的需求你都能搞定。真要说一个最重要的心得,其实就一句话:先保证数据质量,再优化绘图性能。数据不对时,图再炫也白搭。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 2:41:12

Qt桌面项目架构实战:从MVC到MVVM的演进与模块划分

接手过一个别人留下的 Qt 桌面项目。MainWindow.cpp 六千多行&#xff0c;按钮的槽函数里直接写数据库查询&#xff0c;UI 线程上跑网络请求&#xff0c;一个 QTableWidget 塞进去几万行数据&#xff0c;拖动滚动条都能感觉到明显的迟滞。代码不是不能跑&#xff0c;而是没人敢…

作者头像 李华
网站建设 2026/9/9 2:40:46

51单片机贪食蛇游戏机设计与实现:从硬件到代码全解析

简介&#xff1a;这是一份基于89C52单片机的贪食蛇游戏机完整设计&#xff0c;面向51单片机初学者、嵌入式课程设计以及电子制作爱好者&#xff0c;可帮助从零搭建一个多功能交互娱乐项目。项目使用清翔MCS51开发板&#xff0c;代码结构清晰&#xff0c;并提供上位机软件&#…

作者头像 李华
网站建设 2026/9/9 2:39:33

电子专业毕业3个月实操补强,从零基础到拿下硬件工程师offer

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 2:38:27

Node.js + Express + MongoDB 实战:从环境搭建到排错全指南

1. 为什么这个组合&#xff1a;Node、Express 与 MongoDB 的“铁三角”定位1.1 从一次给新人带路说起多年来我一直在做后端开发&#xff0c;接触过不少技术栈。每次有新人问我“后端开发需要学什么”&#xff0c;我通常不会直接甩一张几百行的学习路线图&#xff0c;而是让他先…

作者头像 李华
网站建设 2026/9/9 2:37:39

桥梁路面病害检测实战:从数据标定到YOLOv8训练全流程解析

简介&#xff1a;面向计算机视觉目标检测研究与桥梁养护场景&#xff0c;这份数据集专门用于识别跨江桥梁路面病害和道路资产&#xff0c;解决通用目标检测模型对桥墩、拉索、桥面等桥梁结构元素不敏感、难以直接落地的问题&#xff0c;适合研究者、算法工程师以及基础设施管理…

作者头像 李华
网站建设 2026/9/9 2:37:31

净水器选购指南:超滤、RO反渗透与前置过滤器全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华