ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python上位机开发实战指南:从串口通信到PyQt5界面与实时波形

Python上位机开发实战指南:从串口通信到PyQt5界面与实时波形 1. 项目概述为什么我要写这份Python上位机指南1.1 核心需求解析先说说我为什么想写这篇东西。这几年在工控圈和物联网圈子里总能看到一群人——可能是刚入行的硬件工程师、做嵌入式开发的单片机选手、或者实验室里被导师丢了个做个上位机需求的研究生——他们面临一个共同难题上位机到底怎么做用什么做你打开招聘网站搜上位机开发十有八九写着C#、LabVIEW、Qt C。但问题是很多小白并不会C#也没接触过Visual Studio那一套Windows窗体开发更别提MFC这种老古董了。而Python这几年靠着语法简单、生态丰富已经在数据分析、爬虫、自动化测试领域杀疯了其实在串口通信、网络通信、界面开发这块也非常能打。尤其当你只需要快速做一个工具型上位机——比如读个串口数据、画个波形、发几个控制指令——Python完全够用开发效率还高得离谱。这篇博文的目标读者非常明确有Python基础但没接触过上位机的开发者哪怕Python也是刚学的、熟悉单片机但不熟PC端软件的嵌入式工程师、以及任何需要电脑和硬件设备通信但不想被C#拖入深坑的人。我默认你至少用过Python跑过脚本知道pip是什么。如果你连Python都没装过也没关系我会从环境搭建开始带着你走。1.2 上位机到底在解决什么问题用大白话讲上位机就是坐在电脑屏幕前指挥硬件干活的那个软件。你打开一个串口调试助手选择COM口点连接然后往单片机发一条FF 01 02底下就收到设备回传的温度数据——这就是最基本的上位机。往上走一层如果你要做一个完整的设备监控系统上位机还需要做几件事把接收到的二进制数据解析成人类能看懂的温度、电压、状态把数据实时画成曲线方便观察变化趋势把用户点的按钮、输入的参数封装成协议帧发给下位机再把所有数据记录到文件或数据库里方便事后回放分析。工业上那些看起来很唬人的SCADA系统、组态软件、HMI本质上干的就是这些事——只是它们做得更庞大、更通用、更费钱。而我们用Python自己写一个够用、轻量、还能完全按自己的需求定制不香吗。2. 技术选型为什么Python做上位机是靠谱的选择2.1 Python vs C# vs Qt C到底怎么选很多人一听上位机三个字脑子里蹦出来的就是C#的WinForms或者WPF。确实C#在这个领域耕耘了很多年资料多、模板全尤其在对接PLC、组态软件这块有天然优势。但C#的问题也很明显你得上手Visual Studio那一整套IDE得理解委托、事件、异步编程模型还得跟Windows系统API较劲。对于只想赶紧把数据读出来画个图的人学习成本是真的高。Qt C性能没得说跨平台也很香但是CMake、信号槽、内存管理这一套下来光是把环境配通就能劝退一半新手。Python呢串口通信有pyserial界面开发有PyQt5 / Tkinter曲线绘制有pyqtgraph数据解析有struct和numpy打包发布有PyInstaller。每个环节都有现成的轮子而且语法贴近自然语言出活速度快。我个人的观点是如果你做的是工具型、数据监控型、测试型上位机Python是综合效率最高的选择如果你要做的是面向商业客户的大型组态软件那才需要考虑C#或C那个量级的方案。2.2 核心库清单与功能划分做任何一个上位机逃不开下面这几个功能模块。Python生态里对应库已经很成熟我按模块给大家列一下界面层TkinterPython自带学习成本最低、PyQt5 / PySide2功能强大控件丰富适合做复杂界面串口通信pyserial几乎是串口通信的唯一选择跨平台支持API稳定网络通信socketTCP/UDP、requestsHTTP上位机和设备走TCP时用socket走云端时用requests数据处理struct二进制解析、numpy数值计算、jsonJSON协议波形绘制pyqtgraph快、适合实时刷新、matplotlib功能强但刷新慢日志存储内置的logging模块、csv模块、sqlite3打包发布PyInstaller把Python脚本打包成exe方便在没有Python环境的电脑上运行这里面最需要磨合的是界面库的选择。我的建议是如果你想快速学会、快速出成果直接用Tkinter如果你想做一个看起来比较专业、控件比较漂亮的软件直接上PyQt5。后面我会两个方案都涉及一点主线以PyQt5为主——因为它做出来的软件更像正经软件而不是学生作业。2.3 选型时要避开的几个坑第一坑一上来就追求工业级架构什么MVC、事件总线、多线程池全上。结果代码还没跑通自己先被概念绕晕了。新手阶段先做一个能用的毛坯房再考虑精装修。第二坑选了matplotlib做实时波形。不是说不行而是matplotlib刷新一张图要重新绘制整个画布帧率做不高CPU占用还大。如果数据一秒几十条肉眼已经能感觉到明显的卡顿。换pyqtgraph之后体感完全不一样。第三坑不规划数据协议。很多小白在刚开始做的时候完全没想过设备传来的这串十六进制数到底是什么意思等数据一多解析代码写得一团乱麻。我建议在做任何上位机之前先把通信协议文档写好哪怕只是一张Excel表——帧头、功能码、数据长度、校验方式全部写清楚后面所有工作都会顺畅很多。3. 动手前的准备Python环境搭建与工具安装3.1 Python安装与虚拟环境配置如果你机器上还没装Python建议直接去官网下载Python 3.10或更高版本的安装包。安装的时候有个特别重要的细节一定要勾选Add Python to PATH这个选项否则后面在命令行里敲python会提示找不到命令。这是新手最容易踩的坑没有之一。装好之后打开命令行验证一下按Win R输入cmd回车打开命令提示符输入python --version能输出版本号说明安装成功然后我要强烈建议你做一件事创建虚拟环境。虚拟环境的作用是让每个项目的依赖包互相隔离避免A项目用的PyQt5是5.15版本、B项目忽然被升级到6.x导致代码报错。操作很简单mkdir python-upper-machine cd python-upper-machine python -m venv venv venv\Scripts\activate命令行前面出现(venv)就说明你已经进入了虚拟环境。之后的pip安装都会装到这个环境里不会污染系统全局。3.2 安装必备依赖库激活虚拟环境后一次性安装我们这次要用到的所有库pip install pyserial pyqt5 pyqtgraph numpy这里稍微解释下每个库干什么用pyserial串口通信核心库负责打开/关闭串口、读写数据pyqt5图形界面库负责绘制窗口、按钮、输入框、下拉菜单pyqtgraph高性能实时绘图库基于Qt开发能和PyQt5无缝结合numpy做数组和数值计算解析二进制数据时配合struct一起用如果你平时还习惯用pycharm也可以直接在pycharm的Settings - Project - Python Interpreter里添加这些包效果一样。3.3 编辑器选择与调试建议编辑器这快我推荐Visual Studio Code或者PyCharm。如果电脑配置一般VS Code轻量一些如果喜欢开箱即用、断点调试方便PyCharm社区版免费也够用。我个人的使用习惯是VS Code配合Python插件跑脚本、看变量、断点调试都挺顺手。VS Code里还需要做一个小设置选择正确的Python解释器。按Ctrl Shift P输入Python: Select Interpreter选到刚才创建的那个venv环境。这一步不做的话很可能会出现我在命令行装了pyserial但编辑器里运行脚本却报ModuleNotFoundError的诡异问题——本质就是解释器选错了。4. 通信基础串口通信原理与pyserial实战4.1 串口通信的基本原理上位机最常见的通信方式就是串口学名UART也叫RS-232电平标准不同但软件层面都是串口。在Windows上串口设备一般被识别成COM3、COM4这种编号在Linux上则是ttyUSB0、ttyS0。串口通信有四个关键参数波特率Baud Rate每秒钟传输多少位常见的有9600、115200必须和下位机设置的完全一致数据位Data Bits通常为8位停止位Stop Bits通常为1位校验位Parity无校验N、偶校验E、奇校验O一般选N这四个参数就是上下位机之间的接头暗号。两边参数对不上你发过去的是你好对方听到的就是一串乱码。4.2 pyserial的核心APIpyserial的使用套路非常固定核心就是Serial类。我直接上一个最小可用的串口读写例子import serial # 打开串口 ser serial.Serial( portCOM3, # 串口号根据自己的设备修改 baudrate115200, # 波特率 bytesizeserial.EIGHTBITS, parityserial.PARITY_NONE, stopbitsserial.STOPBITS_ONE, timeout1 # 读超时时间单位秒 ) # 发送数据字符串转bytes ser.write(bAT\r\n) # 读取数据读取最多64字节 data ser.read(64) print(data) # 读取一行以换行符结尾的数据 line ser.readline() print(line) # 用完记得关闭 ser.close()这里有个实用的思路如果串口在打开时被其他程序占用会抛出SerialException。所以写正式代码时最好用try/except包起来并且在程序退出时用ser.close()释放端口否则下次运行会报端口被占用。4.3 自动扫描可用串口做上位机的软件界面上一般都会有一个下拉框让用户选择COM口。但设备插到电脑上到底是COM几不同机器不一样甚至同一台机器换个USB口都可能变。所以我们需要用serial.tools.list_ports获取当前所有可用串口列表from serial.tools import list_ports ports list_ports.comports() for p in ports: print(p.device) # COM3 print(p.description) # USB-SERIAL CH340这样界面上就可以动态刷新串口下拉列表用户不用手动去设备管理器里找COM口号。5. 界面搭建实战PyQt5从上到下的完整布局5.1 主窗口结构与控件规划先想清楚一个串口调试型上位机界面上要放什么。最基本的我需要这几个区域左上角串口设置区串口号下拉框、波特率下拉框、打开/关闭按钮右上角数据显示区接收数据的文本框可以切换HEX显示下方左侧发送区输入框、发送按钮、定时发送开关下方右侧波形绘制区显示实时数据曲线大致布局结构是这样的-------------------------------------------------------------- | 串口设置区 | 数据显示区 | 波形区 | | 波特率 | 接收日志文本框滚动 | | | 打开按钮 | | | -------------------------------------------------------------- | 发送输入框 [发送按钮] [HEX发送] | | --------------------------------------------------------------具体到代码我用PyQt5的QVBoxLayout和QHBoxLayout做布局管理分别实现垂直排列和水平排列。5.2 核心界面代码骨架下面给出一个精简但能运行的PyQt5界面骨架。先不接业务逻辑只把窗口搭出来跑通界面再说import sys from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QLabel, QComboBox, QPushButton, QTextEdit, QLineEdit, QHBoxLayout, QVBoxLayout, QGroupBox, QCheckBox ) from PyQt5.QtCore import Qt class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(简易上位机 - Python版) self.resize(900, 600) self._build_ui() def _build_ui(self): central_widget QWidget() self.setCentralWidget(central_widget) root_layout QVBoxLayout(central_widget) # 第一行串口设置区 serial_group QGroupBox(串口设置) serial_layout QHBoxLayout(serial_group) self.port_combo QComboBox() self.port_combo.setMinimumWidth(120) self.baud_combo QComboBox() self.baud_combo.addItems([9600, 19200, 38400, 115200]) self.baud_combo.setCurrentText(115200) self.open_btn QPushButton(打开串口) self.open_btn.setCheckable(True) serial_layout.addWidget(QLabel(串口)) serial_layout.addWidget(self.port_combo) serial_layout.addWidget(QLabel(波特率)) serial_layout.addWidget(self.baud_combo) serial_layout.addWidget(self.open_btn) serial_layout.addStretch() root_layout.addWidget(serial_group) # 第二行数据接收文本框 data_label QLabel(接收数据) root_layout.addWidget(data_label) self.receive_text QTextEdit() self.receive_text.setReadOnly(True) root_layout.addWidget(self.receive_text) # 第三行发送区域 send_group QGroupBox(发送数据) send_layout QHBoxLayout(send_group) self.send_edit QLineEdit() self.send_edit.setPlaceholderText(请输入要发送的内容如 AT 或 01 03 00 00 00 01) self.send_btn QPushButton(发送) send_layout.addWidget(self.send_edit) send_layout.addWidget(self.send_btn) root_layout.addWidget(send_group) if __name__ __main__: app QApplication(sys.argv) win MainWindow() win.show() sys.exit(app.exec_())跑起来之后你会看到窗口上已经有了串口设置、接收区、发送区。这一步能跑通说明你的环境没问题、PyQt5安装成功。5.3 串口列表刷新与动态波特率配置界面有了得让下拉框里出现真实的串口。写一个刷新串口的方法放到打开窗口时自动执行from serial.tools import list_ports def refresh_ports(self): self.port_combo.clear() ports list_ports.comports() for p in ports: self.port_combo.addItem(f{p.device} - {p.description}) # 在 __init__ 里调用 self.refresh_ports()波特率下拉框的数据你可以直接从预设列表里选也允许用户手动输入。加一行setEditable(True)就支持手动输入了。实际项目里有些非标设备波特率就是19200或者57600预设列表里没有手动输入功能能免去很多麻烦。6. 上下位机通信实现数据发送、接收与解析6.1 串口打开/关闭与发送逻辑现在把串口逻辑接到界面上。打开串口按钮是一个可选中按钮checkable点击一次变成打开再点一次变成关闭。对应逻辑def on_open_clicked(self, checked): if checked: port self.port_combo.currentText().split( - )[0] baud int(self.baud_combo.currentText()) try: self.ser serial.Serial(port, baud, timeout0.5) self.open_btn.setText(关闭串口) self.receive_text.append(f[系统] 串口 {port} 已打开) except Exception as e: self.receive_text.append(f[错误] 打开失败{e}) self.open_btn.setChecked(False) else: if hasattr(self, ser) and self.ser.is_open: self.ser.close() self.open_btn.setText(打开串口) self.receive_text.append([系统] 串口已关闭)发送按钮的逻辑比较简单def on_send_clicked(self): if not hasattr(self, ser) or not self.ser.is_open: self.receive_text.append([错误] 请先打开串口) return text self.send_edit.text().strip() if not text: return # 判断是HEX格式还是ASCII格式 # 简单起见这里先按ASCII发送 self.ser.write(text.encode(utf-8)) self.receive_text.append(f[发送] {text})这里要说明一下很多嵌入式设备用的不是ASCII字符串而是十六进制字节流比如要发送AA 55 01 02 FF这样的数据帧。通常做法是把十六进制字符串转成bytes也就是用bytes.fromhex(text.replace( , ))。我会在界面上加一个复选框HEX发送勾选后就按十六进制解析否则按ASCII处理。6.2 数据接收定时器轮询 vs 线程监听串口数据什么时候会来上位机是不知道的。两种常见方案一种是QTimer定时器每隔几十毫秒去读取一次串口缓冲区简单直接另一种是开一个后台线程用阻塞读的方式持续读取数据到达立刻触发信号更新界面。我推荐用线程方案实时性更好也不会因为定时器间隔丢数据。PyQt里线程和界面交互最安全的方式是信号槽。基本原理是工作线程把接收到的原始数据通过signal发出去主线程的slot函数收到信号后更新界面。不要在工作线程里直接操作UI控件这会导致界面卡死甚至崩溃。实现起来大概是这样的import threading from PyQt5.QtCore import QThread, pyqtSignal class SerialReader(QThread): data_received pyqtSignal(bytes) def __init__(self, ser): super().__init__() self.ser ser self.running True def run(self): while self.running: if self.ser and self.ser.is_open: # 一次最多读1024字节 data self.ser.read(1024) if data: self.data_received.emit(data) else: break # 线程结束前休眠降低CPU占用 self.msleep(10) def stop(self): self.running False self.wait()启动线程的时机就是打开串口成功之后self.reader SerialReader(self.ser)然后连接self.reader.data_received到界面的显示函数。6.3 数据解析从原始字节到可读数据收到原始bytes之后就要做解析。数据解析方式取决于下位机的协议。最常见的两种第一种是ASCII文本协议比如GPS模块输出的$GNGGA,123519,4807.038,N,...这种以逗号分隔字段。解析方法就是解码成字符串再split一下。第二种是二进制协议比如Modbus RTU这种帧结构是帧头 功能码 数据 校验。这里需要用到struct模块来按字节解析。我举一个温度传感器采集系统的例子。假设下位机每100ms上报一帧数据帧格式是0xAA 0x55 帧头0x01 数据长度后面数据区长度0x00 0x1A 温度值有符号16位整数单位0.1℃所以0x001A就是26实际26×0.12.6℃0x?? 校验字节前几个字节累加和取低八位解析代码import struct def parse_temperature_frame(frame: bytes): if len(frame) 6: return None if frame[0] ! 0xAA or frame[1] ! 0x55: return None length frame[2] # 温度值用struct解出有符号16位整数小端模式 raw_temp struct.unpack(h, frame[3:5])[0] temperature raw_temp / 10.0 # 校验除帧头后所有字节累加和取低八位 checksum sum(frame[:5]) 0xFF if checksum ! frame[5]: return None return temperature注意这里有个容易踩坑的地方如果你的温度值是有符号的一定要用struct的hshort而不是Bunsigned byte或Hunsigned short。否则温度零下的时候比如-10℃直接读出来成了65526这样的数值怎么找都找不到问题出在哪。7. 实时波形显示pyqtgraph让数据可视化不再卡顿7.1 为什么要选pyqtgraph做上位机的很大一个需求就是看实时曲线。温度变化、电压波动、PID调试时的目标值和实际值都得画成波形才能直观观察。很多第一次做上位机的人会用matplotlib。不是说matplotlib不行它画静态图、论文图确实无敌但做实时刷新就很吃力了——每次update都要重绘整个figure数据点一多CPU占用直线飙升界面操作都跟着卡。pyqtgraph不一样它是基于Qt的GraphicsView架构做的底层自己管理绘图项专门针对数据频繁更新的实时绘图场景优化过。实测刷新1000个数据点pyqtgraph流畅得很matplotlib已经开始掉帧。7.2 曲线绘制核心代码在PyQt5的界面中加一个pyqtgraph的PlotWidget然后在数据接收的信号处理函数里把解析出的温度值追加到曲线数据里。核心逻辑如下import pyqtgraph as pg # 在 _build_ui 中创建绘图控件 self.plot_widget pg.PlotWidget(title实时温度曲线) self.plot_widget.setLabel(left, 温度, units℃) self.plot_widget.setLabel(bottom, 点数) self.plot_widget.showGrid(xTrue, yTrue) self.temp_curve self.plot_widget.plot(penr, name温度) # 在接收数据处理中追加数据 self.temp_values [] MAX_POINTS 500 def append_temperature(self, temp): self.temp_values.append(temp) if len(self.temp_values) MAX_POINTS: # 只保留最近500个点 self.temp_values.pop(0) # 直接传列表pyqtgraph自动递增x坐标 self.temp_curve.setData(self.temp_values)MAX_POINTS限制是一个小技巧。如果不限制程序跑一晚上内存里攒了几十万个点绘图控件每次setData都要重新处理一遍再强的机器也会卡。设定一个滚动窗口只保留最近500或1000个点视觉上也更好看——你关注的是当下趋势不是几个月前的历史细节。7.3 多曲线绘制与颜色区分做PID调试或者同时监控多路传感器时一条曲线不够用。pyqtgraph在一个PlotWidget里画多条曲线很简单只要多创建几个PlotDataItem。比如同时画温度和湿度self.temp_curve self.plot_widget.plot(penr, name温度) self.humi_curve self.plot_widget.plot(penb, name湿度)如果你还想让两条曲线分两个坐标系显示——因为温度和湿度的量纲、数值范围可能差很多——可以使用pg.PlotWidget里自带的 LinkedView 或者用pg.GraphicsLayoutWidget创建多个plot item让它共享X轴、独立Y轴。这个属于进阶用法新手阶段可以先放在同一个坐标系里等觉得数值范围差距太大一个Y轴根本看不清楚的时候再去研究分轴布局。另外提一句pyqtgraph还支持通过setDownsampling、setClipToView这些方法进一步优化大数据量下的绘制性能不过对咱们这种几千个点的实时监控场景用不上。8. 协议设计心得让上下位机沟通不再鸡同鸭讲8.1 一个清晰通信协议长什么样在做上位机之前很多人会忽略协议设计直接想到什么发什么。结果就是下位机程序写得不规范上位机解析也乱套两边一对接全是坑。我给大家一个模板照着设计基本不会出错帧头1到2个字节固定值比如0xAA 0x55用来识别一帧数据的开始帧类型/功能码1个字节说明这一帧是命令还是数据、是读还是写数据长度1到2个字节表示数据的字节数数据区长度可变承载实际内容校验CRC16或者累加和用来检测传输过程中有没有被干扰以Modbus RTU为例它的帧结构就是地址码 功能码 数据 CRC16。Modbus之所以在工控界长盛不衰一个重要原因就是协议设计得清晰、简单、可靠。8.2 帧同步与粘包处理解析数据时最头疼的问题有两个断帧和粘包。串口是字节流不是按帧到达的。你可能一次read读到半帧数据也可能一次read读到好几帧数据。如果按收到一帧就解析的思路写代码很容易出错。解决方案是维护一个接收缓冲区把每次读取到的原始数据追加进去然后循环从缓冲区里寻找完整的帧self.buffer b def handle_raw_data(self, data): self.buffer data while True: # 查找帧头 start self.buffer.find(b\xAA\x55) if start -1: # 没有帧头清空缓冲区 self.buffer b return if start 0: # 帧头前面有残留垃圾数据丢掉 self.buffer self.buffer[start:] if len(self.buffer) 6: # 缓冲区内数据还不够一帧等下一次数据 return frame_len self.buffer[2] 5 # 数据长度 帧头2字节 长度1字节 校验1字节 if len(self.buffer) frame_len: # 数据还不够一帧 return frame self.buffer[:frame_len] self.buffer self.buffer[frame_len:] self.process_frame(frame)这样做的好处是不管你收到的是半帧、一帧半还是三帧粘在一起解析逻辑都能自愈。这是一个非常实用的经验做串口上位机的人迟早都得学会。8.3 心跳机制与超时重连上位机和下位机的连接不是永远稳定的。USB线可能松了设备可能断电重启无线模块可能掉线。如果没有任何机制检测连接状态上位机显示的数据可能是几百秒前的旧数据而操作员根本不知道设备已经死了。通用做法是心跳机制上位机每隔N秒向下位机发一条心跳指令下位机收到后回一条应答。如果上位机连续几次没收到应答就认为通信异常在界面上给出报警提示并自动尝试重新连接。用QTimer就能实现定时发送心跳from PyQt5.QtCore import QTimer self.heartbeat_timer QTimer() self.heartbeat_timer.timeout.connect(self.send_heartbeat) self.heartbeat_timer.start(5000) # 每5秒发一次 def send_heartbeat(self): if hasattr(self, ser) and self.ser.is_open: self.ser.write(b\xAA\x55\x01\x00\x00)这个功能看起来简单但在实际项目里能避免很多数据不更新了你还不知道的尴尬场面。9. 波形显示进阶pyqtgraph与性能优化实操9.1 实时波形性能瓶颈分析与优化前面提到过pyqtgraph比matplotlib快但并不意味着用了pyqtgraph就可以无脑堆数据。当数据量积累到几万、几十万点时即使pyqtgraph也会开始卡。我这里分享三个实测有效的优化手段。第一个是数据裁剪也就是之前说的滚动窗口。只保留最近N个点既能保证图形刷新速率又能满足绝大多数实时监控需求。第二个是setDownsampling配合setClipToView。这两个方法是pyqtgraph专门为大数据量曲线提供的。setDownsampling会自动对数据进行降采样比如可视区域内只保留1000个点多余的数据点会被忽略或聚合setClipToView则只绘制当前视图范围内的数据窗口外的点直接跳过。开启方法很简单self.temp_curve.setDownsampling(autoTrue, methodpeak) self.temp_curve.setClipToView(True)第三个是控制刷新频率。不需要每收到一帧数据就立刻刷一次图你可以用一个定时器每100ms刷新一次曲线不管这100ms内收到了多少帧数据界面只更新一次。这个思路和批量提交很像能大幅减少UI重绘次数。9.2 坐标轴自动缩放与手动缩放实时曲线通常会遇到一个问题数据一会儿是50一会儿是500如果Y轴范围固定小的波动根本看不清楚。解决方法是开启Y轴自动范围调整self.plot_widget.enableAutoRange(axisy, enableTrue) self.plot_widget.getViewBox().setAutoVisible(yTrue)但自动缩放也有缺点如果数据偶尔出现一个突变尖峰Y轴会被瞬间拉得很开后面所有数据都被挤成一团。更稳的做法是以最近N个点计算Y轴范围比如只取最近100个点的最小值和最大值给一点上下边距再作为Y轴范围。这样既能自适应又不会被偶发尖峰带偏。X轴方面如果你用滚动窗口方案X轴其实就是0到N的递增下标直接把setXRange(0, MAX_POINTS)固定住就行。如果你按时间戳来画也可以使用AxisItem自定义刻度显示时间。9.3 多通道波形布局最后说一下多通道布局。之前提到同一个PlotWidget画多条曲线适合量纲一致的场景如果量纲不一致建议用pg.GraphicsLayoutWidget来做多行布局每个通道占一行共享X轴。self.graphics_layout pg.GraphicsLayoutWidget() self.plot_temp self.graphics_layout.addPlot(row0, col0, title温度) self.plot_humi self.graphics_layout.addPlot(row1, col0, title湿度)这样温度画在上面湿度画在下面滚动查看各自的变化趋势非常直观。共享X轴可以通过setXLink(plot_temp)实现让两个子图绑在同一个X轴时间轴上。这也是pyqtgraph相对matplotlib的又一个优势——matplotlib做这种联动布局要写不少样板代码。10. 实战案例拆解做一个完整的温湿度采集上位机10.1 需求说明与界面设计理论讲了一堆我们来动手做一个完整的例子。假设我手头有一个温湿度传感器模块通过USB转串口接入电脑通信参数是115200、8N1数据帧格式为帧头 0xAA 0x55长度 0x05数据区温度2字节有符号0.1℃精度、湿度2字节无符号大端0.1%精度校验前面6字节累加和取低八位比如一帧数据是 AA 55 05 FF 9C 02 3B 33解析出来就是温度 0xFF9C -100-100×0.1 -10.0℃湿度 0x023B 571571×0.1 57.1%。界面要求串口设置区、数据显示区、实时波形区、数据保存按钮。10.2 完整代码结构与关键模块我把项目拆成下面几个文件分模块写代码方便后期维护project/ ├── main.py # 程序入口创建窗口 ├── serial_worker.py # 串口读写线程 ├── protocol.py # 帧解析和校验算法 └── ui_main.py # 主窗口界面逻辑这里我重点讲一下protocol.py的解析逻辑import struct def check_sum(frame: bytes) - bool: 校验前6字节累加和低8位 第7字节 return sum(frame[:6]) 0xFF frame[6] def parse_frame(frame: bytes): if len(frame) ! 7: return None if frame[0] ! 0xAA or frame[1] ! 0x55: return None if not check_sum(frame): return None raw_temp struct.unpack(h, frame[3:5])[0] raw_humi struct.unpack(H, frame[5:7])[0] temperature raw_temp / 10.0 humidity raw_humi / 10.0 return temperature, humidity注意这里温度用的 h是大端有符号整数湿度用的 H是大端无符号整数。这是根据协议定义来的搭建时一定要跟设备手册核对清楚。在ui_main.py的主窗口里收到串口数据的信号后先把buffer按协议找到完整帧再调protocol.parse_frame解析把结果送到文本框、曲线图和csv记录器。数据保存CSV的代码很简单import csv from datetime import datetime class CsvLogger: def __init__(self, filename): self.f open(filename, w, newline, encodingutf-8) self.writer csv.writer(self.f) self.writer.writerow([时间, 温度℃, 湿度%]) def write(self, temp, humi): now datetime.now().strftime(%Y-%m-%d %H:%M:%S) self.writer.writerow([now, temp, humi]) def close(self): self.f.close()10.3 运行效果与联调要点代码写完接上真实设备测试。这里有几个联调时的要点全部是我的实战经验第一先确认串口号。Windows的任务管理器 - 端口 里能看到设备实际是COM几。如果你的设备用了CH340这种国产USB转串口芯片没有装驱动的话系统是认不出来的先去芯片厂商下驱动。第二用现成的串口调试助手单独测试收发。连上位机之前先用串口助手给设备发一条指令看看设备回不发数据、回的什么格式。这能帮你确认到底是设备问题还是代码问题。很多人代码写完了才发现自己连设备回的数据长什么样都不知道怎么可能写对解析逻辑。第三测试异常数据。人为拔掉USB线、让下位机断电、发送错误指令看上位机能不能正确处理——是崩溃了还是弹提示还是忽略异常继续运行。这些边界情况在真实项目里都要能扛住。11. 打包发布把一个Python脚本变成exe应用11.1 PyInstaller打包的基本操作程序写完了总不能每次都让别人装Python、装依赖、在命令行跑py脚本吧。正经使用场景是把程序打包成一个exe文件发给同事、发给客户双击就能运行。打包工具我用的是PyInstaller命令很简单pip install pyinstaller pyinstaller -F -w main.py参数解释一下-F打包成单个exe文件-w不显示命令行窗口GUI程序必须要加main.py你的程序入口文件打包完成后在dist目录下会生成一个main.exe。把这个exe单独拷出去就能运行。11.2 图标、版本信息和启动器封装默认生成的exe没有图标看起来像临时软件。准备一个.ico图标文件打包时指定pyinstaller -F -w -i app.ico main.py版本信息可以用version.txt文件配置里面写上产品名称、公司、版本号等元数据。打包命令加上--version-file version.txt。这个不是必需的但如果你要发给客户有版本信息看起来专业很多。另外如果是给不熟悉电脑的人用我会额外加一层启动检测如果exe所在目录下没有配置文件就自动生成一份默认配置程序启动后在界面上显示当前版本号。这些小细节能让软件的使用体验上一个台阶。11.3 打包常见坑体积大、杀毒误报、路径问题PyInstaller打包看似简单坑也不少。第一个坑是体积。PyQt5程序打包出来动辄80MB以上因为把整个Qt运行库都打包进去了。网上有一些瘦身方案比如用UPX压缩exe或者不用PyQt5改用Tkinter。但对大多数场景来说80MB真不算什么U盘里放得下云盘随便传没必要为了瘦身在兼容性和功能上妥协。第二个坑是杀毒误报。PyInstaller打包的exe经常会被某些杀毒软件误报为木马因为它的打包方式把Python运行时和压缩数据放在一起和某些恶意软件的静态特征有点像。这基本无解只能建议用户添加信任或者用代码签名证书。个人项目不需要太在意商业分发才需要考虑签名。第三个坑是资源文件路径。如果你在代码里用相对路径引用了一个配置文件或图标双击exe运行时的工作目录不一定是你exe所在的目录。更稳妥的做法是取exe自身目录来拼接路径import sys import os def get_app_dir(): if getattr(sys, frozen, False): # PyInstaller打包后的环境 return os.path.dirname(sys.executable) # 开发调试环境 return os.path.dirname(os.path.abspath(__file__))用这个函数获取基础路径再拼上文件名就能保证无论从哪个目录启动exe都能正确找到配置文件。12. 排查与避坑串口上位机开发中的高频问题12.1 模块缺失、串口打不开与中文乱码我根据自己带新人的经验整理了一份高频问题速查表大家遇到问题先对着查一遍现象可能原因解决方案ModuleNotFoundError: No module named serial没安装pyserial或解释器选错环境pip install pyserial检查VS Code/PyCharm解释器是否指向venvSerialException: could not open port端口被其他程序占用或设备未连接关闭串口调试助手检查设备管理器确认COM口号打开串口后收不到数据波特率/数据位/停止位不匹配或线序错误用串口调试助手测试设备确认参数和接线收到数据是乱码编码不一致设备发GBK你按UTF-8解或波特率错切换解码方式核对波特率发送数据设备没反应协议格式不对或HEX/ASCII模式搞错核对协议文档看是否缺帧头或校验界面卡死在工作线程操作了UI或主线程做了耗时操作用信号槽跨线程更新UI把耗时操作移到子线程程序打包后一打开就闪退缺资源文件或代码用了相对路径用exe同目录方式引用资源命令行运行看报错信息12.2 界面卡死与线程安全界面卡死这个问题值得单独拎出来说。很多第一次做上位机的朋友会在接收数据的slot里做耗时操作比如把数据写入数据库、把数据通过网络上传到服务器。当数据量大时UI线程被阻塞界面就假死了。记住一条黄金法则任何可能耗时的操作磁盘写入、网络请求、复杂计算都不要在UI线程里直接做。要么放到独立线程要么拆成小块异步处理。在PyQt里最简单的做法是用QThread跑一个工作线程通过信号槽把结果传回UI线程更新界面。12.3 我踩过的数据丢失与粘包问题最后分享一个我真正在项目里踩过的坑。有一次做一个六轴传感器数据采集上位机设备以2kHz的频率连续上传数据每帧几十个字节。我一开始用简单的每次read读完就立即解析的写法结果发现数据偶尔会出现个别帧解析失败。排查了很久才找到原因串口驱动底层有缓冲区我的QThread里读到的数据可能是两次read缓冲区合并的结果于是出现半帧 一帧 半帧的坏情况。后来按照前面说的缓冲区累积找帧头方案重写了解析逻辑问题彻底消失。另外一个血泪教训不要在主线程里用time.sleep()做延时。当时为了让波形看起来不那么快我在timer回调里加了sleep(0.05)结果整个界面按钮响应都变迟钝了。正确的做法是直接调整定时器的interval。13. 后续还能往哪扩展基础版上位机做完之后根据你的实际需要还有几个方向可以继续深入。如果你要接PLC或者工业设备可以把modbus协议加进来。Python有pymodbus库配合pyserial能很方便地读写Modbus RTU设备。这样一来很多工业现场的问题都能用你的上位机来排查。如果你要远程监控可以在本地上位机的基础上加一个网络服务层——本地接收串口数据后通过TCP/WebSocket把数据转发到远端再配合一个网页版或者手机端做远程查看。这算是目前很常见的IoT方案。如果你想做成商业产品那就要在用户管理、数据加密、配置持久化、日志审计这些方向下功夫。工具型上位机的价值在于解决具体问题商业型上位机的价值在于让不懂技术的人也能放心使用。两者侧重不同但对个人能力的锻炼都是实实在在的。从烧录一个LED闪烁程序到做一个有界面的上位机软件中间的距离其实就是串口协议、界面框架、多线程这几块知识的距离。Python把这些知识的门槛降得很低。只要你跟着这篇文章把一个能收发数据、能画波形、能保存日志的上位机跑起来后面再往上加任何功能都只是时间问题。我个人做这个项目最大的体会就是上位机开发最难的从来不是某个库怎么用而是你把一个模糊需求拆解成明确功能模块的能力。串口通信、数据解析、界面展示、文件存储——每个模块单拎出来都不难难的是让它们各司其职、协同配合、稳定运行。而这种能力恰恰只能靠一行一行代码、一个一个问题踩出来。希望这篇指南能帮你少走几步弯路剩下的路自己上手跑一遍比看十篇文章都管用。
返回列表