简介:面向C#/.NET开发者的跨平台物联网网关完整源代码,基于.NET6打造,核心解决工业现场设备接入与数据上云问题。通过浏览器可视化配置即可接入PLC、扫码枪、CNC、数据库、串口设备、上位机、OPC Server、MQTT Server等,并支持与Thingsboard、IoTSharp或自建物联网平台双向通信;内置AB、三菱、Modbus全协议、MT机床等多类PLC驱动,同时预留驱动开发接口,便于扩展边缘计算。压缩包共1147个文件,约28.46MB,以487个cs源码文件为主体,配套98个cshtml可视化页面、124个js交互脚本、97张png界面示意、29个dll运行库以及完整工程与配置文件;同时内置MQTT服务端和OPC UA服务端,配置后即可本地实时收发数据,方便联调验证。目前已吸引566人学习下载,适合从事设备数据采集、网关中间件开发或物联网平台集成的中高级开发者,作为基础框架或二次开发起点。
1. C#.net物联网网关:现场协议五花八门,它负责把话翻译成一个MQTT主题
现场设备永远不是干净的。西门子S7 PLC说自己的S7语言,温控表和电表挂在同一根485线上讲着Modbus RTU,有些水表还要按DL/T645解析,罗克韦尔那边更是自成体系。把这些数据统一送到云端,缺的不是一块屏幕,而是一台C#.net物联网网关:采集层挂上Modbus、AB协议、欧姆龙协议、西门子协议驱动,把各站数据轮询回来;上行层用MQTT发布到Broker;最后在组态画面上把实时值、曲线和报警摆出来。这套东西最磨人的反而不是MQTT连不上,而是驱动的稳定性、地址映射对不对、断线重连会不会把云端冲垮。
2. 网关架构四层拆解:驱动采集、MQTT上行、组态呈现从哪下手
见过不少网关项目,我拿到需求的第一件事不是写代码,是先画分层图。C#.net做这种项目最大的优势是开发效率高:SerialPort、Socket、MQTTnet这些库都是同一套异步模型,部署到工控机或者容器里都行。架构上我习惯拆成四层:驱动采集层、点位缓存层、上行协议层、组态呈现层。每层之间用接口隔开,换驱动不碰MQTT,换组态不碰采集,这是后面少踩坑的前提。
2.1 先回答一个问题:Modbus、S7、FINS、EtherNet/IP驱动怎么选
先把现场设备盘一遍,这一步决定了网关里要挂哪几个驱动。Modbus是工业现场应用最广的协议,变频器、温控表、气象站、智能电表几乎都支持,区别只在走哪条路:串口的走Modbus RTU,网口的走Modbus TCP。PLC那一类则看品牌:西门子走S7comm(也就是常说的S7协议),欧姆龙走FINS,AB(罗克韦尔)的新型PLC走EtherNet/IP,老一点的设备还要考虑DF1串口。
| 协议 | 物理层/端口 | 寻址单位 | 帧特征 | 典型设备 |
|---|---|---|---|---|
| Modbus TCP | 以太网 502/TCP | 寄存器/线圈 | MBAP头+功能码 | 变频器、温控表、气象站 |
| Modbus RTU | 串口 RS485 | 寄存器/线圈 | CRC16,3.5字符帧间隔 | 电表、水表、传感器 |
| 西门子 S7comm | 以太网 102/TCP | 区+偏移(DB块) | ROSCTR+TSAP握手 | S7-1200/1500/300 |
| 欧姆龙 FINS | 以太网 9600 UDP/TCP | CIO/WR/DM字地址 | FINS头+命令码 | CJ/CP/CS系列 |
| AB EtherNet/IP | 以太网 44818/TCP | 标签Tag | CIP封装+连接建立 | CompactLogix/ControlLogix |
选型步骤不复杂:先把现场品牌型号、通讯口、站地址、寄存器表列成清单;再按端口分类,串口设备统一看波特率、数据位、校验位,网口设备记IP和端口;最后用Modbus Poll这类调试工具先手动读一遍,确认能通再往点表里写配置。很多网关项目交付后问题不断,根源就是现场根本没盘清楚,驱动挂了一堆,对不上号。
这里还会碰到一种不在表格里的情况:DL/T645电表水表。电力仪表大量使用645协议,它是字节流加校验和的格式,和Modbus完全不同。如果现场有这种表,网关里还要单独挂一个645驱动,别指望Modbus驱动能直接读。
2.2 MQTT在网关里不只是一个客户端:上行发布、下行下发和遗嘱消息
为什么不用HTTP,原因很实际:网关要周期性上报大量数据,HTTP每次都要建连、带请求头,开销大;MQTT一条长连接双向复用,发布订阅模式天然适合多点采集、云端集中消费。C#.net生态里用MQTTnet的比较多,异步API齐全,遗嘱、保留消息、QoS都支持。
MQTT的主题设计在网关项目里算得上灵魂。我一般用两层命名:第一层区分方向,第二层带网关ID和点ID。主题别用中文、别带空格,云端订阅规则才能写得干净。
| 方向 | 主题模板 | QoS | 说明 |
|---|---|---|---|
| 上行实时值 | tele/{gwId}/point/{pointId} | 0 | 周期快照,丢一帧无妨 |
| 上行批量值 | tele/{gwId}/tags | 0 | 一个主题带一组点位 |
| 下行写值 | cmd/{gwId}/write | 1 | 云端下发写命令 |
| 离线通知 | tele/{gwId}/will | 1 | 遗嘱消息,Broker代发 |
QoS不能一刀切。周期上报的实时值用QoS 0,丢了下一轮会补上;控制命令用QoS 1,保证Broker至少收到一次;遗嘱消息必须用QoS 1,否则断线通知可能丢。把实时值设成QoS 1是最容易翻车的做法,后面第5章会展开讲为什么。
2.3 把点表结构先定好:C#驱动接口与数据结构的设计
网关里绕不开的一个词是点表。点表就是一张描述「每个数据点从哪来、是什么类型、怎么换算」的清单。组态、MQTT、驱动全部围着点表转,所以我习惯把点表数据结构先写出来,再写驱动。下面这段是C#里常见的设计:
public enum DataType { Bool, UInt16, Int16, UInt32, Int32, Float32, String } public enum DriverType { ModbusTcp, ModbusRtu, S7, FinsTcp, FinsUdp, AbEIP } public class PointDefine { public string PointId { get; set; } // 唯一标识,如 "Tank1_Temp" public string Name { get; set; } // 中文名,显示用 public DriverType Driver { get; set; } // 走哪个驱动 public byte SlaveId { get; set; } // 从站号/站号 public int Address { get; set; } // 组态视角的起始地址 public DataType Type { get; set; } // 数据类型 public int Offset { get; set; } // 字偏移,读块后取第几个字 public double Scale { get; set; } = 1.0; // 工程量系数 public int ByteOrder { get; set; } // 字节序,AB/CD/BA/DC } public interface IDriver { Task<byte[]> ReadAsync(PointDefine point, int length, CancellationToken ct); Task WriteAsync(PointDefine point, object value, CancellationToken ct); bool IsConnected { get; } }这里有一点要说明:Address的语义不是统一的。Modbus的地址是寄存器索引,S7的地址是DB块号加偏移,FINS又分CIO和DM区,所以Address字段怎么解释由Driver决定,上层MQTT和组态只认PointId。Offset字段是给块读用的,后面第5章讲块读合并时会用到。
点表我一般放在JSON文件或SQLite里,网关启动时加载。现场加点位只需要加一行配置,不用改代码。组态画面绑定PointId,报表逻辑也按PointId取数,这一层定稳了,后面的工作就是往接口里填实现。
3. 采集层编码:用C#实现Modbus TCP/RTU驱动与三款PLC协议封装
3.1 从零写一个Modbus TCP客户端:读保持寄存器的报文与循环收包
Modbus TCP是所有驱动里最好调试的,一个TcpClient加几个字节就能跑起来。以读保持寄存器(功能码0x03)为例,请求报文由MBAP头加协议体组成:事务ID两个字节、协议ID两个字节固定为0、后续长度两个字节,然后是从站号、功能码、起始地址、寄存器数量。这里最容易错的是MBAP的长度字段只算「从站号之后的字节数」,不含MBAP本身。
public async Task<ushort[]> ReadHoldingRegistersAsync( string ip, int port, byte slaveId, ushort startAddr, ushort count) { using var tcp = new TcpClient(); tcp.ReceiveTimeout = 2000; // 局域网内2秒足够 await tcp.ConnectAsync(ip, port); byte[] req = new byte[12]; req[0] = 0x00; req[1] = 0x01; // 事务ID,正式代码要自增 req[2] = 0x00; req[3] = 0x00; // 协议ID固定0 req[4] = 0x00; req[5] = 0x06; // 后续6字节:从站+功能码+地址2+数量2 req[6] = slaveId; req[7] = 0x03; // 03 读保持寄存器 req[8] = (byte)(startAddr >> 8); req[9] = (byte)startAddr; req[10] = (byte)(count >> 8); req[11] = (byte)count; var stream = tcp.GetStream(); await stream.WriteAsync(req, 0, req.Length); byte[] resp = new byte[9 + count * 2]; // MBAP6+从站1+功能码1+字节数1+数据 int total = 0; while (total < resp.Length) { int n = await stream.ReadAsync(resp, total, resp.Length - total); if (n == 0) throw new IOException("连接被关闭"); total += n; if (resp[7] > 0x80) throw new Exception("Modbus异常码:" + resp[8]); } ushort[] values = new ushort[count]; for (int i = 0; i < count; i++) values[i] = (ushort)((resp[9 + i * 2] << 8) | resp[10 + i * 2]); return values; }这段代码有几个细节值得说透。第一是循环收包:TCP是流式协议,一次ReadAsync很可能只收到半帧,必须按resp.Length循环收满,很多新手在这里用一次Read就解析,十次里七次数据是残的。第二是事务ID:正式驱动里每次请求要自增,响应帧要校验事务ID和从站号对不对,否则会把上一个请求的响应当成当前结果。第三是超时,TcpClient的ReceiveTimeout只对阻塞Read有效,异步等待要配合CancellationToken,超时后把连接关掉重连,不能一直挂起,否则采集线程池会被耗尽。
485总线上的气象站、温控表这类设备,大部分走Modbus RTU。处理逻辑和TCP差不多,只是把TcpClient换成SerialPort,从站号、功能码、地址照旧,区别在于帧层多了一个CRC校验。
3.2 Modbus RTU报文与CRC16:串口链路上的读取要拼帧
Modbus RTU请求帧比TCP少了MBAP,多了两个字节的CRC16。读保持寄存器的请求帧是:
从站号(1) + 功能码(1) + 起始地址(2) + 寄存器数量(2) + CRC低字节(1) + CRC高字节(1)
CRC16的算法是固定套路,多项式0xA001,初值0xFFFF,计算范围是从站号到寄存器数量,不包含CRC自己。代码可以直接抄标准实现,别自己发明轮子:
public static ushort Crc16(byte[] data, int start, int len) { ushort crc = 0xFFFF; for (int i = start; i < start + len; i++) { crc ^= data[i]; for (int j = 0; j < 8; j++) { if ((crc & 0x0001) != 0) crc = (ushort)((crc >> 1) ^ 0xA001); else crc >>= 1; } } return crc; }RTU还有一个和TCP完全不一样的坑:帧间隔。Modbus RTU规定帧与帧之间要有3.5个字符时间的间隔,在19200波特率下大约是1.8毫秒。C#的SerialPort.DataReceived事件是按字节片段触发的,一帧数据可能分好几次到达,不能指望一次事件收全。我一般用两种策略处理:请求帧长度已知,直接等收到完整帧长再解析;或者用Stopwatch记录最后一次收字节的时间,连续超过3.5字符时间没新数据就认为一帧结束。
串口链路的时序还要算进点表轮询周期。RTU是从站应答,从站收到请求后一般要在100到500毫秒内回帧,如果点表轮询周期给得太短,会一直撞在上一帧的尾巴上。我做过一个项目,485总线挂了几十台水表采集器,最初轮询周期设了200毫秒,结果整条总线乱成一锅粥,把周期放到1秒、一帧一停,问题立刻消失。
3.3 西门子S7、欧姆龙FINS、AB EtherNet/IP的报文差异与统一封装
PLC协议比Modbus复杂的地方在于「连接建立」过程,不是发个请求就行。三者报文结构差异很大:
| 协议 | 端口 | 握手/连接要求 | 读操作特征 |
|---|---|---|---|
| 西门子 S7comm | 102/TCP | TSAP握手,S7-1200/1500需开PUT/GET | PDU带ROSCTR,读DB/输入/输出区 |
| 欧姆龙 FINS | 9600 UDP/TCP | 无握手,直接发FINS帧 | 命令码0101读,0102写 |
| AB EtherNet/IP | 44818/TCP | 先建立CIP连接,再读Tag | CIP封装,标签路径编码较复杂 |
西门子S7这边,最常见的问题是博途(TIA Portal)里默认禁止远程访问,PLC属性里不勾选「允许来自远程对象的通信」,网关永远握手失败。这个排查点要放在第一位。连接建立后还要协商PDU长度,S7-300老设备的PDU比较小,读大块数据要分段。
欧姆龙FINS相对直白,UDP模式速度快但容易丢包,重传要带序列号去重;TCP模式稳定但连接管理多一些。FINS的地址分三部分:区代码(CIO、WR、DM)、地址、位号,读DM区时地址编码是按字计算的,和Modbus的寄存器编号逻辑不一样,封装时文档要写清楚。
AB EtherNet/IP是最难啃的。读标签前要先发ForwardOpen建立CIP连接,拿到连接的CID/OID,再组读Tag请求,CIP路径里的段类型和类ID还要对得上。网上各型号的报文差异很大,常见做法是抓包分析CIP路径,或者借用开源库解析。老款AB设备如果只出DF1串口,那就得另写一个串口驱动,报文完全另一套。
三个协议差异这么大,统一封装就更重要。不管底层是S7还是FINS,对外只暴露IDriver接口,网关的采集调度器不关心协议细节:
public IDriver CreateDriver(DriverType type, DriverConfig cfg) { switch (type) { case DriverType.ModbusTcp: return new ModbusTcpDriver(cfg); case DriverType.ModbusRtu: return new ModbusRtuDriver(cfg); case DriverType.S7: return new S7Driver(cfg); case DriverType.FinsTcp: case DriverType.FinsUdp: return new FinsDriver(cfg); case DriverType.AbEIP: return new AbEipDriver(cfg); default: throw new NotSupportedException("未支持驱动"); } }cfg里放IP、端口、超时、重试次数,每个驱动类自己管理连接和重连,驱动暴露的IsConnected属性给上行层读状态。这样调试任何一个协议都不影响MQTT和组态层,源码里最该保留的就是这个骨架。起步顺序我的习惯是:先跑通Modbus TCP,再挂RTU串口,最后啃PLC协议,每一类驱动独立验证完再集成。
4. 上行MQTT与组态:从点表到云端,再到人机界面
4.1 MQTT连接与发布:连接参数、主题规则与QoS选择
MQTT协议本身不复杂,复杂的是参数和主题规则。C#.net里用MQTTnet的话,连接和发布的最小链路是这样:
using MQTTnet; using MQTTnet.Client; var options = new MqttClientOptionsBuilder() .WithTcpServer("192.168.1.10", 1883) // 本地Broker或云端地址 .WithClientId("gateway_" + Guid.NewGuid().ToString("N")) .WithCredentials("gwuser", "gwpwd") // 匿名Broker可去掉 .WithKeepAlivePeriod(TimeSpan.FromSeconds(30)) .WithCleanSession(true) .Build(); var client = new MqttFactory().CreateMqttClient(); await client.ConnectAsync(options, CancellationToken.None); var payload = JsonSerializer.Serialize(new { ts = DateTime.Now, Tank1_Temp = 36.5, Tank1_Level = 78.2 }); var msg = new MqttApplicationMessageBuilder() .WithTopic("tele/gateway01/tags") .WithPayload(payload) .WithQualityOfServiceLevel(MQTTnet.Protocol.MqttQualityOfServiceLevel.AtMostOnce) .Build(); await client.PublishAsync(msg);几个参数说清楚。ClientId必须全局唯一,两个网关用同一个ClientId,后者会把前者踢下线,这是分布式部署时最容易踩的。KeepAlivePeriod默认60秒,我习惯设30秒,Broker检测断线会快一些,代价是每30秒多一条PING报文,对几百台网关来说可以忽略。CleanSession设true,表示断开后不保留会话,实时值主题不依赖离线消息;如果设false,断线期间的QoS1消息会在Broker排队,攒一夜能堆积几十万条。
Payload统一用JSON,带时间戳、点位值、质量码。组态和云端解析同一套格式,调试也方便。注意发布的时候按PointId而不是中文名,画面显示名映射放在组态层,协议栈里永远用点ID。
4.2 订阅、遗嘱与离线缓存:下行命令在断网时不丢
网关不只是往上发数据,还要接收云端下发的写命令。下行链路的代码重点在遗嘱和订阅回调:
var will = new MqttApplicationMessageBuilder() .WithTopic("tele/gateway01/will") .WithPayload("offline") .WithQualityOfServiceLevel(MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce) .Build(); var options = new MqttClientOptionsBuilder() .WithTcpServer("192.168.1.10", 1883) .WithClientId("gateway_001") .WithWill(will) // 遗嘱必须在连接前设置 .Build(); await client.ConnectAsync(options, CancellationToken.None); await client.SubscribeAsync("cmd/gateway01/write"); client.ApplicationMessageReceived += async e => { // 这里只做解析和入队,不直接写PLC var cmd = JsonSerializer.Deserialize<WriteCommand>(e.ApplicationMessage.PayloadSegment); _commandQueue.Enqueue(cmd); };遗嘱消息要在建立连接之前放进options里,Broker才会在连接异常断开时代发「offline」,云端据此判断网关失联。订阅回调里别做重活,MQTTnet的回调线程处理太多逻辑会导致收包变慢,正确做法是解析后丢进一个命令队列,由独立的写线程串行处理。
下行命令的处理时序我建议是:解析命令 -> 校验点表是否存在 -> 记录操作日志 -> 调IDriver.WriteAsync -> 回读确认 -> 发布确认消息。MQTT的QoS1只保证Broker收到消息,不保证PLC真的写成功了,必须靠回读确认。断线期间的命令要不要缓存?要,但要在命令里带时间戳,重连后只补发最近5分钟内的指令,把「昨天要执行的指令今天突然执行」这种事故从根上堵掉。这个设计也是后面消息风暴治理的一部分。
4.3 组态控件绑定:实时值与画面刷新的正确姿势
标题里的「组态」落到交付物上,要么是源码自带的一套轻量人机界面,要么是对接Fuxa、Wonderware这类第三方组态软件。C#.net的自带画面一般用WinForms或WPF做,核心逻辑是一样的:控件绑定点表、定时刷新。
实时值刷新最容易写错的地方,是在采集回调里直接操作UI控件。正确做法是采集层只写数据缓存,UI层用一个定时器统一读缓存:
// 采集线程回写的缓存 private readonly ConcurrentDictionary<string, object> _dataCache = new(); // 采集回调里只做这一步 _dataCache["Tank1_Temp"] = 36.5; // UI层每500毫秒取一次缓存刷新界面 var timer = new System.Windows.Forms.Timer(); timer.Interval = 500; timer.Tick += (s, e) => { textBox_Temp.Text = _dataCache.TryGetValue("Tank1_Temp", out var v) ? Convert.ToDouble(v).ToString("0.0") : "--"; }; timer.Start();刷新周期我默认500毫秒,低于200毫秒人眼基本看不出差别,CPU占用却会成倍增长。缓存用ConcurrentDictionary,读多写少,Key用PointId。画面上的每个控件绑定一个PointId,界面层做一个统一的Bind方法,不要每个控件写一套订阅逻辑,否则点表一多,刷新代码就失控了。
历史曲线是组态的常见需求。最小做法是在内存里给每个点保留一个环形缓冲,存最近N个值,画折线图时直接取;要存历史趋势的,常见做法是网关本地落一个时序库,或者定期把快照攒起来推给云端的时序数据库。真要做到完整组态,报警、报表、权限管理,自研成本远高于接Fuxa这类成熟组态软件,这也是不少团队选择源码里保留组态接口、画面交给专业组态的原因。
5. 常见问题排查:物联网网关最容易翻车的五个现场
5.1 双字合并与字节序错乱:读到天文数字时先别怀疑PLC
现象:Modbus读回来的浮点数显示成1.401298e-45、3.2212e+16这类天文数字,或者负值变成巨大正值。
原因:Modbus寄存器是16位的,一个float32要占两个寄存器。不同PLC的合并顺序不一样,西门子是大端、高字在前,AB允许配置字序,很多国产仪表是小端。两层顺序各四个组合:字序(高字在前/低字在前)和字节序(大端/小端),配置错了读出来的值自然疯了。
解决:在点表里加一个ByteOrder字段,驱动按配置去合并两个寄存器。合并函数可以这么写:
public static float MergeFloat32(ushort highWord, ushort lowWord, int order) { byte[] b = new byte[4]; switch (order) { case 0: // 大端:高字在前,字节高在前 b[0] = (byte)(highWord >> 8); b[1] = (byte)highWord; b[2] = (byte)(lowWord >> 8); b[3] = (byte)lowWord; break; case 1: // 小端:低字在前,字节低在前 b[0] = (byte)lowWord; b[1] = (byte)(lowWord >> 8); b[2] = (byte)highWord; b[3] = (byte)(highWord >> 8); break; case 2: // 只交换字序,字节内部仍大端 b[0] = (byte)(lowWord >> 8); b[1] = (byte)lowWord; b[2] = (byte)(highWord >> 8); b[3] = (byte)highWord; break; } return BitConverter.ToSingle(b, 0); }调试手段很笨但有效:用Modbus Slave软件在从站里写入一个已知浮点数1.0(十六进制0x3F800000),然后把四种组合试一遍,哪个数值对了,就把这种组合记进点表。每接一种新设备都这样做一次,比猜快得多。这也是网关源码里最该留住的工具函数,否则每次新项目都要从零踩一遍。
5.2 Modbus地址从0还是从1开始:全盘错位来自这里
现象:组态软件里写的寄存器是40001,代码里填了40001读不到数据,填3又能读到,但值对不上。
原因:Modbus协议线上地址是从0开始的,而组态软件和PLC梯形图习惯从1开始,地址里还常常混入寄存器区号。40001是保持寄存器第一路,协议地址实际是0x0000。
解决:点表里统一存「组态地址」,驱动内部做一个地址换算函数,别在每处代码里自己减1。
| 寄存器区 | 功能码 | 组态地址示例 | 协议地址换算 |
|---|---|---|---|
| 线圈 | 0x01 | 00001 | 组态地址 - 1 |
| 离散输入 | 0x02 | 10001 | 组态地址 - 10001 - 1 |
| 保持寄存器 | 0x03 | 40001 | 组态地址 - 40001 |
| 输入寄存器 | 0x04 | 30001 | 组态地址 - 30001 |
这个坑几乎每个网关项目都会遇到。我的习惯是点表里写的是组态地址,比如40001、30010,程序启动时统一转成协议地址,转换逻辑集中在AddressTranslator一个类里。驱动代码里永远操作协议地址,界面和导出文件里显示组态地址。两边都遵循这一个约定,后期排查错位问题会省很多时间。
5.3 MQTT QoS=1导致的离线积压:把Broker内存吃干抹净
现象:网关断网一晚上,第二天重连,Broker内存陡增,云端消费端积压几十万条消息,业务侧看到的全是过期数据。
原因:订阅者订阅时用了QoS1,Broker为离线客户端保留消息队列。网关断线期间数据一直在往主题上发布,QoS1的消息会在Broker侧排队,一个点位每秒一条,一晚上就是几万条。网关本地补发逻辑如果没做时间窗过滤,重连后还会把缓存里的老数据一股脑倒上去,双重叠加。
解决:实时值主题用QoS0,丢了就丢了,下一轮会补上,这是设计上的取舍。必须补发的场景,单独开一个「补偿数据」主题,QoS1发布,但网关重连后只补最近5分钟窗口内的数据,并且每条带时间戳,云端按时间戳去重。Broker侧的持久会话(CleanSession=false)要谨慎使用,它适合命令下发场景,不适合高频遥测。遗嘱消息用QoS1是对的,那是低频关键消息,不会积压。
5.4 轮询风暴:点表越大,越要合并块读
现象:网关接入点数到几百个之后,PLC程序运行变慢,网关频繁报超时,组态画面里的数值一会儿有一会儿空。
原因:每个点位独立发一条Modbus请求。500毫秒轮询周期下,500个点就是每秒1000条请求,PLC的CPU大量时间在处理通讯,正常的逻辑扫描被拖慢。串口RTU更严重,485是半双工总线,一个请求一个应答,根本扛不住这么高频的读写。
解决:把连续地址的寄存器合并成块读,一次请求读回一整块,再从块里按偏移取每个点的值。Modbus TCP的03功能码单次最多读125个寄存器,RTU建议单次不超过40个寄存器,因为帧越长485总线占用越久。块内地址间隔超过某个阈值就拆成两块。点表轮询周期默认1秒,串口链路建议1秒以上。
块合并的骨架可以先写成这样:
public static List<ReadBlock> BuildBlocks(List<PointDefine> points, int maxSpan) { var sorted = points .GroupBy(p => new { p.Driver, p.SlaveId }) .SelectMany(g => g.OrderBy(p => p.Address)) .ToList(); var blocks = new List<ReadBlock>(); ReadBlock cur = null; foreach (var p in sorted) { if (cur == null || p.Address - (cur.Start + cur.Length) > maxSpan || cur.Count >= 120) { cur = new ReadBlock { Driver = p.Driver, SlaveId = p.SlaveId, Start = p.Address, Points = new List<PointDefine>() }; blocks.Add(cur); } cur.Points.Add(p); cur.Length = Math.Max(cur.Length, p.Address - cur.Start + 1); cur.Count = cur.Length; } return blocks; }块读做完,采集线程从「每点一请求」变成「每块一请求」,请求量能下降一个数量级。PLC侧的通讯负载问题,组态软件里配置Modbus TCP时同样要注意,别把每个寄存器都当成独立连接去建会话。
5.5 组态画面卡死:UI线程上做了解析
现象:画面拖动卡顿,最小化再恢复假死,CPU占用长时间100%。
原因:串口DataReceived事件或MQTT回调线程里直接操作UI控件,每个点位每次刷新都触发一次Control.Invoke,UI消息队列被塞满。更严重的是在UI线程里直接做协议解析,比如把CRC计算、浮点合并都放在Invoke里,UI线程忙不过来,画面自然假死。
解决:回调线程只负责「收数据、写缓存」,UI刷新统一走定时器。Invoke频率控制在200毫秒以上,不要逐点刷新,一次Tick刷新所有可见控件。数据缓存用ConcurrentDictionary避免锁竞争。组态层只读缓存,永远不直接碰驱动对象。这套约束要写进团队的代码规范里,否则后面加功能的人很容易又回到回调里写UI。
6. 最后一章:验证网关的三种手段与一个帧级诊断技巧
6.1 用Modbus Slave和MQTTX做闭环验证
网关写完,第一件事是搭一个能复现的闭环环境,别直接上现场。我常用的组合:本机起一个Modbus Slave模拟从站,里面放几组已知值;再本地起一个MQTT Broker,EMQX或mosquitto都可以;用MQTTX订阅主题做收包检查。
验证步骤按这个顺序走:
- Modbus Slave开两个从站,一个放整数数组,一个放浮点数,值都设成带小数的已知数。
- 网关程序连上读,对比帧日志里的原始值和点表解析后的值,确认字节序配置正确。
- 本地Broker起来后,用MQTTX订阅tele/#,确认实时值主题有数据进来,Payload能按JSON解析。
- 从MQTTX的发布窗口往cmd/gateway01/write发一条写命令,看Modbus Slave里对应的寄存器值是否变化。
- 杀掉网关进程,计时看遗嘱主题有没有在5秒内收到offline消息;重启网关,再确认命令队列和重连逻辑正常。
这套闭环跑通了,再拿到现场对接真实PLC,剩下的就是地址表核对了。现场对接时Wireshark抓包和帧日志配合使用,协议报文谁对谁错一目了然。
6.2 帧级日志开关:给网关留一个黑匣子
排查协议问题,最怕的是看不到原始报文。所以我在驱动层一定要留一个帧级日志开关,默认关闭,调试时打开,把收发报文按十六进制打出来:
public static void LogFrame(string dir, byte[] tx, byte[] rx) { if (!DebugLogOn) return; // 线上必须关闭,写盘有IO开销 var sb = new StringBuilder(); sb.Append(DateTime.Now.ToString("HH:mm:ss.fff")); sb.Append(" TX: ").Append(BitConverter.ToString(tx)); sb.Append(" RX: ").Append(rx == null ? "" : BitConverter.ToString(rx)); File.AppendAllText( Path.Combine(dir, $"frame_{DateTime.Now:yyyyMMdd}.log"), sb.AppendLine().ToString()); }日志按天切文件,每条带毫秒时间戳。Modbus CRC错、地址错、字节序错,拿帧日志和Modbus Slave回显一比对,十秒就能定位。线上环境不开启,文件增长会很快;需要远程排障时,临时开几分钟,收集到日志就关掉。我调AB的EtherNet/IP时,各型号CIP路径没有公开对照表,最后就是靠帧日志和抓包比对,发现是TCP分片后少收了一段,补上循环收包才通。
现在接手任何一套网关源码,我第一件事就是确认点表导出的功能在不在、帧级日志开关有没有。没有就先补上,否则后面每接一个新协议都是黑匣子猜谜。希望帮到你。
本文还有配套的精品资源,点击获取