这次我们来看一个非常典型的工控软件需求:用 Python + PyQt5 自己写一套 Modbus 多串口工业上位机系统。项目标题里的功能点很完整——仪表台、实时图表、预警、CSV 保存、完整源码,这基本上覆盖了中小型设备监控上位机 80% 以上的日常需求。
这套系统适合谁?如果你正在做设备调试、产线数据采集、环境监控,或者只是需要把几个串口设备的数据统一汇总到一个界面里,那 PyQt5 + Modbus 这套组合是目前 Python 生态里落地最快、技术栈最清晰的一条路。它不用买商业组态软件,也不用碰 C#/MFC 那种偏重的桌面框架,一个 Python 脚本加几个依赖库就能把上位机跑起来。
文章会按“先看规格、再搭环境、然后跑通通信、最后补功能”的顺序展开。你会看到核心能力速览、完整的代码结构和实现思路、Modbus RTU/TCP 通信封装、仪表台与实时曲线实现、预警与 CSV 存储逻辑、功能测试方法(含模拟从站验证)、常见问题排查清单。读者照着走一遍,基本就能搭出一套可用的多串口数据采集上位机。
1. 核心能力速览
先把这套系统的整体规格列出来,方便你快速判断值不值得自己动手。
| 能力项 | 说明 |
|---|---|
| 项目类型 | 桌面式工业上位机(数据采集与监控) |
| 技术栈 | Python 3.8+、PyQt5、pymodbus、pyserial、pyqtgraph |
| 重点功能 | 多串口并发采集、Modbus RTU/TCP、仪表台展示、实时曲线、越限预警、CSV 落盘 |
| 硬件门槛 | 普通办公电脑即可,无需独立显卡,推荐 8G 内存以上 |
| 系统支持 | Windows 10/11、Ubuntu/Debian、树莓派等 Linux 平台 |
| 启动方式 | 命令行一键启动(也可用 PyInstaller 打包成 exe) |
| 通信接口 | 串口(RS232/RS485/USB 转串口)、Modbus TCP |
| 批量任务 | 支持按仪表轮询批量采集、CSV 批量导出 |
| 是否收费 | Python 生态全部开源免费,无授权费用 |
| 适合场景 | 产线设备监控、传感器数据采集、能源监测、环境监测、实验室设备记录 |
从功能覆盖来看,这个项目最值得关注的点不是某一个界面控件,而是把“多串口调度 + 协议解析 + 数据可视化 + 存储报警”这条完整链路用 Python 打通了。后面不管你是要换成别的协议,还是要接数据库、MQTT、Web 页面,这套骨架都能改。
2. 适用场景与使用边界
2.1 适合做什么
这类上位机系统最常见的落地场景是:
- 产线设备数据采集:把多台 PLC、智能仪表、变频器通过 RS485 总线接上来,定时读取运行参数。
- 环境监测:温度、湿度、压力、流量等传感器数据集中显示和记录。
- 能源管理:电表、水表、气表的 Modbus 数据汇总,生成日报/月报 CSV。
- 实验室设备记录:多台仪器同时测试时,用上位机统一记录实验数据。
- 设备预警提醒:实时监控关键参数,超限后立即在界面报警并保存现场数据。
2.2 不适合做什么
- 硬实时控制:Python 不是硬实时系统,不能用于需要毫秒级响应的安全联锁控制。
- 大规模点位采集:如果单机需要采集几千甚至上万个寄存器且刷新频率很高,建议用 C#/Go 或者组态软件,Python 更适合中等规模。
- 复杂联动逻辑:如果设备之间有大量互锁、顺序控制、配方切换逻辑,上位机文本语言维护成本会很高。
2.3 合规与安全边界
Modbus 协议本身没有加密和认证,默认端口 502 在公网暴露非常危险。部署时务必注意:
- 上位机与设备之间尽量走独立局域网或工业网段,不要直接暴露到互联网。
- 如果系统涉及对设备的写操作(写线圈、写寄存器),必须在软件里加入二次确认、操作日志、权限区分,避免误操作引发设备故障或安全事故。
- 涉及设备运行数据的采集,请确认已获得设备厂家或现场管理方的授权;涉及人员信息、隐私数据的系统请遵守相关数据保护法规。
- 在真实设备上测试前,先用模拟从站和虚拟串口验证逻辑,降低现场调试风险。
- 人脸、声音、位置等个人信息不是本项目典型场景,但如果你的上位机系统接入相关传感器数据,同样需要遵循隐私合规要求。
3. 环境准备与依赖安装
3.1 基础环境清单
开发这套系统,建议按下面的清单准备环境:
| 项目 | 建议 |
|---|---|
| 操作系统 | Windows 10/11 或 Ubuntu 20.04+ |
| Python | 3.8 到 3.11 均可,推荐 3.10 |
| 驱动 | USB 转串口设备需安装对应驱动,Windows 下以 CH340/FTDI 芯片最常见 |
| 虚拟环境 | 建议使用 venv 或 conda 隔离依赖 |
| 编辑器 | VSCode、PyCharm 均可 |
安装基础 Python 时,记得勾选“Add Python to PATH”,避免后面命令行找不到python。
3.2 安装 Python 依赖库
核心依赖只有四个:PyQt5 负责界面,pymodbus 负责 Modbus 协议解析,pyserial 负责串口通信,pyqtgraph 负责高性能实时绘图。
# 建议先创建虚拟环境 python -m venv venv # Windows 激活 venv\Scripts\activate # Linux / macOS 激活 source venv/bin/activate # 安装依赖 pip install pyqt5 pyqt5-tools pymodbus pyserial pyqtgraph如果网络环境比较慢,可以换用国内镜像源:
pip install pyqt5 pyqt5-tools pymodbus pyserial pyqtgraph -i https://pypi.tuna.tsinghua.edu.cn/simple安装完成后,可以快速验证一下关键库是否正常导入:
import PyQt5 import pymodbus import serial import pyqtgraph print("PyQt5:", PyQt5.QtCore.PYQT_VERSION_STR) print("pymodbus:", pymodbus.__version__) print("pyserial:", serial.VERSION) print("pyqtgraph:", pyqtgraph.__version__)注意:pymodbus 大版本迭代较快,2.x 和 3.x 的 API 有差异。本文示例以 3.x 风格为主,如果你用的是 2.x,需要把异步客户端导入路径调整一下。
3.3 串口驱动与虚拟串口
连接真实 RS485/RS232 设备前,先确认设备管理器中能看到对应的 COM 口。
如果没有真实串口设备,可以用两类工具模拟:
- 虚拟串口对工具:创建成对的虚拟 COM 口,比如 COM3 和 COM4 互相连通,方便串口调试。Windows 下常见的有 com0com、Virtual Serial Port Driver,注意选择正规渠道下载。
- Modbus 模拟从站:用一个软件模拟 Modbus 设备,提供寄存器数据,供上位机读取。Windows 下常见的是 Modbus Slave,但要注意其授权模式;也可以直接使用开源方案,例如 pymodbus 自带的 server 示例,或者
modbus-cli这类命令行工具。
在 Ubuntu 或树莓派上,还可以用 socat 快速创建一对伪终端来模拟串口:
sudo apt install socat socat -d -d pty,raw,echo=0 pty,raw,echo=0运行后,socat 会输出两个/dev/pts/X设备名,它们互相连接。你可以在一个终端里写数据,另一个终端里读到相同内容。
4. 系统架构与设计思路
4.1 整体架构
一个多串口上位机系统,最忌讳把所有代码堆在主窗口里。建议按“界面层-通信层-数据层-业务层”分层:
界面层(MainWindow) ├── 仪表台显示控件 ├── 实时曲线控件 ├── 报警表格 / 状态栏 └── 参数配置面板 通信层(SerialManager + ModbusClient) ├── 串口扫描 / 打开 / 关闭 ├── 多串口线程调度 ├── Modbus RTU 读写 └── Modbus TCP 客户端 数据层(DataWorker) ├── 数据缓存队列 ├── CSV 写入 └── 报警记录 业务层(AlarmEngine) ├── 阈值判断 ├── 声音 / 弹窗提示 └── 异常计数这样分层之后,串口通信不会阻塞界面刷新,CSV 写入也不影响实时图表绘制,后面要加数据库或 MQTT 也只是在数据层做扩展。
4.2 线程模型
PyQt5 中不能直接在子线程操作界面控件。推荐的做法是:
- 主线程:负责界面绘制、用户操作事件。
- 通信线程:每个串口一个 QThread,负责循环读取 Modbus 寄存器。
- 数据处理线程:把采集到的原始数据从队列里取出,做越限判断和 CSV 写入。
- 线程间通信:通过
pyqtSignal信号把数据传递回 UI 线程。
下面是一个典型的信号定义示例:
from PyQt5.QtCore import QThread, pyqtSignal class ModbusReadThread(QThread): data_ready = pyqtSignal(int, dict) # 设备ID, 数据字典 error_occurred = pyqtSignal(str) # 错误信息 def __init__(self, client, device_id, register_map, interval): super().__init__() self.client = client self.device_id = device_id self.register_map = register_map self.interval = interval self._running = True def stop(self): self._running = False def run(self): while self._running: try: values = self.client.read_registers(self.device_id, self.register_map) self.data_ready.emit(self.device_id, values) except Exception as e: self.error_occurred.emit(f"设备 {self.device_id} 读取失败: {e}") self.msleep(self.interval)这种设计的好处是:每个串口设备独立线程,一个设备超时不会影响其他设备;刷新间隔可以按设备单独配置,比如电表 1 秒读一次,温湿度 5 秒读一次。
5. 多串口 Modbus 通信实现
5.1 Modbus 客户端封装
无论你是用串口(Modbus RTU)还是网口(Modbus TCP),在业务代码里最好统一封装成一个客户端类,这样界面层不用关心底层是 RTU 还是 TCP。
from pymodbus.client import ModbusSerialClient, ModbusTcpClient class ModbusClient: def __init__(self, mode="rtu", **kwargs): self.mode = mode.lower() if self.mode == "rtu": self.client = ModbusSerialClient( port=kwargs.get("port"), baudrate=kwargs.get("baudrate", 9600), bytesize=kwargs.get("bytesize", 8), parity=kwargs.get("parity", "N"), stopbits=kwargs.get("stopbits", 1), timeout=kwargs.get("timeout", 1) ) elif self.mode == "tcp": self.client = ModbusTcpClient( host=kwargs.get("host"), port=kwargs.get("port", 502), timeout=kwargs.get("timeout", 3) ) else: raise ValueError("mode 只支持 rtu 或 tcp") def connect(self): return self.client.connect() def close(self): self.client.close() def read_holding_registers(self, device_id, address, count): response = self.client.read_holding_registers(address, count, slave=device_id) if response.isError(): raise RuntimeError(f"读取保持寄存器失败: {response}") return response.registers def read_input_registers(self, device_id, address, count): response = self.client.read_input_registers(address, count, slave=device_id) if response.isError(): raise RuntimeError(f"读取输入寄存器失败: {response}") return response.registers def write_single_register(self, device_id, address, value): response = self.client.write_register(address, value, slave=device_id) if response.isError(): raise RuntimeError(f"写寄存器失败: {response}") return True需要注意,不同厂家设备的地址描述可能不同。有的设备文档写“寄存器地址 40001”,对应协议地址 0;有的直接写协议地址 0。采集时建议以 Modbus 协议地址为准,并在配置表里注释设备原始编号,避免搞混。
5.2 多串口管理
多串口的本质是“一个串口对应一条 RS485 总线,一条总线上可以挂多个从站”。所以管理对象至少要有两级:串口和设备。
class SerialPortItem: def __init__(self, port, baudrate, devices): self.port = port self.baudrate = baudrate self.devices = devices # [{device_id, name, registers}] self.client = None self.thread = None def open(self): self.client = ModbusClient( mode="rtu", port=self.port, baudrate=self.baudrate, parity="N", stopbits=1, timeout=1 ) return self.client.connect() def close(self): if self.thread: self.thread.stop() if self.client: self.client.close()这个类负责一个串口上所有设备的打开、读取、关闭。主界面只需要维护一个SerialPortItem列表,就能同时运行多个串口。
5.3 串口扫描
用户不知道设备插在哪个 COM 口,所以启动界面时最好自动扫描可用串口。
import serial.tools.list_ports def list_serial_ports(): ports = serial.tools.list_ports.comports() result = [] for p in ports: result.append({ "port": p.device, "description": p.description, "hwid": p.hwid }) return result # 使用示例 for p in list_serial_ports(): print(p)扫描结果会显示 COM 口号、芯片描述和硬件 ID。如果设备管理器中能看到串口,但这里扫不到,多半是驱动问题,优先检查 CH340/FTDI 驱动。
6. 仪表台与实时图表实现
6.1 仪表台设计
仪表台适合展示温度、压力、转速这类直观数值。PyQt5 中没有内置仪表控件,通常有两种做法:
- 使用 QSS + 自绘控件:用
paintEvent画出圆形表盘,工作量适中。 - 使用 QLCDNumber + 指示灯:大量即时数值用大号数字显示,配合红绿指示灯表示状态,最实用。
下面是一个简化的仪表盘控件示例,使用QPainter绘制弧形表盘:
from PyQt5.QtWidgets import QWidget from PyQt5.QtCore import Qt from PyQt5.QtGui import QPainter, QColor, QPen, QFont class GaugeWidget(QWidget): def __init__(self, title="", min_value=0, max_value=100, parent=None): super().__init__(parent) self.title = title self.min_value = min_value self.max_value = max_value self.current_value = min_value def set_value(self, value): self.current_value = value self.update() def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) width = self.width() height = self.height() side = min(width, height) - 20 rect = self.rect().adjusted(10, 10, -10, -10) # 绘制背景弧 pen = QPen(QColor("#333333")) pen.setWidth(10) painter.setPen(pen) painter.drawArc(rect, 210 * 16, -240 * 16) # 进度弧 progress_ratio = (self.current_value - self.min_value) / (self.max_value - self.min_value) progress_ratio = max(0.0, min(1.0, progress_ratio)) pen2 = QPen(QColor("#00CC66")) pen2.setWidth(10) painter.setPen(pen2) painter.drawArc(rect, 210 * 16, int(-240 * 16 * progress_ratio)) # 数值文字 painter.setPen(QColor("#FFFFFF")) font = QFont("Arial", 16, QFont.Bold) painter.setFont(font) text = f"{self.current_value:.1f}" painter.drawText(rect, Qt.AlignCenter, text) # 标题 painter.setPen(QColor("#AAAAAA")) title_font = QFont("Arial", 10) painter.setFont(title_font) painter.drawText(self.rect().adjusted(0, 10, 0, -10), Qt.AlignTop | Qt.AlignHCenter, self.title)真实项目里建议直接把表盘做成一个单独的widgets/gauge.py模块,界面中用多个GaugeWidget实例展示不同点位。
6.2 实时曲线
实时曲线推荐 pyqtgraph,它的绘制性能远好于 Matplotlib,适合秒级刷新的工业数据。
import pyqtgraph as pg from PyQt5.QtWidgets import QWidget from PyQt5.QtCore import QTimer from collections import deque class RealtimeCurveWidget(QWidget): def __init__(self, parent=None): super().__init__(parent) self.data_len = 300 self.data_buffers = {} self.plot_widget = pg.PlotWidget() self.plot_widget.showGrid(x=True, y=True, alpha=0.3) layout = QVBoxLayout(self) layout.addWidget(self.plot_widget) def add_curve(self, name, color=(0, 255, 0)): if name not in self.data_buffers: self.data_buffers[name] = deque(maxlen=self.data_len) self.plot_widget.plot( name=name, pen=pg.mkPen(color, width=2) ) def append_data(self, name, value): if name in self.data_buffers: self.data_buffers[name].append(value) self.plot_widget.getPlotItem().curves[name].setData(list(self.data_buffers[name])) def clear_all(self): self.data_buffers.clear() self.plot_widget.clear()使用 300 点固定缓冲区的原因是:工业实时曲线不需要保留所有历史点,只保留最近 5 分钟左右的趋势即可,历史数据交给 CSV 和数据库存储。
6.3 仪表台布局示例
一个典型的仪表台界面可以这样组织:
- 顶部:设备状态栏 + 报警指示灯。
- 中间:多个 GaugeWidget 排成 3 列或 4 列,展示主要参数。
- 底部:RealtimeCurveWidget,支持多曲线切换显示。
from PyQt5.QtWidgets import QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QLabel class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Modbus 多串口工业上位机系统") self.resize(1280, 800) central = QWidget() self.setCentralWidget(central) main_layout = QVBoxLayout(central) # 仪表台区域 gauge_container = QHBoxLayout() self.gauge_temp = GaugeWidget("温度", 0, 100) self.gauge_press = GaugeWidget("压力", 0, 10) self.gauge_flow = GaugeWidget("流量", 0, 50) gauge_container.addWidget(self.gauge_temp) gauge_container.addWidget(self.gauge_press) gauge_container.addWidget(self.gauge_flow) main_layout.addLayout(gauge_container) # 实时曲线区域 self.curve_widget = RealtimeCurveWidget() main_layout.addWidget(self.curve_widget, stretch=1)7. 预警系统与 CSV 保存
7.1 预警判断逻辑
预警不要写到 UI 层,建议单独维护一个报警引擎。每个点位配置上限、下限、回差、是否启用报警。
回差( hysteresis )非常重要:如果不加回差,数值在阈值附近小幅波动时,会出现反复报警/消除报警,造成大量无效记录和声音提示。
class AlarmEngine: def __init__(self): self.alarm_rules = {} self.current_alarm_state = {} def add_rule(self, point_name, high_limit=None, low_limit=None, hysteresis=0.0): self.alarm_rules[point_name] = { "high": high_limit, "low": low_limit, "hysteresis": hysteresis } self.current_alarm_state[point_name] = False def check(self, point_name, value): if point_name not in self.alarm_rules: return False rule = self.alarm_rules[point_name] is_alarm = False if rule["high"] is not None: if self.current_alarm_state[point_name]: is_alarm = value > (rule["high"] - rule["hysteresis"]) else: is_alarm = value > rule["high"] if not is_alarm and rule["low"] is not None: if self.current_alarm_state[point_name]: is_alarm = value < (rule["low"] + rule["hysteresis"]) else: is_alarm = value < rule["low"] self.current_alarm_state[point_name] = is_alarm return is_alarm报警触发后的动作可以包括:界面红色闪烁、状态栏文字、声音提示、CSV 写入报警记录。
7.2 CSV 数据保存
CSV 保存要解决两个问题:按时间归档、写入时不阻塞 UI。最简单可靠的方案是按天生成文件,文件名为data_20250101.csv。
import csv import os from datetime import datetime class CsvRecorder: def __init__(self, base_dir="data"): self.base_dir = base_dir os.makedirs(base_dir, exist_ok=True) self.current_file = None self.current_path = None self.fieldnames = [] def _ensure_file(self, extra_fields): today = datetime.now().strftime("%Y%m%d") path = os.path.join(self.base_dir, f"data_{today}.csv") if self.current_path != path: self.current_path = path self.current_file = open(path, "a", newline="", encoding="utf-8-sig") self.fieldnames = ["timestamp"] + extra_fields is_new = os.path.getsize(path) == 0 writer = csv.DictWriter(self.current_file, fieldnames=self.fieldnames) if is_new: writer.writeheader() self.current_file.flush() return self.current_file def write_row(self, values: dict): extra_fields = list(values.keys()) f = self._ensure_file(extra_fields) row = {"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:23]} row.update(values) writer = csv.DictWriter(f, fieldnames=self.fieldnames) writer.writerow(row) f.flush() def close(self): if self.current_file: self.current_file.close()编码使用utf-8-sig是为了让 Excel 直接打开 CSV 时中文不乱码。写入后调用flush()是为了防止异常退出时丢失数据,但也不能频繁 flush,否则磁盘 IO 压力大。常规 1~5 秒一条数据的情况下,这个方案完全够用。
7.3 信号联动示例
把通信线程、报警引擎、CSV 记录器接起来,只需在 UI 线程写一个槽函数:
def on_data_received(self, device_id, values): # 刷新仪表台 if "temperature" in values: self.gauge_temp.set_value(values["temperature"]) # 追加曲线 for key, value in values.items(): self.curve_widget.append_data(f"{device_id}_{key}", value) # 报警判断 for key, value in values.items(): point_name = f"{device_id}_{key}" if point_name in self.alarm_engine.alarm_rules: is_alarm = self.alarm_engine.check(point_name, value) if is_alarm: self.statusBar().showMessage(f"警告: {point_name} 越限,当前值 {value}", 5000) # CSV 保存 row = {f"{device_id}_{key}": value for key, value in values.items()} self.csv_recorder.write_row(row)这里的关键点:所有数据传递都通过 Qt 信号回到了 UI 线程,所以可以直接更新控件,不需要再加锁。
8. 功能测试与效果验证
在真实设备上调试之前,强烈建议先做一轮模拟验证。下面这套流程能帮你确认“界面-通信-存储-报警”整条链路是否正常。
8.1 用 pymodbus 模拟从站
pymodbus 3.x 提供了服务器功能,可以启动一个本地 Modbus TCP 从站。先在另一个终端运行下面的脚本:
import asyncio from pymodbus.server import StartAsyncTcpServer from pymodbus.datastore import ModbusSequentialDataBlock, ModbusSlaveContext, ModbusServerContext def make_server_context(): block = ModbusSequentialDataBlock(0, [100, 25, 50, 0, 0, 0, 0, 0, 0, 0]) slave_context = ModbusSlaveContext(di=block, co=block, hr=block, ir=block) return ModbusServerContext(slaves=slave_context, single=True) async def main(): context = make_server_context() await StartAsyncTcpServer(context, address=("127.0.0.1", 5020)) if __name__ == "__main__": asyncio.run(main())然后用前面写的ModbusClient连接 TCP 模式读取:
client = ModbusClient(mode="tcp", host="127.0.0.1", port=5020) client.connect() registers = client.read_holding_registers(device_id=1, address=0, count=4) print("寄存器值:", registers) client.close()预期输出:
寄存器值: [100, 25, 50, 0]如果读到 [100, 25, 50, 0],说明 Modbus 客户端封装正确。接下来就是把采集值通过信号灌进界面,观察仪表台和曲线是否刷新。
8.2 用虚拟串口测试 RTU 模式
Windows 下先创建一对虚拟串口(例如 COM3、COM4),然后让上位机打开 COM3,另一个模拟从站程序打开 COM4。这样不需要真实硬件就能验证 RTU 收发。
Linux 下用 socat:
socat -d -d pty,raw,echo=0 pty,raw,echo=0得到两个伪终端路径后,在一个终端用minicom或 Python 脚本打开一端,在上位机中配置另一端即可。注意:RTU 模式下串口参数必须一致,通常为 9600 8N1,确认不要配错。
8.3 测试用例清单
| 测试项 | 操作 | 预期结果 | 失败排查方向 |
|---|---|---|---|
| 串口扫描 | 插入 USB 转串口设备后点击刷新 | 列表出现对应 COM 口 | 驱动未安装、设备接线不良 |
| Modbus TCP 读取 | 连上模拟从站,读取寄存器 | 返回模拟数据 | 端口写错、从站未启动、防火墙拦截 |
| Modbus RTU 读取 | 通过虚拟串口连接从站 | 返回模拟数据 | 波特率/校验位/停止位不一致 |
| 仪表台刷新 | 修改模拟从站寄存器值 | 表盘数值实时变化 | 信号未连接、线程未启动 |
| 实时曲线 | 连续运行 1 分钟 | 曲线按时间滚动 | 缓冲区长度、更新频率 |
| 预警触发 | 把寄存器值设到阈值以上 | 页面报警、状态栏提示 | 报警阈值配置、回差设置 |
| CSV 写入 | 运行 10 分钟 | data 目录生成本日 CSV 文件 | 目录权限、字段名冲突 |
| 多串口并发 | 同时打开两个虚拟串口 | 两路数据互不干扰 | 线程异常、串口被占用 |
8.4 判断成功标准
一套基础版多串口上位机系统跑通的标准:
- 能同时打开至少 2 个串口,每个串口上至少挂 2 个从站。
- 断开任意一个从站时,其他从站和界面不卡死。
- 仪表台数值与模拟从站寄存器值一致。
- 报警触发后能在 1 秒内反映到界面。
- 当天 CSV 文件能按字段完整记录所有点位数据。
9. 资源占用与性能观察
9.1 如何观察资源占用
运行上位机时,重点观察几项指标:
- CPU 占用率:正常情况下 Python 进程 CPU 占用应该很低,因为大部分时间在等待串口返回。如果 CPU 持续飙高,优先怀疑线程里存在忙循环或者刷新频率太高。
- 内存占用:PyQt5 程序基础内存大约 100M~200M 起步。如果持续增长,检查数据缓冲区是否无限扩大,CSV 文件句柄是否未关闭。
- 串口响应时间:可以通过打印每次读取的耗时来判断,超过 1 秒就要检查串口参数和从站响应速度。
Windows 下可以打开任务管理器,Linux 下用top或htop。更精确的方式是给程序加日志:
import time import logging start = time.perf_counter() registers = client.read_holding_registers(1, 0, 10) elapsed = time.perf_counter() - start logging.info(f"读取耗时: {elapsed * 1000:.1f} ms")9.2 影响性能的因素
| 因素 | 影响 | 优化建议 |
|---|---|---|
| 刷新频率 | 刷新越快,CPU 和串口负载越高 | 普通采集 1~5 秒一次足够 |
| 设备数量 | 设备越多,单轮轮询时间越长 | 按优先级分组轮询 |
| 数据点数 | 寄存器数量越多,单次报文越大 | 只采集需要的点位 |
| 绘图缓冲 | 曲线缓冲区过大消耗内存 | 保留 300~600 点即可 |
| CSV 写入频率 | 高频写入会拖慢主线程 | 写操作放到独立线程或降低频率 |
| 日志输出 | DEBUG 级日志大量打印 | 生产环境用 INFO/WARNING |
9.3 降低资源占用的通用手段
- 使用生产者-消费者模式,把串口读取和数据写入分开。
- 关闭不需要的串口,避免持有多个空闲 COM 口句柄。
- 曲线刷新使用定时器节流,例如 UI 最多 10 FPS,而不是每个数据点都重绘。
- 长时间运行后定期清理报警历史列表,防止 QTableWidget 行数无限增长。
10. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 扫描不到串口 | USB 转串口驱动未安装 | 打开设备管理器查看端口 | 安装 CH340/FTDI 驱动并重新插拔 |
| 串口被占用 | 其他软件已打开该 COM 口 | 关闭串口调试工具 | 关闭占用串口的程序后重试 |
| Modbus 读取超时 | 波特率/校验位/从站地址错误 | 用串口调试助手抓报文 | 核对设备手册,逐一调整参数 |
| 读取返回非法功能码 | 功能码与设备类型不匹配 | 确认设备支持 03/04/06/16 哪个功能码 | 修改读取寄存器函数 |
| 界面卡死 | 在 UI 线程写了串口读操作 | 检查是否在槽函数里调用阻塞读取 | 把通信放到 QThread 中 |
| 曲线不刷新 | 信号未连接或线程未启动 | 打印日志确认数据是否到达 UI | 检查 pyqtSignal sender 与 connect |
| CSV 中文乱码 | 编码方式不对 | 用记事本/编辑器查看编码 | 改为 utf-8-sig 写入 |
| 多串口同时断开时崩溃 | 线程关闭顺序不对 | 查看异常堆栈 | 先停线程再关闭串口 |
| 打包成 exe 后找不到驱动库 | PyInstaller 未包含 pyserial 数据 | 观察打包日志 | 在 spec 文件中加入 hidden imports |
| 长时间运行内存增长 | 缓冲区或表格行数无限增加 | 观察内存曲线 | 增加最大长度限制与定期清理 |
10.1 串口调试辅助手段
排查通信问题时,先用串口调试助手确认设备在自发自收模式下是否正常。如果设备手册说地址是 1,寄存器地址是 0,但读出来一直是超时,最容易忽略的点有:
- 485 总线需要 A/B 线正确连接,且终端电阻按现场要求接。
- 设备地址可能不是 1 而是拨码开关设置后的值。
- 串口参数中的校验位可能是 Even,不是默认的 None。
- 有些设备要求读取前先发送“唤醒”字符,这在 Modbus 标准里不常见,需要单独处理。
11. 最佳实践与合规提醒
11.1 工程化建议
把代码按模块拆分:
project/ ├── main.py ├── config/ │ └── device_config.json ├── core/ │ ├── modbus_client.py │ ├── serial_manager.py │ ├── alarm_engine.py │ └── csv_recorder.py ├── ui/ │ ├── main_window.py │ ├── widgets/ │ │ ├── gauge_widget.py │ │ └── curve_widget.py ├── utils/ │ └── logger.py └── data/ └── (运行时生成的 CSV)配置不要写死在代码里,建议使用 JSON 文件管理设备列表:
{ "serial_ports": [ { "port": "COM3", "baudrate": 9600, "parity": "N", "devices": [ { "device_id": 1, "name": "温度表", "registers": [ {"name": "temperature", "address": 0, "count": 1, "scale": 0.1} ] } ] } ], "poll_interval_ms": 1000, "alarm_rules": { "温度表_temperature": { "high": 80.0, "low": -10.0, "hysteresis": 0.5 } } }配置与代码分离后,现场调试时只需要改 JSON,不需要重新打包。
11.2 常见工程坑
- 寄存器数据的字节序问题:有些设备高位在前,有些低位在前,解析时必须根据设备手册调整。建议在配置里增加
byte_order字段。 - 数据缩放问题:很多仪表实际值是寄存器的 10 倍或 100 倍,读取后要乘以 scale 再显示。
- 负数表示:温度可能用补码表示,Python 中需要对大于 0x8000 的值做符号转换。
- 线程安全:不要在多个线程里同时使用同一个 Modbus 客户端连接,一个串口一条总线只能有一个客户端负责轮询。
- 写操作必须加二次确认:如果系统支持写线圈/寄存器,界面上的“写”按钮最好配合弹窗确认,避免误触。
11.3 合规与安全提醒
- 涉及真实生产设备时,先获得现场管理方的测试授权,尽量在停产或低风险时段首次联调。
- 涉及对设备的写操作时,程序要保留完整操作日志(操作人、时间、点位、修改前后的值)。
- Modbus 无加密,建议只在可信局域网使用,必要时用工业防火墙隔离。
- 如果系统最终要用于商业交付,请确认设备厂家协议文档的授权范围,不要使用非正规途径获取的协议资料。
- 如果采集的数据涉及个人信息或受监管数据,部署时需符合当地数据保护法规。
12. 总结与下一步
这套 Python + PyQt5 多串口 Modbus 上位机系统,最值得尝试的点是它把数据采集、界面展示、报警存储串成了一条完整链路。最先应该验证的功能是基本 Modbus 读取和多串口并发,先把通信层跑稳,再补界面优化。最容易踩的坑是线程模型和寄存器解析:数据读取放 UI 线程会卡界面,寄存器字节序和缩放不对会让显示值完全错误。
后续可以扩展的方向很多:把 CSV 换成 SQLite/MySQL 做历史查询;接 MQTT 把数据上报到物联网平台;用 pyqtgraph 或 QCustomPlot 做更复杂的趋势对比;把配置界面做成可视化并支持远程更新;用 PyInstaller 打包成可分发的 Windows exe。
如果你正在规划小型设备监控项目,这套技术路线值得收藏备用。建议先从模拟从站开始验证,跑通后再接真实硬件,这样能把协议调试和界面开发解耦,现场联调时间能大幅缩短。