C#上位机与Profinet分布式IO组网实战:从选型到排障全记录
2026/9/24 22:51:05 网站建设 项目流程

在产线自动化这一行干了十来年,我越来越觉得,方案的价值不在于用了多新的技术,而在于能不能让现场少停机、快排障。最近刚移交的一条装配产线就是个典型例子:30多米的产线,原先走集中式IO柜,所有传感器和阀岛信号都往同一个柜子里拉,客户提了两个硬要求——新增20多个点位,同时数据实时性要上一个台阶。我们最终定的是Profinet分布式IO组网方案,上位机由C#开发。等真正下场做的时候才发现,C#要跟Profinet“玩得转”,远不是写几个Socket的事,组网拓扑、协议栈选型、数据链路设计、现场排障是一整套的活。这篇文章我把从选型到落地、从代码到踩坑的完整过程拆开讲,适合正在做C#上位机、准备上分布式IO、或者被Profinet组网问题卡住的工程师参考。

1. 改造前的现场困境:80路信号往同一个柜子拉线的代价

1.1 需求盘点:这条产线到底要采多少点

客户是给汽车零部件做小件装配的,产线全长约35米,分成4个主要工位:上料装配、视觉检测、分拣下料,外加一个安全门联锁区。最开始我按传统做法摸了一遍点表,结果数量有点吓人:数字量输入40多路,包含接近开关、光电传感器、气缸磁性开关、急停、安全门、按钮;数字量输出30多路,包含电磁阀、继电器、三色灯、蜂鸣器、伺服使能;模拟量输入10路,主要是温度、压力和位移传感器。

这么一算,整条线的IO点位数接近90个。而且客户因为新增加的追溯需求,还要在检测工位旁边加扫码枪和视觉相机,这20多个新增点位如果继续沿用老架构,光是放线就得再停半天产线。当时和客户聊完需求,我心里基本有数了:这个项目必须上分布式IO,把IO站点从中央柜里解放出来,部署到工位附近,才能从根本上解决线缆长度和后期扩容的问题。

1.2 集中式IO的三笔“隐性成本”

集中式IO柜不是不能用,小产线、点位少、距离短,完全没问题。但一旦点位超过50、距离超过20米,问题就接踵而至。

第一笔成本是线缆。90多路信号全部拉回中央柜,按平均每根线20米算,光信号线就接近2000米,桥架里塞得满满当当。这还不算屏蔽线、接地、标签的成本,施工队光是理线就花了2天。

第二笔成本是维护。信号一多,柜内端子排上的标签迟早被灰尘和油污盖住,一旦某个点位出问题,电工得拿着图纸从头找。我们接手时,柜子里有不少线是后加的,走的都是桥架空隙,查线的难度大家都能想象。

第三笔成本是信号质量。模拟量信号在20多米长的线路上传输,现场变频器一启动,温度读数就开始飘,严重的时候能偏2到3摄氏度。这在装配工艺里是致命的,压力、位移传感器对信号稳定性要求极高。

1.3 为什么对比了一圈还是选Profinet

当时对比了Profinet、Modbus TCP、EtherCAT、CC-Link IE四种方案,核心指标放在实时性、布线难度、生态工具链、C#接入难度四个维度上:

方案实时性布线方式生态与工具链C#接入难度
ProfinetRT可到1~10ms,支持IRT标准以太网线,可用线型/星型西门子为主,GSDML统一描述,工具成熟需经PLC或网关中转,中等
Modbus TCP10~100ms,看从站实现标准以太网生态最广,几乎所有设备都支持直接TCP Socket,最简单
EtherCAT1ms以内专用IO端子,站间链式倍福生态偏强,第三方支持够用上位机侧基本依赖控制器
CC-Link IE5ms左右专用线缆三菱生态圈第三方支持较少

