简介:这是一份基于Qt框架实现的轻量级示波器波形显示Demo,面向嵌入式开发、信号处理及GUI编程初学者与中级开发者,解决实时波形动态绘制、缩放平移交互及历史状态恢复等核心可视化问题。资源包共8个文件(282KB),含3个cpp源文件(主窗口逻辑、绘图控制与主程序入口)、2个头文件(界面定义与QCustomPlot图表封装)、1个.ui界面文件、1个.pro工程配置及1个.user用户设置文件,结构清晰,便于理解Qt信号槽驱动数据刷新、坐标系变换实现缩放/平移、以及操作历史栈管理“找回”功能的技术路径。已有662人学习下载,提供可直接编译运行的完整工程,包含QCustomPlot集成示例、实时数据模拟机制与基础交互响应代码,是掌握Qt跨平台波形可视化开发的实用入门参考。 最近手里有个小项目,需要把ADC采集的数据实时画出来看波形,一开始用的是串口把数据导到PC,再丢给Python画图,来回折腾效率太低。后来干脆用Qt写了个简易示波器,也就是这个plottest_QT_示波器项目。它能通过串口实时读取数据、滚动绘制波形、带电压和时间的粗略测量,虽然比不上商用示波器的精度,但日常调试传感器、看PWM信号、观察电源上电时序完全够用。这篇文章就把我整个实现过程、几个关键模块的设计思路和踩过的坑都整理出来,给想用Qt做数据采集和波形显示的朋友做个参考。
1. 项目初衷与整体设计思路
1.1 为什么用Qt做示波器
说实话,一开始我也纠结过是用上位机配合Python还是纯C++写。Python的matplotlib虽然画图方便,但实时性不行,刷新一快界面就卡,而且打包分发环境很麻烦。Qt这边有成熟的QSerialPort串口类、QChart图表库,C++的性能也足够支撑高频数据刷新。
另外还有一个重要的点:Qt的跨平台特性。我在Windows上调试,但板子那边的采集程序是跑在Linux上的,以后如果想把示波器界面直接跑在配了触摸屏的Linux板卡上,Qt这套代码基本不用动。这也是为什么项目名叫plottest_QT_示波器——plottest就是"plot test",绘图测试,本质上是用Qt验证实时绘图和数据采集的可行性。
1.2 核心功能模块划分
整个项目我按功能拆成了四个模块,每个模块各管一摊,方便后续扩展。
- 数据采集模块:基于QSerialPort实现,负责从串口读取下位机发来的波形数据,解析成double类型的数值。
- 数据处理模块:负责把原始数据放入环形缓冲区,做滑动窗口切割和数值校准,比如把ADC原始值转换成实际电压值。
- 波形绘制模块:基于QChart或者自定义QPainter绘制,负责把缓冲区里的数据画成波形,支持暂停、缩放、滚动。
- 交互控制模块:负责按钮、旋钮、参数输入框这些UI交互,比如波特率设置、触发模式选择、电压档位切换。
模块之间通过信号槽通信,采集模块收到新数据就发一个信号,绘图模块收到信号就刷新波形。这样串口速率再快,也不会因为UI卡顿导致数据丢失。
1.3 技术选型考量
这里重点说我为什么用QChart而不是QPainter直接画。
QChart是Qt Charts模块提供的现成图表库,有坐标轴、网格线、图例这些现成组件,缩放和平移也自带了,省去很多底层绘图的烦琐工作。但它的性能在数据量大的时候不太好,实测QChart在波形数据点超过5万时,刷新率会明显下降。
QPainter直画则更灵活,性能上限高,但所有东西都要自己实现:坐标轴刻度计算、网格线绘制、波形缩放、触发标记,工作量翻倍。
我的方案是折中:先用QChart快速搭出可用版本,如果后面遇到性能瓶颈,再用QPainter替换绘制核心。目前项目实测串口1M波特率、每秒约2000个采样点,QChart完全扛得住,所以暂时没有切到QPainter。
2. 环境搭建与基础框架实现
2.1 Qt开发环境的选择与配置
这个项目我用的版本是Qt 5.15.2 + Qt Creator 4.13,编译器选的MSVC 2017 64位。为什么不用最新的Qt 6?这里有几个现实原因。
第一,Qt 5.15.2是LTS版本,稳定性好,网上资料多,出问题容易搜到解决方案。第二,QSerialPort和Qt Charts这两个模块在Qt 5.15里都是独立封装好的,直接加模块就行。第三,项目的目标平台既有Windows又有Linux,Qt 5.15两边的兼容性都验证过很多次。
下载安装时有个坑要注意:Qt官方在线安装包如果不加镜像,大概率会卡在下载进度条上。我用的清华镜像,具体操作是在安装程序启动时添加命令行参数:
qt-unified-windows-x64-online.exe --mirror https://mirrors.tuna.tsinghua.edu.cn/qt/装完之后在Qt Creator里确认组件勾选情况:Qt 5.15.2下的MSVC 2017 64-bit、Qt Charts、Qt SerialPort这几个模块务必选上,否则后面工程文件里加QT += charts serialport时会编译不通过。
2.2 项目结构组织
项目文件名就叫plottest_QT_示波器,工程文件组织如下:
plottest_QT_示波器/ ├── plottest_QT_示波器.pro ├── main.cpp ├── mainwindow.h / mainwindow.cpp ├── datacapture.h / datacapture.cpp ├── databuffer.h / databuffer.cpp ├── waveformview.h / waveformview.cpp └── resources/ ├── style.qss └── icons/.pro文件里除了基础配置,还要加上模块声明:
QT += core gui charts serialport greaterThan(QT_MAJOR_VERSION, 4): QT += widgets CONFIG += c++11 SOURCES += \ main.cpp \ mainwindow.cpp \ datacapture.cpp \ databuffer.cpp \ waveformview.cpp HEADERS += \ mainwindow.h \ datacapture.h \ databuffer.h \ waveformview.h这样拆的目的很明确:datacapture只管串口收发,databuffer只管数据缓存和格式转换,waveformview只负责绘图,mainwindow做界面集成。模块间接口用信号槽串联,任何一个模块出问题,单独调试都不受牵连。
2.3 主窗口UI布局设计
主窗口布局我用了QMainWindow加QWidget组合的方式。左侧放参数控制区,右侧是波形显示区,底部是状态栏。
左侧控制区包含这些控件:
- 串口选择下拉框(QComboBox)
- 波特率选择(QComboBox,预置9600到921600)
- 连接/断开按钮(QPushButton)
- 启动/停止采集按钮
- 电压档位选择(QComboBox:±1V、±2V、±5V、±10V)
- 时基选择(QComboBox:10ms/div到1s/div)
- 触发电平输入框(QDoubleSpinBox)
- 暂停显示按钮
波形显示区我用一个QChartView作为主轴,同时叠了一个QLabel用来显示光标读数。之所以用叠层而不是把读数放在状态栏,是因为调试时视线焦点要始终在波形区域,读数放在波形上比放在角落要直观得多。
界面初始化时我加了一点QSS美化,深色背景更适合长时间盯着波形看,而且波形显示区用黑色背景、绿色波形,对比度更清晰,这也是大多数商用示波器的配色习惯。
3. 核心细节解析与实操要点
3.1 串口数据采集模块的完整实现
串口模块是数据链路的第一环,也是最容易出问题的地方。我的实现里,QSerialPort的配置包括端口名、波特率、数据位、停止位、校验位,其中端口名和波特率留给用户在界面上选,其余参数固定为8-N-1。
关键代码是数据接收的异步处理。我利用QSerialPort的readyRead信号,每次收到数据就触发读取,而不是用定时器去轮询。这样CPU占用率低,响应也快。
void DataCapture::onReadyRead() { QByteArray data = serialPort->readAll(); // 追加到内部解析缓冲区 parseBuffer.append(data); // 尝试解析出完整的数据帧 extractFrames(); }这里有一个非常关键的坑,也是很多初学者容易踩的:串口数据是流式的,不会保证一次readAll就拿到一帧完整数据。下位机可能发送的是"帧头 + 数据长度 + 数据体 + 校验"的封装格式,而上位机每次收到的可能是半帧、一帧半甚至多帧粘在一起。如果直接按固定长度去解析,肯定会出问题。
我的处理方式是维护一个内部解析缓冲区:
void DataCapture::extractFrames() { // 寻找帧头 0xAA 0x55 while (true) { int startIndex = parseBuffer.indexOf(QByteArray::fromHex("AA55")); if (startIndex < 0) { // 没找到帧头,丢弃多余数据 parseBuffer.clear(); return; } if (startIndex > 0) { // 帧头之前的数据是无效数据,丢弃 parseBuffer.remove(0, startIndex); } if (parseBuffer.size() < FRAME_LENGTH) { // 缓冲区数据不足一帧,等待下个readyRead return; } // 校验帧头,提取ADC数据 QByteArray frame = parseBuffer.left(FRAME_LENGTH); parseBuffer.remove(0, FRAME_LENGTH); bool ok = verifyChecksum(frame); if (ok) { int adcValue = extractAdcValue(frame); emit newSampleReady(adcValue); } } }帧格式我是这样约定的:帧头2字节(0xAA 0x55),数据长度1字节,ADC高位1字节,ADC低位1字节,校验和1字节(前面所有字节累加取低8位)。这样一帧总共6字节。下位机按这个格式发,上位机按这个格式解析,就能把ADC值稳定地传上来。
3.2 数据缓冲与实时波形绘制核心逻辑
数据采集解析的频率很高,如果每收到一个数据点就刷新一次波形,界面会疯狂重绘,CPU占用率飙升。我的做法是利用环形缓冲区加定时刷新机制。
环形缓冲区用QVector实现,固定大小例如20000个点,存满后覆盖最老的数据:
void DataBuffer::push(double value) { buffer[writeIndex] = value; writeIndex = (writeIndex + 1) % bufferSize; count++; }绘图时通过一个QTimer定时器,每30ms触发一次,从缓冲区读取数据并刷新波形。这样既保证了波形足够实时,又不会因为高频刷新导致UI卡顿。
电压值的转换也是在这个模块完成的:下位机发送的是12位ADC原始值,范围0到4095,参考电压3.3V,所以电压值 = adcValue * 3.3 / 4095。再根据用户选择的电压档位做等比缩放,决定纵轴显示范围。
这一点必须提醒:参考电压必须要准确。我用的是板载TL431基准源,实测3.294V,而不是理论上的3.3V,直接按3.294算,否则电压测量会有一个固定偏差,看波形趋势无所谓,但要想做精度测量就得配个万用表实测校准。
绘制方面,QChart的数据更新用的是QLineSeries的replace()方法,而不是append()。append会反复触发重绘和坐标轴重算,性能差很多。replace是整体替换数据点,坐标轴范围可以保持稳定,刷新速度高一个量级。这个是我实测下来最明显的性能优化点:
void WaveformView::updateWaveform(const QVector<double>& points) { // 使用replace代替append,避免坐标轴反复重算 series->replace(points); chart->axisX()->setRange(0, points.size() - 1); chart->axisY()->setRange(minVoltage, maxVoltage); }时基功能(时间轴跨度)是通过滑动窗口实现的。比如时基调成20ms/div,窗口宽度是10格,那么窗口就是200ms。根据采样率计算出窗口内应该显示多少个点,然后从缓冲区中截取对应区间进行绘制。
3.3 波形显示、缩放与测量功能实现
波形显示增强功能我做了三件事:滚动显示、拖动查看历史数据、光标测量。
滚动显示是默认模式,随着新数据进来,波形像医院心电监护仪一样从右往左移动。实现上就是根据当前窗口内最后一个数据点的时间戳,计算出窗口的起始位置,不断右移窗口终点。
触发功能我用了一个简单的上升沿触发:触发电平设置好之后,在数据中搜索第一个超过触发电平的点,然后把该点放到波形窗口的左端作为触发参考点。这样波形看起来"稳定"了,不会满屏乱飘。
int WaveformView::findTriggerPoint(const QVector<double>& data, double triggerLevel) { for (int i = 1; i < data.size(); ++i) { if (data[i - 1] < triggerLevel && data[i] >= triggerLevel) { return i; } } return -1; }触发模式我只实现了自动触发和正常触发两种。自动触发是找不到触发点就自由滚动,正常触发是先暂停等待触发条件成立。手动测量方面,我加了一个光标模式,点击波形上的两个位置,软件会自动计算这两点的电压差和时间差,显示在波形上方。这个功能用来测量信号周期和峰峰值特别方便。
4. 常见问题与排查技巧实录
4.1 串口通信丢包、粘包、校验失败问题
这几个问题基本是串口类上位机最容易踩的坑。我调试的时候前后折腾了两天,最后总结出三个最核心的原因。
第一个原因是缓冲区开得太小。QSerialPort读取时如果没有及时把数据取走,数据会积压在系统串口缓冲区里,超了就会丢。解决办法是把串口接收缓冲区调大,同时确保readyRead信号触发后立刻readAll,不要做多余的处理再读。
serialPort->setReadBufferSize(64 * 1024);第二个原因是下位机发送间隔不稳。有些下位机用的是MCU,串口发送时因为中断优先级的问题,两帧之间的间隔会抖动,上位机如果超时判断设得太短,就会把一帧数据拆成两次接收,拆包逻辑又没处理好,就丢了。解决方案是给解析加状态机,而不是固定时间片。
第三个原因最隐蔽:校验算法不一致。下位机用CRC16,上位机用累加和,两边约定的时候没对齐,结果所有数据都解析失败。所以强烈建议在通信协议文档里把校验方式写得清清楚楚,测试时先用一个固定数据包做联调,确认校验一致了再开始全速跑。
4.2 波形绘制卡顿与性能优化
波形绘制卡顿,本质上就是数据量和绘制频率不匹配。遇到过几次,逐一排查下来,除了前面提到的replace代替append,还有几个关键点。
第一个是QChart坐标轴智能重算。默认情况下QChart可能会根据数据自动调整坐标轴范围,但滚动波形时我们并不想让它自动调整,否则波形会被来回拉伸。显式使用chart->axisY()->setRange()固定住纵轴范围,就能显著减少重绘计算量。
第二个是定时器的刷新频率。QTimer刷新间隔设置低于16ms没有意义,因为显示器的刷新率一般也就是60Hz,即使能画到100fps,肉眼也看不出区别,反而白白消耗CPU。我的经验是30ms到50ms比较合适,波形实时感和CPU占用率能取得平衡。
第三个是硬件加速。如果屏幕分辨率很高,可以尝试在QChartView上启用OpenGL加速:
chartView->setRenderHint(QPainter::Antialiasing, true); chartView->setRenderHint(QPainter::HighQualityAntialiasing, false);Antialiasing打开会让波形边缘平滑,但数据量大时也费性能。实测在2万点、30fps刷新的场景下,关闭抗锯齿能带来30%以上的性能提升。所以我会在性能模式中关掉它,在暂停查看模式下再打开。
4.3 Qt开发过程中的高频报错与编译问题
这里整理几个我反复遇到的编译和运行时报错,新手看到会一脸懵,其实处理起来都有套路。
QT unknown module multimedia这个错特别常见。原因是.pro文件里加了QT += multimedia,但安装Qt时根本没有勾选multimedia模块。解决办法有两个:要么在Qt安装管理器里补装对应模块,要么把.pro里的multimedia模块去掉。如果你只是做串口示波器,multimedia模块根本用不到,去掉最省事。
还有cannot find -lSerialPort类似的链接错误,这个多半是因为工程文件缺少QT += serialport,或者安装Qt时漏了SerialPort模块。安装Qt时务必在组件列表里勾选Qt SerialPort。
至于Qt崩溃问题,最常见的原因是在lambda表达式里捕获了栈上的局部变量,然后这个局部变量已经析构了,后续在信号槽触发时访问了野指针。我的排查方法非常简单粗暴:编译时开启AddressSanitizer看看报错的调用栈,或者检查所有connect的lambda捕获列表,把捕获方式改为[this]并且在类成员里持有关键对象,而不是依赖局部临时变量。
还有一个老生常谈的坑:用Qt Creator编译时,如果出现了奇怪的报错,先看看构建套件(Kit)是不是选对了。你代码复制过来,套件用的还是别人的,编译器路径不对,当然编不过。切到正确的MSVC或MinGW套件后,往往一下子就通了。
5. 后续扩展思路与个人体会
目前这个plottest_QT_示波器主干功能已经稳定,日常调试够用了。但如果想做得更完善,有几个方向可以考虑。
一个是增加数据存储功能,把采集到的波形数据导出成CSV文件。这个实现上就是在DataBuffer模块里加一个导出函数,把缓冲区数据按时间戳和数值写进文件就行。做完之后配合Python做FFT频谱分析或者长时间的漂移监测,就非常方便了。
另一个方向是增加FFT频谱显示。Qt Charts没有现成的频谱图组件,需要自己把时域数据做FFT变换,再绘制频谱曲线。QT下可以用FFTW库或者自己实现一个简化版基2FFT,配合现有的绘图模块,就能做出一个简易的频谱分析功能。
还有一个我最近正在琢磨的扩展点:通过Qt UDP网络模块把波形数据推送到局域网内的网页端。这样不需要在电脑前也能远程观察设备状态。搜到的open-meteo等API场景给了我一个思路——把采集端的网络协议做成类似REST风格,网页端或者小程序端拉取数据渲染波形。
最后说说我个人的一点体会。
用Qt做示波器这类工具软件,前期的模块划分比写代码更重要。如果一开始就把串口解析、数据处理、波形绘制全部堆在一个类里,后面想加功能或者修bug都会非常痛苦。我这个项目虽然叫plottest,看起来只是个绘图测试,但正是这种把基础模块理清楚的小项目,让我后面再做复杂的数据采集系统时,直接复用代码,省了大量时间。
另外一点就是,不要迷信参数和理论值。实际调试中,参考电压、波特率偏差、数据校验方式这些细节,都要以实测为准。花点时间做校准和协议验证,远比后来发现问题再返工来得划算。
如果你也在做类似的数据采集或者波形显示项目,希望这篇文章能帮你少踩几个坑。有更好的想法欢迎一起交流。
本文还有配套的精品资源,点击获取