ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于Qt的串口数据实时绘图:QSerialPort与CustomPlot实战

2026/9/17 3:46:42 拓冰建站 浏览量
基于Qt的串口数据实时绘图:QSerialPort与CustomPlot实战 简介面向Qt开发者的串口通信与实时曲线绘图参考工程基于QSerialPort与CustomPlot实现适合需要完成设备数据采集、监控界面开发的初中级Qt程序员。资源共9个文件包含4个cpp、3个头文件、1个pro工程文件及1个ui界面文件压缩包仅255KB结构紧凑便于直接导入Qt Creator查看源码与界面布局。核心代码演示了串口打开、参数配置、readyRead信号接收、write发送的完整流程同时结合CustomPlot将接收数据动态添加到曲线并定时刷新形成一套可运行的收发与可视化示例。目前已有2386人学习下载可作为串口调试助手或简单物联网上位机的改造基础。通过阅读工程可理解QSerialPort事件驱动读写方式、CustomPlot的addData与replot更新机制以及两者在数据采集场景中的协作模式对快速搭建类似功能模块具有直接参考价值。1. 从串口调试助手到实时曲线为什么要用 CustomPlot拿到“Qt串口收发数据CustomPlot实时绘图”这个压缩包名字第一反应是这不就是一个带曲线功能的串口调试助手嘛。但真上手写过的人都知道把串口数据收下来和把数据画成能看的曲线中间隔着解析、缓冲、刷新调度和界面卡顿好几座山。很多工程师用串口助手只看到数字在跳想看传感器波形、PID响应曲线就得自己写上位机。Qt 里收发串口有现成的 QSerialPort 模块绘图库选 CustomPlot 也比 QPainter 手搓高效得多。这篇内容就围绕这个组合展开适合需要快速搭数据采集上位机的嵌入式工程师也适合刚接触 Qt 想拿串口做练手的开发者。下面从串口配置一步步讲到 CustomPlot 的实时刷新边界。2. 串口通信基础与 QSerialPort 的选型2.1 为什么选择 QSerialPort 而不是第三方串口库Qt 5.1 之后 QSerialPort 就进入了核心模块不需要额外编译直接QT serialport就能用。它封装了 Windows 的 COM 口、Linux 的 ttyS/ttyUSB 和 macOS 的 /dev/tty.usbserial底层差异被抹平。对绝大多数场景不需要依赖第三方库如 qextserialport后者老且维护少在 Qt 6 环境还容易有编译问题。QSerialPort 的跨平台能力足够覆盖常用工控机尤其是 ch340、ftdi 这类常见 USB 转串口芯片只要系统驱动装好QSerialPort 拿到的就是一个普通串口设备。选型上还有一个理由QSerialPort 的消息循环是事件驱动的readyRead信号与 Qt 的事件循环天然契合。在接收高频数据时把读数据放在槽函数里不会像轮询那样白占 CPU。后面 CustomPlot 绘图同样需要事件循环来驱动刷新两者正好共用同一个机制。2.2 打开串口的完整配置代码2.2.1 枚举可用串口写一个串口工具第一步是让用户选设备。枚举串口不能靠手写/dev/ttyS0要用 QSerialPortInfo#include QSerialPortInfo #include QSerialPort void ListSerialPorts(QComboBox *combo) { combo-clear(); const auto infos QSerialPortInfo::availablePorts(); for (const QSerialPortInfo info : infos) { QString desc info.portName(); if (!info.description().isEmpty()) { desc ( info.description() ); } // 比如 CH340 的 description 会显示 USB-SERIAL CH340 combo-addItem(desc, info.portName()); } }availablePorts()返回系统当前所有可用串口信息不仅包含端口名还有description()、manufacturer()、serialNumber()。把description显示在界面上方便用户区分 ch340 和 ft232 设备。注意这里用addItem(desc, info.portName())把纯端口名存在 itemData 里打开时用combo-currentData().toString()取避免界面显示干扰解析。2.2.2 打开与配置参数串口打开参数有六个端口名、波特率、数据位、停止位、校验位、流控。常规嵌入式通信多用 8 数据位、1 停止位、无校验、无流控但某些设备会要求偶校验或者 2 停止位所以界面要留出选项。打开流程如下QSerialPort *m_port new QSerialPort(this); void OpenPort(const QString portName, int baud) { if (m_port-isOpen()) { m_port-close(); } m_port-setPortName(portName); m_port-setBaudRate(baud); m_port-setDataBits(QSerialPort::Data8); m_port-setStopBits(QSerialPort::OneStop); m_port-setParity(QSerialPort::NoParity); m_port-setFlowControl(QSerialPort::NoFlowControl); if (!m_port-open(QIODevice::ReadWrite)) { qWarning() open failed: m_port-errorString(); return; } connect(m_port, QSerialPort::readyRead, this, YourClass::OnReadyRead); }setBaudRate在 Qt 5 里可以直接传整数但注意有些串口芯片对非标准波特率如 250000 支持不一致传进去以后最好再调用m_port-requestToSend()或读一下errorString()验证。打开失败通常有几个原因端口被别占用、驱动不工作、设备被拔掉。用QSerialPort::errorOccurred信号在运行时捕获异常而不是只在open()时检查返回值。2.3 收发数据的基本模式发送是主动的直接用write()void SendHexData(const QByteArray data) { if (m_port-isOpen()) { m_port-write(data); } }接收是被动的在readyRead槽里读。注意readAll()返回的是当前缓冲区里所有数据但串口数据是按字节流到达的一次信号可能只到几个字节也可能积压一批。正确姿势是把readAll()的数据追加到一个自定义缓冲区再统一解析而不能假定每次信号都是一个完整帧。这块详细内容见第 4 章。3. CustomPlot 实时绘图的核心实现3.1 CustomPlot 的集成方式CustomPlot 是一个基于 QPainter 的开源绘图库只有一个qcustomplot.h和qcustomplot.cpp文件。不需要安装直接把两个文件加入工程即可。在pro文件里加QT core gui printsupport greaterThan(QT_MAJOR_VERSION, 4): QT widgets CONFIG c11 SOURCES $$PWD/qcustomplot.cpp HEADERS $$PWD/qcustomplot.hprintsupport不可少否则无法编译因为 CustomPlot 里用了QPrinter相关的类。放到界面上的方式是给 QWidget 提升在.ui文件里放一个 QWidget。右键选择“提升为”。提升类名填QCustomPlot头文件填qcustomplot.h。或者在代码里直接new QCustomPlot(this)。建议优先用提升方式因为 CustomPlot 继承自 QWidget可以在 Qt Designer 里调整尺寸、叠放层次。运行时通过布局管理窗口缩放时曲线区域自动跟着变。3.2 创建曲线并初始化坐标轴假设我们要绘制一个浮点变量随时间的波形x 轴是时间秒y 轴是数值。初始化代码void SetupPlot(QCustomPlot *plot) { plot-addGraph(); // 添加一条曲线 plot-graph(0)-setName(Raw Data); plot-xAxis-setLabel(Time (s)); plot-yAxis-setLabel(Value); plot-xAxis-setRange(0, 10); // 默认显示最近 10 秒 plot-yAxis-setRange(-1, 1); // 先粗给一个范围后面自动调 plot-graph(0)-setPen(QPen(Qt::blue)); plot-replot(); }addGraph()创建一条曲线本质上是两个 QVectorQVectordouble x, y。CustomPlot 不自动管理数据每次更新都要把新的数据填充进去然后调用replot()触发重绘。如果你只追加一个点就调用一次replot()每秒 1000 点就会重绘 1000 次界面会卡死。所以常见做法是积累一段时间再刷新或者用定时器固定刷新频率。3.3 追加数据与自动滚动3.3.1 固定窗口滚动最常见的实时曲线是一个可左右滑动的“滚动窗口”只显示最近 N 个点。当数据点超过 N 时前面的点被丢弃。下面是一个典型的实现void AppendPoint(QCustomPlot *plot, double x, double y) { static QVectordouble xs, ys; static const int MAX_POINTS 500; xs.append(x); ys.append(y); // 超出窗口就将最老的数据移除 while (xs.size() MAX_POINTS) { xs.remove(0); ys.remove(0); } plot-graph(0)-setData(xs, ys); plot-xAxis-setRange(xs.first(), xs.last()); // x 轴跟着滚动 plot-replot(); }这里remove(0)是 O(n) 操作500 个点问题不大超过一万点就会明显耗时。优化做法是记录一个起始索引只更新setData的局部区间或者使用环形缓冲区。对于大部分串口应用几百到几千个点足够看清波形。3.3.2 自适应坐标轴y 轴范围如果固定小信号可能被压成一条直线。可以让 y 轴跟随当前数据的最小最大值void AutoScaleY(QCustomPlot *plot) { QCPGraph *g plot-graph(0); if (g-dataCount() 0) return; double minY std::numeric_limitsdouble::max(); double maxY std::numeric_limitsdouble::lowest(); // 遍历数据容器查找最小最大值 QSharedPointerQCPGraphDataContainer data g-data(); for (auto it >QByteArray m_rxBuffer; void OnReadyRead() { m_rxBuffer.append(m_port-readAll()); while (true) { int newlinePos m_rxBuffer.indexOf(\n); if (newlinePos -1) { break; // 没读到完整行等下一批 } QByteArray line m_rxBuffer.left(newlinePos); m_rxBuffer.remove(0, newlinePos 1); ProcessLine(line); } }按行解析适合文本协议比如设备持续输出temperature25.6\n。ProcessLine里做字符串分割和类型转换。如果设备发送的是二进制帧如帧头0xAA 0x55 长度 数据 校验就要按长度字段找完整帧。4.2 按行解析与二进制帧二进制帧解析示例假设帧格式为帧头 2 字节 (0xAA 0x55)1 字节数据长度 LL 字节数据1 字节累加和校验void TryParseBinaryFrame(QByteArray buffer) { while (buffer.size() 4) { if ((unsigned char)buffer[0] ! 0xAA || (unsigned char)buffer[1] ! 0x55) { buffer.remove(0, 1); // 跳过垃圾字节 continue; } quint8 len (quint8)buffer[2]; int frameLen 4 len; // 帧头2 长度1 数据len 校验1 if (buffer.size() frameLen) { break; // 未收完整帧 } quint8 checksum 0; for (int i 0; i len 3; i) { checksum (quint8)buffer[i]; } if (checksum (quint8)buffer[frameLen - 1]) { // 解析数据 QByteArray payload buffer.mid(3, len); EmitFrame(payload); buffer.remove(0, frameLen); } else { buffer.remove(0, 1); // 校验失败从下一个字节重新找帧头 } } }这种循环解析模式要求ProcessLine或者帧处理函数尽量快不要在槽函数里做耗时 UI 操作。如果解析结果要显示到 QTableWidget 或者写入数据库用信号发出去让另一个槽去处理。4.3 用缓冲队列平滑数据有时候设备发送频率比绘图刷新高很多比如串口每 5ms 来一个数据点而 CustomPlot 每 50ms 才刷新一次。如果每来一个点都直接改图形数据并 replot就会白白多做十倍重绘。常见做法是用一个“待绘图队列”或者双缓冲。QVectordouble m_pendingX, m_pendingY; QMutex m_mutex; // 如果串口槽和绘图定时器在不同线程 void OnFrameReceived(double x, double y) { QMutexLocker locker(m_mutex); m_pendingX.append(x); m_pendingY.append(y); } void OnTimerReplot() { QMutexLocker locker(m_mutex); if (m_pendingX.isEmpty()) return; QVectordouble newX m_pendingX; QVectordouble newY m_pendingY; m_pendingX.clear(); m_pendingY.clear(); locker.unlock(); // 把新数据追加到 CustomPlot 的 graph 中 QVectordouble oldX plot-graph(0)-data()-keys(); // 这里简单处理直接合并后 setData // 严格做法是使用 QCPGraphDataContainer 的 add 方法 QVectordouble allX oldX newX; QVectordouble allY; plot-graph(0)-setData(allX, allY); }如果你只有一个线程可以不加锁但串口事件和定时器事件都在主线程实际上串口数据处理已经占了一部分主线程时间。更稳妥的方案是把串口读数据放到 QThread 子线程里只用信号把帧传回主线程。不过对于一般每秒几百个点的数据主线程内处理绰绰有余。5. 进阶调试刷新率控制与常见坑5.1 用定时器控制刷新率而非事件驱动在上一章中我们已经提前把缓冲机制引入了这里再补一个关键点CustomPlot 的replot()必须和串口接收频率解耦。最理想的刷新率是 30~60 FPS即每隔 16~33ms 刷一次。用 QTimer 做固定间隔刷新QTimer *plotTimer new QTimer(this); plotTimer-setInterval(33); // 约 30 FPS connect(plotTimer, QTimer::timeout, this, YourClass::UpdatePlot); plotTimer-start();UpdatePlot中把缓冲区里的新数据合并到 graph然后调用replot()。注意不要用QCustomPlot::setInteractions的QCP::iRangeDrag和iRangeZoom同时开启否则用户拖拽坐标轴时实时滚动逻辑和用户的手动范围设置会产生冲突。一个折中是拖动结束后自动切换回跟随模式或者只在暂停绘图的模式下允许手动拖拽。5.2 串口热插拔与错误恢复嵌入式开发中设备重启、USB 线松脱会让串口一句话不说就断开。QSerialPort 会发出errorOccurred信号常见枚举值是ResourceError。处理方式connect(m_port, QSerialPort::errorOccurred, this, [this](QSerialPort::SerialPortError err){ if (err QSerialPort::ResourceError) { qWarning() device unplugged; if (m_port-isOpen()) { m_port-close(); } // 策略通知 UI 显示断开等待用户重连 // 也可以内部延时 500ms 重新尝试 open。 } });自动重连不要无限循环比如连续失败 5 次就停止防止设备故障时程序死循环。重连成功后要重新连接readyRead信号并清空旧缓冲区和绘图数据。5.3 发布时不要忘记串口驱动与库文件Windows 下用 windeployqt 打包时Qt5SerialPort.dll和Qt5Core.dll会自动拷过去。但使用 CH340、FTDI 这类 USB 转串口芯片的目标机器如果没装驱动QSerialPort 枚举不到任何端口。发布包内要附带驱动安装说明或静默安装包。另外 CustomPlot 的头文件版本差异较大1.x 和 2.x 的 API 不完全一样代码中用的data()指针是 2.x 风格如果拿到的压缩包是老版本要先确认qcustomplot.h头部的版本号再对应调整setData的参数类型。最后一个实用技巧在ProcessLine中如果解析出非数值数据直接丢弃并在状态栏计数不要弹 QMessageBox否则弹窗阻塞事件循环串口缓冲会越积越多。把错误计数显示在状态栏调试完再决定要不要处理异常帧。本文还有配套的精品资源点击获取