1. 工控现场的协议之乱:为什么我决定先搭一套采集框架再写功能
1.1 一个现场的真实场景
做上位机开发的朋友应该都有过这种经历:项目还没正式启动,需求文档先给你甩过来一页密密麻麻的设备清单。我碰到过最离谱的一个模拟项目,现场设备足有十七种,仪表占了七八种,全是不同厂家的;PLC有四套,每套一种协议;还有三台变频器和两组温控器。它们没有统一的标准,通信方式也是五花八门:有的走Modbus RTU,挂在串口服务器上;有的走Modbus TCP,直接网线接到交换机;还有一个老设备只支持UDP广播;PLC那边又是专用的通信协议。
那会儿如果按传统思路,把每个设备都单独封装一个类,每个类里面自己写Socket连接、自己处理分包粘包、自己在界面上定时器轮询一遍,代码数量会非常恐怖。而这些代码的维护成本才是真正让人头疼的地方——现场加一台设备你要改代码,换一个型号你要改代码,协议版本升级你还要改代码。每改一次都要重新编译、重新部署、重新测试,还要担心把别的地方改坏了。
所以我后来定了一个原则:凡是接进上位机的设备,一律走一套统一的采集框架。设备种类再多、协议再杂,框架的部分不重写,每次新增需求只做"插一个新的协议适配器"和"改一份配置文件"两件事。这个原则我坚持了三四年,前后跑过十多个现场,效果确实值得分享一下。
1.2 重写采集代码的代价到底在哪里
很多刚入行的朋友不太理解,为什么一定要费劲搭一套框架。他们的想法是:Modbus TCP不就是创建一个TCP连接,然后按协议组报文、发请求、收应答吗?每个设备写一套也不难啊。
真正做过工控项目就知道,问题从来不出在"单个协议能不能打通"上,而在于多个协议放到一个进程里同时跑的时候,各种边缘问题会集中爆发:
- 设备轮询是同步的还是异步的?如果每个设备一个Task死循环轮询,几十个Task同时在跑,线程切换和锁竞争会拖垮UI线程;
- 串口是半双工的,多个设备共用一条串口总线,谁先发谁后发谁等待,处理不好就是数据错乱;
- 设备掉线了怎么办?TCP连接断开后要不要自动重连?重连的间隔设置多少合适?
- 点位数据和UI绑定,几百个点位同时刷新,界面卡成幻灯片;
- 某个设备响应特别慢,一直占着通道不释放,其他设备全部饿死;
- 字节序不统一,同一份Float数据在不同的PLC里高低字节存放顺序不一样,读出来数据对不上。
这些问题的解决方式如果散落在各个设备类里,今天这里修一个明天那里补一个,代码很快就变成一团乱麻。等到现场调试的时候,查一个莫名其妙的数据错误,你根本不知道问题出在协议解析、线程调度还是配置映射上。
框架的价值就是把这些横切问题集中收口:轮询调度统一做,通道管理统一做,数据分发统一做,协议解析只留一个薄薄的适配层。真正属于现场业务的那部分代码,反而被压到最薄。
1.3 这套模板框架能解决什么问题(目标与边界)
这套框架的设计目标非常明确:给工控机上跑的上位机软件,提供一个可以直接落地使用的多协议采集底座。它要解决的是"设备数据怎么稳定地、实时地、可配置地拿到手里"这个共性问题,不替你解决具体的业务逻辑——报表怎么做,告警怎么判定,工艺流程怎么控制,那都是上层的事。
具体来说,框架要满足六条硬指标:
- 新协议接入不改任何已有代码,只新增一个协议适配类,配置一下就能上线;
- 轮询周期可按设备配置,每台设备独立控制,互不拖累;
- 通道层统一抽象,TCP、串口、UDP切换不需要改业务代码;
- 设备掉线自动检测、自动重连,重连间隔可以配置;
- 点位采集结果统一推送到数据中心,UI只管订阅,不直接碰通信;
- 现场排查问题必须靠日志,所以日志系统从一开始就要设计好。
接下来说的分层结构、协议适配、配置加载、轮询调度、断线自愈,都是围绕这六条来做的。模板里给出的代码是核心骨架,直接复制过去改改配置就能跑通第一版,后面在这个骨架上往里填设备就行。
2. 框架的分层骨架:通道、协议、设备、点位到底怎么切
2.1 四层职责划分
网络通信程序最容易犯的错误就是把所有逻辑塞进一个类里。一个设备类既管Socket连接,又管协议组包解包,又管数据存储,又管界面刷新。我见过两千多行的一个"超级设备类",任何一行改动都让人心惊胆战。
框架把整个采集链路切成四层,每层只干一件事:
通道层(Channel)管物理链路。TCP就管连接和字节流收发,串口就管打开串口和读写字节流,UDP就管收发数据报。通道层完全不关心字节流的内容是什么,它只承诺一件事:我收到完整数据后,会原样把字节交出去。
协议层(Protocol)管报文格式。Modbus RTU、Modbus TCP、S7协议、MC协议,它们的差异在这一层被吸收掉。协议层的输入是通道层给出的原始字节流,输出是解析好的寄存器值或者线圈状态;反向则是把读写请求组装成字节流。
设备层(Device)管点位寻址。一台设备有一张点位表,每个点位对应协议层里的某个数据地址。设备层负责把上层要读的逻辑点位(比如"1号炉温度")映射成协议层的地址(比如"保持寄存器0号,Float类型"),然后调用协议层去读。设备层还管着这台设备的轮询周期、超时时间、离线判定这些个性参数。
数据中心(DataHub)管数据分发。所有设备读到的点位值统一汇到这里,按点位ID索引存储,对外提供订阅通知。UI、报表、告警模块都只消费数据中心的数据,不直接跟设备打交道。
我画过一张草图给同事看:通道在最底下,像水管;协议层是转换接头,不同设备不同接头;设备层是水表,记录流过的数据;数据中心是水箱,大家从水箱取水,没人直接去拧水管。这个比喻虽然简单,但新人看完基本立刻能明白自己的代码该放哪一层。
2.2 通道层:把TCP、串口、UDP抽象成同一个收发接口
通道层是物理链路的抽象。C#里写起来其实很直接,先定一个接口,把收发数据的核心行为固定下来:
public interface IChannel { string Id { get; } bool IsConnected { get; } Task<bool> ConnectAsync(CancellationToken token); Task DisconnectAsync(); Task<byte[]> SendReceiveAsync(byte[] request, int timeoutMs, CancellationToken token); void Dispose(); }这里最关键的就是SendReceiveAsync这个方法。它把所有协议的请求模式统一成"发一段字节,等一段字节"的问答式。实际运行的时候,绝大多数工控协议也确实都是这种问答式:上位机发请求帧,设备回响应帧。
TCP通道实现的重点在于分包粘包处理。简单做法是给通道配置一个结束符或固定头长度。我这里用的是三种模式:
- 固定长度:收到N个字节就算一包;
- 结束符:收到指定字节(如 \x16 或 \r\n)就算一包;
- 超时截止:距离最后一个字节到达超过 X 毫秒,就认为当前缓冲区的数据是完整一包。
实际操作中,因为协议层还要做完整性校验(比如Modbus的CRC校验),所以通道层做了简化:先用超时截止的方式把一段数据收回来,交给协议层去校验,校验不过就丢弃等下一包。这种方式脏活少,通用性却很强,适配各种奇奇怪怪的设备都没问题。
串口通道比TCP麻烦一点,因为从"打开串口"到"能稳定读写"中间有很多细节:波特率、数据位、停止位、校验位要设置正确,还要处理串口被占用的情况。这个模板里在串口通道上做了打开重试,打开失败不会抛异常直接退出,而是按照配置的时间间隔反复尝试。
2.3 设备层:点位表和协议驱动的挂接
设备层最关键的数据结构是点位表。一个点位本质上是一个"逻辑名到物理地址的映射",再加一堆采集参数。我常用这样一组字段来描述一个点位:
| 字段 | 含义 | 典型值 |
|---|---|---|
| Id | 点位唯一标识 | "TE101.Temp" |
| Name | 中文描述 | "1号反应器温度" |
| Address | 物理地址 | 0(保持寄存器地址) |
| DataType | 数据类型 | Float / Int16 / UInt32 / Bool |
| ByteOrder | 字节序 | ABCD / BADC / CDAB |
| Scale | 缩放系数 | 0.1 |
| Offset | 偏移量 | 0.0 |
有了点位表之后,设备层的工作就变成了一个循环:按配置好的周期遍历点位,每个点位调用协议层的读写接口,拿到原始字节后按 DataType 和 ByteOrder 解析成数值,再乘 Scale 加 Offset,最后推给数据中心。
这个循环不能做得太傻。比如点位很多的时候,连续逐个读会导致单轮时间过长。模板里做了一处优化:同一台设备下,支持把一批地址连续的寄存器打包成一个批量请求,一次就读回一大片数据,然后在本地拆分到各个点位。这个优化在Modbus上特别管用,因为Modbus协议本身支持一次读多个寄存器,网络往返次数能减少一半以上。
设备层的另一个职责是判定设备是否在线。模板里采用连续超时计数的方式:连续超时3次,把状态从 Online 改成 Offline,触发掉线事件;之后每次轮询仍然照发,但只要有一次成功响应,状态立刻恢复 Online,并记录一次恢复日志。
2.4 为什么不让设备直接操作套接字
经常有朋友问我,为什么中间非要夹一个通道层,直接在协议类里 new 一个 TcpClient 不行吗?看起来还省事。
这其实是复用性的问题。现场有个常见情况:设备本身支持Modbus TCP,但因为现场布线原因,只能通过串口服务器把设备转成Modbus RTU接进来。也就是说,同一个协议可能跑在TCP上,也可能跑在串口上,甚至可能跑在4G透传模块上。协议和通道相互独立,组合起来才是完整的链路。
如果协议类里写死了Socket操作,换通道就得重写协议类,代码没法复用。而把通道抽象出来之后,Modbus TCP和Modbus RTU虽然报文格式略有不同(RTU带CRC校验,TCP带MBAP头),但核心的寄存器读写逻辑完全一致。框架里这两种协议共享了大量公共代码,只重写了报文帧的封包和解包部分,工程量小了很多。
还有一层考量是并发控制。设备层在调用协议层时,协议层在调用通道层,每次调用必须是一个完整的"发-收"原子操作。如果不统一收口,两个线程同时对同一个通道发数据,字节流在底层就会混在一起,解析必然错乱。通道层里加了一个SemaphoreSlim锁,保证同一个通道同时只允许一个请求在收发,从源头上解决了半双工串口的数据交错问题。
3. 协议接入的核心机制:适配器让新协议"插上就能用"
3.1 协议接口的设计
协议层是整个框架里最有技术含量的部分,也是接入新设备时唯一需要写代码的地方。接口设计得是否合理,直接决定了以后加协议的工作量。
模板里协议接口定义如下:
public abstract class ProtocolBase : IDisposable { protected IChannel Channel { get; } protected ProtocolBase(IChannel channel) { Channel = channel; } public abstract Task<byte[]> ReadAsync(DevicePoint point, CancellationToken token); public abstract Task WriteAsync(DevicePoint point, byte[] value, CancellationToken token); public abstract bool ValidateResponse(byte[] response, byte[] request); public virtual void Dispose() { } }注意这里的ReadAsync和WriteAsync用的是DevicePoint,而不是裸的寄存器地址。为什么?因为不同协议对点位地址的理解不一样:Modbus里地址说"保持寄存器0号",S7协议里地址说的是"DB1.DBD10",如果协议接口传裸地址,协议实现里就得自己判断地址的含义,编排逻辑就乱套了。
把DevicePoint传进去之后,协议实现就能根据点位的协议选项(比如 Address、Block、DataType、ByteOrder)来决定怎么构造报文。例如Modbus协议里,根据Block是线圈、离散输入、保持寄存器还是输入寄存器,选择功能码 01/02/03/04;S7协议里,根据Block是 DB 号加偏移地址,构造对应寻址命令。每个协议只认自己需要的那些字段,互不干扰。
3.2 一个Modbus TCP协议类的写法思路
拿最常用的Modbus TCP举例,看协议层怎么写。Modbus TCP报文格式是:事务ID(2字节)+ 协议ID(2字节)+ 长度(2字节)+ 单元ID(1字节)+ 功能码(1字节)+ 数据区。读取保持寄存器的功能码是 0x03。
封装读取报文的核心代码大概长这样:
public class ModbusTcpProtocol : ProtocolBase { public override async Task<byte[]> ReadAsync(DevicePoint point, CancellationToken token) { var transactionId = (ushort)(DateTime.Now.Ticks & 0xFFFF); var unitId = point.Device.ProtocolOptions.SlaveId; var functionCode = point.Block switch { "COIL" => 0x01, "DISC_IN" => 0x02, "HOLD_REG" => 0x03, "INPUT_REG" => 0x04, _ => throw new InvalidOperationException($"未知Block类型: {point.Block}") }; int byteCount = point.DataType switch { "Bool" => 1, "Int16" => 2, "Float" => 4, _ => 2 }; int registerCount = byteCount / 2; var request = new byte[12 + 5]; request[0] = (byte)(transactionId >> 8); request[1] = (byte)transactionId; request[2] = 0; request[3] = 0; request[4] = 0; request[5] = 6; request[6] = unitId; request[7] = functionCode; request[8] = (byte)(point.Address >> 8); request[9] = (byte)point.Address; request[10] = (byte)(registerCount >> 8); request[11] = (byte)registerCount; var response = await Channel.SendReceiveAsync(request, point.Device.TimeoutMs, token); return ParseResponse(response, point); } }实际代码比这个长一些,因为还要处理读多个寄存器再按字节序拆分的逻辑、错误码异常(比如从机返回 0x02 非法地址)等情况。但骨架就是这样:根据点位类型确定功能码和寄存器数量,组请求报文,发出去,收回来,解析。
3.3 协议注册表与配置文件联动
框架要支持"新增协议不改已有代码",靠的是协议工厂和注册表。用一句话说就是:配置里写什么协议名,框架就用反射帮你创建对应的协议实例。
public static class ProtocolRegistry { private static readonly Dictionary<string, Type> _registry = new(StringComparer.OrdinalIgnoreCase) { { "ModbusTcp", typeof(ModbusTcpProtocol) }, { "ModbusRtu", typeof(ModbusRtuProtocol) }, { "S7", typeof(S7Protocol) }, { "McProtocol", typeof(McProtocol) } }; public static ProtocolBase Create(IChannel channel, string protocolName, DeviceOptions options) { if (!_registry.TryGetValue(protocolName, out var type)) throw new NotSupportedException($"未知协议: {protocolName}"); return (ProtocolBase)Activator.CreateInstance(type, channel, options); } }新增一个协议时,只需要写一个继承ProtocolBase的类,然后在_registry里加一行注册,配置文件的Protocol字段填对应的名称,重启就生效了。已有的代码一律不动,这就是"插上就能用"的实际含义。
我试过把这个机制再激进一点,做成外部程序集动态加载——协议类编译成单独的DLL,放到程序目录下指定文件夹就自动发现。这样连主程序都不用重编,现场升级协议包只需替换DLL。这个小改进在现场非常受欢迎,因为工控软件上线之后,对主程序动刀总是让人提心吊胆,单独换个DLL的心理压力小很多。
3.4 新增协议的接入步骤(实操清单)
如果现场来了一个不认识的设备,手头只有一份设备通信手册,照着这个清单走就行:
- 通读手册,先搞清楚三点:物理接口是串口还是网口,报文是问答式还是有主动上报,数据的字节序是什么样的;
- 根据物理接口选对应的通道类型,不要自己重写通道;
- 新建一个协议类,继承
ProtocolBase,把手册里的报文格式翻译成ReadAsync/WriteAsync的封包解包逻辑; - 在
ProtocolRegistry注册表里加一行; - 配置文件里填上设备信息,协议名填新注册的名字,点位表按手册里的地址规则填一遍;
- 跑起来,看日志,先手动读一个点,通了之后再成批加点位;
- 如果手册里没有写明CRC/校验方式,用通信助手抓一下真实报文,把校验逻辑逆向出来。
整个流程走顺了,一个中等复杂度的协议半天到一天就能接进去。我最快的一次只用了两个小时——那是一个温控器设备,Modbus RTU协议,手册写得又清楚,翻译成代码几乎是一对一。
4. 配置驱动设计:改配置不改代码,现场交付才能少跑几趟
4.1 配置文件长什么样
框架的配置用一个JSON文件承载,整体分三层,和分层架构一一对应。模板里默认配置长这样:
{ "Channels": [ { "Id": "CH-TCP-1", "Kind": "Tcp", "Host": "192.168.1.10", "Port": 502, "TimeoutMs": 3000 }, { "Id": "CH-SERIAL-1", "Kind": "Serial", "PortName": "COM3", "BaudRate": 9600, "Parity": "Even", "StopBits": "One", "DataBits": 8, "TimeoutMs": 2000 } ], "Devices": [ { "Id": "DEV-PLC-1", "ChannelId": "CH-TCP-1", "Protocol": "ModbusTcp", "SlaveId": 1, "PollIntervalMs": 500, "TimeoutMs": 1000, "ReconnectMs": 5000 } ], "Points": [ { "Id": "P01", "DeviceId": "DEV-PLC-1", "Block": "HOLD_REG", "Address": 0, "DataType": "Float", "ByteOrder": "ABCD", "Scale": 1.0, "Offset": 0.0, "Enable": true, "Description": "1号温度" } ] }这里三个数组分别对应通道列表、设备列表、点位列表。设备通过ChannelId关联通道,点位通过DeviceId关联设备。逻辑很直白:先把物理链路配好,再把设备挂到链路上,最后把点位挂到设备上。
4.2 配置到对象的绑定与校验
配置文件的解析用System.Text.Json绑定到强类型对象。这一步没什么技巧,但绑完之后的校验一定不能省。现场配置是工程师手写的,一个逗号错了或者字段名拼错了,你不想等他运行到一半才知道。
模板里做了一组启动校验,列几个关键的:
- 通道ID必须唯一,设备引用的通道ID必须存在;
- 一个通道被多台设备共享没问题,但设备ID必须唯一;
- 点位的
DeviceId必须存在,Address不能为负数; - 设备轮询周期
PollIntervalMs不能小于50毫秒,太快的轮询没有实际意义反而增加负载; - 点位启停字段
Enable默认填 true,经常有人忘了配,导致点全部不采。
校验失败直接抛异常,程序启动时弹窗告诉用户第几行第几项有问题,而不是默默跳过。这个设计帮我在现场至少省了十次以上来回折腾。
还有一个细节:配置文件的编码统一用UTF-8。Windows下记事本默认可能存成带BOM的格式,解析的时候要自动把BOM干掉,不然第一行第一个键名会带一个看不见的字符,导致绑定失败。这类坑遇到一次就记住了。
4.3 点位字节序、缩放系数这类坑必须做成配置项
是不是所有参数都要做成配置?不是。但有一类参数必须配,就是"不同设备之间差异很大、而且不配就会读错数据"的那些。
最典型的就是字节序。Modbus协议本身规定寄存器内部字节按大端序传输,但两个相邻寄存器拼成一个Float的时候,不同厂家就各玩各的了。有的厂按 ABCD 顺序存,有的按 CDAB,还有的按 BADC,甚至同一台设备切换了固件版本之后顺序还能变。这个差异不暴露成配置项,协议代码里就只能写死,换一台设备就要改代码重新编译。写死了就是慢性自杀。
所以模板里每个点位都带ByteOrder和Scale、Offset三个字段。读回原始字节后,解析链路会先按ByteOrder调整字节顺序,再转换成数值,最后做缩放偏移:
public static object Decode(byte[] raw, string dataType, string byteOrder, double scale, double offset) { byte[] ordered = ByteOrderHelper.Resolve(raw, byteOrder); double value = dataType switch { "Int16" => BitConverter.ToInt16(ordered, 0), "UInt16" => BitConverter.ToUInt16(ordered, 0), "Int32" => BitConverter.ToInt32(ordered, 0), "Float" => BitConverter.ToSingle(ordered, 0), _ => throw new InvalidOperationException($"未知类型: {dataType}") }; return value * scale + offset; }Float 类型的点位,现场十有八九是要配ByteOrder的。第一次接入新设备时,如果读回来的数据和设备面板对不上,优先检查字节序,不要急着怀疑协议封包写错了。
5. 轮询调度与断线自愈:现场稳定性的最后一块拼图
5.1 多设备并行轮询的调度策略
设备轮询的直观做法是每台设备开一个Task,循环执行自己的采集。这种做法在设备少的时候没问题,但设备多了就会遇到两个麻烦:一是线程数量膨胀,二是一台设备阻塞时,它所在通道上的其他设备全部跟着排队。
框架里换了一种思路:每个设备还是一个独立的任务,但任务内部通过信号量控制对通道的占用。每个通道有一个独立的SemaphoreSlim,同一时刻只放行一个请求。设备任务先等信号量,拿到后再发请求,收完响应就释放。这样做的好处是:多路TCP设备可以真正并行,因为它们不在同一个通道上;同一通道内的设备则严格串行,不会互相干扰。
轮询循环本身要做得健壮。模板里每台设备的采集任务是一个while循环,但每次迭代都检查取消令牌,退出时释放通道引用:
private async Task PollingLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { var sw = Stopwatch.StartNew(); foreach (var point in _enabledPoints) { try { var raw = await _protocol.ReadAsync(point, token); var value = Decode(raw, point.DataType, point.ByteOrder, point.Scale, point.Offset); _dataHub.UpdatePoint(point.Id, value, Quality.Good); } catch (Exception ex) { _dataHub.MarkPointOffline(point.Id, ex.Message); } if (token.IsCancellationRequested) break; } var elapsed = sw.ElapsedMilliseconds; var remaining = _pollIntervalMs - elapsed; if (remaining > 0) await Task.Delay((int)remaining, token); } }单轮采集时间超过轮询周期的情况要特别处理:remaining为负数时直接进入下一轮,不做负延时。否则Task.Delay会抛出 ArgumentOutOfRangeException,导致采集任务直接退出。
5.2 串口通道的排队机制
串口通道的并发控制比TCP更严格。串口是共享总线,半双工,同一个串口上可能挂着好几十台设备。如果设备任务各自为政,一个任务发请求还没等响应,另一个任务又发出去了,总线上的报文就完全串了。
这里必然要实现先到先得的排队。在通道层里,请求到达后先进入一个Channel自带的SemaphoreSlim(1,1)队列,拿到信号量才允许执行读写。读完全部字节并确认一帧结束后释放信号量,下一个请求才能进来。
我曾经在一个项目里为了追求"一点点吞吐量",尝试把串口响应的超时时间从1000毫秒压到200毫秒。结果那一排挂在串口总线上的一百多台仪表,时不时就有一台因为响应稍慢被判定超时。后来在设备日志里发现,每轮轮询都有几个点的质量戳在 Good 和 Bad 之间反复横跳,查了很久才锁定是超时参数太激进。串口设备大多是低速仪表,响应时间本来就有波动,超时阈值至少要留足余量。
5.3 断线检测与重连退避
现场通信不可能永远稳定。交换机断电、网线被老鼠咬断、设备侧重启,这些事迟早要发生。上位机要做的不是祈祷不出故障,而是出了故障之后自动恢复。
TCP通道的断线检测逻辑比较简单:SendReceiveAsync内部在异常时把IsConnected置为 false,然后进入重连周期。重连周期用指数退避算法:
- 第一次重连等待 1 秒;
- 之后每次失败翻倍:2秒、4秒、8秒;
- 最大不超过 30 秒;
- 连接成功后退避计数清零,下次掉线重新从 1 秒开始。
这个退避算法非常重要。如果没有退避,设备掉线后立刻连接失败又立刻重试,看起来是每秒重试一次,实际上形成了高频重连风暴,交换机日志里全是一堆无效连接记录,严重的时候甚至会把设备的服务端程序拖死。加了指数退避后,故障期间网络开销被压到最低,恢复也是平滑的。
串口通道的重连稍微不同:串口没有"连接"状态,只有打开/关闭。重连就是周期性地尝试打开串口,打开成功就算恢复。串口被其他程序占用时会反复失败,退避等待也是需要的。还有一种情况是串口线被拔掉后又插回,Windows 的设备号可能发生变化,COM口从 COM3 变成 COM5。这种模板里没有好的自动处理办法,只能靠现场人员重新配置。我做过一个辅助检查,启动时扫描可用串口列表并打日志,至少能帮助远程快速判断问题在哪。
5.4 数据质量戳与历史值保留
采集系统里最容易被忽视但又绝对不能丢的,是数据的"质量戳"。质量戳告诉你这个值到底能不能信。UI点一个按钮看起来是绿色的,后台数据其实已经是十分钟前的旧值,这在工业现场是要出大问题的。
模板里给每个点位定义了几个质量等级:
| 质量等级 | 含义 |
|---|---|
| Good | 本次轮询正常读到并解析成功 |
| Bad | 本次轮询超时或协议错误 |
| Stale | 连续多次读不到,或设备已离线,保留最后一次值 |
| NotConnected | 设备未连接或通道不可用 |
底层DataHub更新点位时,把质量等级和前一次值一起存下来。每次新值到达时先比较质量戳:如果新值是 Good,直接覆盖旧值;如果新值是 Bad,不覆盖旧值,只更新质量戳。这样UI上展示的值永远是最近一次真实读到的数据,而质量状态则诚实地反映当前的采集健康度。
这个机制在后期做历史趋势曲线时价值更大——回看数据的时候能一眼看出哪个时间段的数据是可用的,哪个时间段是设备离线状态下的"假数据"。很多现场事故追溯到根因,其实都是把离线假数据当成了真实工况。
6. 落地实测中的坑与经验(跑过多个现场后写下的)
6.1 日志怎么打才不会把工控机跑死
工控机性能普遍一般,磁盘也是普通的机械硬盘甚至老旧的SSD。轮询系统如果每轮都打一条日志,一个设备500毫秒一轮,一天下来日志文件能膨胀到几个GB,磁盘满了采集系统就彻底停摆。
模板从一开始就设计了三层日志策略:
- 正常轮询结果不打日志,只在点位值变化超过死区时记录一条;
- 点位超时、协议异常、设备掉线必打,但同一点位同一类异常在5秒内只记一条,避免故障风暴刷爆日志;
- 关键事件(框架启动、配置加载、通道重连成功)完整记录,包括耗时和关键参数。
日志文件的目录放程序同级目录下的 Logs 文件夹,按日期划分文件,每天一个。保留最近三十天的文件,自动清理更早的。现场远程排查问题时,我一般先看当天日志里有没有大量的异常关键字,再按点位ID过滤,基本能快速定位问题。
6.2 轮询周期不是越短越好
刚做上位机时,我总觉得采集越快越好,500毫秒不够就100毫秒。后来发现一个现象:点位数据在界面上的趋势图里抖得越来越厉害,而设备CPU的负载也明显升高。原因在于有些PLC的通信服务是单线程处理的,上位机轮询太频繁,PLC内部的通信任务队列被占满,反而导致通信变慢,甚至影响到PLC自身的控制周期。
轮询周期的正确打开方式是按设备类型区分:
- PLC:500~1000毫秒足够,PLC数据是内部周期刷新后从共享区读出来的,不需要更快的轮询;
- 温控器仪表:500毫秒到2秒,仪表响应本身慢;
- 电能表/多功能表:1~3秒,这类表内部计算有一定延迟;
- 传感器网关:200~500毫秒,快一点有助于及时捕捉波动。
模板里每台设备独立配置PollIntervalMs,就是为了让不同设备各取所需,而不是全局一刀切。
6.3 交付前必做的几项验证
框架写完之后,跑通Demo只是第一步。我在交付前一定会做一组"事故演习",确认框架面对异常时有正确表现:
- 拔掉设备网线,确认应报掉线而不是卡死;插回网线,确认能自动恢复且不需要重启程序;
- 把设备侧断电再上电,确认设备重启期间轮询能持续重试,设备恢复后数据能自动续上;
- 故意配置一个错误的点位地址,确认单点报错不影响其他点位轮询;
- 在通道上挂一台故意不回包的设备,确认它能超时而不是拖慢同通道其他设备;
- 连续跑72小时,检查日志文件大小、内存占用、是否有线程泄漏。
这些东西不是说代码写对了就自动是对的,必须真实验证过才敢说"可落地"。我见过不少框架Demo跑得很漂亮,一到现场就各种歇菜,原因就是没做这些容错验证。框架的价值不在于把正常流程写得多顺,而在于异常发生时能不能兜住底。这一点,多花几天反复测试,绝对值回票价。