做工控软件这行,我见过太多人在框架选型上走弯路。有个做注塑机控制系统的朋友,项目做到一半才发现自己选的框架根本撑不住实时数据刷新——界面卡得像PPT,客户现场演示当场翻车。
工业上位机不是普通桌面软件。它要同时处理串口数据、Modbus通信、实时曲线绘制,还得保证界面响应不能掉链子。这种场景下,框架选错了,后期重构的代价比从头写还高。
咱们今天就把这个问题掰开了说清楚。
在聊具体框架之前,先得搞清楚工控界面到底有哪些"特殊癖好"——这跟做个普通管理系统完全不是一回事。
实时性要求苛刻。 传感器数据可能100ms刷新一次,界面必须跟得上,不能有明显的视觉延迟。
数据可视化密集。 温度曲线、压力波形、设备状态图——这些东西不是随便画个折线图就完事的,得支持大数据量高频更新。
稳定性是命根子。 工厂环境里,软件崩一次可能导致生产线停工,损失直接按小时算。
Windows部署为主。 大多数工控现场还是Windows 7/10环境,有些甚至是XP(别笑,真的存在)。
操作员不是程序员。 界面要足够直观,触摸屏友好,字体要大,按钮要明显。
带着这些需求,我们来看几个主流方案。
说实话,如果你问我工控领域用哪个框架最稳,我会毫不犹豫说PyQt。不是因为它完美,而是因为它踩过的坑都被前人填好了。
Qt本身就是为工业和嵌入式场景设计的,背后是诺基亚和西门子这些老牌工业公司多年投入的成果。PyQt只是给它套了层Python外衣。
pythonimport sys
from PyQt5.QtWidgets import (QApplication, QMainWindow,
QWidget, QVBoxLayout, QLabel)
from PyQt5.QtCore import QTimer, Qt
from PyQt5.QtGui import QFont
import pyqtgraph as pg
import random
class IndustrialMonitor(QMainWindow):
"""工业监控主窗口 - 实时数据显示示例"""
def __init__(self):
super().__init__()
self.setWindowTitle("设备监控系统 v2.1")
self.setMinimumSize(1200, 800)
# 数据缓冲区,保留最近500个点
self.data_buffer = []
self.max_points = 500
self._setup_ui()
self._start_data_timer()
def _setup_ui(self):
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
# 状态标签
self.status_label = QLabel("设备状态:运行中")
font = QFont("Microsoft YaHei", 14, QFont.Bold)
self.status_label.setFont(font)
self.status_label.setStyleSheet("color: #00AA00; padding: 8px;")
layout.addWidget(self.status_label)
# 实时曲线图(pyqtgraph 比 matplotlib 快10倍以上)
self.plot_widget = pg.PlotWidget(title="主轴温度实时曲线")
self.plot_widget.setBackground('#1a1a2e')
self.plot_widget.showGrid(x=True, y=True, alpha=0.3)
self.plot_widget.setLabel('left', '温度', units='°C')
self.plot_widget.setLabel('bottom', '时间', units='s')
# 设置Y轴范围(工控场景通常有明确的量程)
self.plot_widget.setYRange(0, 150)
self.curve = self.plot_widget.plot(
pen=pg.mkPen(color='#00d4ff', width=2)
)
layout.addWidget(self.plot_widget)
def _start_data_timer(self):
"""100ms刷新一次,模拟真实采集频率"""
self.timer = QTimer()
self.timer.timeout.connect(self._update_data)
self.timer.start(100)
def _update_data(self):
# 实际项目中这里替换为串口/Modbus读取
new_value = 75 + random.gauss(0, 3)
self.data_buffer.append(new_value)
if len(self.data_buffer) > self.max_points:
self.data_buffer.pop(0)
self.curve.setData(self.data_buffer)
# 超温报警
if new_value > 100:
self.status_label.setText(f"⚠️ 温度告警:{new_value:.1f}°C")
self.status_label.setStyleSheet("color: #FF4444; padding: 8px;")
else:
self.status_label.setText(f"设备状态:正常 | 当前温度:{new_value:.1f}°C")
self.status_label.setStyleSheet("color: #00AA00; padding: 8px;")
if __name__ == '__main__':
app = QApplication(sys.argv)
window = IndustrialMonitor()
window.show()
sys.exit(app.exec_())

