FEATURED · 精选文章

Python+PyQt5上位机开发实战:串口通信、实时曲线与打包全解析

发布时间 / 2026/9/1 5:31:03
来源 / 创域科博编辑部
栏目 / 资讯中心
Python+PyQt5上位机开发实战:串口通信、实时曲线与打包全解析 简介一套完整的Python上位机开发源码覆盖界面设计、数据采集、通信协议、异常处理等关键模块适合自动化设备调试、嵌入式交互等场景也适合有一定Python基础、希望快速上手上位机项目的开发者参考。压缩包共55个文件以9个py源码和17个pyc编译文件为核心另有ui界面、qrc图标资源、spec打包配置及exe可执行程序既能阅读二次开发也能直接运行验证xml与ini配置项、gitignore与iml等工程文件则方便还原PyCharm开发环境。整体包大小81.07MB已有741人学习下载。源码还附带txt说明文档与html预览文件便于梳理项目结构从零搭建上位机通常耗时较长借助这套资源可快速获得可运行模板再结合Tkinter、PySerial等库按需扩展适合学习串口通信、图形界面与打包发布流程。 串口屏、单片机、PLC这些下位机设备打交道多年我一直觉得上位机开发是自动化项目里绕不开的一环。每次提到上位机很多人第一反应是C#、MFC那套老技术栈但坦白讲这几年我用Python PyQt写的上位机项目数量已经反超C#了。这套Python上位机整套源码不是那种零散的教程demo而是我在实际项目里反复打磨出来的通用模板串口通信、实时曲线、数据存储、参数配置一应俱全拿过去改改就能对接你的设备协议。如果你正在做硬件调试、实验室测试台、小型自动化设备的上位机或者你刚入门想知道一套完整的上位机代码到底该长什么样这篇内容应该能帮你省掉至少一周的试错时间。文中会讲清楚每一块代码为什么这么写里面有哪些坑是我踩过的以及怎么把这套程序打包成exe发给同事用。1. 先聊清楚为什么用Python写上位机1.1 Python和C#上位机的真实差异我最早也是用C#写上位机的Visual Studio拖控件确实方便部署有.NET Framework兜底串口控件SerialPort也成熟。但后来项目场景变了客户今天要加一个数据回放功能明天要接一个Modbus协议后天想用MQTT把数据推到云端。C#改起来不慢但每改一次都要重新编译发布而且处理数据分析、画图表这些事没有Python来得顺手。Python这边的核心优势是迭代速度和使用门槛。如果你的下位机数据量不大串口115200波特率以下Python的性能完全够用。加上pandas、numpy、pyqtgraph这些库做二次分析、画专业级曲线图就是几行代码的事。还有个现实因素团队里能看懂Python的人越来越多了上位机源码交给别人维护也更容易。当然Python也有短板比如GIL锁、打包后体积偏大、实时性不如C。所以我的结论是中小型项目、测试类工具、数据可视化要求高的场景首选Python对微秒级响应有硬性要求的工业运动控制再去考虑C#或C。1.2 整套源码的技术选型清单说下这套源码里用的核心库都是经过实际项目验证的组合模块选型理由GUI框架PyQt5兼容PySide2控件丰富信号槽机制适合串口异步场景串口通信pyserial跨平台API简洁稳定实时曲线pyqtgraph绘图性能强CPU占用比matplotlib低一个量级数据存储csv模块 pandas通用格式Excel直接打开配置管理configparser读写ini配置简单直接打包发布PyInstaller一条命令出exe主流方案这里多说一句选型时容易踩的坑PyQt5和PySide2本质上是同一套库的不同绑定网上答案混着来。如果你的代码里用了某几个高级特性建议先确认主用方案再动手别两边API混写。1.3 源码目录结构与分层的意义整套源码我做了清晰分目录。打开就能看懂谁管界面、谁管通信、谁管数据。serial_host/ ├── main.py # 程序入口 ├── requirements.txt # 依赖清单 ├── config/ │ └── config.ini # 串口参数配置 ├── core/ │ ├── serial_manager.py # 串口通信管理线程 │ ├── data_parser.py # 数据解析与校验 │ ├── data_logger.py # CSV数据记录 │ └── signaling.py # 自定义信号与事件 ├── ui/ │ ├── main_window.py # 主窗口UI │ └── plot_widget.py # 曲线图控件封装 ├── utils/ │ └── helpers.py # 通用工具函数 └── assets/ # 图标、样式表等资源这个结构其实就是所谓的三层架构ui放界面core放业务逻辑utils放公共函数。好处很明显——当你要调试串口接收时不用在几百行UI代码里翻来翻去当你要调整界面布局时也完全不影响通信逻辑。很多初学者写上位机喜欢把所有代码塞进一个文件里一上来就几千行后面加功能时牵一发而动全身。分层不是炫技是替你未来的自己省时间。2. 核心模块的细节拆解串口、解析与界面2.1 串口连接与参数配置这一套代码直接抄串口通信是整个上位机的命脉。我的serial_manager.py里通信逻辑放在继承QThread的类中这样串口的读取循环不会阻塞UI主线程。import serial from PyQt5.QtCore import QThread, pyqtSignal class SerialManager(QThread): data_received pyqtSignal(bytes) # 收到原始数据的信号 connection_state pyqtSignal(bool) # 连接状态变化信号 def __init__(self): super().__init__() self.serial None self.port None self.baudrate 115200 self.running False def connect_device(self, port, baudrate, timeout0.1): self.port port self.baudrate baudrate self.serial serial.Serial(port, baudrate, timeouttimeout) self.running True self.start() def run(self): while self.running: if self.serial and self.serial.is_open: try: data self.serial.read(self.serial.in_waiting or 1) if data: self.data_received.emit(data) except serial.SerialException: self.connection_state.emit(False) break def send_data(self, payload: bytes): if self.serial and self.serial.is_open: self.serial.write(payload) def close_device(self): self.running False if self.serial and self.serial.is_open: self.serial.close()关键要知道每个参数字段干什么timeout读操作的等待时间。0.1秒这个值在多数场景下稳Timeout太小CPU空转太大关闭程序时会有卡顿感。self.serial.read(self.serial.in_waiting or 1)先把缓冲区里已有的数据全读出来没有新数据就阻塞等待1字节。这个写法保证不丢帧又不会死循环。data_received pyqtSignal(bytes)把原始字节数据发出去不在通信线程里做解析。解析逻辑单独放方便各协议复用。2.2 数据解析的字节序、校验与粘包处理串口数据到了之后最麻烦的问题不是收不到而是收不全。下位机发送的一帧数据可能分两次到也可能两帧数据粘在一起到。我处理这类问题的方式是先建一个接收缓存再按帧格式去匹配。class DataParser: def __init__(self): self.buffer bytearray() def feed(self, data: bytes): self.buffer.extend(data) frames [] while True: header_index self.buffer.find(b\xAA\x55) if header_index 0: self.buffer.clear() break if header_index 0: del self.buffer[:header_index] if len(self.buffer) 6: break length self.buffer[4] frame_length 5 length 2 if len(self.buffer) frame_length: break frame bytes(self.buffer[:frame_length]) del self.buffer[:frame_length] if self.check_crc(frame): frames.append(self._parse_payload(frame[5:-2])) return frames def check_crc(self, frame): payload frame[5:-2] crc_calc self._crc16(payload) crc_recv int.from_bytes(frame[-2:], big) return crc_calc crc_recv这里几个细节值得展开帧头对齐找0xAA 0x55作为帧头找不到就把缓冲区清掉。如果残留垃圾数据会导致后续拼接全部错位所以第一个动作永远是找帧头。粘包拆包通过帧头长度字段把完整的一帧切出来。长度字段算准了多帧粘在一起也能一帧帧剥离。CRC校验很多入门上位机不验证数据读出来就用结果一个干扰字节导致整个数据解析错乱。用CRC16做一次校验垃圾帧直接丢弃可靠性能提升一大截。2.3 界面和信号槽别在子线程里碰控件PyQt的规则很简单任何对UI控件的操作只能在主线程发生。子线程里直接调用label.setText这种写法十次里九次会随机崩溃。正确做法是子线程发出信号主线程的槽函数去更新界面。class MainWindow(QMainWindow): def __init__(self): super().__init__() self.serial_manager SerialManager() self.parser DataParser() self.serial_manager.data_received.connect(self.on_data_received) def on_data_received(self, raw: bytes): frames self.parser.feed(raw) for frame in frames: timestamp time.strftime(%H:%M:%S.%f) value frame[value] self.plot_widget.add_point(timestamp, value) self.table_widget.add_row(timestamp, value) self.logger.write(timestamp, value)主窗口里做的事情很简单收到数据传给解析器解析出帧之后分别画曲线、填表格、写日志。这样通信、解析、显示三个环节各干各的事没有谁卡谁。3. 从零跑通整套源码环境、界面、打包全流程3.1 五分钟搭好开发环境先确认你的机器装的是Python 3.8到3.11之间的版本。3.12虽然新但部分依赖库还没完全跟上我建议先避开。pip install PyQt5 pyserial pyqtgraph pandas pyinstaller如果下载慢切换到国内镜像源pip install -i https://pypi.tuna.tsinghua.edu.cn/simple PyQt5 pyserial pyqtgraph pandas pyinstaller装完可以把依赖导出到requirements.txt方便别人一键复现pip freeze requirements.txt3.2 主窗口的搭建逻辑不手动设坐标UI我是用纯代码写的没用Qt Designer。为什么因为设计器生成的.ui文件虽然可视化但和代码交互多了一道转编译步骤改动布局要来回切换工具。纯代码写布局改动一行就能立刻看效果。from PyQt5.QtWidgets import QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QComboBox, QLabel class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(Python通用串口上位机) self.setMinimumSize(1000, 700) central QWidget() self.setCentralWidget(central) layout QVBoxLayout(central) top_bar QHBoxLayout() self.port_combo QComboBox() self.baud_combo QComboBox() self.baud_combo.addItems([9600, 19200, 115200]) self.connect_btn QPushButton(连接设备) self.send_btn QPushButton(发送数据) self.status_label QLabel(未连接) top_bar.addWidget(QLabel(串口:)) top_bar.addWidget(self.port_combo) top_bar.addWidget(QLabel(波特率:)) top_bar.addWidget(self.baud_combo) top_bar.addWidget(self.connect_btn) top_bar.addWidget(self.send_btn) top_bar.addStretch() top_bar.addWidget(self.status_label) self.plot_widget PlotWidget() layout.addLayout(top_bar) layout.addWidget(self.plot_widget, stretch1)注意我用addStretch()把状态标签推到右侧。用QVBoxLayout和QHBoxLayout嵌套不用任何绝对坐标窗口拉大拉小控件都会自适应。3.3 通信线程与UI联动从按下按钮到数据上屏连接按钮的槽函数是这么写的def on_connect(self): if not self.serial_manager.serial: port self.port_combo.currentText() baud int(self.baud_combo.currentText()) self.serial_manager.connect_device(port, baud) self.status_label.setText(f已连接 {port} {baud}) self.connect_btn.setText(断开连接) else: self.serial_manager.close_device() self.status_label.setText(未连接) self.connect_btn.setText(连接设备)再配合窗口关闭事件确保退出程序时把串口关掉不然串口会一直被占用下次打开会报端口被占用。def closeEvent(self, event): self.serial_manager.close_device() super().closeEvent(event)这样整套流程就很顺了点击连接 → 启动串口线程 → 串口线程读到数据 → 发信号 → 主线程解析 → 曲线和表格更新。3.4 数据落盘CSV日志到底怎么设计上位机光显示不够很多场景还要把数据存下来方便事后分析。我的data_logger.py输出CSV格式import csv import threading class CsvLogger: def __init__(self, pathdata_log.csv): self.file open(path, a, newline, encodingutf-8) self.writer csv.writer(self.file) self.lock threading.Lock() def write(self, timestamp, value): with self.lock: self.writer.writerow([timestamp, value]) self.file.flush() def close(self): self.file.close()两个关键点加threading.Lock防止多线程同时写数据导致文件损坏每次写完flush()确保数据直接落到磁盘不会被内存缓存吞掉。程序崩溃了前边的数据还在。3.5 用PyInstaller打包成exe开发完发给同事用不能要求人家装Python环境。PyInstaller打包命令很短但有几个参数值得注意。PyInstaller -F -w -n SerialHost main.py参数说明-F打包成单个exe文件方便分发。-w不显示黑色控制台窗口。-n指定exe名称。打包完的exe在dist目录下双击就能跑。有个坑要提醒如果你的代码里动态加载了资源文件图片、样式表等PyInstaller不会自动帮你打包进去需要用--add-data把它带进去并且代码里读取时要判断打包路径。我常用的路径解析写法是import sys, os def resource_path(relative_path): base_path getattr(sys, _MEIPASS, os.path.dirname(os.path.abspath(__file__))) return os.path.join(base_path, relative_path)这样无论源码运行还是exe运行都能正确找到资源文件。4. 你大概率会遇到的几个问题我当年的排查记录4.1 串口列表刷不出来或打不开刚开始用pyserial读串口列表Windows下用serial.tools.list_ports.comports()就能拿到所有可用串口。典型问题是新插的USB转串口设备没有被系统识别或者被其他程序比如串口调试助手占用了。处理方式每次界面启动时自动尝试刷新串口列表并且提供手动刷新按钮如果打开失败弹窗提示设备被占用。关闭程序时一定要执行close_device()这个我前面强调了但真正踩过的人才明白串口被占有多难受——你只能拔掉USB重新插。4.2 数据窗口卡死曲线像PPT一样一顿一顿这是最高频的问题。原因基本只有一个你在主线程里做了耗时操作比如把收到的数据存Excel、或者一次性把几百个点绘到普通图表上。解决方法就是用QThread做通信用pyqtgraph做绘图。pyqtgraph底层用OpenGL加速的GraphicsView框架绘制几千个点毫无压力。另外如果数据量太大比如上千个点每秒可以做个简单抽稀策略只保留最近N个点显示MAX_POINTS 5000 def add_point(self, timestamp, value): self.x_data.append(len(self.x_data)) self.y_data.append(value) if len(self.x_data) MAX_POINTS: self.x_data.pop(0) self.y_data.pop(0) self.curve.setData(self.x_data, self.y_data)4.3 解析出来的数据时不时错一帧如果你发现100帧里偶尔错1帧先别怀疑单片机。多半是解析逻辑没有做好粘包和半包处理。比如下位机一次发来8字节read只读到5字节下一批数据又跟过来3字节——如果直接按帧长切结果就乱了。我的习惯做法是像前面那样加一个bytearray接收缓存每次收完数据先整帧匹配再取有效数据。测试时人为模拟分批发送# 模拟粘包 分批到达 test_frame bytes.fromhex(AA55 03 010203 AABB) parser.feed(test_frame[:4]) parser.feed(test_frame[4:]) frames parser.feed(b) assert len(frames) 1这个测试用例能很快暴露解析问题。4.4 打包后的exe在别人电脑上报错常见原因有两类一是对方没装VC运行库PyQt依赖一些C运行时解决办法是打包时使用--uac-admin或者提供运行库安装包二是路径问题程序里的配置文件和日志路径写成绝对路径了换个电脑就找不到。稳妥做法是程序运行目录下的相对路径或者直接用os.path.join(os.path.dirname(sys.executable), config.ini)。这套源码接下来还可以怎么扩展如果设备通信协议不只是串口后面的扩展方向其实很清晰把SerialManager抽象成BaseTransport接口再实现TcpManager、UdpManager、ModbusManager界面部分完全不用改就可以支持网络设备。数据处理层也可以把CSV换成SQLite或者直接接InfluxDB做时间序列存储。我个人在实际项目里还加过两个功能一个是通过UDP把串口数据转发给局域网其他设备做联动另一个是加了一个简单的命令日志模块记录每次发送的下发指令方便后期排查设备行为。这套源码当初就是从先把串口打通到稳稳跑三个月不重启一步步磨出来的每次改动都比想象中值。最后想说的是上位机开发没有想象中那么难但也没网上一堆速成教程说的那么无脑。把通信、解析、界面、存储这几条线理顺了任何设备协议上身都能快速落地。项目源码再怎么给自己动手把每个模块跑通一遍、改一版才算真的学会了。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