只要在工控圈里待过几年,尤其是干过上位机开发的兄弟,应该都体会过那种被PLC协议轮番轰炸的滋味。三菱走MC Protocol、西门子走S7协议、欧姆龙用FINS、罗克韦尔走EtherNet/IP,Modbus TCP算是最友好的了,但碰上老设备还得用RTU。项目里一旦出现两个以上品牌的PLC,代码里就开始堆满各种协议解析的if-else,每个设备的寄存器地址全靠人肉维护一张Excel表,改一个点位全组人都要跟着遭殃。
所以我一直觉得,搞.NET工控上位机的人,迟早得有一个属于自己的PLC通信网关。这篇文章就聊聊我自己在项目里折腾出来的一套方案:基于.NET实现一个可扩展的多品牌PLC通信网关,把底层协议差异全部屏蔽掉,让上层业务系统只管读写设备点位,不关心对面到底接的是三菱还是西门子。这套思路适合正在做产线数字化、设备数据采集、上位机监控系统的开发者,也适合那些被多品牌设备混用折磨到头疼的朋友。
1. 为什么需要一款自己的PLC通信网关
1.1 工控上位机开发绕不开的痛
先聊点实际的。做产线数据采集或者设备监控的时候,最麻烦的不是业务逻辑,反而是跟PLC通信这一层。每个品牌的PLC都有自己的通信协议,而且不同系列之间还有差异。三菱的McProtocol要区分QnA兼容帧和3E帧,西门子S7协议要处理DB块寻址和Job协商,欧姆龙FINS还要考虑数据链路层和FINS帧格式。就算都用Modbus,不同设备的寄存器映射表也是千奇百怪。
我最早做的一个项目就是典型的多品牌混用场景。一套设备是西门子S7-1200,另一套是三菱FX5U,第三套是欧姆龙CP1H。上位机要用同一个看板同时展示三台设备的状态和数据。最开始我想得简单,直接在监控页面里引用三个品牌的通信库,各写一套读取逻辑。结果代码写了一半就发现问题了:每个库的接口风格完全不同,有的返回DataTable、有的返回数组、有的要用回调,页面的数据绑定逻辑被拆得七零八落。这还不算完,设备点位表散落在各个模块里,新增一个点位要在三处同时改,改错一处就是半夜产线报障。
那段时间我最大的感受就是:通信层不抽象,业务层永远在给协议擦屁股。只要底层接口不一致,所有上层代码都会被拽着走,逻辑越写越乱,联调成本成倍上涨。
1.2 网关卡在设计上要解决什么
所谓PLC通信网关,本质上就是一个中间层,它夹在上位机应用和PLC设备之间。这个中间层的定位很清楚:把协议差异挡在外面,把统一的数据访问接口暴露给上层。
具体来说,它至少要解决四个问题。第一,屏蔽通信协议差异,不管是S7、MC还是FINS,在上层眼里都只是"一个设备挂在某个设备名下面"。第二,统一数据模型,点位用逻辑名访问,上位机不用关心物理地址是DB2.DBD4还是D300,更不用自己拼接协议帧。第三,管理连接生命周期,包括建立连接、保活、断线重连、超时控制,这些脏活累活不能丢给业务代码。第四,提供统一的异常处理方式,通信失败时上层收一个统一的异常对象即可,而不是面对一堆协议层的TimeoutException或者SocketException。
我做这套网关的时候,给自己定的原则是:上层永远不出现"PLC"三个字的品牌相关代码,所有设备都以设备名+点位名的方式暴露。这样无论是换设备品牌还是换协议驱动,上层代码一行都不用动。这个原则听起来简单,真正实现起来需要架构层面下功夫。
2. 架构设计思路:一头对内,一头对外
2.1 先定义对外接口:让上层不感知协议
架构设计第一步,是先把对外接口给钉死。我对外暴露的核心就两个操作:读取点位和写入点位。围绕这两个操作做了一套接口,上层只依赖这套接口干活,完全不感知底层驱动。
public interface IPlcGateway : IDisposable { Task<object> ReadAsync(string deviceName, string pointName, CancellationToken ct = default); Task<T> ReadAsync<T>(string deviceName, string pointName, CancellationToken ct = default); Task WriteAsync(string deviceName, string pointName, object value, CancellationToken ct = default); Task<bool> CheckHealthAsync(string deviceName, CancellationToken ct = default); }这里有个细节值得说一下:deviceName和pointName都是字符串,不是IP地址加寄存器编号。上层业务代码只认这两个字符串,比如"weldingRobot.currentTemperature"或者"packingLine.servoPosition"。配置层负责把设备名映射到具体的PLC连接信息(IP、端口、PLC型号、机架号、槽号),把点位名映射到具体的物理地址(数据块、寄存器区、偏移量)。这样一来,上层代码写起来就像在用一个虚拟化的设备对象,感觉完全不一样。
泛型方法ReadAsync<T>也是为了方便上层做反序列化。比如设备采集的温度值在PLC里是16位整数,但业务系统需要的是浮点温度值。只要在映射配置里声明了转换规则,网关层可以把裸值先读出来,再按规则转成目标类型返回。
2.2 设备层驱动的组织方式:插件化的思路
既然要支持多品牌,驱动层就必须设计成可扩展的。我的做法是定义一个IPlcDriver接口,每个品牌一个实现类,通过配置和DI容器动态注册。
public interface IPlcDriver { string ProtocolType { get; } Task<object> ReadPointAsync(DeviceContext context, PointConfig point, CancellationToken ct = default); Task WritePointAsync(DeviceContext context, PointConfig point, object value, CancellationToken ct = default); Task ConnectAsync(DeviceContext context, CancellationToken ct = default); Task DisconnectAsync(DeviceContext context, CancellationToken ct = default); }每个品牌的驱动负责自己那一套协议细节。S7驱动内部处理DB块寻址和PDU协商,MC驱动内部处理副头部和帧格式,FINS驱动内部处理ICF和命令码。上层完全不需要知道这些。新接入一个品牌时,只需要新建一个类实现IPlcDriver,然后在配置里把ProtocolType挂上去就行。
这里还要提一下设备上下文DeviceContext。它封装了设备的连接参数、当前连接状态、最近通信时间、错误计数等信息。不同请求可以共享一个上下文,驱动内部基于上下文维护会话数据,比如三菱的会话编号、西门子的PDU序号。这种设计让多线程访问也有了一个清晰的收口点,避免全局变量满天飞。
2.3 为什么选择.NET而不是其他方案
说实话,PLC网关用C++或者Java也能做,但.NET在工控上位机领域有天然的亲和力。首先是生态成熟,NuGet上有不少成熟可靠的品牌通信库,比如S7系列相关的库、三菱MC协议库、欧姆龙FINS库都有社区实现,省去了从零拼协议帧的工作量。
其次是异步模型顺手。.NET的async/await非常适合做高并发的通信调度。网关同时去读十几台设备,每个设备的IO操作可以异步开展,完全不会出现Java那种传统的线程池把线程占满的情况。配合Channel或者SemaphoreSlim还能做精细的流量控制,这个后面细说。
第三点很实际:做上位机的团队大概率熟悉C#和.NET技术栈,用同一个技术栈做网关,后续维护成本最低。项目里如果既有WPF或WinForms做的监控界面,又有.NET Core写的后端服务,网关做成.NET类库可以无缝嵌入,甚至不需要单独部署一个网关进程,直接作为模块打进上位机程序里就能用。
我实际做的时候把网关做成了可以两种形态运行的东西。一种形态是嵌入式类库,直接被上位机进程引用;另一种形态是独立部署的Windows服务,对外提供HTTP/SignalR接口,供多个客户端共享访问。两种形态底层跑的是同一套代码,只是挂载方式不同。
3. 多品牌协议适配的核心细节
3.1 协议差异到底差在哪里
做多品牌网关,绕不开的是搞清楚各品牌协议之间的差异点。只有先弄清楚差在哪,才能设计出统一的数据模型。下表是几个主流协议的对比:
| 协议项目 | 西门子S7 | 三菱MC 3E帧 | 欧姆龙FINS | Modbus TCP |
|---|---|---|---|---|
| 传输方式 | TCP | TCP/UDP | TCP/UDP | TCP |
| 数据区描述 | DB块+偏移 | D区/M区/X区 | DM区/CIO区 | 线圈/寄存器 |
| 单字节序 | 大端为主 | 小端为主 | 大端为主 | 大端 |
| 数据类型粒度 | 偏字节级 | 按字/位访问 | 按字/位访问 | 按字/位 |
| 会话管理 | 有 | 可带序列号 | 可带节点号 | 无状态 |
| 批量读支持 | 支持较大PDU | 支持批量 | 支持批量 | 支持批量 |
这里面最坑的是数据区描述方式和字节序。西门子S7的DB块寻址,要同时指定DB编号和字节偏移,而且偏移还要区分按位、按字节、按字、按双字访问。三菱MC协议则是按软元件编号访问,D300就是数据寄存器第300号,M100就是中间继电器第100号,映射关系比较直接。Modbus TCP又是另一套逻辑,区分位地址和寄存器地址,地址从0开始编号。
字节序的感受我后面会细说。简单提一句,三菱MC协议的偶数地址字数据是小端排列,而西门子S7默认大端,同一个Int16数值在两边的字节排列完全相反。网关层必须有一套明确的数据类型转换策略,否则读到就是乱码。
3.2 数据类型映射与内存布局
数据类型映射是网关内部最核心的一块,也是踩坑最多的地方。PLC里的数据基础类型就那么几种:BIT、BYTE、WORD、DWORD、INT、DINT、REAL。但这些类型在不同协议里的表达方式不一样,有的按位访问,有的按字访问,有的字节序相反。
我在网关里做了一套统一的点位类型系统,把各协议的类型统一成一套中间表示。每个点位既有协议相关的物理属性(比如存储区、偏移、位偏移),又有逻辑属性(逻辑名、统一数据类型、读写权限、缩放系数)。举几个例子:
{ "deviceName": "s7_1200_station1", "connection": { "protocolType": "S7", "ipAddress": "192.168.0.10", "rack": 0, "slot": 1 }, "points": [ { "logicalName": "conveyorSpeed", "dataType": "REAL", "address": { "dbNumber": 2, "byteOffset": 4, "bitOffset": 0 }, "accessMode": "Read" }, { "logicalName": "motorStatus", "dataType": "BOOL", "address": { "dbNumber": 3, "byteOffset": 12, "bitOffset": 3 }, "accessMode": "Read" }, { "logicalName": "targetTemperature", "dataType": "INT", "address": { "softElement": "D", "number": 300 }, "accessMode": "ReadWrite" } ] }这套配置把一个设备的通信参数和点位表都集中在了一块。驱动根据点位配置里的address结构去协议层取值,取到的裸字节数组,再交给类型转换器统一转成.NET类型。
类型转换这里有个值得注意的细节。很多新手在写转换器时容易忽略位序和字序的区别。比如三菱的D寄存器读出来一个32位的数据,它可能是由D300(低16位)和D301(高16位)组成的DINT,也可能是D300和D301组成一个REAL。转换器必须知道目标类型是多宽、按什么顺序拼装、最终用哪个编码解释。我在网关里做了一个规则表,每种协议+数据类型的组合都有明确的字节序定义:
| 协议 | 数据类型 | 字节序说明 |
|---|---|---|
| S7 | INT/DINT | 高位字节在前(大端) |
| MC 3E | WORD/DWORD | 低地址在前(小端),32位时需注意字顺序 |
| FINS | INT | 高位字节在前 |
| Modbus | REG | 高位字节在前,32位数据默认字顺序高前低后 |
有了这个规则表,类型转换做成一个纯函数。网关内部统一用Span<byte>处理,转换时不产生额外分配,性能上也能扛住高频采集。
3.3 连接管理与断线重连机制
PLC通信和普通的HTTP请求不一样,每台设备是一套长连接或者重复建连的模式。网关不能用"每次请求都新建Socket"这种傻办法,一是PLC点对点连接数有限,二是频繁建连会引入毫秒级到秒级的额外延迟。我实际用下来,S7协议保持一个长连接的效果最好,三菱MC协议在批量采集时也建议复用连接。
断线重连是网关里非常重要的一块。PLC设备在产线上经常因为断电、重启、网线松动导致连接断开。网关要能自动感知异常并重连,而且重连不能影响其他设备的通信。我的机制是:每台设备维护一个ConnectionState状态对象,包含当前连接实例、失败次数、上次成功时间。网关的扫描线程定时巡检,超过一定时间没有成功交互就触发主动重连。
重连策略上我用了指数退避,连续失败时重试间隔从1秒翻倍到最多30秒。这里有个细节:如果设备处于停机状态,重连会一直失败,无限快速重试只会浪费CPU和端口资源。加上最大重试次数和退避之后,整个网关在十台设备只有一台掉线的情况下也能保持稳定。掉线设备的点位读取请求会快速失败并返回一个统一的PlcConnectionException,上层拿到这个异常可以做降级展示,比如显示"设备离线"而不是一直转圈。
4. 实操:从配置到跑通的完整流程
4.1 网关核心通信模块的代码骨架
先上一个简化版的网关调度核心,让大家看看整个架构落地后的样子。完整实现比这段要复杂不少,但核心脉络就是这样的。
public class PlcGateway : IPlcGateway { private readonly ConcurrentDictionary<string, DeviceRuntime> _devices; private readonly ILogger<PlcGateway> _logger; public async Task<T> ReadAsync<T>(string deviceName, string pointName, CancellationToken ct = default) { var runtime = _devices.GetValueOrDefault(deviceName); if (runtime == null) { throw new UnknownDeviceException(deviceName); } var point = runtime.Config.GetPoint(pointName); // 先走本地缓存的逻辑,如果开启了缓存并且数据足够新 if (runtime.Cache.TryGetFreshValue(point, out var cacheValue)) { return ConvertValue<T>(cacheValue); } await runtime.EnsureConnectedAsync(ct); try { var rawValue = await runtime.Driver.ReadPointAsync( runtime.Context, point, ct); runtime.Cache.Update(point, rawValue); return ConvertValue<T>(rawValue); } catch (Exception ex) when (IsCommunicationException(ex)) { runtime.MarkFailure(ex); throw new PlcCommunicationException( $"Read {deviceName}.{pointName} failed", ex); } } }这个实现里有几个逻辑我觉得值得展开说说。第一,本地缓存层是很实用的设计。采集频率高、变化不频繁的点位(比如设定值、配方参数)完全没必要每次都走一次PLC通信,缓存命中直接在内存里拿值,响应速度上了一个台阶。第二,异常统一转换处理很关键。驱动层抛出的Socket异常、超时异常,在网关边界处统一包成PlcCommunicationException,上层只跟这一种异常打交道,处理逻辑能写得非常干净。
DeviceRuntime这个类把每台设备的配置、上下文、驱动实例、缓存、健康状态打包在一起。一个设备对应一个runtime实例,网关启动时根据配置文件批量构建。这比把所有状态散落在几个静态字典里要好维护得多。
4.2 配置驱动的做法与设备接入流程
网关支持多品牌,接入新设备时配置要简单。我的做法是使用一个JSON配置文件作为设备清单,网关启动时读取并动态加载驱动。配置项里最关键的是protocolType,这个字段决定用哪个驱动实现。
{ "devices": [ { "deviceName": "station1_mitsubishi", "connection": { "protocolType": "McProtocol", "ipAddress": "192.168.1.31", "port": 6000, "useUdp": false, "frameType": "3E" } }, { "deviceName": "station2_omron", "connection": { "protocolType": "Fins", "ipAddress": "192.168.1.32", "port": 9600, "nodeNumber": 10 } }, { "deviceName": "station3_siemens", "connection": { "protocolType": "S7", "ipAddress": "192.168.1.33", "port": 102, "rack": 0, "slot": 1 } }, { "deviceName": "station4_modbus", "connection": { "protocolType": "ModbusTcp", "ipAddress": "192.168.1.34", "port": 502, "byteOrder": "BigEndian" } } ] }设备接入流程很简单:先在配置里增加一台设备并填好通信参数,然后按品牌对应关系填点位表,最后重启网关服务。新增一个点位时,只需要在点位表里加一行配置,不需要改动任何业务代码。这个流程我用了很久,稳定可靠。
不过有一个坑需要提醒:有些PLC的通信端口默认不是开放的,比如S7-1200的PUT/GET通信功能需要你在PLC侧勾选允许,三菱的MC协议也有一段端口号是CPU内置的,并不是随便映射一下就能通。第一次接入时如果连不上,先排查PLC侧的开放设定,再去查网络通不通。这个问题排查了我太多次了,值得专门记这么一笔。
4.3 网关部署形态:嵌入式还是独立服务
网关做成一个类库之后,部署形态就很灵活了。我在实际项目里用过两种形态,各有各的适用场景。
嵌入式形态是把网关类库直接打进上位机程序里。这种方式最直接,不用额外部署服务。上位机启动时初始化网关,把所有点位访问的代码做成一个数据服务模块,界面层直接调用。这对单机监控场景特别合适,配置简单、调试方便。
独立服务形态则是把网关跑成一个后台服务(Windows服务或者Linux daemon),上层不同的客户端系统通过HTTP、gRPC或者SignalR来访问点位数据。我在一个产线数据采集项目里就是这么干的:网关服务部署在产线机房的一台工控机上,负责采集四条产线的PLC数据,然后把数据通过SignalR推送到服务器端,MES系统再从服务端拿数据。网关服务本身不存储业务数据,它只管把PLC数据读出来、推送出去、接收写入指令、再写回PLC。
独立服务形态需要额外考虑权限问题。设备点位有的只读有的可写,网关暴露的接口如果被乱调,可能误触发产线动作,这是很危险的。我在网关里加了一层访问令牌机制,写入接口需要携带令牌,而且令牌绑定了允许写人的点位列表。这样即使接口被内部其他系统调用,也做了一层权限校验,安全系数提升不少。
5. 常见问题与排查技巧实录
5.1 通信偶发超时,日志看不出明显问题
第一个典型现象是:通信大部分时间是好的,但偶尔某个点位读取超时,日志里只有一个Timeout异常,其他什么都看不出来。这个问题我排查了很久,最后定位到根因是网关并发请求数过大。
有些PLC的CPU同一个时刻只能处理有限个请求,比如S7-1200的PUT/GET功能对同时激活的连接数有限制,如果网关这边是多线程并发地去读同一台设备,某些请求就会被PLC侧排队甚至拒绝。解决办法是在网关内部为每台设备加一个信号量,限制同一设备的并发请求数。
private readonly SemaphoreSlim _deviceGate = new(4); // 读取某个设备点位前先获取信号量 await _deviceGate.WaitAsync(ct); try { rawValue = await runtime.Driver.ReadPointAsync(...); } finally { _deviceGate.Release(); }我后来统一把并发度调成了4,S7和三菱都稳住了,没再出现过随机超时。这里要强调一下,不是并发越高越好。并发太高反而增加PLC的CPU负载和网络排队,4到8个并发是多数中端PLC的甜蜜区间。具体值还是要按设备型号去压测。
5.2 数据偶尔"跳变",根因在字节序和类型解释
第二个常见问题特别坑:采集回来的数据看着正常,但偶尔某个数值会出现离奇的跳变,比如温度突然从25度变成6890度,过一会儿自己又变回来了。这种问题第一直觉会怀疑是PLC程序有毛病,其实很多时候是数据类型解释错了。
我之前接三菱的D寄存器存了一个REAL数值,因为三菱MC协议按小端存储,而我在协议解析时没有正确翻转,导致高低字节反了。平时温度在25度左右时,浮点表示是0x41C80000,小端读取再按大端解释就变成了一个极小值,看起来就是跳变。这类问题之所以"偶尔"出现,是因为不是所有数值高低字节翻转后都看起来离谱,有些翻转后碰巧还是正常数范围内的奇葩值。
排查方法很简单:读取原始字节打印出来,跟PLC侧的在线监视值对比一下字节排列,一眼就能看出问题在哪。网关里的类型规则表建好之后,这类问题就再也没犯过了。
5.3 上位机重启后,网关怎么恢复状态
网关在独立服务形态下,如果上位机或者网关服务重启了,最需要担心的不是怎么重新读数据,而是怎么恢复到之前的点位缓存和连接状态。
我的方案是把设备状态缓存做成可持久化的。网关运行期间,每台设备最近一次成功读取的数据会写入本地SQLite数据库。重启后,网关先加载缓存数据,再据此判断哪些设备在线、哪些离线。上层界面启动时能立即显示最新一帧数据,而不是等网关重新建立连接轮询一遍才开始刷数据。这个体验差异在车间现场非常大,工人不会因为系统重启就盯着空屏幕等半天。
另一个状态恢复的点是设备点位写入记录的留存。我在网关里把所有写操作也记了日志,带时间戳和写入值。产线如果出了质量问题,可以去查某个点位在什么时间被写成过什么值,工控现场这点非常重要,经常能帮工艺工程师定位到是哪次误操作导致的异常。
5.4 网关性能上不去,批量和缓存来凑
最后说一下性能优化。单个点位的读写协议帧开销挺大,如果上位机每秒要采集几百个点,逐个读写肯定不行。我的做法是支持点位批量读写。同一台设备下,把多个连续地址的点位打包进一次协议请求,网关内部按点位配置自动合并。三菱MC协议的批量读可以一次带多个软元件地址,S7也可以按DB块连续读。
批量读之后还有一层缓存兜底。变化不频繁的数据(设备型号、版本号、配方参数)可以直接把缓存过期时间设长一些,比如5秒。这样高频读取这类数据时,网关根本不用发协议帧,直接从缓存返回。实测下来,有效请求量能降掉一大半,整个网关的压力小了很多。
6. 写在最后的个人体会
这套网关前前后后折腾了差不多两个版本,踩了不少坑,也优化了不少设计。要真说心得体会,我最想强调的是开头那个原则:上层绝不能依赖任何协议细节。一开始我也犯过简单封装库的错,觉得每个库包一层方法就算完事了,结果业务代码里还是散落着各种寄存器地址字符串。后来痛定思痛,把点位逻辑名这套机制做进核心接口之后,后面的所有扩展才变得轻松起来。
如果你打算自己做一套,或者正在评估现成的.NET网关方案,我建议先不要急着写协议解析代码,先花时间把点位类型系统和配置结构设计清楚。协议适配反而是辛苦但不算难的体力活,难的是把接口边界咬死,让上层、驱动层、配置层各司其职。把这个根基打好了,后续不管是接新品牌PLC,还是把网关从嵌入式形态迁移到独立服务形态,都会顺畅很多。
最后再分享一个小技巧:调试的时候多用模拟器。不少PLC厂商官方提供了仿真软件,可以在一台没有真实设备的环境里把通信链路完整走通。配合我在网关里加的那套日志跟踪工具(把收发帧的十六进制报文打印出来),开发期就能把绝大多数协议格式问题消化掉,不用产线真机上反复试错。这套组合拳,值得每位做.NET工控网关的朋友试试。