这里有个细节值得说一下:用pyqtgraph而不是matplotlib。后者在高频刷新场景下会卡成狗,前者基于OpenGL渲染,100ms刷新500个数据点轻轻松松。这是工控开发里最常见的一个坑,我见过不止三个项目因为这个问题重写了图表模块。
PyQt的适用场景: 中大型上位机系统、需要自定义控件(仪表盘、指示灯、流程图)、有长期维护需求的项目。
需要注意的地方: 商业项目需要购买授权(或者用LGPL的PySide6替代),学习曲线比tkinter陡一些。
tkinter是Python自带的,零安装成本,这是它最大的优势。拿来做个简单的参数配置界面、小工具,完全够用。
但是——在工控场景下,它的天花板很低。
原生控件丑,自定义难度大。没有内置的图表组件,得靠matplotlib嵌入,而matplotlib的实时性能在工控场景下经常不够用。多线程处理也比较麻烦,稍不注意就会遇到界面假死的问题。
pythonimport tkinter as tk
from tkinter import ttk, messagebox
import json
import os
# 配置文件路径(与脚本同目录)
CONFIG_FILE = "device_params.json"
# 默认参数定义:(显示名, 默认值, 最小值, 最大值)
PARAM_DEFS = [
("最大速度 (mm/s)", "1000", 1, 9999),
("加速度 (mm/s²)", "500", 1, 5000),
("定位精度 (μm)", "10", 1, 1000),
]
class SimpleParamConfig(tk.Tk):
"""工控设备参数配置界面(完整版)"""
def __init__(self):
super().__init__()
self.title("设备参数配置")
self.geometry("420x320")
self.resizable(False, False)
self.entries: dict[str, ttk.Entry] = {}
self._build_form()
self._load_config() # 启动时读取上次保存的配置
# 界面构建
def _build_form(self):
frame = ttk.LabelFrame(self, text="运动控制参数", padding=15)
frame.pack(fill="both", expand=True, padx=12, pady=(12, 6))
for i, (label, default, lo, hi) in enumerate(PARAM_DEFS):
ttk.Label(frame, text=label).grid(
row=i, column=0, sticky="w", pady=6
)
entry = ttk.Entry(frame, width=14, justify="right")
entry.insert(0, default)
entry.grid(row=i, column=1, padx=10, pady=6)
# 范围提示
ttk.Label(
frame,
text=f"[{lo} ~ {hi}]",
foreground="#888888",
font=("", 8),
).grid(row=i, column=2, sticky="w")
self.entries[label] = entry
self.status_var = tk.StringVar(value="就绪")
status_bar = ttk.Label(
self,
textvariable=self.status_var,
relief="sunken",
anchor="w",
padding=(6, 2),
)
status_bar.pack(fill="x", side="bottom")
btn_frame = ttk.Frame(self)
btn_frame.pack(pady=8)
ttk.Button(btn_frame, text="保存配置",
command=self._save_config).grid(row=0, column=0, padx=8)
ttk.Button(btn_frame, text="重置默认",
command=self._reset_defaults).grid(row=0, column=1, padx=8)
# 核心逻辑
def _validate(self) -> dict | None:
"""
校验所有输入项。
返回 {label: int} 字典,校验失败返回 None。
"""
result = {}
for label, lo, hi in [
(name, lo, hi) for name, _, lo, hi in PARAM_DEFS
]:
raw = self.entries[label].get().strip()
# 必须是整数
if not raw.lstrip("-").isdigit():
messagebox.showerror(
"输入错误",
f"「{label}」请输入整数,当前值:{raw!r}",
)
self.entries[label].focus_set()
return None
val = int(raw)
# 范围检查
if not (lo <= val <= hi):
messagebox.showerror(
"范围错误",
f"「{label}」超出范围 [{lo} ~ {hi}],当前值:{val}",
)
self.entries[label].focus_set()
return None
result[label] = val
return result
def _save_config(self):
"""校验 → 写 JSON → 更新状态栏"""
params = self._validate()
if params is None:
return # 校验失败,弹窗已提示
try:
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(params, f, ensure_ascii=False, indent=2)
self.status_var.set(f"✔ 配置已保存至 {os.path.abspath(CONFIG_FILE)}")
print("[保存成功]")
for k, v in params.items():
print(f" {k}: {v}")
except OSError as e:
messagebox.showerror("保存失败", f"写入文件时出错:\n{e}")
def _load_config(self):
"""从 JSON 读取上次保存的值,文件不存在则静默跳过"""
if not os.path.exists(CONFIG_FILE):
return
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
params: dict = json.load(f)
for label, entry in self.entries.items():
if label in params:
entry.delete(0, tk.END)
entry.insert(0, str(params[label]))
self.status_var.set(f"已加载上次配置:{CONFIG_FILE}")
except (OSError, json.JSONDecodeError) as e:
self.status_var.set(f"⚠ 读取配置失败:{e}")
def _reset_defaults(self):
"""一键恢复所有默认值"""
for label, default, *_ in PARAM_DEFS:
self.entries[label].delete(0, tk.END)
self.entries[label].insert(0, default)
self.status_var.set("已重置为默认值(未保存)")
# 入口
if __name__ == "__main__":
app = SimpleParamConfig()
app.mainloop()

