十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Modbus RTU监听的老系统旁路网关实现声光报警联动

基于Modbus RTU监听的老系统旁路网关实现声光报警联动 1. 一个不能动的老上位机逼我做了一个“旁路网关”1.1 改造需求是怎么冒出来的上半年产线改造新装了一台声光语音终端。设备方给的接入方式很简单上位机通过 TCP 连接按固定字节帧给终端发报警语音和灯光指令。难点不在终端而在现场那台老上位机。它是很多年前用 VS2015 开发的 C# WinForm 程序.NET Framework 4.0靠 Modbus RTU 串口和 PLC 通信。程序一直跑得很稳但也没有源码能安全改甚至不能用新工具链随便动。产线不能停程序不能停报警状态必须原样传到新终端上这就是改造的前提。1.2 为什么不动老程序不是不能动而是代价太离谱。如果改老上位机等于要把一个跑了几年的闭源程序重新拿回来评审重新编译、回归测试还要拉上工艺、设备、操作工一起验收。改一个字节现场可能停半个小时损失比买十台终端都大。还有一层原因后来客户翻出一份 VS2019 的 C# 上位机源码VS2015 连打开都打不开——新版 SDK 项目格式和 PackageReference 早就不是老 csproj 那套了硬转回来会引入一堆依赖问题。所以我的结论是老程序继续当它的“黑盒”改造全部放在老程序外面做。最终落地方案是在老上位机之外加一个“旁路网关”。它不碰老代码不改变原有通信链路只在 Modbus RTU 总线上监听报警状态再把报警映射成声光语音终端认识的 TCP 字节帧发出去。2. 不改程序也能拿报警状态串口监听为什么是最优解2.1 三种旁路取数方案拿到需求先想的不是代码而是“报警数据从哪个口出来”。老上位机自身不提供接口但它的数据一定有来源。我列了三个方案。第一个方案是读老上位机的数据库。很多老 HMI 会把报警历史写到 SQL Server新程序每隔几百毫秒查一次表。这个方案理论上可行但历史表结构谁都没文档而且老程序写库有延迟等它落库再转发终端响应明显慢半拍。第二个方案是给老上位机的通信链路做透明代理。如果老上位机走的是网络和 PLC 通信把目标 IP/端口指到代理程序代理转发请求并解析响应。这个方案改动最小实时性最好但要求老上位机的通信配置能改而且代理程序挂了老系统也跟着断线。第三个方案是直接在 RS-485 串口线上做监听。老上位机用 Modbus RTU 读取 PLC 寄存器我只需要在 RS-485 A/B 线上并一路出来接一个小网关专门吃同一份报文。老上位机照常轮询PLC 照常回监听端看到的响应帧和老上位机完全一致。三种方案对比如下方案改老程序改老配置依赖老系统稳定性实时性实施成本读数据库否否低秒级延迟中透明代理否是高毫秒级低RS-485 监听否否极低毫秒级低我最后选了 RS-485 监听。原因很简单它不改变老系统里任何一个字节的时序老上位机甚至不知道网关存在。只要报警位在 Modbus 响应寄存器里网关就能拿到。2.2 监听接线和选型细节接线不是随便拿两根线并上去。RS-485 是差分总线偷懒用 Y 型并联长距离会引起反射反而把原通信搞乱。我当时用了一个 RS-485 集线器/隔离中继器把 A/B/GND 隔离出一路给网关终端电阻按现场总线波特率配好。波特率 9600 的现场哪怕多一个节点只要接线规范影响可以忽略。网关侧我用的是一个 USB-RS485 转接口串口参数设成和旧上位机一致9600、8N1、从站地址 1。这里有个容易被忽略的点Modbus RTU 是主从轮询监听端只收不发所以网关程序里要把写功能码的代码全部关掉纯监听模式才是最稳妥的。3. 声光语音终端只认原生 TCP 字节帧协议得先谈明白3.1 帧结构怎么定这类声光语音终端通常是裸 TCP 服务器不是 HTTP不跑 MQTT更不会解析 JSON。它就是一个简单的端口监听程序数据包必须按它约定的字节帧来。我最终定下来的帧结构也是很多工业小终端常见的套路偏移长度字段说明02帧头0xA5 0x5A21版本0x0131命令字0x02 控制0x03 心跳41终端地址0x00 默认设备52负载长度网络字节序大端7N负载由命令字决定7N2CRC16-Modbus低字节在前高字节在后9N2帧尾0x0D 0x0A这里“原生”的意思是网关不借助任何 SDK 或中间件直接用 C# 的TcpClient把这段字节流发过去。TCP 是流协议没有消息边界所以帧头、长度、CRC 三样必须齐全。3.2 控制报警的负载格式报警控制命令我设计成 10 字节的负载固定长度解析简单字段长度说明AlarmId2报警编号1~65535AlarmLevel1报警等级1 最低Action11 表示触发0 表示恢复LightCtrl1bit0 红灯bit1 黄灯bit2 绿灯bit3 旋转灯VoiceId2语音文件编号Volume1音量 0~100DurationSec2持续秒数0 表示一直保持比如“1号皮带过流”对应的 AlarmId1VoiceId100触发红灯和语音播报 30 秒整段报文长这样A5 5A 01 02 00 00 0A 00 01 01 01 01 00 64 64 00 1E CRC_L CRC_H 0D 0APayload 拆开看就是00 01AlarmId 101等级01触发01红灯00 64语音 10064音量 10000 1E30 秒。CRC 两个字节由程序算这里不手工列。3.3 组帧与 CRC 的小细节组帧代码里最容易翻车的是字节序。C# 的BitConverter在 Windows 上是小端而终端设备几乎都要求网络字节序也就是大端。所以不要图省事直接用BitConverter.GetBytes而是手动移位(byte)(value 8) (byte)(value 0xFF)CRC16-Modbus 是工业现场最低成本、最高性价比的校验方式。我把它做成公共方法凡是组帧都过一遍public static ushort CRC16(byte[] data, int offset, int count) { ushort crc 0xFFFF; for (int i 0; i count; i) { crc ^ data[offset i]; for (int j 0; j 8; j) { crc (crc 1) ! 0 ? (ushort)((crc 1) ^ 0xA001) : (ushort)(crc 1); } } return crc; }完整组帧方法可以这样写public static byte[] BuildFrame(byte cmd, byte address, byte[] payload) { payload payload ?? new byte[0]; int len payload.Length; byte[] frame new byte[7 len 4]; frame[0] 0xA5; frame[1] 0x5A; frame[2] 0x01; frame[3] cmd; frame[4] address; frame[5] (byte)(len 8); frame[6] (byte)(len 0xFF); Array.Copy(payload, 0, frame, 7, len); ushort crc CRC16(frame, 0, 7 len); frame[7 len] (byte)(crc 0xFF); frame[7 len 1] (byte)(crc 8); frame[7 len 2] 0x0D; frame[7 len 3] 0x0A; return frame; }注意 CRC 计算范围是从帧头到负载不含 CRC 本身也不含帧尾。发送时 CRC 低字节在前高字节在后。如果终端文档里写的是“高字节在前”那就在发送时把两个字节调换我这次遇到的是低字节在前。4. 旁路网关落地Modbus RTU 解析 Socket 组帧发送4.1 串口侧先把 Modbus RTU 的“边界”切出来写网关之前最大的通信问题是串口数据没有边界。老上位机每 200ms 轮询一次 PLC收到的 Modbus RTU 响应帧可能一次读完也可能读半个还可能在两个响应之间混入噪声。所以串口程序第一步是做一个“协议帧切割器”。我的处理思路是收到数据先压入缓冲区然后从缓冲区里找从站地址。找到之后判断功能码如果是 0x03/0x04则按“地址功能码字节数数据CRC”的长度切帧如果是异常帧则按 5 字节短帧切。private void OnSerialData(object sender, SerialDataReceivedEventArgs e) { int len _serial.BytesToRead; byte[] buf new byte[len]; _serial.Read(buf, 0, len); lock (_rxLock) { _rx.AddRange(buf); while (TryExtractFrame(out byte[] frame)) { ParseModbusResponse(frame); } } } private bool TryExtractFrame(out byte[] frame) { frame null; int idx _rx.IndexOf(_plcAddr); if (idx 0) { _rx.Clear(); return false; } if (idx 0) _rx.RemoveRange(0, idx); if (_rx.Count 3) return false; byte fun _rx[1]; int totalLen; if (fun 0x04) { if (_rx.Count 5) return false; int byteCount _rx[2]; totalLen 3 byteCount 2; } else { totalLen 5; } if (_rx.Count totalLen) return false; frame _rx.GetRange(0, totalLen).ToArray(); _rx.RemoveRange(0, totalLen); return true; }每次切出完整帧后再按 Modbus RTU 的 CRC 校验一下。如果 CRC 不对这一帧直接丢弃。这里我不建议直接把整条数据流清空重来老上位机的轮询没有暂停键丢弃一个错帧反而是最安全的。4.2 报警跳变才发送避免把终端刷爆拿到 PLC 返回的寄存器值以后要做的是“沿跳变”判断而不是每轮都把报警重新发一遍。终端播放语音和亮灯都需要时间如果每 200ms 都发一次同一个 AlarmId语音会被反复打断现场基本没法听。我的做法是维护一份寄存器快照_last每一轮新数据来了以后先和上一轮做异或只有发生变化的位才处理private ushort[] _last new ushort[8]; private void ApplyRegisterSnapshot(ushort[] regs) { if (!_initialized) { Array.Copy(regs, _last, Math.Min(regs.Length, _last.Length)); _initialized true; return; } for (int r 0; r Math.Min(regs.Length, _last.Length); r) { ushort oldValue _last[r]; ushort newValue regs[r]; ushort diff (ushort)(oldValue ^ newValue); if (diff 0) continue; for (int bit 0; bit 16; bit) { if ((diff (1 bit)) 0) continue; bool triggerNow (newValue (1 bit)) ! 0; int alarmId _map.GetAlarmId(r, bit); if (alarmId 0) { SendAlarm(alarmId, triggerNow); } } _last[r] newValue; } }这里有个经验网关刚启动时第一个完整快照先只初始化、不发送。因为串口可能从半帧开始收前几个数据并不完整直接发会把错误状态送到终端。等第二个快照出来才是真实状态。如果网关重启后需要让终端恢复当前报警状态可以在第一个快照之后把所有仍为 1 的报警位按“触发”动作补发一次。这个功能我当时做成配置项默认关闭因为终端那边可能已经由人工确认过再触发会干扰。4.3 TCP 客户端的连接管理与重连终端作为 TCP 服务端网关作为客户端主动连。TCP 三次握手只能说明连接建立过不能说明现在还能用。TcpClient.Connected属性并不可靠它只反映最后一次 IO 的状态不代表当前通道一定健康。所以必须靠应用层心跳来确认。我每 15 秒发一帧 0x03 心跳连续两次没收到 ACK就强制重连。发送报警的核心方法如下private void SendAlarm(int alarmId, bool active) { var info _map.GetInfo(alarmId); byte[] payload new byte[] { (byte)(alarmId 8), (byte)(alarmId 0xFF), info.Level, (byte)(active ? 1 : 0), info.LightCtrl, (byte)(info.VoiceId 8), (byte)(info.VoiceId 0xFF), info.Volume, (byte)(info.DurationSec 8), (byte)(info.DurationSec 0xFF) }; byte[] frame BuildFrame(0x02, 0x00, payload); SendFrame(frame); }TCP 发送方法要加锁防止心跳帧和报警帧同时进入NetworkStream把字节流弄乱private TcpClient _tcp; private readonly object _sendLock new object(); private bool SendFrame(byte[] frame) { lock (_sendLock) { try { if (_tcp null || !_tcp.Connected) { Reconnect(); } _tcp.GetStream().Write(frame, 0, frame.Length); _tcp.GetStream().Flush(); return true; } catch (Exception ex) { Reconnect(); return false; } } }第一次踩坑之后我立刻给TcpClient加了两条设置_tcp.NoDelay true; _tcp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);NoDelay true是为了关闭 Nagle 算法。现场很多小字节帧一帧只有几十字节开了 Nagle 之后TCP 栈会等一会才发终端响应的延迟会从毫秒级变成几十毫秒甚至更高。声光报警这种事晚两秒响就是事故。重连次数不能无限循环。我按 1 秒重试一次连续 5 次失败后只保留状态日志等下一轮报警数据到了再重试。这样网关网络断了还能保证老上位机不受任何影响。5. 现场测试最常踩的五个坑5.1 串口缓冲区分帧时被噪声带偏Modbus RTU 总线上的数据不是只属于一个主机。老上位机可能还会读到别的现场设备串口缓冲区里可能出现地址 1 的帧、地址 2 的帧中间还夹着噪声字节。处理方式是找到从站地址后把前面所有字节清掉再按长度切帧。但如果你监听的从站地址不止一个就不能粗暴清空要按各地址可能的功能码长度分别尝试。我这次只监听一个从站地址代码可以写得简单如果以后接多从站建议做一个按地址功能码的双层状态机。5.2 CRC 字节序搞反终端死活不回 ACK第一次联调时我确定 CRC 算法没问题终端就是不回 ACK。最后用协议分析软件抓包发现 CRC 的高低位和终端要求相反。文档写的是“CRC16-Modbus”但没写高低字节顺序终端实测要低字节在前。这种事很常见调试时先看抓包帧再看终端日志不要靠猜。5.3 Nagle 算法让报警迟了半秒刚才提到过NoDelay这里单独拎出来说。现场报警帧只有不到 30 字节开 Nagle 后TCP 栈会等待更多数据或者延迟 ACK 超时。听起来只有几十毫秒但在 9600 波特率的串口环境下再叠加上位机轮询周期现场感知就是“灯亮了但语音来得慢”。把NoDelay打开配合 15 秒心跳整个链路延迟稳定在 50ms 以内。5.4 掉线重连后终端还停留在旧状态TCP 断线重连成功只是通道恢复不代表终端里的灯光和语音状态正确。如果掉线前某个报警正在播重连后上电的终端已经回到默认状态而网关里还记着“这个报警不用重发”报警就丢了。解决方法是每次重连成功后把当前所有仍然有效的报警位重新发一遍。虽然可能会多触发一次语音但比漏报强得多。把“重连后同步快照”做成一个方法在Reconnect()的最后调用现场验证过效果很稳。5.5 报警恢复和触发用同一个命令字容易忘记清灯声光语音终端和普通网络摄像头不一样它需要显式下发“恢复”命令灯才会熄灭、语音才会停止。所以我在报警映射表里单独存了AlarmId和VoiceId触发时发Action1恢复时发Action0两个动作共用一份映射表。这样即使以后 PLC 程序改了报警位只需要改 Excel 映射表网关代码一行不动。这次改造从接到需求到上线前后不到一周。最花时间的不是 C# 代码而是把老上位机的 Modbus 地址表和终端的帧协议对齐。最后说一个很土但很管用的经验把报警寄存器的 bit 对应关系整理成 Excel字段写清楚 AlarmId、VoiceId、LightCtrl、Action调试时拿着表格逐条核对比在代码里翻注释快得多。做老系统改造真正需要的不是“改得动”而是“接得上”和“看得清”。
返回列表