编辑
2026-04-17
C#
00

目录

一、问题引入
二、经验分析
设备状态的本质是什么
常见的三种错误做法
我最终选择的方案
三、技术方案
状态定义与合法迁移路径
整体架构
核心表结构设计
四、代码与示例
状态机核心实现(C#)
心跳超时检测(C#)
建表 SQL(SQL Server)
五、经验总结
可以直接拿去用的几条原则
几个常见坑
下一步可以做什么

一、问题引入

两年前,我在一个离散制造车间做上位机改造项目。现场有四十多台设备,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,不用关心底层协议细节。

核心表结构设计

设备档案表 t_device

字段名类型说明
idBIGINT PK主键,自增
device_codeVARCHAR(50)设备编码,唯一
device_nameVARCHAR(200)设备名称
device_typeVARCHAR(50)设备类型(CNC/注塑机/输送线…)
workshop_codeVARCHAR(50)所属车间
current_statusTINYINT当前状态(枚举值)
status_sinceDATETIME当前状态开始时间
last_heartbeat_atDATETIME最后一次心跳时间
offline_threshold_secINT离线判定阈值(秒),默认 30
is_activeTINYINT是否启用
created_atDATETIME创建时间
updated_atDATETIME最后更新时间

设备状态变更历史表 t_device_state_log

字段名类型说明
idBIGINT PK主键,自增
device_idBIGINT关联设备 ID
from_statusTINYINT变更前状态
to_statusTINYINT变更后状态
trigger_eventVARCHAR(50)触发事件(枚举名)
trigger_sourceVARCHAR(50)触发来源(PLC/System/Operator)
started_atDATETIME本次状态开始时间
ended_atDATETIME本次状态结束时间(NULL 表示当前仍在此状态)
duration_secINT持续秒数(ended_at 写入时计算)
remarkVARCHAR(500)附加说明(报警代码、操作员等)

t_device_state_log 建议在 (device_id, started_at) 上建联合索引,支撑按设备查时间段历史的查询场景。单台设备一天状态变更通常在几十到几百次,一年数据量在百万级以内,查询性能不是问题。


四、代码与示例

状态机核心实现(C#)

csharp
/// <summary> /// 设备状态枚举 /// </summary> public enum DeviceStatus { Running = 1, Idle = 2, Alarm = 3, Stopped = 4, Offline = 5 } /// <summary> /// 触发状态迁移的业务事件 /// </summary> public enum DeviceEvent { CommunicationRestored, // 通信恢复 StartCommandReceived, // 收到启动指令 StopCommandReceived, // 收到停止指令 AlarmTriggered, // 报警触发 AlarmCleared, // 报警解除 SelfProtectionStop, // 自保护停机 RestartCompleted, // 重启完成 HeartbeatTimeout // 心跳超时,进入离线 } using System; using System.Collections.Generic; using System.Data; using System.Threading.Tasks; using Dapper; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; using AppWpf202610.Models; namespace AppWpf202610.Services { public class DeviceStateMachine { // 合法迁移表:key=(当前状态, 事件),value=目标状态 private static readonly Dictionary<(DeviceStatus, DeviceEvent), DeviceStatus> _transitions = new() { { (DeviceStatus.Offline, DeviceEvent.CommunicationRestored), DeviceStatus.Idle }, { (DeviceStatus.Idle, DeviceEvent.StartCommandReceived), DeviceStatus.Running }, { (DeviceStatus.Running, DeviceEvent.StopCommandReceived), DeviceStatus.Stopped }, { (DeviceStatus.Running, DeviceEvent.AlarmTriggered), DeviceStatus.Alarm }, { (DeviceStatus.Running, DeviceEvent.HeartbeatTimeout), DeviceStatus.Offline }, { (DeviceStatus.Idle, DeviceEvent.HeartbeatTimeout), DeviceStatus.Offline }, { (DeviceStatus.Alarm, DeviceEvent.AlarmCleared), DeviceStatus.Running }, { (DeviceStatus.Alarm, DeviceEvent.SelfProtectionStop), DeviceStatus.Stopped }, { (DeviceStatus.Alarm, DeviceEvent.HeartbeatTimeout), DeviceStatus.Offline }, { (DeviceStatus.Stopped, DeviceEvent.RestartCompleted), DeviceStatus.Idle }, { (DeviceStatus.Stopped, DeviceEvent.HeartbeatTimeout), DeviceStatus.Offline }, }; private readonly DatabaseService _dbService; private readonly ILogger<DeviceStateMachine> _logger; // 状态变更事件,供 ViewModel 订阅刷新 UI public event Action<long, DeviceStatus, DeviceStatus>? StatusChanged; public DeviceStateMachine( DatabaseService dbService, ILogger<DeviceStateMachine> logger) { _dbService = dbService; _logger = logger; } /// <summary> /// 触发状态迁移(线程安全,内部使用 SQLite 事务) /// </summary> public async Task<bool> TriggerAsync( long deviceId, DeviceEvent triggerEvent, string triggerSource = "System", string? remark = null) { using var conn = _dbService.CreateConnection(); conn.Open(); // 1. 查询当前状态 var device = await conn.QueryFirstOrDefaultAsync<DeviceSnapshot>( "SELECT id, current_status, status_since FROM t_device WHERE id = @Id", new { Id = deviceId }); if (device == null) { _logger.LogWarning("TriggerEvent: 设备 {DeviceId} 不存在", deviceId); return false; } var currentStatus = device.Status; // 2. 查找目标状态 if (!_transitions.TryGetValue((currentStatus, triggerEvent), out var targetStatus)) { _logger.LogWarning( "非法状态迁移被拒绝:设备 {DeviceId},当前 {From},事件 {Event}", deviceId, currentStatus, triggerEvent); return false; } var now = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss"); using var tran = conn.BeginTransaction(); try { // 3. 封闭上一条历史记录 await conn.ExecuteAsync(@" UPDATE t_device_state_log SET ended_at = @Now, duration_sec = CAST( (julianday(@Now) - julianday(started_at)) * 86400 AS INTEGER) WHERE device_id = @DeviceId AND ended_at IS NULL", new { Now = now, DeviceId = deviceId }, transaction: tran); // 4. 写入新历史记录 await conn.ExecuteAsync(@" INSERT INTO t_device_state_log (device_id, from_status, to_status, trigger_event, trigger_source, started_at, ended_at, duration_sec, remark) VALUES (@DeviceId, @FromStatus, @ToStatus, @TriggerEvent, @TriggerSource, @Now, NULL, NULL, @Remark)", new { DeviceId = deviceId, FromStatus = (int)currentStatus, ToStatus = (int)targetStatus, TriggerEvent = triggerEvent.ToString(), TriggerSource = triggerSource, Now = now, Remark = remark }, transaction: tran); // 5. 更新设备快照 await conn.ExecuteAsync(@" UPDATE t_device SET current_status = @Status, status_since = @Now, updated_at = @Now WHERE id = @DeviceId", new { Status = (int)targetStatus, Now = now, DeviceId = deviceId }, transaction: tran); tran.Commit(); _logger.LogInformation( "设备 {DeviceId} 状态迁移:{From} → {To},事件:{Event},来源:{Source}", deviceId, currentStatus, targetStatus, triggerEvent, triggerSource); // 通知 UI 刷新 StatusChanged?.Invoke(deviceId, currentStatus, targetStatus); return true; } catch (Exception ex) { tran.Rollback(); _logger.LogError(ex, "设备 {DeviceId} 状态迁移异常,已回滚", deviceId); return false; } } /// <summary> /// 查询某状态下可用的事件列表(供 UI 按钮绑定) /// </summary> public static IEnumerable<DeviceEvent> GetAvailableEvents(DeviceStatus status) { foreach (var key in _transitions.Keys) if (key.Item1 == status) yield return key.Item2; } } }

几个设计细节值得说明:

  • 迁移表用静态字典,在类加载时初始化一次,查找是 O(1) 的,不存在性能问题。所有合法路径一眼看完,新增迁移路径只需要加一行,不用改任何分支逻辑。
  • 历史记录采用"封闭上一条 + 新开一条"的写法,保证每个时间段的起止时间连续,方便后续按时间段统计各状态累计时长(OEE 计算的基础数据就从这里来)。
  • 所有时间用 UTC 存储,展示时在应用层转换为本地时间,避免服务器时区和数据库时区不一致导致的时序混乱。

心跳超时检测(C#)

这是一个容易被忽视的部分。设备离线往往不是收到一个"离线事件",而是心跳超时——采集线程发现超过阈值时间没有收到数据,才判定为离线。

csharp
using System; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using AppWpf202610.Models; namespace AppWpf202610.Services { /// <summary> /// 心跳超时检测后台服务 /// 每 10 秒扫描一次所有非离线设备,超时则触发 HeartbeatTimeout /// </summary> public class HeartbeatMonitorService { private readonly DeviceStateMachine _stateMachine; private readonly DatabaseService _dbService; private readonly ILogger<HeartbeatMonitorService> _logger; private CancellationTokenSource? _cts; private Task? _runningTask; public HeartbeatMonitorService( DeviceStateMachine stateMachine, DatabaseService dbService, ILogger<HeartbeatMonitorService> logger) { _stateMachine = stateMachine; _dbService = dbService; _logger = logger; } public void Start() { _cts = new CancellationTokenSource(); _runningTask = Task.Run(() => ExecuteAsync(_cts.Token)); } public void Stop() { _cts?.Cancel(); _runningTask?.Wait(TimeSpan.FromSeconds(5)); } private async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("心跳监测服务已启动"); while (!stoppingToken.IsCancellationRequested) { try { await CheckHeartbeatsAsync(); } catch (Exception ex) { _logger.LogError(ex, "心跳检测异常"); } await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken) .ContinueWith(_ => { }); // 忽略取消异常 } _logger.LogInformation("心跳监测服务已停止"); } private async Task CheckHeartbeatsAsync() { var now = DateTime.UtcNow; var devices = await _dbService.GetOnlineDevicesAsync(); foreach (var device in devices) { var elapsed = (now - device.LastHeartbeatAt).TotalSeconds; if (elapsed >= device.OfflineThresholdSec) { _logger.LogWarning( "设备 {Code} 心跳超时 {Elapsed:F0}s(阈值 {Threshold}s)", device.DeviceCode, elapsed, device.OfflineThresholdSec); await _stateMachine.TriggerAsync( device.Id, DeviceEvent.HeartbeatTimeout, triggerSource: "System", remark: $"心跳超时 {elapsed:F0}s"); } } } } }

心跳超时阈值(offline_threshold_sec)建议按设备单独配置,而不是全局统一。有些老旧设备通信本来就不稳定,阈值设短了会频繁误报离线;有些关键设备要求 10 秒内感知断连,阈值就要设得紧。

建表 SQL(SQL Server)

sql
-- 设备档案表 CREATE TABLE t_device ( id BIGINT NOT NULL IDENTITY(1,1) PRIMARY KEY, device_code VARCHAR(50) NOT NULL, device_name VARCHAR(200) NOT NULL, device_type VARCHAR(50) NULL, workshop_code VARCHAR(50) NULL, current_status TINYINT NOT NULL DEFAULT 2, -- 默认 Idle status_since DATETIME NOT NULL DEFAULT GETUTCDATE(), last_heartbeat_at DATETIME NULL, offline_threshold_sec INT NOT NULL DEFAULT 30, is_active TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT GETUTCDATE(), updated_at DATETIME NOT NULL DEFAULT GETUTCDATE(), CONSTRAINT uq_device_code UNIQUE (device_code) ); -- 设备状态历史表 CREATE TABLE t_device_state_log ( id BIGINT NOT NULL IDENTITY(1,1) PRIMARY KEY, device_id BIGINT NOT NULL, from_status TINYINT NOT NULL, to_status TINYINT NOT NULL, trigger_event VARCHAR(50) NOT NULL, trigger_source VARCHAR(50) NOT NULL DEFAULT 'System', started_at DATETIME NOT NULL, ended_at DATETIME NULL, duration_sec INT NULL, remark VARCHAR(500) NULL, CONSTRAINT fk_state_log_device FOREIGN KEY (device_id) REFERENCES t_device(id) ); -- 查询性能索引 CREATE INDEX idx_state_log_device_time ON t_device_state_log (device_id, started_at DESC); -- 查询当前未结束状态的索引(ended_at IS NULL 的行) CREATE INDEX idx_state_log_open ON t_device_state_log (device_id) WHERE ended_at IS NULL;

image.png

image.png


五、经验总结

可以直接拿去用的几条原则

1. 状态迁移逻辑必须集中管理。 用一个专门的状态机类,所有状态变更都走它,禁止外部直接改状态字段。这是整个方案最重要的约束,没有之一。

2. 快照 + 历史双轨制。 t_device 存当前状态用于实时展示,t_device_state_log 存完整历史用于追溯和统计。两者通过事务同步更新,不能分离。

3. 信号解析和状态迁移要分层。 采集层只负责拿原始值,解析层负责把原始信号翻译成业务事件,状态机只处理业务事件。三层职责清晰,换 PLC 品牌只改解析层,状态机不用动。

4. 心跳超时阈值按设备配置。 不要用全局常量,数据库里存,支持运行时调整,不用重启服务。

5. 历史记录存 duration_sec 虽然可以用 ended_at - started_at 实时计算,但预计算存入字段能大幅简化 OEE、停机时长等统计查询的 SQL 复杂度。

几个常见坑

  • 坑1:防抖没做,历史记录全是噪声。 PLC 信号抖动时,同一个状态可能在 1 秒内反复触发。建议在信号解析层加防抖窗口(比如同一事件 2 秒内只触发一次),或者在写历史时过滤掉持续时间小于阈值的记录。

  • 坑2:并发触发导致状态混乱。 采集线程和心跳检测线程可能同时触发同一台设备的状态变更。WITH (UPDLOCK) 行锁是必要的,或者对每台设备使用单线程队列串行化处理。

  • 坑3:ended_at IS NULL 的记录超过一条。 正常情况下每台设备只有一条 ended_at IS NULL 的记录。如果出现多条,说明事务有问题。建议定期跑一个巡检 SQL 检查这种异常,发现了立即修复。

  • 坑4:离线后恢复状态写死为 Idle 有些场景下设备恢复通信时实际上还在运行,直接判为 Idle 会导致状态和现实不符。建议通信恢复后先读一次设备寄存器,根据实际信号决定恢复到哪个状态。

  • 坑5:忘记处理重启后的初始化。 服务重启时,所有设备的 ended_at IS NULL 记录的 started_at 可能是重启前的时间。需要在服务启动时做一次"历史记录修复",把中断期间的记录补全,否则 duration_sec 会出现异常大的值。

下一步可以做什么

这套状态机跑稳之后,下一步很自然的延伸是报警管理模块——把 Alarm 状态细化为不同的报警类型和等级,加入报警确认、报警升级、报警统计的流程。这是从"知道设备出了问题"到"能系统性管理设备健康度"的关键一步,也是客户最愿意为之付费的功能之一。

完整示例代码(含建表脚本、状态机实现、心跳服务和单元测试)我整理在一个示例项目中,方便大家对照学习和实践。


你们项目里的设备状态是怎么管理的?有没有遇到过 PLC 信号抖动把历史记录搞得一团糟的情况?欢迎在评论区聊聊你的做法,或者补充你认为更合适的状态迁移设计。

通过网盘分享的文件:AppWpf202610.zip 链接: https://pan.baidu.com/s/1o64Qv9fExKVlmGwUFmAMkQ?pwd=zm71 提取码: zm71 --来自百度网盘超级会员v9的分享

本文作者:技术老小子

本文链接:

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