结论: 小工具、内部配置面板用tkinter没问题。一旦涉及实时数据显示、复杂交互,果断换PyQt。
这个框架很多工控开发者可能没听说过。Dear PyGui基于ImGui(游戏开发里大名鼎鼎的即时模式GUI库),GPU加速渲染,性能相当彪悍。
pythonimport dearpygui.dearpygui as dpg
import random
import math
import time
def generate_waveform(points=100):
return [math.sin(i * 0.1) * 50 + 75 + random.gauss(0, 2)
for i in range(points)]
pressure_data = generate_waveform()
temp_data = [math.sin(i * 0.05) * 10 + 85 + random.gauss(0, 1) for i in range(100)]
flow_data = [math.cos(i * 0.08) * 20 + 60 + random.gauss(0, 1.5) for i in range(100)]
running = True
last_update = time.time()
def update_data():
global pressure_data, temp_data, flow_data, last_update
now = time.time()
if now - last_update < 1.0:
return
last_update = now
new_p = math.sin(now * 0.5) * 50 + 75 + random.gauss(0, 2)
new_t = math.sin(now * 0.3) * 10 + 85 + random.gauss(0, 1)
new_f = math.cos(now * 0.4) * 20 + 60 + random.gauss(0, 1.5)
pressure_data.append(new_p)
pressure_data = pressure_data[-100:]
temp_data.append(new_t)
temp_data = temp_data[-100:]
flow_data.append(new_f)
flow_data = flow_data[-100:]
dpg.set_value("pressure_val", f"{new_p:.1f}")
dpg.set_value("temp_val", f"{new_t:.1f}")
dpg.set_value("flow_val", f"{new_f:.1f}")
xs = list(range(len(pressure_data)))
dpg.set_value("pressure_series", [xs, pressure_data])
dpg.set_value("temp_series", [xs, temp_data])
dpg.set_value("flow_series", [xs, flow_data])
def toggle_running(sender, app_data):
global running
running = not running
def reset_data(sender, app_data):
global pressure_data, temp_data, flow_data
pressure_data = generate_waveform()
temp_data = [math.sin(i * 0.05) * 10 + 85 + random.gauss(0, 1) for i in range(100)]
flow_data = [math.cos(i * 0.08) * 20 + 60 + random.gauss(0, 1.5) for i in range(100)]
print("Creating context...")
dpg.create_context()
print("Creating window...")
with dpg.window(label="Monitor System", width=700, height=450, tag="main_window"):
dpg.add_text("Real-time Monitor System", color=[100, 200, 255])
dpg.add_separator()
dpg.add_spacer(height=5)
# Chart
with dpg.plot(label="Data Chart", height=220, width=-1):
dpg.add_plot_axis(dpg.mvXAxis, label="Samples")
with dpg.plot_axis(dpg.mvYAxis, label="Values"):
dpg.add_line_series(
list(range(100)),
pressure_data,
label="Pressure (bar)",
tag="pressure_series"
)
dpg.add_line_series(
list(range(100)),
temp_data,
label="Temperature (C)",
tag="temp_series"
)
dpg.add_line_series(
list(range(100)),
flow_data,
label="Flow (L/min)",
tag="flow_series"
)
dpg.add_spacer(height=10)
# Data Display
with dpg.group(horizontal=True):
dpg.add_text("Pressure: ")
dpg.add_text("78.3", color=[0, 255, 100], tag="pressure_val")
dpg.add_text("bar")
dpg.add_spacer(width=60)
dpg.add_text("Temperature: ")
dpg.add_text("85.0", color=[255, 180, 0], tag="temp_val")
dpg.add_text("C")
dpg.add_spacer(width=60)
dpg.add_text("Flow: ")
dpg.add_text("60.0", color=[0, 180, 255], tag="flow_val")
dpg.add_text("L/min")
dpg.add_spacer(height=10)
# Buttons
with dpg.group(horizontal=True):
dpg.add_button(label="Pause/Resume", callback=toggle_running, width=120)
dpg.add_spacer(width=20)
dpg.add_button(label="Reset", callback=reset_data, width=100)
print("Creating viewport...")
dpg.create_viewport(title="Real-time Monitor System", width=720, height=480)
dpg.setup_dearpygui()
dpg.show_viewport()
dpg.set_primary_window("main_window", True)
print("OK - Started!")
frame = 0
try:
while dpg.is_dearpygui_running():
if running:
update_data()
dpg.render_dearpygui_frame()
time.sleep(0.05)
frame += 1
if frame % 40 == 0:
print(f"Running - {frame} frames")
except KeyboardInterrupt:
print("\nStopped by user")
finally:
dpg.destroy_context()
print("Done!")

