两年前,我在一个离散制造车间做上位机改造项目。现场有四十多台设备,PLC 品牌混杂,有西门子、三菱、台达,通信协议也各不相同。客户的需求听起来很简单:"我想在大屏上看到每台设备现在是什么状态,出了问题能报警。"
第一版我做得很粗糙——用一个定时器每隔 5 秒轮询设备,把采集到的信号直接写进数据库,前端读库展示。跑了两周,问题来了:设备断网后状态一直显示"运行中";PLC 偶发抖动,报警状态一秒钟出现又消失,历史记录里全是噪声;更麻烦的是,有台设备的"停机"和"待机"信号用的是同一个寄存器位,不同班次的操作员对状态的理解还不一样。
这些问题的本质,不是采集频率不够,也不是数据库设计不好,而是根本没有"状态机"的概念。 设备的状态不是一个孤立的值,它是一系列事件驱动下的有序迁移。没有状态机,你就永远在追噪声,永远说不清楚"这台设备到底出了什么问题、从什么时候开始的"。
这篇文章,我想把设备状态机的建模思路、表结构设计和 C# 实现完整讲一遍。
很多开发者第一反应是:状态不就是个枚举值吗,Running = 1,Idle = 2,Alarm = 3,存到数据库里不就行了?
这个想法在数据量小、设备少的时候能凑合,但它忽略了三个关键问题:
第一,状态是有来源的。 同样是"停机",是操作员主动按了停止按钮,还是设备因为过温自保护停下来的,还是通信中断导致系统判断为停机?这三种"停机"在业务上的处理方式完全不同。没有来源,维修人员就不知道该去查哪里。
第二,状态是有时序的。 设备不能从"运行"直接跳到"离线",中间一定经历了通信超时的过程。如果你不约束状态迁移的合法路径,前端展示就会出现"刚才还在运行,刷新一下变成离线了"这种让人困惑的情况。
第三,状态是有持续时间的。 报警持续了 3 秒还是 3 小时,对维护决策的意义完全不同。只存当前状态,你永远算不出设备的 OEE,也无法做任何趋势分析。
做法一:只存当前状态,不存历史。 这是最常见的坑。上线第一天 PM 就会问:"这台设备今天报警了几次?每次持续多久?" 你答不上来。
做法二:用定时轮询直接覆盖状态,不做防抖。 PLC 信号天然有抖动,尤其是继电器类型的输入点。没有防抖逻辑,报警记录里会充斥大量持续时间不足 1 秒的"幽灵报警",历史数据完全失去参考价值。
做法三:状态迁移逻辑散落在各处。 有人在采集线程里改状态,有人在 API 里改状态,有人在定时任务里改状态。三个月后没人敢动这块代码,因为不知道改了会影响哪里。
根据我的经验,设备状态机的核心是两张表 + 一个状态机服务:
t_device:设备档案,存静态信息和当前状态快照t_device_state_log:状态变更历史,每次状态迁移写一条记录,记录开始时间、结束时间、持续秒数、触发原因状态迁移逻辑全部收拢到一个 DeviceStateMachine 类里,任何地方想改设备状态,都必须通过这个类,不允许直接 UPDATE t_device SET status = xxx。
这个约束听起来有点强硬,但在项目中执行下来效果非常好——状态变更的来龙去脉一目了然,出了问题三分钟之内能定位到根因。
首先明确五种状态的业务含义:
| 状态 | 枚举值 | 业务含义 |
|---|---|---|
Running | 1 | 设备正在生产,主轴/执行机构处于工作状态 |
Idle | 2 | 设备上电待机,未在生产,等待指令 |
Alarm | 3 | 设备触发报警,需人工干预,可能仍在运行 |
Stopped | 4 | 设备主动或被动停机,执行机构停止 |
Offline | 5 | 通信中断,系统无法获取设备真实状态 |
合法的状态迁移路径如下(只有在这张图里的箭头才允许发生):
用文字描述关键路径:
Offline → Idle:通信恢复,设备重新上线,初始化为待机Idle ↔ Running:操作员启动 / 停止设备Running → Alarm:设备运行中触发报警(报警不一定停机)Alarm → Running:报警解除,恢复运行Alarm → Stopped:报警后设备自保护停机Running / Idle → Stopped:正常停机Stopped → Idle:重启完成,进入待机任意状态 → Offline:通信超时(心跳丢失超过阈值)任何不在上述路径中的状态迁移,状态机应当拒绝执行并记录警告日志,而不是静默接受。这是保证数据可信的关键约束。

信号解析层的存在非常重要。 不同品牌 PLC 的寄存器定义各不相同,把"寄存器值 → 业务事件"的映射逻辑单独抽出来,状态机本身就只需要处理标准化的 DeviceEvent,不用关心底层协议细节。
设备档案表 t_device
| 字段名 | 类型 | 说明 |
|---|---|---|
id | BIGINT PK | 主键,自增 |
device_code | VARCHAR(50) | 设备编码,唯一 |
device_name | VARCHAR(200) | 设备名称 |
device_type | VARCHAR(50) | 设备类型(CNC/注塑机/输送线…) |
workshop_code | VARCHAR(50) | 所属车间 |
current_status | TINYINT | 当前状态(枚举值) |
status_since | DATETIME | 当前状态开始时间 |
last_heartbeat_at | DATETIME | 最后一次心跳时间 |
offline_threshold_sec | INT | 离线判定阈值(秒),默认 30 |
is_active | TINYINT | 是否启用 |
created_at | DATETIME | 创建时间 |
updated_at | DATETIME | 最后更新时间 |
设备状态变更历史表 t_device_state_log
| 字段名 | 类型 | 说明 |
|---|---|---|
id | BIGINT PK | 主键,自增 |
device_id | BIGINT | 关联设备 ID |
from_status | TINYINT | 变更前状态 |
to_status | TINYINT | 变更后状态 |
trigger_event | VARCHAR(50) | 触发事件(枚举名) |
trigger_source | VARCHAR(50) | 触发来源(PLC/System/Operator) |
started_at | DATETIME | 本次状态开始时间 |
ended_at | DATETIME | 本次状态结束时间(NULL 表示当前仍在此状态) |
duration_sec | INT | 持续秒数(ended_at 写入时计算) |
remark | VARCHAR(500) | 附加说明(报警代码、操作员等) |
t_device_state_log 建议在 (device_id, started_at) 上建联合索引,支撑按设备查时间段历史的查询场景。单台设备一天状态变更通常在几十到几百次,一年数据量在百万级以内,查询性能不是问题。
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;
}
}
}
几个设计细节值得说明:
这是一个容易被忽视的部分。设备离线往往不是收到一个"离线事件",而是心跳超时——采集线程发现超过阈值时间没有收到数据,才判定为离线。
csharpusing 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-- 设备档案表
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;


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 许可协议。转载请注明出处!