PyQt结合pyqtgraph实时绘制单片机传感器波形

小岛来信
小岛来信
发布时间:2026-11-08| 8人浏览过
原创

针对单片机传感器高速数据采集场景,详细讲解如何使用 PyQt 与 pyqtgraph 构建高帧率、低 CPU 占用的实时波形上位机,包含多线程解耦、数据缓冲、定时刷新及完整工程代码。

PyQt结合pyqtgraph实时绘制单片机传感器波形
图 0:基于 PyQt 与 pyqtgraph 的单片机传感器实时波形上位机概览

实时显示单片机(如 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() 刷新,使图形刷新率始终与屏幕显示器帧率相吻合。
PyQt结合pyqtgraph实时绘制单片机传感器波形
图 1:单片机数据采集到 pyqtgraph 渲染的双线程解耦数据流模型

二、通信协议与数据缓冲规范

上位机与单片机通信时,串口本质上是无边界的字节流通道。若未制定严密的传输协议,通信过程中必然发生“粘包”或“截断分包”。根据实际应用需求,可分为以下两种典型协议:

1. 文本协议(适合低速调试与快速验证)

单片机以文本字符串形式发送,数据项之间用逗号分隔,并以换行符 结尾。例如:12.45,8.90,3.31 。Python 端通过 serial.readline() 读取整行,然后调用 split(',') 进行转换。该方式直观易懂,但文本转换开销较大,且占用带宽较高。

2. 二进制协议(适合高速多通道场景)

单片机将浮点数直接按 IEEE-754 字节编码打包,并在首尾增加同步标志与校验码。例如定义定长帧结构:

  • 帧头(2 字节):0xAA 0x55,用于在数据流中快速同步帧起始位置;
  • 数据载荷(4 字节 × 通道数):标准 float32 原始字节;
  • 校验和(1 字节):载荷所有字节的模 256 和,用于校验信号传输完整性。
数据缓冲要点
上位机内存放波形数据的容器切勿使用无限制长度的普通 Python 列表(list)。随着运行时间增加,列表无限增长会导致内存泄漏,且每次绘制全量历史数据会拖垮显存与 CPU。应统一使用 collections.deque(maxlen=N),当数据到达最大长度 N(例如 1000 个采样点)时,新数据会自动挤出最旧数据,保持固定时间窗口滚动,时间复杂度为严格的 O(1)。

三、全流程工程实施步骤

构建完整的上位机工程,整体流程分为 5 个核心环节,如下图所示:

PyQt结合pyqtgraph实时绘制单片机传感器波形
图 2:PyQt + pyqtgraph 实时波形绘制的五个实施步骤

在开始编写代码前,请确保 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 采样率下的运行特征:

PyQt结合pyqtgraph实时绘制单片机传感器波形
图 3:三种主流 Python 绘图方案在实时单片机波形场景下的机制对比

为了在长时间运行或超多通道采集时保持极高的帧率,建议应用以下四大性能调优技巧:

  1. 关闭全局抗锯齿(Anti-aliasing):调用 pg.setConfigOptions(antialias=False)。抗锯齿会带来巨量的 GPU 光栅混合运算,在 1000+ 点高频刷新场景下关闭抗锯齿可使绘图吞吐量提升 3 倍以上。
  2. 开启视窗视锥裁剪(setClipToView):调用 plot_widget.setClipToView(True)。当用户放大某个区间时,pyqtgraph 会自动忽略位于屏幕可视范围之外的数据点,只对视窗内部的数据调用绘图接口。
  3. 启用峰值下采样(Peak Downsampling):调用 plot_widget.setDownsampling(mode='peak')。若屏幕宽度只有 1000 像素,但缓冲区包含 50000 个采样点,下采样会在每组像素内仅提取最大值和最小值,既保全了波形的所有尖峰毛刺,又将渲染计算量降低几十倍。
  4. 避免在定时器中执行复杂的格式化运算:主线程的 _update_plot() 只负责 setData(),所有协议解包、校验和浮点数转换均交由后台 QThread 完成。

六、常见问题与故障排查清单

在工程实测与联调过程中,开发者最容易遇到界面卡顿、断包甚至崩溃报错。下表与图解总结了四大典型问题的排查路径:

PyQt结合pyqtgraph实时绘制单片机传感器波形
图 4:单片机实时上位机开发常见故障现象、根因与排除总结
故障现象 根本原因 工程解决方法
窗口界面点击出现“未响应” 在主线程执行了阻塞式的 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 占用率轻松胜任数百赫兹的单片机传感器实时波形监控。

相关标签:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。