2026-04-17
C#
0

一、问题引入

两年前,我在一个离散制造车间做上位机改造项目。现场有四十多台设备,PLC 品牌混杂,有西门子、三菱、台达,通信协议也各不相同。客户的需求听起来很简单:"我想在大屏上看到每台设备现在是什么状态,出了问题能报警。"

第一版我做得很粗糙——用一个定时器每隔 5 秒轮询设备,把采集到的信号直接写进数据库,前端读库展示。跑了两周,问题来了:设备断网后状态一直显示"运行中";PLC 偶发抖动,报警状态一秒钟出现又消失,历史记录里全是噪声;更麻烦的是,有台设备的"停机"和"待机"信号用的是同一个寄存器位,不同班次的操作员对状态的理解还不一样。

这些问题的本质,不是采集频率不够,也不是数据库设计不好,而是根本没有"状态机"的概念。 设备的状态不是一个孤立的值,它是一系列事件驱动下的有序迁移。没有状态机,你就永远在追噪声,永远说不清楚"这台设备到底出了什么问题、从什么时候开始的"。

这篇文章,我想把设备状态机的建模思路、表结构设计和 C# 实现完整讲一遍。


二、经验分析

设备状态的本质是什么

很多开发者第一反应是:状态不就是个枚举值吗,Running = 1Idle = 2Alarm = 3,存到数据库里不就行了?

这个想法在数据量小、设备少的时候能凑合,但它忽略了三个关键问题:

第一,状态是有来源的。 同样是"停机",是操作员主动按了停止按钮,还是设备因为过温自保护停下来的,还是通信中断导致系统判断为停机?这三种"停机"在业务上的处理方式完全不同。没有来源,维修人员就不知道该去查哪里。

第二,状态是有时序的。 设备不能从"运行"直接跳到"离线",中间一定经历了通信超时的过程。如果你不约束状态迁移的合法路径,前端展示就会出现"刚才还在运行,刷新一下变成离线了"这种让人困惑的情况。

第三,状态是有持续时间的。 报警持续了 3 秒还是 3 小时,对维护决策的意义完全不同。只存当前状态,你永远算不出设备的 OEE,也无法做任何趋势分析。

常见的三种错误做法

做法一:只存当前状态,不存历史。 这是最常见的坑。上线第一天 PM 就会问:"这台设备今天报警了几次?每次持续多久?" 你答不上来。

做法二:用定时轮询直接覆盖状态,不做防抖。 PLC 信号天然有抖动,尤其是继电器类型的输入点。没有防抖逻辑,报警记录里会充斥大量持续时间不足 1 秒的"幽灵报警",历史数据完全失去参考价值。

做法三:状态迁移逻辑散落在各处。 有人在采集线程里改状态,有人在 API 里改状态,有人在定时任务里改状态。三个月后没人敢动这块代码,因为不知道改了会影响哪里。

我最终选择的方案

根据我的经验,设备状态机的核心是两张表 + 一个状态机服务

  • t_device:设备档案,存静态信息和当前状态快照
  • t_device_state_log:状态变更历史,每次状态迁移写一条记录,记录开始时间、结束时间、持续秒数、触发原因

状态迁移逻辑全部收拢到一个 DeviceStateMachine里,任何地方想改设备状态,都必须通过这个类,不允许直接 UPDATE t_device SET status = xxx

这个约束听起来有点强硬,但在项目中执行下来效果非常好——状态变更的来龙去脉一目了然,出了问题三分钟之内能定位到根因。


三、技术方案

状态定义与合法迁移路径

首先明确五种状态的业务含义:

状态枚举值业务含义
Running1设备正在生产,主轴/执行机构处于工作状态
Idle2设备上电待机,未在生产,等待指令
Alarm3设备触发报警,需人工干预,可能仍在运行
Stopped4设备主动或被动停机,执行机构停止
Offline5通信中断,系统无法获取设备真实状态

合法的状态迁移路径如下(只有在这张图里的箭头才允许发生):

image.png 用文字描述关键路径:

  • Offline → Idle:通信恢复,设备重新上线,初始化为待机
  • Idle ↔ Running:操作员启动 / 停止设备
  • Running → Alarm:设备运行中触发报警(报警不一定停机)
  • Alarm → Running:报警解除,恢复运行
  • Alarm → Stopped:报警后设备自保护停机
  • Running / Idle → Stopped:正常停机
  • Stopped → Idle:重启完成,进入待机
  • 任意状态 → Offline:通信超时(心跳丢失超过阈值)

任何不在上述路径中的状态迁移,状态机应当拒绝执行并记录警告日志,而不是静默接受。这是保证数据可信的关键约束。

整体架构

image.png

