简介:一套完整的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, timeout=0.1): self.port = port self.baudrate = baudrate self.serial = serial.Serial(port, baudrate, timeout=timeout) 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, stretch=1)注意我用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, path='data_log.csv'): self.file = open(path, 'a', newline='', encoding='utf-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把串口数据转发给局域网其他设备做联动;另一个是加了一个简单的命令日志模块,记录每次发送的下发指令,方便后期排查设备行为。这套源码当初就是从"先把串口打通"到"稳稳跑三个月不重启"一步步磨出来的,每次改动都比想象中值。
最后想说的是,上位机开发没有想象中那么难,但也没网上一堆速成教程说的那么无脑。把通信、解析、界面、存储这几条线理顺了,任何设备协议上身都能快速落地。项目源码再怎么给,自己动手把每个模块跑通一遍、改一版才算真的学会了。
本文还有配套的精品资源,点击获取