报警系统设计:不只是弹个框那么简单
这是「从零搭建工业控制系统」系列第9篇。工艺能跑了,数据能看了。但万一设备出故障呢?这篇讲怎么设计一套靠谱的报警系统。
报警系统踩过的坑
最早做报警,就是在代码里弹 MessageBox.Show("温度过高!")。简单粗暴,能用。
然后被现场教训了:
问题1:操作员关了弹框就没了。出了事故翻记录,什么都没有。
问题2:温度过高的报警一直触发,每秒弹一个框,屏幕上堆了几十个对话框。
问题3:程序异常退出后重启,之前哪些报警在持续、什么时候开始的,全不知道。
问题4:进气压力低和加热器过温用同一种方式提示,操作员分不清哪个紧急哪个不紧急。
这些问题逼着我重新设计了报警系统。核心思路:报警不是一次性事件,而是一个有生命周期的状态机。
报警状态机
每条报警有四个状态:
Normal → Active → Recovered
↘ RecoveredOnStartup(异常退出后启动自愈)
↘ Ignored(调试期忽略)
- Normal:正常,没有报警
- Active:报警中,从触发瞬间开始计时
- Recovered:恢复正常,记录了持续时长
- RecoveredOnStartup:程序异常退出后重启时自动关闭的遗留记录
- Ignored:调试模式下忽略的报警

这个状态机由 AlarmHistoryService 管理:
public interface IAlarmHistoryService
{
/// <summary>
/// 启动自愈:关闭所有EndTime为NULL的遗留记录
/// </summary>
Task InitializeAsync();
/// <summary>
/// 报警源状态跳变入口
/// Normal→Abnormal: 插入新记录(Status=Active)
/// Abnormal→Normal: 更新原记录(EndTime/Status=Recovered)
/// </summary>
void OnSourceStateChanged(HardwareAlarmSnapshot snapshot);
}
报警源定义
项目里定义了若干类硬件报警源,每类有明确的元数据:
private static readonly IReadOnlyDictionary<string, AlarmSourceMeta> SourceMetaTable =
new Dictionary<string, AlarmSourceMeta>
{
["ALM_COVER"] = new AlarmSourceMeta
{
SourceCode = "ALM_COVER",
Severity = AlarmSeverity.Critical,
Description = "腔室盖未关闭",
DeviceInfo = "ChamberCover",
TriggerCondition = "腔室盖开到位信号为1(点位号略)",
},
["ALM_ARC"] = new AlarmSourceMeta
{
SourceCode = "ALM_ARC",
Severity = AlarmSeverity.Critical,
Description = "气柜检测到起弧",
DeviceInfo = "气柜DI信号",
TriggerCondition = "起弧检测信号激活",
},
["ALM_GAS_PRESSURE"] = new AlarmSourceMeta
{
SourceCode = "ALM_GAS_PRESSURE",
Severity = AlarmSeverity.Critical,
Description = "进气压力异常(<0.7MPa)",
DeviceInfo = "压力AI信号",
TriggerCondition = "进气压力 < 0.7MPa",
DefaultThreshold = 0.7m,
Unit = "MPa",
},
["ALM_HEATER1_OT"] = new AlarmSourceMeta
{
SourceCode = "ALM_HEATER1_OT",
Severity = AlarmSeverity.Critical,
Description = "加热器1过温",
DeviceInfo = "Heater1",
TriggerCondition = "过温DI信号为1(点位号略)",
Unit = "°C",
},
// ... 加热器2、加热器3 类似
};
三个关键设计:
① 严重等级分三级 — Critical(必须立即处置)、Warning(需关注)、Info(仅记录)。UI可以根据等级用不同颜色显示。