Dear PyGui的优势在于极低的渲染开销——即便界面上有几十个实时更新的控件,CPU占用也能保持在一个很低的水平。对于需要同时显示大量通道数据的场景(比如多轴运动控制、多路传感器采集),这个特性很有价值。
注意:这个对Windows 支持有些问题!!!
不过它的生态还不成熟,中文资料少,遇到问题基本只能啃英文文档。在对稳定性要求极高的生产系统里,我个人还是会优先选PyQt。
wxPython用的是原生系统控件,界面风格跟Windows系统融合度最高。如果客户对"软件看起来要像Windows原生程序"有执念,wxPython是个不错的选择。
性能中规中矩,生态比Dear PyGui成熟,但比PyQt弱一些。现在新项目选它的人越来越少了,但维护老项目时经常会遇到。
| 维度 | PyQt5/6 | tkinter | Dear PyGui | wxPython |
|---|---|---|---|---|
| 实时性能 | ★★★★★ | ★★☆☆☆ | ★★★★★ | ★★★☆☆ |
| 学习成本 | ★★★☆☆ | ★★★★★ | ★★★☆☆ | ★★★☆☆ |
| 自定义控件 | ★★★★★ | ★★☆☆☆ | ★★★★☆ | ★★★☆☆ |
| 生态成熟度 | ★★★★★ | ★★★★☆ | ★★★☆☆ | ★★★★☆ |
| 商业授权 | 需购买* | 免费 | 免费 | 免费 |
| Windows兼容 | 极佳 | 良好 | 良好 | 极佳 |
*注:PySide6是Qt官方的Python绑定,LGPL授权,商业免费,功能与PyQt6基本一致,可作替代。
选好框架只是第一步。工控上位机最容易出问题的地方,往往不是框架本身,而是把数据采集和界面更新混在一起写。
正确的做法是分层:
pythonimport threading
import queue
from PyQt5.QtCore import QTimer
class DataAcquisitionThread(threading.Thread):
"""数据采集线程 - 独立于UI运行"""
def __init__(self, data_queue: queue.Queue):
super().__init__(daemon=True)
self.data_queue = data_queue
self._stop_event = threading.Event()
def run(self):
while not self._stop_event.is_set():
# 这里放串口读取、Modbus查询等IO操作
# IO操作绝对不能放在主线程里
data = self._read_device()
# 用队列安全地传递数据给UI线程
try:
self.data_queue.put_nowait(data)
except queue.Full:
pass # 丢弃过期数据,保证实时性
self._stop_event.wait(timeout=0.1) # 100ms采集间隔
def _read_device(self):
# 实际项目替换为真实设备通信
import random
return {"temperature": 75 + random.gauss(0, 2),
"pressure": 6.0 + random.gauss(0, 0.1)}
def stop(self):
self._stop_event.set()
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.data_queue = queue.Queue(maxsize=100)
# 启动采集线程
self.daq_thread = DataAcquisitionThread(self.data_queue)
self.daq_thread.start()
# UI刷新定时器(只负责从队列取数据并更新界面)
self.ui_timer = QTimer()
self.ui_timer.timeout.connect(self._refresh_ui)
self.ui_timer.start(50) # 50ms刷新一次界面
def _refresh_ui(self):
"""UI线程只做界面更新,不做任何IO"""
try:
while True: # 清空队列积压
data = self.data_queue.get_nowait()
self._update_display(data)
except queue.Empty:
pass
def _update_display(self, data):
# 更新界面控件
pass
def closeEvent(self, event):
self.daq_thread.stop()
event.accept()
这个模式——采集线程负责IO,主线程只管渲染——是工控上位机开发的基本功。违反这个原则,界面卡顿和数据丢失是迟早的事。
1. 中大型工控项目,PyQt5/PySide6是最稳妥的选择,别因为"看起来复杂"就退缩。
2. 高频多通道数据显示,记得把matplotlib换成pyqtgraph,这一步能让你的界面从PPT变成流畅动画。
3. 无论选哪个框架,IO操作和UI渲染必须分线程,这是工控软件稳定运行的铁律。
#Python工控开发 #PyQt5实战 #上位机开发 #工业自动化 #GUI框架选型
欢迎在评论区聊聊你在工控界面开发中踩过的坑,或者分享你目前在用的框架和遇到的问题——这类实战经验往往比文档更有价值。


本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!