选Profinet的原因很直接:客户现场已经有西门子S7-1200和S7-1500 PLC,Profinet是它们的原生协议,工程组态工具TIA Portal一套全搞定;另一个原因是Profinet的分布式IO设备选择面宽,西门子ET200SP、国产第三方远程IO、万可、菲尼克斯都有大量成熟产品,后期替换、扩容的余地大。Modbus TCP虽然C#接入最简单,但现场实时性要求40ms以内的周期,Modbus TCP在站数量多、数据量大的时候容易拉胯,而且它的诊断功能远不如Profinet丰富,掉站、断线这类问题排查起来全靠猜。

2. 分布式IO网络设计与硬件选型:拓扑比代码更决定成败

2.1 混合拓扑:线型省成本,星型保可靠性

Profinet的组网拓扑有几种常见形态,很多人一开始就直接无脑接,其实这里面讲究不少。线型拓扑是让每个IO站用自己的双端口交换机往下串,好处是省交换机、省网线,缺点是中间任何一条网线出问题,后面所有站全部掉线。星型拓扑是每个站点都单独拉线回到中央交换机,可靠性高、排查容易,但网线数量多,沿途布线成本高。环型拓扑需要支持MRP介质冗余协议的交换机,故障倒换通常要200ms,对单条产线来说是杀鸡用牛刀。

我们在现场用的其实是混合方案:装配工位和分拣工位的两个IO站连成线型,从中央柜的交换机串出去,省掉了一段10多米的桥架穿线;视觉检测工位因为视觉相机数据量大、传输频率高,用单独一根网线以星型方式直连中央交换机。这样做的考虑是,检测工位一旦掉站整条线都要停,给它最可靠的网络路径,而另外两个工位的线型串接在这个项目里经过计算和现场验证是可以接受的。多花的一个交换机端口,换来了故障隔离能力和排障效率,这笔账划算。

2.2 远程IO站选型和点位规划

远程IO站是分布式架构的骨架,选型不能只看报价单。这个项目里,装配工位和分拣工位用了西门子ET200SP,检测工位因为成本预算卡得紧,用了国产品牌的Profinet远程IO。这里有个值得说的点:第三方远程IO虽然GSDML导入TIA Portal以后功能和诊断看起来都正常,但实际下载周期有差异,西门子ET200SP在RT模式下最小更新周期可以做到4ms,国产那台实测最小只能做到8ms。所以选型时一定要看GSDML里标注的“MinimumDeviceInterval”,别被型号宣传的超高性能带偏。

点位规划上,我给每个站都预留了15%到20%的余量,按工位就近分配,避免以后加两个点位又得新开一个站。规划结果大概是:装配站配1个16路数字量输入模块、1个16路数字量输出模块、1个8路模拟量输入模块;检测站配1个32路输入模块和1个8路模拟量输入模块;分拣站配1个16路输入、1个16路输出。每个站的电源模块也按总功耗乘以1.5倍来选,防止启动瞬间电流把站拉崩。

2.3 IP与设备名规划:Profinet站点名的坑

Profinet有一个和普通以太网非常不一样的地方,它靠设备名而不是IP地址来识别设备。这看起来只是一个概念差异,实际调试时踩坑率极高。设备名必须符合类似DNS的命名规则:不能有下划线、不能用中文、不能用数字开头,只能包含字母、数字、连字符和点。我第一次做这个项目时,把一个IO站命名为“IO_STATION_01”,结果DCP搜索怎么都认不出来,查了半天资料才发现下划线不合法。

站点IP和设备名都在TIA Portal的“在线和诊断”里通过DCP协议分配,也可以在调试阶段用Profinet专用的DCP工具单独改。项目里我提前做好了命名表,每个人照着填,杜绝临场瞎起名的混乱:

设备角色设备名IP地址说明
PLCPLC_LINE01192.168.0.10S7-1500
C#上位机HMI-LINE01192.168.0.5静态IP
装配IO站IO-ASM-01192.168.0.21ET200SP
检测IO站IO-TEST-01192.168.0.22ET200SP
分拣IO站IO-SORT-01192.168.0.23国产远程IO
中央交换机SW-CORE-01192.168.0.1管理型交换机

IP地址规划要避开DHCP自动分配区域,上位机网卡也设置成静态IP。后面排查问题的时候,这套规范表帮了大忙,任何时候打开表格就能定位设备,不用对着网线一根一根捋。