② 静态元数据表 — 描述、触发条件、设备信息都是写死的。不会因为配置文件丢了就不知道这个报警是什么意思。
③ 加热器拆分 — 原本"加热器过温"是一个报警源,但项目有多个加热器。拆成各自独立的报警源,每个可以独立触发和恢复。
状态跳变处理
OnSourceStateChanged 是报警系统的核心入口。每次设备寄存器变化导致报警源状态翻转时调用:
public void OnSourceStateChanged(HardwareAlarmSnapshot snapshot)
{
// 启动冷却期守卫
if (_readyAfter == null || DateTime.Now < _readyAfter)
return;
var sourceCode = snapshot.SourceCode;
if (snapshot.IsAbnormal)
{
// Normal → Abnormal:新增Active记录
// TryAdd原子占位,防止并发重复插入
if (!_activeAlarms.TryAdd(sourceCode, -1))
{
// 已有Active记录,重复触发跳过(去抖)
return;
}
var record = BuildAlarmHistory(snapshot);
// fire-and-forget:写库不阻塞UI事件链
_ = Task.Run(async () =>
{
var newId = await _databaseService.InsertAlarmHistoryAsync(record);
if (newId > 0)
_activeAlarms[sourceCode] = newId;
else
_activeAlarms.TryRemove(sourceCode, out _);
});
}
else
{
// Abnormal → Normal:更新EndTime
if (!_activeAlarms.TryRemove(sourceCode, out var activeId))
return;
_ = Task.Run(async () =>
{
await _databaseService.UpdateAlarmHistoryEndTimeAsync(
activeId, snapshot.Timestamp, "Recovered");
});
}
}
几个关键设计决策:
① 内存字典做去抖 — _activeAlarms 是 ConcurrentDictionary<string, int>,key是报警源代码,value是数据库ID。用 TryAdd 保证同一个报警源只插入一次。温度过高的报警不管触发多少次,数据库里只有一条Active记录。
② fire-and-forget写库 — 数据库写入是异步的,不阻塞调用线程。因为 OnSourceStateChanged 是在UI事件链里被调用的,如果同步写库,设备状态变化的响应会变慢。
③ 占位值-1 — TryAdd 时先放-1,INSERT成功后更新为真实ID。这防止了并发场景下重复插入。

启动自愈
程序异常退出时,可能有报警记录的 EndTime 还是NULL(报警没恢复就退出了)。重启后这些"悬挂记录"必须处理:
public async Task InitializeAsync()
{
try
{
var startupTime = DateTime.Now;
// 关闭所有悬挂的报警记录
var alarmClosed = await _databaseService.CloseHangingAlarmHistoriesAsync(startupTime);
var traceClosed = await _databaseService.CloseHangingTraceAlarmHistoriesAsync(startupTime);
_logger.Info($"启动自愈完成:关闭报警历史悬挂记录{alarmClosed}条," +
$"Trace报警历史悬挂记录{traceClosed}条");
// 设置15秒冷却期
_readyAfter = DateTime.Now.AddSeconds(15);
}
catch (Exception ex)
{
// 自愈失败不阻断启动
_logger.Error($"启动自愈失败: {ex.Message}", ex);
}
}
为什么设15秒冷却期? 程序刚启动时,设备还没完成首轮Modbus轮询,寄存器值都是0。如果立刻开始判断报警,0值可能被误判为"压力异常"等,产生假报警。15秒后设备数据稳定了才开始真正的报警监测。
这个冷却期是踩坑后加的。之前没这个机制,每次启动都会冒出一堆假报警,操作员被搞烦了直接把报警功能关了。