信号解析层的存在非常重要。 不同品牌 PLC 的寄存器定义各不相同,把"寄存器值 → 业务事件"的映射逻辑单独抽出来,状态机本身就只需要处理标准化的 DeviceEvent,不用关心底层协议细节。

2026-04-17
Python
0

📊 一张图表,难倒了多少Tkinter开发者

做数据展示类的桌面工具,早晚会遇到这个坎——用户要看图表。折线图、柱状图、实时曲线,这些东西Tkinter自带的Canvas画起来费劲,效果还不好看。自然而然就想到了matplotlib。但一搜怎么嵌入,发现网上的例子要么过时,要么跑起来窗口一闪而过,要么图表和界面完全对不上号。

不只是matplotlib。PIL/Pillow处理图片显示、ttkbootstrap美化界面、pyqtgraph做高性能实时曲线——这些第三方库各有各的渲染机制,跟Tkinter的主循环整合起来,坑比想象的多。

这篇文章把这几个最常用的融合场景逐一拆解,从原理到代码,每段示例都在Windows环境下验证过。


🔬 先搞懂一件事:为什么嵌入会出问题

Tkinter有自己的事件循环(mainloop()),matplotlib有自己的渲染后端,PIL有自己的图像对象体系。把它们揉在一起,本质上是在让三个各自为政的系统协同工作。

问题的根源,几乎都指向同一个地方:渲染时机和主线程的控制权争夺。matplotlib默认用独立窗口显示图表(plt.show() 会阻塞主线程),PIL的 Image 对象不能直接贴到Tkinter控件上,这些都需要用特定的桥接方式绕过去。

知道了根源,解法就清晰了——用各个库提供的"嵌入模式"接口,把渲染权交还给Tkinter主循环来统一调度。


📈 融合一:matplotlib静态图表嵌入

这是最高频的需求。报表工具、数据分析小程序,基本都要用到。

python
import tkinter as tk from tkinter import ttk import matplotlib matplotlib.use('TkAgg') # 关键:必须在import pyplot之前设置后端 import matplotlib.pyplot as plt from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk from matplotlib.figure import Figure import numpy as np class StaticChartPanel(tk.Frame): """ 静态图表面板 可作为独立组件嵌入任意Tkinter布局 """ def __init__(self, parent, figsize=(8, 4), dpi=100, **kwargs): super().__init__(parent, **kwargs) plt.rcParams['font.sans-serif'] = ['Microsoft YaHei'] # Windows下显示中文 plt.rcParams['axes.unicode_minus'] = False # 创建matplotlib Figure对象,不通过plt接口 # 直接用Figure而不是plt.figure(),避免全局状态污染 self.fig = Figure(figsize=figsize, dpi=dpi, facecolor='#f8f9fa') self.ax = self.fig.add_subplot(111) # FigureCanvasTkAgg 是连接matplotlib和Tkinter的核心桥梁 self.canvas = FigureCanvasTkAgg(self.fig, master=self) self.canvas_widget = self.canvas.get_tk_widget() self.canvas_widget.pack(fill='both', expand=True) # 可选:加上matplotlib自带的工具栏(缩放、平移、保存) self.toolbar = NavigationToolbar2Tk(self.canvas, self) self.toolbar.update() # 初始化一个空图表 self._draw_empty() def _draw_empty(self): self.ax.set_facecolor('#ffffff') self.ax.text( 0.5, 0.5, '暂无数据', transform=self.ax.transAxes, ha='center', va='center', fontsize=14, color='#cccccc', fontproperties='Microsoft YaHei' # Windows下中文字体 ) self.canvas.draw() def plot_line(self, x_data, y_data, title='', xlabel='', ylabel='', color='#1976d2'): """绘制折线图,外部调用这个方法更新图表内容""" self.ax.clear() self.ax.plot(x_data, y_data, color=color, linewidth=2, marker='o', markersize=4) # 样式设置 self.ax.set_title(title, fontproperties='Microsoft YaHei', fontsize=13, pad=10) self.ax.set_xlabel(xlabel, fontproperties='Microsoft YaHei') self.ax.set_ylabel(ylabel, fontproperties='Microsoft YaHei') self.ax.grid(True, alpha=0.3, linestyle='--') self.ax.set_facecolor('#fafafa') self.fig.tight_layout() # draw() 触发重绘,必须显式调用 self.canvas.draw() def plot_bar(self, categories, values, title='', color='#42a5f5'): """绘制柱状图""" self.ax.clear() bars = self.ax.bar(categories, values, color=color, alpha=0.85, width=0.6) # 在柱子顶部标注数值 for bar, val in zip(bars, values): self.ax.text( bar.get_x() + bar.get_width() / 2, bar.get_height() + max(values) * 0.01, f'{val:.1f}', ha='center', va='bottom', fontsize=9 ) self.ax.set_title(title, fontproperties='Microsoft YaHei', fontsize=13) self.ax.set_facecolor('#fafafa') self.fig.tight_layout() self.canvas.draw() # --- 完整使用示例 ---class ReportWindow: def __init__(self): self.root = tk.Tk() self.root.title('销售数据报表') self.root.geometry('900x600') self._build_ui() self._load_sample_data() def _build_ui(self): # 左侧控制面板 ctrl_frame = tk.Frame(self.root, width=160, bg='#eceff1') ctrl_frame.pack(side='left', fill='y', padx=0) ctrl_frame.pack_propagate(False) tk.Label(ctrl_frame, text='图表类型', bg='#eceff1', font=('微软雅黑', 11, 'bold')).pack(pady=(20, 8)) self.chart_type = tk.StringVar(value='line') for text, val in [('折线图', 'line'), ('柱状图', 'bar')]: tk.Radiobutton( ctrl_frame, text=text, variable=self.chart_type, value=val, bg='#eceff1', font=('微软雅黑', 10), command=self._refresh_chart ).pack(anchor='w', padx=20) # 右侧图表区域 chart_frame = tk.Frame(self.root) chart_frame.pack(side='right', fill='both', expand=True, padx=10, pady=10) self.chart = StaticChartPanel(chart_frame, figsize=(7, 4.5)) self.chart.pack(fill='both', expand=True) def _load_sample_data(self): self.months = ['1月', '2月', '3月', '4月', '5月', '6月'] self.sales = [42.3, 58.1, 51.7, 67.4, 73.2, 69.8] self._refresh_chart() def _refresh_chart(self): if self.chart_type.get() == 'line': self.chart.plot_line( self.months, self.sales, title='2025年上半年销售额(万元)', xlabel='月份', ylabel='销售额' ) else: self.chart.plot_bar( self.months, self.sales, title='2025年上半年销售额(万元)' ) def run(self): self.root.mainloop() if __name__ == '__main__': ReportWindow().run()

