PyQt结合pyqtgraph实时绘制单片机传感器波形
针对单片机传感器高速数据采集场景,详细讲解如何使用 PyQt 与 pyqtgraph 构建高帧率、低 CPU 占用的实时波形上位机,包含多线程解耦、数据缓冲、定时刷新及完整工程代码。
实时显示单片机(如 STM32、ESP32、Arduino 等)采集的加速度计、陀螺仪、ADC 采样电压或生物电信号,是嵌入式开发与算法验证的核心需求。常规方案若直接采用 Matplotlib 嵌入 Qt,在刷新率超过 20Hz 时容易出现界面卡死和鼠标拖拽停滞。本文采用 PyQt 搭配专为高性能科学绘图设计的 pyqtgraph,结合独立工作线程(QThread)与环形缓冲区(collections.deque),彻底解耦“串口数据采集”与“图形界面渲染”,在极低 CPU 占用下稳定实现 30~60 FPS 以上的实时波形绘制。
一、系统架构:为什么必须解耦数据接收与波形绘制
在单片机传感器数据采集中,单片机往往以 100Hz 到 1000Hz 甚至更高的频率向电脑串口发送数据包。如果上位机每从串口读取到一个数值就立刻触发一次界面重绘,Qt 的主事件循环(Event Loop)会被海量的绘图重绘事件淹没,导致界面按钮无法点击、窗口无法移动,最终崩溃。解决这一问题的标准设计模式是双端解耦架构:
- 后台采集线程(QThread):全权负责监听物理串口(pyserial),以阻塞或短超时模式持续接收原始字节流,执行帧同步校验并将有效数据解析为浮点数值。
- 跨线程安全数据传递:解析出的数据通过 Qt 的
Signal/Slot机制传递给主线程,存入固定容量的双端队列(Ring Buffer),避免主线程在数据突发时产生竞争态。 - 主线程节流渲染(QTimer):主线程由独立的硬件定时器(例如设定间隔为 30ms,约 33 帧/秒)定期唤醒,一次性从缓冲区读取最新数据窗口并通过
curve.setData()刷新,使图形刷新率始终与屏幕显示器帧率相吻合。
二、通信协议与数据缓冲规范
上位机与单片机通信时,串口本质上是无边界的字节流通道。若未制定严密的传输协议,通信过程中必然发生“粘包”或“截断分包”。根据实际应用需求,可分为以下两种典型协议:
1. 文本协议(适合低速调试与快速验证)
单片机以文本字符串形式发送,数据项之间用逗号分隔,并以换行符
结尾。例如:12.45,8.90,3.31
。Python 端通过 serial.readline() 读取整行,然后调用 split(',') 进行转换。该方式直观易懂,但文本转换开销较大,且占用带宽较高。
2. 二进制协议(适合高速多通道场景)
单片机将浮点数直接按 IEEE-754 字节编码打包,并在首尾增加同步标志与校验码。例如定义定长帧结构:
- 帧头(2 字节):
0xAA 0x55,用于在数据流中快速同步帧起始位置; - 数据载荷(4 字节 × 通道数):标准
float32原始字节; - 校验和(1 字节):载荷所有字节的模 256 和,用于校验信号传输完整性。
list)。随着运行时间增加,列表无限增长会导致内存泄漏,且每次绘制全量历史数据会拖垮显存与 CPU。应统一使用 collections.deque(maxlen=N),当数据到达最大长度 N(例如 1000 个采样点)时,新数据会自动挤出最旧数据,保持固定时间窗口滚动,时间复杂度为严格的 O(1)。
三、全流程工程实施步骤
构建完整的上位机工程,整体流程分为 5 个核心环节,如下图所示:
在开始编写代码前,请确保 Python 环境中已安装必要依赖:
pip install PyQt5 pyqtgraph pyserial numpy
四、完整可运行代码实现
以下是具备工业级可用性的完整单文件上位机示例。代码内内置了真实串口通信与模拟传感器数据回退机制:若本地未接入物理单片机串口,程序会自动生成包含高斯噪声的正弦测试波形,便于读者直接复制运行验证。
# -*- coding: utf-8 -*-
"""
PyQt结合pyqtgraph实时绘制单片机传感器波形
依赖库: PyQt5, pyqtgraph, pyserial, numpy
"""
import sys
import time
import math
import random
from collections import deque
import numpy as np
from PyQt5.QtCore import QThread, pyqtSignal, QTimer, Qt
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QLabel, QPushButton, QComboBox, QMessageBox
)
import pyqtgraph as pg
# 可选导入 pyserial,若未安装或未插串口则自动切到模拟模式
try:
import serial
import serial.tools.list_ports
HAS_SERIAL = True
except ImportError:
HAS_SERIAL = False
class SerialReaderWorker(QThread):
"""
后台串口读取工作线程
负责在独立事件循环中监听串口,杜绝主界面卡顿
"""
data_received = pyqtSignal(float)
status_changed = pyqtSignal(str)
def __init__(self, port_name="COM3", baud_rate=115200, parent=None):
super().__init__(parent)
self.port_name = port_name
self.baud_rate = baud_rate
self.is_running = True
self.use_simulation = False
self.serial_port = None
def run(self):
if not HAS_SERIAL or self.port_name == "SIMULATOR":
self.use_simulation = True
if not self.use_simulation:
try:
self.serial_port = serial.Serial(
port=self.port_name,
baudrate=self.baud_rate,
timeout=0.1
)
self.status_changed.emit(f"已连接硬件串口: {self.port_name} @ {self.baud_rate}")
except Exception as e:
self.status_changed.emit(f"串口打开失败: {e},自动切换为模拟信号源")
self.use_simulation = True
sim_t = 0.0
while self.is_running:
if self.use_simulation:
# 模拟单片机输出 100Hz 传感器正弦波叠加噪声
time.sleep(0.01)
sim_t += 0.05
sim_val = 2.5 + 1.2 * math.sin(sim_t) + random.uniform(-0.08, 0.08)
self.data_received.emit(sim_val)
else:
try:
if self.serial_port and self.serial_port.in_waiting > 0:
line = self.serial_port.readline().decode('utf-8', errors='ignore').strip()
if line:
# 尝试解析文本浮点数
val = float(line)
self.data_received.emit(val)
else:
time.sleep(0.002)
except Exception:
# 容错跳过无效截断帧
continue
if self.serial_port and self.serial_port.is_open:
self.serial_port.close()
def stop(self):
"""安全停止线程"""
self.is_running = False
self.wait(1500)
class WaveformWindow(QMainWindow):
"""
上位机主窗口:基于 pyqtgraph 的高性能波形可视化面板
"""
def __init__(self):
super().__init__()
self.setWindowTitle("PyQt结合pyqtgraph实时绘制单片机传感器波形")
self.resize(960, 560)
# 核心数据缓冲结构:固定长度环形队列
self.buffer_size = 1000
self.data_buffer = deque(maxlen=self.buffer_size)
# 初始化后台线程
self.worker = None
self._init_ui()
self._init_plot()
self._init_render_timer()
def _init_ui(self):
main_widget = QWidget()
self.setCentralWidget(main_widget)
main_layout = QVBoxLayout(main_widget)
# 控制工具栏
tool_layout = QHBoxLayout()
tool_layout.addWidget(QLabel("端口选择:"))
self.port_combo = QComboBox()
self.port_combo.addItem("SIMULATOR (模拟波形)")
if HAS_SERIAL:
ports = serial.tools.list_ports.comports()
for p in ports:
self.port_combo.addItem(p.device)
tool_layout.addWidget(self.port_combo)
self.btn_toggle = QPushButton("开始采集")
self.btn_toggle.clicked.connect(self._toggle_acquisition)
tool_layout.addWidget(self.btn_toggle)
self.status_label = QLabel("状态: 等待启动")
self.status_label.setStyleSheet("color: #5C6B73;")
tool_layout.addWidget(self.status_label)
tool_layout.addStretch()
main_layout.addLayout(tool_layout)
# pyqtgraph 绘图控件
self.plot_widget = pg.PlotWidget()
main_layout.addWidget(self.plot_widget)
def _init_plot(self):
# 优化显示样式:深色科技背景与网格线
self.plot_widget.setBackground('#1E242B')
self.plot_widget.showGrid(x=True, y=True, alpha=0.25)
self.plot_widget.setLabel('left', '传感器测量值', units='V')
self.plot_widget.setLabel('bottom', '采样序号', units='Points')
self.plot_widget.setTitle("实时波形监控通道", color='#6487A5', size='12pt')
# 曲线绘制对象 (软蓝色柔和曲线)
pen = pg.mkPen(color='#6487A5', width=2)
self.curve = self.plot_widget.plot(pen=pen)
# 高性能关键参数配置:禁用抗锯齿以达到极致性能
self.plot_widget.setDownsampling(mode='peak')
self.plot_widget.setClipToView(True)
def _init_render_timer(self):
"""主线程定时渲染器:每 33ms (约 30 FPS) 刷新一次画布"""
self.render_timer = QTimer(self)
self.render_timer.timeout.connect(self._update_plot)
def _toggle_acquisition(self):
if self.worker is None or not self.worker.isRunning():
selected_port = self.port_combo.currentText().split()[0]
self.worker = SerialReaderWorker(port_name=selected_port, baud_rate=115200)
self.worker.data_received.connect(self._on_data_received)
self.worker.status_changed.connect(lambda s: self.status_label.setText(f"状态: {s}"))
self.worker.start()
self.render_timer.start(33)
self.btn_toggle.setText("停止采集")
self.port_combo.setEnabled(False)
else:
self._stop_acquisition()
def _stop_acquisition(self):
if self.worker:
self.worker.stop()
self.worker = None
self.render_timer.stop()
self.btn_toggle.setText("开始采集")
self.port_combo.setEnabled(True)
self.status_label.setText("状态: 已停止采集")
def _on_data_received(self, value):
"""槽函数:仅负责追加数据至缓冲,耗时几乎为 0"""
self.data_buffer.append(value)
def _update_plot(self):
"""定时器触发:统一渲染当前视窗的数据数组"""
if len(self.data_buffer) > 0:
# 转换为一维 NumPy 数组进行高速渲染
y_data = np.array(self.data_buffer, dtype=np.float32)
self.curve.setData(y_data)
def closeEvent(self, event):
"""窗口关闭保护:确保后台子线程彻底终止"""
self._stop_acquisition()
event.accept()
if __name__ == '__main__':
app = QApplication(sys.argv)
window = WaveformWindow()
window.show()
sys.exit(app.exec_())
五、绘图引擎性能对比与调优
实时绘图不仅关乎代码逻辑,核心在于底层的图像呈现机制。为了说明选择 pyqtgraph 的必要性,以下对比不同绘图库在 500Hz 采样率下的运行特征:
为了在长时间运行或超多通道采集时保持极高的帧率,建议应用以下四大性能调优技巧:
- 关闭全局抗锯齿(Anti-aliasing):调用
pg.setConfigOptions(antialias=False)。抗锯齿会带来巨量的 GPU 光栅混合运算,在 1000+ 点高频刷新场景下关闭抗锯齿可使绘图吞吐量提升 3 倍以上。 - 开启视窗视锥裁剪(setClipToView):调用
plot_widget.setClipToView(True)。当用户放大某个区间时,pyqtgraph 会自动忽略位于屏幕可视范围之外的数据点,只对视窗内部的数据调用绘图接口。 - 启用峰值下采样(Peak Downsampling):调用
plot_widget.setDownsampling(mode='peak')。若屏幕宽度只有 1000 像素,但缓冲区包含 50000 个采样点,下采样会在每组像素内仅提取最大值和最小值,既保全了波形的所有尖峰毛刺,又将渲染计算量降低几十倍。 - 避免在定时器中执行复杂的格式化运算:主线程的
_update_plot()只负责setData(),所有协议解包、校验和浮点数转换均交由后台QThread完成。
六、常见问题与故障排查清单
在工程实测与联调过程中,开发者最容易遇到界面卡顿、断包甚至崩溃报错。下表与图解总结了四大典型问题的排查路径:
| 故障现象 | 根本原因 | 工程解决方法 |
|---|---|---|
| 窗口界面点击出现“未响应” | 在主线程执行了阻塞式的 serial.readline(),阻塞了 Qt 事件循环。 |
严格采用 QThread 承载串口读取,将数据解析完成后通过信号槽投递。 |
| 波形偶发性乱跳、飞线 | 串口波特率配置错误,或通信断包后解析出脏数据。 | 在解析处使用 try-except (ValueError, UnicodeDecodeError) 拦截,并制定帧头校验机制。 |
| 程序运行越久,内存占用越大且越来越卡 | 使用全局 list 无限 append,未限制波形绘制的历史窗口长度。 |
采用 collections.deque(maxlen=N) 或定长 NumPy 环形缓冲区,超出后自动覆盖。 |
| 关闭窗口时 Python 解释器崩溃(Crash on exit) | 主窗口已被操作系统销毁,但后台子线程仍在向已被回收的控件发送信号。 | 重写主窗口的 closeEvent(event),通过退出标志位通知子线程并调用 worker.wait()。 |
通过遵循“后台线程收数据 + 环形队列暂存 + 前端定时器限频刷新”的三层架构,PyQt 结合 pyqtgraph 能够在常规家用电脑乃至工控机上,以低于 5% 的 CPU 占用率轻松胜任数百赫兹的单片机传感器实时波形监控。
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