3. C#接入Profinet的三种路径:从稳妥方案到抓包调试

3.1 路径一:C#上位机通过S7协议读取PLC,再由PLC访问Profinet站点

这是最稳妥、覆盖90%以上实际场景的架构。PLC作为Profinet的IO控制器,把所有分布式IO站的输入输出数据在每次扫描周期中映射到自身的DB块或IO地址区里,C#上位机只需要通过S7协议去读PLC的数据区域,就能间接拿到整个Profinet网络的数据。这个架构的关键优势,是把Profinet RT周期通信的时序压力全部放在了PLC侧,Windows上位机只需要做自己擅长的事——通过TCP 102端口读写数据。

C#侧用S7netplus这个NuGet包就够了。要注意的是,S7-1200和S7-1500默认不允许PUT/GET通信,必须在TIA Portal的“防护与安全 > 连接机制”里勾选“允许来自远程伙伴的PUT/GET通信访问”,否则C#连上去就会报错。连接代码如下:

using S7.Net; var plc = new Plc(CpuType.S71500, "192.168.0.10", 0, 0); plc.Timeout = 2000; ErrorCode result = plc.Open(); if (result != ErrorCode.NoError) { Console.WriteLine($"连接失败: {result}"); return; } // 一次批量读取DB100中前128个字节,避免逐点读取 byte[] data = plc.ReadBytes(DataType.DataBlock, 100, 0, 128); Console.WriteLine($"温度区前4字节: {BitConverter.ToString(data, 0, 4)}");

这里我特别强调批量读取。刚开始用的时候,很多人习惯用plc.Read("DB100.DBD0")这种逐点读取方式,点位一多性能立刻崩。实测下来,同样是读128字节数据,用ReadBytes一次读取比用单点方式读16个点位要快好几倍。PLC侧的DB数据要做到结构化规划,把相关点位连续存放在同一块区域,C#按固定偏移解析,这是C#数组最常见的应用场景,比塞一堆List再到处查找高效得多。

3.2 路径二:走网关把Profinet“翻译”成Modbus TCP

并不是所有项目都有权限或者有条件改PLC程序。有些产线是原集成商做的,程序不开放;有些第三方IO站点只要求给MES上报数据,不想占用PLC的DB资源。这时候Profinet转Modbus TCP网关就派上用场了。网关设备在Profinet网络中作为IO设备存在,同时又能把接收到的IO数据映射成Modbus寄存器,C#这边用Modbus TCP直接连接网关读取。

C#侧用EasyModbus库实现起来非常轻量:

using EasyModbus; var client = new ModbusClient("192.168.0.30", 502); client.Connect(); // 从寄存器100开始连续读取40个保持寄存器 int[] values = client.ReadHoldingRegisters(100, 40); for (int i = 0; i < values.Length; i++) { Console.WriteLine($"R[{100 + i}] = {values[i]}"); } client.Disconnect();

这个方案的好处是C#完全不需要理解Profinet内部机制,坏处是网关本身会引入10到20ms的额外延迟,而且需要在网关的配置工具里手动建立Profinet输入输出到Modbus寄存器的映射关系。所以这个路径更适合做监控、数据采集、历史记录这类非联锁功能,如果要用它做安全回路或者高速Interlocking,还是老老实实让PLC干活在先。另外,如果环境里已经有OPC UA服务器在采集Profinet数据,C#也可以走OPC UA客户端接口,但本质上和网关中转是一个思路,数据链路长,实时性天花板有限。

3.3 路径三:用SharpPcap直接抓Profinet帧做诊断

还有一类场景,C#不是为了做业务控制,而是做诊断和调试。Profinet的RT周期通信在数据链路层直接使用EtherType 0x8892的帧,这几类帧不走IP协议,普通Socket根本收不到。要在C#里抓这些帧,可以用SharpPcap加PacketDotNet直接挂网卡做原始数据包捕获:

using SharpPcap; using PacketDotNet; var device = CaptureDeviceList.Instance .FirstOrDefault(d => d.Name.Contains("Ethernet")); device!.Open(DeviceModes.Promiscuous, 50); device.OnPacketArrival += (s, e) => { var raw = e.GetPacket(); var packet = Packet.ParsePacket(raw.LinkLayerType, raw.Data); var eth = packet.Extract<EthernetPacket>(); // Profinet RT/DCP帧的EtherType是0x8892 if (eth != null && (ushort)eth.Type == 0x8892) { byte[] payload = eth.PayloadPacket?.PayloadData ?? Array.Empty<byte>(); if (payload.Length >= 2) { // 帧头2字节是FrameID,区分周期数据、告警、DCP报文 ushort frameId = (ushort)((payload[0] << 8) | payload[1]); Console.WriteLine($"捕获Profinet帧, FrameID=0x{frameId:X4}, 长度={payload.Length}"); } } }; device.StartCapture(); Thread.Sleep(5000); device.StopCapture();

这段代码做不到像PLC那样参与RT周期通信,但配合镜像端口可以判断某个站点是否在正常发送周期数据、告警帧是否频繁、DCP报文有没有冲突。我一直强调,纯C#直接在Windows上实现Profinet RT从站或者主站是不现实的,时序精度和驱动深度都达不到工业级别,真要这么做,得买Hilscher、西门子CP卡这类硬件配合专用SDK。但这不妨碍C#作为强力排障工具存在,前面说的好多现场问题,最后都是靠抓帧把根因挖出来的。

4. C#侧实时数据链路设计:事件驱动、队列与线程模型

4.1 别在UI线程里轮询:独立采集线程+委托事件才是正路

C#上位机最常见的错误做法,是直接在WPF或者WinForms的Timer事件里定时读PLC,然后立刻更新界面控件。看着简单,实际跑起来问题一堆:UI线程被网络等待卡住,界面卡顿;频繁刷新造成渲染抖动;一旦PLC短暂超时,整个窗体跟着假死。

正确的做法是把采集职责和显示职责彻底分开。一个后台采集任务负责和PLC保持长连接、周期读取数据,数据更新后通过事件发布出去,UI层订阅事件进行显示。这里C#的委托和事件机制正好派上用场,比到处传回调或者硬编码引用清晰得多。核心服务大概是这样的:

public sealed class PlcPollingService { private readonly CancellationTokenSource _cts = new(); private readonly string _plcIp; public event EventHandler<IoDataUpdatedEventArgs>? IoDataUpdated; public PlcPollingService(string plcIp) { _plcIp = plcIp; } public void Start() { _ = Task.Run(PollLoopAsync, CancellationToken.None); } public void Stop() { _cts.Cancel(); } private async Task PollLoopAsync() { using var plc = new Plc(CpuType.S71500, _plcIp, 0, 0); plc.Timeout = 1500; while (!_cts.IsCancellationRequested) { try { if (!plc.IsConnected) { if (plc.Open() != ErrorCode.NoError) { await Task.Delay(500, _cts.Token).ConfigureAwait(false); continue; } } byte[] data = plc.ReadBytes(DataType.DataBlock, 100, 0, 256); IoDataUpdated?.Invoke(this, new IoDataUpdatedEventArgs(data)); } catch (Exception ex) { // 记录日志后等待下一周期,保证循环不退出 Console.WriteLine($"读取异常: {ex.Message}"); } await Task.Delay(10, _cts.Token).ConfigureAwait(false); } } }

UI层只需要订阅这个事件,并用Dispatcher切回UI线程更新控件。事件回调的频率在10ms一个周期的时候是很高的,界面刷新没必要做到10ms一次,一般我设置50到100ms的界面刷新节流,既不影响数据实时性,也避免界面无谓重绘。

4.2 数据缓冲与背压:MES写入慢不能拖累采集周期

做过MES对接的工程师一定遇到过这个场景:采集线程好不容易把数据读回来了,正要往数据库写,结果数据库瞬间卡顿了一下,写入超时,采集线程被阻塞。这一阻塞,PLC侧的数据就出现断档,后面所有数据的时序全乱了。解决这个问题的标准套路是生产消费分离,用队列做缓冲。

C#里比较推荐System.Threading.Channels,用起来比传统BlockingCollection更轻量,还支持异步迭代。采集线程只负责把数据快照写进Channel,由独立的消费任务去写数据库,二者的速度互相不影响。而且如果还想把实时数据推送给现场看板、平板,完全可以在消费端再加一个基于TcpListener的多客户端订阅服务,每个客户端一个订阅通道,数据通过队列分发,这正是C#队列接收数据和TcpListener多客户端比较典型的落地场景。

var channel = Channel.CreateUnbounded<Dictionary<string, object>>(); // 采集线程只写队列 channel.Writer.TryWrite(new Dictionary<string, object> { ["timestamp"] = DateTime.Now, ["temperature"] = 23.5, ["pressure"] = 0.62 }); // 独立消费任务异步写库,用Dapper批量写入 await foreach (var snapshot in channel.Reader.ReadAllAsync()) { using var conn = new SqlConnection(_connString); await conn.ExecuteAsync( "INSERT INTO LineData(Timestamp,Temperature,Pressure) VALUES(@timestamp,@temperature,@pressure)", snapshot); }

很多人在这个环节会纠结队列的容量上限。我的建议是:正常情况下队列长期为空或者只有几条数据,只有在数据库故障的时候队列才会积压,这个时候积压再多都已经说明系统有严重问题,应该触发告警而不是吞掉数据。所以用无界队列加告警监控,比用有界队列把数据丢掉更稳妥。

4.3 实测数据:从20ms抖动压到5ms稳定周期的过程

这一段是压箱底的实测记录。项目初期我没太管架构,先用最直觉的写法搭了个原型,结果测出来的数据周期非常难看。我一共调了四轮,每一轮都有明确的优化点:

阶段平均周期最大抖动主要瓶颈
UI Timer + 逐点读取20ms60msUI线程被网络等待阻塞,逐点Read太慢
独立线程 + 批量ReadBytes12ms30ms数据库写入偶发阻塞采集循环
加入Channel解耦数据库8ms15ms网卡节能导致周期性延迟尖峰
关闭网卡节能 + Server GC5ms9ms已接近Windows上位机极限

最后一轮调整细节后面排障部分会细说。这里想强调一个结论:C#上位机在常规Windows环境下,采集周期压到10ms以内完全可行,但想稳定到1ms级别就非常困难了,GC暂停、系统调度抖动都会成为瓶颈。所以如果工艺要求1ms以内的确定性操作,必须在PLC里完成,上位机做的是监视和策略层,这个边界从一开始就要和客户讲清楚。

5. 现场排障复盘:断站、闪断、读超时的完整排查链路

5.1 IO站周期性掉站的排查:问题出在交换机

项目调试到第三天,客户反馈检测工位那个IO站每隔几个小时就报一次“设备故障”,故障持续一两秒又自己恢复。这种间歇性掉站是最烦人的,因为它不按你的排查节奏出现。我的排查链路是这样的:

第一步,先看物理层。检查IO站到交换机的网线、水晶头、交换机端口灯,把检测站那根网线重新压了一次头,换了个交换机端口。消停了半天,问题又回来了,说明物理线路不是根因。

第二步,看供电。用万用表和示波器量了IO站的24V电源,电压稳定,纹波在正常范围,暂时排除。

第三步,看协议层。在中央交换机上做端口镜像,把检测站所在的端口流量镜像给一台笔记本电脑,用Wireshark过滤eth.type == 0x8892,抓了半小时。结果发现,检测站发送周期数据出现明显断片,每次断片都对应着一条来自现场看板摄像头发出的超大UDP广播包。原来现场工人加了台摄像头做监控,接在了交换机上,摄像头每秒都在发送大量广播和组播流量,把交换机的转发队列堵住了,Profinet的周期帧被延迟丢弃。

根因找到后,处理就简单了:把那台摄像头挪到独立的接入交换机,和Profinet网络隔离开;同时把中央交换机换成支持IGMP Snooping的管理型交换机,给Profinet报文配置PCP优先级。从那以后,掉站报警再也没出现过。这个案例让我深刻意识到,Profinet组网里的交换机不是随便一个家用路由器能替代的,网络基础设施建设不到位,后面的协议优化全是白搭。

5.2 C#读PLC偶发超时:连接资源与CPU扫描时间的博弈

系统上线跑了一周,客户反映上位机偶尔会出现数据不刷新的情况,持续几秒后自动恢复。我远程一看,应用日志里全是S7netplus抛的超时异常。这类偶发超时排查起来要特别注意链路思维。

我先把S7netplus换成一个只打印返回错误码的测试程序,在故障时间段观察。结果发现PLC返回的除了超时,还有连接被拒绝的错误。进一步查PLC诊断缓冲区,发现CPU最大扫描周期在某些时候飘到了18ms,而平时只有8ms左右。PLC程序在MES请求频繁的时候会执行一大段数据整理逻辑,导致扫描周期飙高,PLC响应S7读写请求变慢。

同时我又发现一个更隐蔽的问题:客户的TIA Portal一直在线连着PLC,调试人员没有断开;HMI触摸屏占一个连接;C#程序如果用一次新建一次连接,很快会把PLC有限的S7连接资源占满。S7-1500虽然连接资源比老款大了很多,但也架不住反复建连不释放。

最终的修复策略是双管齐下:C#侧改为全局单例连接,用信号量做并发保护,断线重连采用退避策略,不再频繁创建新连接;PLC侧建议客户把MES的数据整理任务挪到非高峰时段执行,降低CPU峰值负载。修复后的C#连接管理大概长这样:

private Plc? _plc; private readonly SemaphoreSlim _semaphore = new(1, 1); public async Task<byte[]> ReadDbSafelyAsync(int dbNumber, int offset, int length) { await _semaphore.WaitAsync(); try { if (_plc is null || !_plc.IsConnected) { _plc?.Close(); _plc = new Plc(CpuType.S71500, "192.168.0.10", 0, 0) { Timeout = 2000 }; var err = _plc.Open(); if (err != ErrorCode.NoError) { throw new InvalidOperationException($"PLC连接失败: {err}"); } } return _plc.ReadBytes(DataType.DataBlock, dbNumber, offset, length); } finally { _semaphore.Release(); } }

这轮踩坑之后我养成了一个习惯:每次上线前都会检查现场有多少工程师在线连PLC,TIA Portal的在线连接不关,谁来做上位机都可能踩到资源耗尽这个坑。

5.3 数据抖动:网卡电源管理是隐藏元凶

还有一次抖动问题,现象很诡异:上位机数据趋势图上每15秒左右会有一个30ms的延迟尖峰,其他时候都正常。我一度怀疑是CPU资源被别的程序抢占,但任务管理器显示CPU占用率只有10%左右;又怀疑是GC暂停,但用PerfView观察GC暂停时间远远达不到30ms。

排查到最后,问题居然藏在网卡设置里。Windows网卡属性默认勾选了“允许计算机关闭此设备以节约电源”,系统在空闲时会让网卡进入低功耗状态,下一次收发数据前需要一个唤醒过程,这个唤醒过程就是周期性延迟尖峰的来源。把这个选项关掉之后,抖动立刻消失。

另外,针对.NET程序的GC暂停,我把上位机项目切到了Server GC加Concurrent GC模式,在项目文件里配置:

<PropertyGroup> <ServerGarbageCollection>true</ServerGarbageCollection> <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection> </PropertyGroup>

这里要说明一下,桌面UI程序用Server GC不是所有情况都更优,但对这种带有独立后台采集循环的进程,实测下来整体停顿更少,趋势图也更平滑。调整完系统配置和GC策略后,最终周期稳定在5ms,最大抖动9ms,满足客户40ms以内的实时性验收要求。

6. 可维护性设计:点位表自动生成、告警联动与产线扩容

6.1 用GSDML文件自动生成点位表

分布式IO方案点位动辄上百个,如果全靠人工在C#里挨个写变量,简直是灾难。幸好Profinet的每个设备都带一个GSDML文件,本质上是XML,里面描述了设备支持的所有模块、子模块、通道和数据类型。C#可以用XDocument解析GSDML,自动生成点位清单,再结合PLC的DB导出文件,生成上位机用的点位映射配置。

using System.Xml.Linq; var ns = XNamespace.Get("http://www.profibus.com/gsdm/ns/gsdm/2.36"); var doc = XDocument.Load("GSDML-V2.36-PN-ET200SP.xml"); var modules = doc.Descendants(ns + "Module") .Select(m => new { Id = m.Attribute("ID")?.Value, Name = m.Element(ns + "Name")?.Element(ns + "Value")?.Value }) .ToList(); foreach (var module in modules.Take(20)) { Console.WriteLine($"模块: {module.Id} => {module.Name}"); }

实际项目里,我用这个思路做了一个离线工具:导入GSDML生成设备模块树,再导入PLC导出的DB结构,自动拼接出一个JSON格式的点位映射文件。上位机启动时读取JSON,运行时按配置创建标签树,所有数据绑定都走配置驱动,完全不需要为新增点位重新编译程序。

6.2 告警事件分发与现场通知

实时数据采集上来了,告警系统必须跟上。产线上最典型的告警是安全门打开、急停按下、气缸动作超时、模拟量越限。我的做法是在采集服务之上加一个轻量级的告警引擎,订阅采集事件,对配置为告警的DI点位做边沿检测和越限判断,触发后分发给所有订阅者。

public sealed class AlarmEngine { private readonly Dictionary<string, List<Action<AlarmInfo>>> _subscribers = new(); private readonly object _lock = new(); public void Subscribe(string tagName, Action<AlarmInfo> handler) { lock (_lock) { if (!_subscribers.TryGetValue(tagName, out var list)) { list = new List<Action<AlarmInfo>>(); _subscribers[tagName] = list; } list.Add(handler); } } public void Raise(AlarmInfo alarm) { List<Action<AlarmInfo>>? snapshot; lock (_lock) { snapshot = _subscribers.TryGetValue(alarm.TagName, out var list) ? list.ToList() : null; } snapshot?.ForEach(handler => Task.Run(() => handler(alarm))); } }

告警订阅者可以有很多个:WPF界面上的告警列表、写入数据库的告警记录、推送企业微信或者钉钉的HTTP回调,甚至现场声光报警器的控制逻辑。这比每个模块自己发事件互相引用干净得多,后面加一个告警渠道,只需要增加一个订阅者,不用改动告警引擎。

6.3 加站不加代码:配置驱动的点位映射

最后一个经验,也是我这次项目里最得意的一点:配置驱动让产线扩容变得异常轻松。POS机式的点位映射配置长这样:

{ "stations": [ { "name": "IO-ASM-01", "enabled": true, "db": 100, "inputs": [ { "tag": "IN_ESTOP_01", "bit": 0, "category": "alarm" }, { "tag": "IN_PHOTO_01", "bit": 1, "category": "status" } ] }, { "name": "IO-TEST-01", "enabled": true, "db": 110, "inputs": [ { "tag": "IN_TEMP_01", "bit": 0, "category": "analog" } ] } ] }

上位机启动时加载这个JSON,用反射把配置里的TagName映射到ViewModel的属性和界面控件上。新加一个IO站,只需要PLC侧新增一个DB块,C#这边往JSON里加一段配置,重启上位机即可看到新站的数据。如果配置里的某个站被设置成enabled: false,程序自动跳过该站的读取,不影响其他站的正常运行。这种设计对产线长期的柔性改造特别友好,客户自己也能通过培训掌握配置方法,而不是每次都喊开发人员来改代码。

最后再分享一点个人体会。这个项目搞完后,我复盘时发现真正花时间最多的不是写C#代码,反而是网络规划和排障。Profinet这套协议栈,纯靠C#从零实现RT周期通信是不现实的,但把它当成数据源,用对架构、用好抓包工具,C#照样能扛住产线的实时性要求。所以我现在的习惯是:每个Profinet项目先画拓扑、列点位、定设备名,代码反而是最后写的东西。这套思路如果对你有帮助,少走点弯路,这篇就没白写。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询