image.png

image.png

这里有个细节很多人会踩——matplotlib.use('TkAgg') 必须在 import matplotlib.pyplot 之前调用,否则后端已经初始化完了,再改就不生效了,还不报错,只是图表显示异常。这个坑我在一个项目里排查了大半天才找到。

2026-04-15
C#
0

上周有个做自动化设备的朋友找我诉苦——他们的点胶机控制系统,每隔几分钟就会莫名丢一批传感器数据,客户投诉不断,排查了两周愣是没找到根儿。我远程看了眼代码,问题一目了然:DataReceived 回调里直接写业务逻辑,串口缓冲区早就撑爆了。

这种问题,我见过太多次了。


🔥 问题到底出在哪?

先说个让很多人不舒服的真相:大多数串口程序,从架构上就是错的。

典型的"意大利面条"写法长这样——

csharp
// 反面教材:千万别这么写 private void port_DataReceived(object sender, SerialDataReceivedEventArgs e) { string data = port.ReadLine(); ParseProtocol(data); // 解析协议 SaveToDatabase(data); // 写数据库 UpdateUI(data); // 刷界面 }

看着没毛病对吧?但你仔细想想——DataReceived 是硬件中断驱动的回调,它不等人。你在里面做的事情越耗时,下一帧数据到来时上一帧还没处理完,操作系统的接收缓冲区就开始积压。115200 bps 的波特率,理论上每秒能塞进来 14400 字节。你的数据库写入哪怕卡了 50ms,就可能吞掉 720 字节的数据,悄无声息,没有任何报错。

这就是为什么工业现场的数据丢失问题如此难以复现——它不是必现 bug,是概率性的架构缺陷


👨‍💻先看效果

image.png

image.png

💡 正确姿势:三级缓冲 + 生产者消费者

咱们换个思路。把整个数据流水线拆成三段:

硬件中断 → [一级缓冲] → 入队 → [二级队列] → 消费线程 → [三级聚合] → UI渲染

每一级各司其职,互不阻塞。这才是工业级串口程序该有的样子。

🗂️ 一级缓冲:操作系统接管突发流量