报警数据模型
每条报警记录在数据库里长这样:
public class AlarmHistory
{
public int Id { get; set; }
public DateTime StartTime { get; set; } // 报警开始时间
public DateTime? EndTime { get; set; } // 结束时间(Active时为NULL)
public int? DurationSeconds { get; set; } // 持续秒数
public string Category { get; set; } // Hardware/Custom
public string SourceCode { get; set; } // 报警源代码
public string Severity { get; set; } // Critical/Warning/Info
public string DeviceInfo { get; set; } // 设备位号
public string TriggerCondition { get; set; } // 触发条件表达式
public decimal? TriggerValue { get; set; } // 触发时的瞬时值
public decimal? Threshold { get; set; } // 触发阈值
public string Unit { get; set; } // 物理单位
public string Description { get; set; } // 中文描述
public string Status { get; set; } // Active/Recovered/...
public string Barcode { get; set; } // 关联晶圆Barcode
public string SequenceName { get; set; } // 当前运行Sequence
}
TriggerValue 和 Threshold 这两个字段特别有用。比如进气压力报警,不仅记录"压力异常",还记录"当时压力0.5MPa,阈值0.7MPa"。事后分析时一眼就能看出是压力确实低了还是传感器有问题。
Barcode 和 SequenceName 把报警跟工艺批次关联起来。查某个晶圆加工过程中出了什么报警,一条SQL就搞定。
UI层的报警显示
报警在UI上分两部分:状态栏和报警列表。
状态栏显示当前报警概览:
// MainWindowViewModel.AlarmStatus.cs
[ObservableProperty]
private string alarmStatusText = "未知";
[ObservableProperty]
private string alarmStatusTime = string.Empty;
[ObservableProperty]
private string abnormalAlarmSources = "";
public bool HasAlarm => TriggerStatus == TriggerStatus.Alarm;
报警源状态变化时,通过 WeakReferenceMessenger 通知ViewModel更新:
// 监听报警源属性变化
hdps100Status.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(ChamberSubsystemStatus.AlarmSourceChamberClose) ||
e.PropertyName == nameof(ChamberSubsystemStatus.AlarmSourceGasBoxActing) ||
// ... 其他报警源
e.PropertyName == nameof(ChamberSubsystemStatus.AlarmSourceHeaterOvertemp))
{
// 切到UI线程更新
Application.Current?.Dispatcher?.BeginInvoke(
new Action(() => UpdateAlarmStatus()));
// 通知AlarmHistoryService持久化
NotifyAlarmHistoryService();
}
};
为什么要切到UI线程? 因为 PropertyChanged 事件可能从Modbus轮询线程触发。UI更新必须在Dispatcher上执行。
报警列表条目
报警列表里的每一项是个简单的model:
public partial class AlarmItem : ObservableObject
{
public string Message { get; } // "[硬件] Heater1过温报警"
public DateTime Time { get; } // 触发时间
public string TimeText => Time.ToString("MM-dd HH:mm:ss");
[ObservableProperty]
private string elapsedText = string.Empty; // "1分钟30秒"
}
ElapsedText 是持续时间的文本,由外部定时刷新。报警列表每秒更新一次已持续时间,让操作员知道这个报警已经持续多久了。
报警与工艺的关联
报警系统不是孤立的。AlarmHistoryService 在构造报警记录时,会尝试从 ExecutionProgressService 读取当前工艺上下文:
private AlarmHistory BuildAlarmHistory(HardwareAlarmSnapshot snapshot)
{
return new AlarmHistory
{
StartTime = snapshot.Timestamp,
SourceCode = meta.SourceCode,
Severity = meta.Severity.ToString(),
Description = meta.Description,
Status = "Active",
Barcode = TryGetBarcode(), // 当前晶圆
SequenceName = TryGetSequenceName(), // 当前Sequence
};
}
private string TryGetBarcode()
{
var prop = _executionProgress?.GetType().GetProperty("Barcode");
return prop?.GetValue(_executionProgress) as string;
}
用反射是因为 ExecutionProgressService 的 Barcode 属性是后加的,不一定每个版本都有。反射安全降级,没有就返回null。
报警踩坑清单
| 坑 | 现象 | 解决 |
|---|---|---|
| 弹框式报警 | 关了就没了 | 改用状态机+数据库持久化 |
| 重复触发 | 每秒一条记录 | ConcurrentDictionary去抖 |
| 启动假报警 | 寄存器初始值0被误判 | 15秒冷却期 |
| 异常退出残留 | EndTime永远NULL | 启动自愈关闭悬挂记录 |
| 同步写库卡UI | 设备状态变化响应慢 | fire-and-forget异步写 |
| 分不清严重程度 | 全用红色显示 | Critical/Warning/Info三级 |
| 报警跟工艺脱节 | 不知道哪个晶圆出的 | 关联Barcode和SequenceName |
本篇小结
| 知识点 | 关键做法 |
|---|---|
| 报警状态机 | Normal→Active→Recovered 生命周期 |
| 去抖机制 | ConcurrentDictionary + TryAdd 原子占位 |
| 启动自愈 | 关闭EndTime为NULL的悬挂记录 |
| 冷却期 | 15秒等设备数据稳定 |
| 异步写库 | fire-and-forget 不阻塞UI |
| 严重等级 | Critical/Warning/Info 三级分类 |
| 工艺关联 | 记录Barcode和SequenceName |
好的报警系统不是"出了事告诉你",而是"出了事记录下来、告诉你多严重、事后能追溯"。
下期预告
第10篇:权限管理与角色控制
不是所有人都能操作所有设备。下一篇讲用户认证、角色权限、UI元素按权限显隐。
:报警系统设计——不只是弹个框那么简单&spm=1001.2101.3001.5002&articleId=163957394&d=1&t=3&u=9ff8275c47694cf4aaac6ba494d259fa)
624

被折叠的 条评论
为什么被折叠?



