去年接手某汽配厂MES系统升级项目时,遇到一个典型的工业现场数据采集难题。车间三条产线共96个工艺报警点(包括温度、压力、电机状态等)需要通过S7-1500 PLC接入新建的WPF上位机系统。初期采用传统WinForms混合架构开发,在产线满负荷运行时暴露出两个致命问题:
通过Wireshark抓包分析发现,原始架构存在三个设计缺陷:
采用严格的MVVM模式重构系统,各层职责明确划分:
code复制┌───────────────────────────────────────┐
│ View层 │
│ XAML界面(DataGrid+样式触发器) │
└───────────────┬───────────────────────┘
│ Binding
┌───────────────▼───────────────────────┐
│ ViewModel层 │
│ ObservableCollection<AlarmModel> │
│ 用户操作命令处理(RelayCommand) │
└───────────────┬───────────────────────┘
│ Event/Message
┌───────────────▼───────────────────────┐
│ Model层 │
│ S7netplus通讯模块(PlcService) │
│ 报警逻辑处理(AlarmProcessor) │
└───────────────────────────────────────┘
MVVMLight 5.4.1.1:
S7netplus 2.0.2:
WPF特性应用:
与电气工程师协作,在PLC端建立专用报警DB块(DB100):
| 地址范围 | 数据类型 | 用途说明 | 示例值 |
|---|---|---|---|
| DB100.DBW0 | WORD | 心跳计数器(100ms自增) | 0-65535循环 |
| DB100.DBB2 | BYTE | 报警位1-8 | 0x01 |
| ... | ... | ... | ... |
| DB100.DBB13 | BYTE | 报警位89-96 | 0x80 |
| DB100.DBD14 | DWORD | 预留扩展 | 0 |
这种设计的优势:
在OB35循环中断组织块(100ms周期)中实现:
STL复制// 心跳计数器
L DB100.DBW0
L 1
+I
T DB100.DBW0
// 报警状态更新
A "温度传感器1故障"
= DB100.DBX2.0
A "液压压力过高"
= DB100.DBX3.2
csharp复制public class PlcService : IDisposable
{
private Plc _plc;
private Timer _readTimer;
private const int DB_NUMBER = 100;
private const int READ_LENGTH = 18;
public event EventHandler<byte[]> DataUpdated;
public void Connect(string ip)
{
_plc = new Plc(CpuType.S71500, ip, 0, 1);
_plc.Open();
_readTimer = new Timer(100);
_readTimer.Elapsed += async (s, e) =>
{
var data = await _plc.ReadBytesAsync(DataType.DataBlock, DB_NUMBER, 0, READ_LENGTH);
DataUpdated?.Invoke(this, data);
};
_readTimer.Start();
}
public async Task WriteAckAsync(int alarmId)
{
// 使用位操作写入确认状态
byte[] mask = new byte[1] { (byte)(1 << (alarmId % 8)) };
await _plc.WriteBytesAsync(DataType.DataBlock, DB_NUMBER, 2 + alarmId/8, mask);
}
}
csharp复制public class AlarmProcessor
{
private readonly Dictionary<int, AlarmModel> _alarmMap;
private byte[] _lastStatus;
public AlarmProcessor()
{
_alarmMap = LoadAlarmConfig(); // 从配置文件加载报警定义
}
public IEnumerable<AlarmModel> ProcessData(byte[] plcData)
{
// 心跳检测
if (_lastStatus == null || plcData[0] != _lastStatus[0] + 1)
{
throw new PlcCommException("心跳检测失败");
}
List<AlarmModel> activeAlarms = new List<AlarmModel>();
for (int i = 2; i < 14; i++) // 遍历报警字节段
{
byte current = plcData[i];
byte previous = _lastStatus?[i] ?? 0;
byte changes = (byte)(current ^ previous);
if (changes == 0) continue;
for (int bit = 0; bit < 8; bit++)
{
if ((changes & (1 << bit)) != 0)
{
int alarmId = (i - 2) * 8 + bit;
if (_alarmMap.TryGetValue(alarmId, out var alarm))
{
alarm.IsActive = (current & (1 << bit)) != 0;
alarm.LastChangeTime = DateTime.Now;
activeAlarms.Add(alarm);
}
}
}
}
_lastStatus = plcData;
return activeAlarms;
}
}
csharp复制public class MainViewModel : ViewModelBase
{
private readonly ObservableCollection<AlarmModel> _alarms = new ObservableCollection<AlarmModel>();
private readonly object _lockObj = new object();
public ICollectionView AlarmView { get; }
public MainViewModel(IPlcService plcService)
{
// 配置集合视图过滤和排序
AlarmView = CollectionViewSource.GetDefaultView(_alarms);
AlarmView.Filter = o => ((AlarmModel)o).IsActive;
AlarmView.SortDescriptions.Add(new SortDescription("LastChangeTime", ListSortDirection.Descending));
// 订阅PLC数据更新
plcService.DataUpdated += (s, e) =>
{
var newAlarms = _alarmProcessor.ProcessData(e);
Application.Current.Dispatcher.Invoke(() =>
{
lock (_lockObj)
{
foreach (var alarm in newAlarms)
{
var existing = _alarms.FirstOrDefault(a => a.Id == alarm.Id);
if (existing != null)
{
existing.UpdateFrom(alarm);
}
else
{
_alarms.Add(alarm);
}
}
}
});
};
}
}
csharp复制public RelayCommand<int> AcknowledgeCommand => new RelayCommand<int>(alarmId =>
{
try
{
_plcService.WriteAckAsync(alarmId).Wait();
var alarm = _alarms.First(a => a.Id == alarmId);
alarm.IsAcknowledged = true;
}
catch (Exception ex)
{
_messenger.Send(new ErrorMessage(ex.ToString()));
}
});
xml复制<DataGrid ItemsSource="{Binding AlarmView}"
EnableRowVirtualization="True"
EnableColumnVirtualization="True"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling">
<DataGrid.Resources>
<Style TargetType="DataGridRow">
<Style.Triggers>
<DataTrigger Binding="{Binding IsActive}" Value="True">
<Setter Property="Background" Value="#FFF0F0"/>
<Setter Property="FontWeight" Value="Bold"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsAcknowledged}" Value="False">
<Setter Property="Foreground" Value="Red"/>
</DataTrigger>
</Style.Triggers>
</Style>
</DataGrid.Resources>
</DataGrid>
xml复制<Style TargetType="Border" x:Key="AlarmIndicator">
<Style.Triggers>
<DataTrigger Binding="{Binding IsActive}" Value="True">
<DataTrigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<ColorAnimation From="Transparent" To="Red"
Storyboard.TargetProperty="Background.Color"
Duration="0:0:0.5"
AutoReverse="True"
RepeatBehavior="Forever"/>
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
</DataTrigger>
</Style.Triggers>
</Style>
| 方案 | 单次读取耗时 | 96点轮询周期 | CPU占用率 |
|---|---|---|---|
| 离散地址读取 | 15-20ms | 1.5-2s | 35-40% |
| DB块批量读取 | 3-5ms | 100ms | 8-12% |
三层线程架构:
锁机制选择:
csharp复制public class PlcErrorHandler
{
public void HandleException(Exception ex)
{
switch (ex)
{
case PlcCommException plcEx:
_messenger.Send(new ReconnectMessage());
break;
case TimeoutException timeoutEx:
_logger.Warn("PLC响应超时,尝试降低读取频率");
_plcService.AdjustInterval(200);
break;
default:
_logger.Error(ex, "未处理的异常");
break;
}
}
}
json复制{
"Serilog": {
"WriteTo": [
{
"Name": "File",
"Args": {
"path": "Logs/alarm-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 7
}
},
{
"Name": "Udp",
"Args": {
"remoteAddress": "192.168.1.100",
"remotePort": 514
}
}
]
}
}
安装包制作:
远程诊断:
升级策略:
经过两周连续运行测试,系统达到以下指标:
关键改进前后的对比数据:
| 指标 | 改进前 | 改进后 | 提升幅度 |
|---|---|---|---|
| 最大延迟 | 3200ms | 190ms | 94% |
| 漏报率 | 0.7% | 0% | 100% |
| 内存泄漏 | 2MB/h | 0MB/h | 100% |
这套架构已经在三个同类项目中成功复用,最近一个项目甚至实现了1500+报警点的稳定监控。对于需要开发工业级上位机系统的同行,我的建议是:前期多花时间在架构设计上,后期维护成本能降低80%以上。