基于Modbus RTU监听的老系统旁路网关实现声光报警联动
2026/9/15 8:41:07 网站建设 项目流程

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 0x5A
21版本0x01
31命令字0x02 控制,0x03 心跳
41终端地址0x00 默认设备
52负载长度网络字节序,大端
7N负载由命令字决定
7+N2CRC16-Modbus低字节在前,高字节在后
9+N2帧尾0x0D 0x0A

这里“原生”的意思是,网关不借助任何 SDK 或中间件,直接用 C# 的TcpClient把这段字节流发过去。TCP 是流协议,没有消息边界,所以帧头、长度、CRC 三样必须齐全。

3.2 控制报警的负载格式

报警控制命令,我设计成 10 字节的负载,固定长度,解析简单:

字段长度说明
AlarmId2报警编号,1~65535
AlarmLevel1报警等级,1 最低
Action11 表示触发,0 表示恢复
LightCtrl1bit0 红灯,bit1 黄灯,bit2 绿灯,bit3 旋转灯
VoiceId2语音文件编号
Volume1音量 0~100
DurationSec2持续秒数,0 表示一直保持

比如“1号皮带过流”对应的 AlarmId=1,VoiceId=100,触发红灯和语音播报 30 秒,整段报文长这样:

A5 5A 01 02 00 00 0A 00 01 01 01 01 00 64 64 00 1E CRC_L CRC_H 0D 0A

Payload 拆开看就是:00 01=AlarmId 1,01=等级,01=触发,01=红灯,00 64=语音 100,64=音量 100,00 1E=30 秒。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 报警恢复和触发用同一个命令字,容易忘记清灯

声光语音终端和普通网络摄像头不一样,它需要显式下发“恢复”命令,灯才会熄灭、语音才会停止。所以我在报警映射表里单独存了AlarmIdVoiceId,触发时发Action=1,恢复时发Action=0,两个动作共用一份映射表。这样即使以后 PLC 程序改了报警位,只需要改 Excel 映射表,网关代码一行不动。

这次改造从接到需求到上线,前后不到一周。最花时间的不是 C# 代码,而是把老上位机的 Modbus 地址表和终端的帧协议对齐。最后说一个很土但很管用的经验:把报警寄存器的 bit 对应关系整理成 Excel,字段写清楚 AlarmId、VoiceId、LightCtrl、Action,调试时拿着表格逐条核对,比在代码里翻注释快得多。做老系统改造,真正需要的不是“改得动”,而是“接得上”和“看得清”。

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

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

立即咨询