csharp
_port = new SerialPort { ReadBufferSize = 65536, // 约 4.5 秒的 115200 bps 数据量 WriteBufferSize = 16384, ReadTimeout = 500, };

65536 这个数字不是拍脑袋来的。115200 bps ÷ 8 bits ≈ 14400 B/s,预留 1 秒延迟容量再乘以安全系数 4,取最近的 2 的幂次,刚好 65536。这一级完全由操作系统驱动管理,你的代码还没跑,数据就已经安全落地了。

2026-04-15
C#
0

在C#开发中,我们经常遇到需要在运行时动态创建类型的场景。比如从数据库读取表结构动态生成实体类,或者根据用户配置动态创建数据模型。System.Reflection.Emit命名空间为我们提供了强大的动态类型创建能力。本文将通过详细的示例,带你掌握这项高级技术。

什么是Reflection.Emit?

Reflection.Emit是.NET Framework提供的一组API,允许我们在运行时动态创建程序集、模块、类型和方法。它的核心优势包括:

  • 动态性:运行时创建类型,无需预定义
  • 灵活性:根据实际需求动态调整类型结构
  • 性能:比传统反射调用更高效
  • 扩展性:可以创建复杂的类型层次结构

核心概念解析

主要组件

c#
// 程序集构建器 - 最顶层容器 AssemblyBuilder assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly( assemblyName, AssemblyBuilderAccess.Run); // 模块构建器 - 包含类型定义 ModuleBuilder moduleBuilder = assemblyBuilder.DefineDynamicModule("DynamicModule"); // 类型构建器 - 定义具体类型 TypeBuilder typeBuilder = moduleBuilder.DefineType("ClassName", TypeAttributes.Public);

IL代码生成

IL(Intermediate Language)是.NET的中间语言,我们需要通过ILGenerator来生成IL指令:

c#
// 获取IL生成器 ILGenerator il = methodBuilder.GetILGenerator(); // 常用IL指令 il.Emit(OpCodes.Ldarg_0); // 加载第一个参数(this) il.Emit(OpCodes.Ldfld, fieldBuilder); // 加载字段值 il.Emit(OpCodes.Ret); // 返回
2026-04-15
Python
0

🏭 当触摸屏遇上工控现场

车间里那台老旧的触摸屏,手指划过去,界面卡了整整两秒——操作工扭头看了我一眼,那眼神我至今记得。那是我接手第一个工控HMI项目的第三周。

工业触摸屏不是手机。这句话听起来像废话,但真正踩过坑的开发者才明白这里面藏着多少门道。工厂现场的屏幕分辨率往往是固定的800×480或1024×600,操作工戴着手套,手指触点面积是普通人的三倍,而且同一个按钮一天可能被点击上千次。用写桌面应用的思路去做工控HMI,结果往往是——界面好看,但用起来一塌糊涂。

这篇文章,我把这几年在Windows工控项目里摸索出来的Tkinter触摸屏优化技巧整理出来,从布局到响应,从字体到线程,每一条都是真实项目里踩过的坑。


🔍 工控触摸屏的核心矛盾

在动手写代码之前,咱们得先把问题想清楚。工控触摸屏界面和普通桌面应用,本质矛盾集中在三个地方:

触控精度与操作效率的矛盾。 工业触摸屏的触控精度远不如电容屏手机,误触率高,所以按钮必须足够大,间距必须足够宽。但屏幕就那么大,控件一多,布局就会很难看。

实时数据刷新与界面流畅度的矛盾。 工控软件要不断从PLC、传感器读取数据并刷新显示,频繁的UI更新很容易让主线程阻塞,界面变得迟钝。

稳定性要求与开发效率的矛盾。 工厂现场要求软件7×24小时运行,内存泄漏、线程死锁这些问题在办公软件里可能只是小麻烦,在工控现场就是停产事故。

搞清楚这三对矛盾,后面所有的优化技巧都是围绕它们展开的。


🎨 布局优化:让手套也能精准操作

触控友好的控件尺寸标准

工业界有个非正式的经验值:触摸按钮的最小尺寸不低于44×44像素,推荐60×60像素以上,按钮间距不低于8像素。这个数字来自人机工程学研究,也被我在现场反复验证过。

python
import tkinter as tk from tkinter import ttk class IndustrialButton(tk.Button): """ 工业触控按钮基类 封装了触控友好的尺寸和视觉反馈逻辑 """ def __init__(self, parent, **kwargs): # 工控场景下的默认样式 defaults = { 'width': 10, 'height': 3, 'font': ('微软雅黑', 16, 'bold'), 'relief': 'raised', 'bd': 3, 'activebackground': '#cccccc', 'cursor': 'hand2' } defaults.update(kwargs) super().__init__(parent, **defaults) # 绑定触摸反馈动画 self.bind('<ButtonPress-1>', self._on_press) self.bind('<ButtonRelease-1>', self._on_release) def _on_press(self, event): """按下时视觉下沉效果,给操作工明确的触觉反馈替代""" self.config(relief='sunken', bd=2) def _on_release(self, event): self.config(relief='raised', bd=3)

image.png

这里有个细节值得说一下——cursor='hand2' 在工控现场其实用处不大,因为操作工用手指不用鼠标,但如果调试阶段工程师要用鼠标操作,这个光标样式能明显提示可点击区域。小细节,但体现专业度。