尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

旧上位机不改?TCP透明代理实现报警语音联动改造实录

旧上位机不改?TCP透明代理实现报警语音联动改造实录 “旧上位机不肯改怎么办”这个问题最近半年我已经被问过不止一次。这次接手的项目特别典型现场跑着一套设备厂商用VS2019写的C#上位机负责跟PLC做TCP长连接通讯业务稳定但厂商合同早到期源码没完整移交。车间突然要求加装声光语音终端——产线报警时不仅要亮灯还得把故障内容直接喊出来。找原厂商谈报价高得离谱自己动上位机没有源码想编译都无从下手。最后走的路线是在上位机和原有目标服务器之间加一层原生TCP字节帧透明中转把报警数据“顺带”翻译给声光语音终端。说白了就是在别人家的水管子上接一个三通——水照常流用户无感知但咱们能从管路上读出里面的内容把需要的那部分引到新的设备上去。这篇改造实录适合正在做老系统集成、上位机对接、协议转换的工程师看尤其适合那些面临“老系统不能动、新设备必须上”死局的项目。整条路子走下来核心不是写代码而是如何在一个满是历史包袱的环境里用最少的侵入把事办成。1. 先摸清现场这潭水有多深需求与约束拆解1.1 为什么“改上位机”这条路一开始就被堵死刚接触项目时我也是先问了句“能不能改上位机”得到的答复基本可以分成三类。第一类没源码。厂商交付的是编译后的exe配置文件里能动的只有IP和端口程序逻辑全是黑盒。第二类有源码但不敢改。源码虽然是C#的但项目用了大量第三方控件、旧版依赖包而且开发环境是VS2019现场维护工程师还停留在VS2015一打开就是一堆兼容性报错。网上也总有人问“vs2019开发的C#上位机源码程序能用vs2015打开吗”实际结论就是大概率不行就算强开也会有项目文件格式、NuGet包版本、语言版本等等一堆坑。第三类更麻烦是“能改但承担不起后果”。上位机不只是通讯还连着数据库、报表、操作员权限、历史曲线。哪怕只改一个报警处理函数也要停机、重新编译、回归测试产线停一小时就是几十万损失。客户的原话是“这机器跑得好好的谁动谁负责。”所以项目从一开始就被锁死在一条红线上旧上位机的程序逻辑绝对不能动通讯关系也尽量不要破坏。1.2 需求清单里藏着哪些隐藏约束把客户需求一条条列出来后真正影响方案选型的不是“加一台声光语音终端”而是下面这些约束需求项表面说法实际约束不改上位机不让改程序不能动代码最好不要动配置加声光语音终端报警时亮灯语音播报终端只认自己的字节帧协议报警联动要有报警才触发必须从老链路里识别出报警状态不影响原通讯上位机不能断线代理转发延迟要低不能丢字节可回退出问题能还原原链路要能快速切回最好是无损介入还有一个容易漏掉的隐藏约束老上位机跟PLC/服务器之间用的是私有TCP协议既不是标准Modbus TCP也不是OPC UA。很多人一听“协议转换”就推荐Modbus网关但那个方案成立的前提是两端都支持Modbus这里显然不满足。所以不要一上来就套标准协议先把现场链路性质搞清楚再说。2. 方案选型为什么最终还是回到TCP透明代理2.1 我试过的几条路为什么都否了最开始我列过几个备选方案逐一推演后都排除了。第一条路是数据库共享。老上位机把报警记录写进SQL Server或MySQL我们去轮询数据库发现新报警再触发声光终端。这个方案实现简单但致命问题是“实时性”。上位机往往是先内部处理再周期落库报警延迟几秒甚至十几秒都可能产线上的声光报警要的就是即时性晚一秒都可能出大事。而且有些报警只在内存里闪烁压根不写库数据库方案根本覆盖不全。第二条路是屏幕识别/OCR。用摄像头或截图工具抓上位机报警弹窗再识别文字内容。听着很玄实际更坑不同分辨率、不同皮肤、弹窗遮挡、识别延迟样样都是雷。我见过有人用这个方案做按钮自动点击维护成本极高稍微换个界面主题就全废。第三条路是在PLC侧并联输出。如果报警判断逻辑在PLC里完全可以在PLC程序里加一段报警时额外驱动一个输出点或发一帧数据给声光终端。但现实是老上位机本身就承担了报警判断PLC只管设备动作报警逻辑在上位机里这条路也走不通。几条路走完剩下的方向就很明确了必须在上位机发出的TCP字节流上做文章。但“听”和“截”是两码事最终落地的是透明代理模式——既监听又转发。2.2 透明代理的本质当一个合法中间人透明代理的原理用一句话说就是让老上位机以为自己在跟原来的服务器通讯实际上中间多了一个搬运工搬的同时把货拆开看了一眼。改造后的链路是这样改造前上位机 (192.168.1.10:5000) → 原服务器 (192.168.1.20:502)改造后上位机 → 代理程序 (192.168.1.88:502) → 原服务器 (192.168.1.20:502)实现上有两种做法。第一种是改上位机配置把目标地址指向代理机这是最省事的绝大多数现场可以接受“不改程序但允许调配置文件”。第二种是连配置都不让动那就得在网络层做文章比如把代理机部署在原服务器同网段用策略路由做流量牵引或者直接把原服务器的IP迁移到代理机上让原服务换个端口。后一种更“无感”但涉及面大、风险高我这次跟客户协商后采用的是第一种。为什么这条路能行因为TCP是流协议上层怎么解析完全是应用的事。老上位机只负责“往这个IP和端口发TCP数据”它并不关心对端是不是原来的机器。中间代理只要做到两点一是字节一个不少地转发保证原通讯正常二是把报警相关的帧解析出来额外推给声光终端。这就是“原生TCP字节帧接入”的核心——我们处理的是最底层的字节流而不是某个现成的应用协议。2.3 实现语言选型为什么还是用C#底层抓包分析工具我用了Wireshark但正式落地程序最终选了C#。原因很实在现场是Windows工控机老上位机本身就是C#写的后续维护的人对.NET技术栈最熟。.NET的Socket、Async/Await、服务部署生态都很成熟整个代理程序能控制在几百行内。也考虑过Go、Python但Python在Windows上打包部署多一层麻烦Go虽然性能和跨平台好但现场极少有人会改Go代码。选型这事儿技术最好只是第二考量团队能不能维护才是第一位的。3. 吃透协议从字节流里把报警帧“捞”出来3.1 先当“哑巴代理”偷听一段时间别急着写解析很多人拿到项目第一反应就是写代码但我建议先做一个不带任何解析逻辑的“哑巴代理”——只做双向转发同时把每一段流经的字节完整记录到日志文件里。这个代理跑起来后老上位机是无感的原通讯也不受影响。我的做法是在日志里同时记录方向和原始十六进制数据跑一到两周。这段时间里正常生产报警该出就出日志会把所有关键报文留下来。等拿到足够多的样本再慢慢比对分析。磨刀不误砍柴工这步省不得后面所有解析规则都要靠这些真实样本来验证。3.2 识别帧结构的三板斧找头、找长、找校验拿到一堆十六进制日志后怎么从字节流里切出一帧一帧的数据我总结了三板斧。第一板斧是“找帧头”。绝大多数工业私有协议都会用一两个固定魔数作为帧起始标志比如AA 55、5A A5、55 AA、68 04等等。在Wireshark或十六进制编辑器里搜一下如果某个字节组合周期性出现那基本就是帧头。这一步能把“哪里有边界”感觉出来。第二板斧是“找长度字段”。帧头之后通常会有一个或两个字节表示长度。要特别注意的是长度字段的“单位”到底是整个帧的长度还是从某个偏移到校验位之间的长度还是仅仅数据区的长度。同一个协议不同厂家定义能差出三四个字节去。判断方法是拿两帧不同长度的报文比对看长度字段的变化跟整个帧长的变化是否一致。第三板斧是“找校验字段”。常见的有CRC16_MODBUS、CRC16_CCITT、累加和、异或校验。用已知帧反推CRC校验占了绝大多数。算完校验如果对得上帧边界基本就锁死了。举个例子我在现场抓到的报警相关帧长这样AA 55 01 04 00 01 00 00 0C 3BAA 55帧头01命令字状态查询04负载长度说明后面4个字节是数据00 01 00 00数据区其中报警代码是00 010C 3BCRC16校验当然每个现场的协议细节都不一样但“找头、找长、找校验”这套流程是通用的。3.3 确认报警字段靠对比实验别靠猜光会切帧还不够还得知道哪几个字节代表“报警发生”。我的做法是主动制造一次报警比如让现场人员在安全条件下触发一个已知故障然后把触发前后的报文拿来对比。哪几个字节变了基本就是报警相关字段。以这次项目为例正常状态下数据区是00 00报警后变成01 01。翻协议资料发现高字节是报警分区低字节是报警类型。后来我把报警时抓到的几帧都列出来整理成了一张映射表报警代码含义声光终端联动音源0x01011号区域温度过高1号语音0x01021号区域压力异常2号语音0x02012号区域设备故障3号语音这张表就是后续翻译逻辑的“业务字典”。如果协议文档缺失这个表就得靠长时间日志比对和现场确认来补全急不来。3.4 声光语音终端的协议对接点声光语音终端各家协议格式大同小异这次用的终端支持TCP Client/Server两种模式字节帧结构也是“帧头命令长度数据校验”。但有个特别容易被忽略的机制终端上电后必须先发注册帧收到应答后才会接收触发帧。如果漏了注册这一步后面触发帧发过去也是石沉大海。另外要区分终端的“触发”和“复位”。有些终端是电平触发报警帧来了开始响必须再发一个复位帧才停有些是脉冲触发触发一下响几秒自己停。现场要是不搞清楚要么报警一直响惹人烦要么响几秒就停根本起不到警示作用。这次项目最终是按“有报警发触发、报警消失发复位”的方式做的后面代码里会体现。4. 核心代码落地C#实现字节帧透明转换服务4.1 程序整体结构和职责划分程序没有用任何重型框架就是纯.NET控制台应用三段职责ProxyServer负责TCP监听与双向转发同时把字节流喂给解析器FrameParser负责缓冲、切帧把连续字节流还原成完整帧AlarmTranslator负责从老协议帧里提取报警代码翻译成声光终端指令这样拆分的好处是各管一摊转发逻辑不关心业务解析逻辑不关心网络后续如果换一种终端协议只需要改AlarmTranslator。4.2 TCP代理转发字节一个都不能少Core转发部分的核心代码长这样public class ProxyServer { private readonly string _targetHost; private readonly int _targetPort; private readonly int _listenPort; private readonly TcpListener _listener; private readonly FrameParser _parser; public ProxyServer(string targetHost, int targetPort, int listenPort) { _targetHost targetHost; _targetPort targetPort; _listenPort listenPort; _listener new TcpListener(IPAddress.Any, listenPort); _parser new FrameParser(); } public FrameParser Parser _parser; public void Start() { _listener.Start(); Console.WriteLine($[代理] 监听 {_listenPort}转发到 {_targetHost}:{_targetPort}); _ AcceptLoopAsync(); } private async Task AcceptLoopAsync() { while (true) { var client await _listener.AcceptTcpClientAsync(); _ HandleClientAsync(client); } } private async Task HandleClientAsync(TcpClient client) { using (client) using (var upstream new TcpClient()) { try { await upstream.ConnectAsync(_targetHost, _targetPort); var clientStream client.GetStream(); var upstreamStream upstream.GetStream(); var task1 RelayAsync(clientStream, upstreamStream, 上→下); var task2 RelayAsync(upstreamStream, clientStream, 下→上); await Task.WhenAny(task1, task2); } catch (Exception ex) { Console.WriteLine($[代理] 链路异常: {ex.Message}); } } } private async Task RelayAsync(NetworkStream from, NetworkStream to, string direction) { var buffer new byte[4096]; int read; while ((read await from.ReadAsync(buffer, 0, buffer.Length)) 0) { var chunk new byte[read]; Array.Copy(buffer, chunk, read); _parser.Feed(direction, chunk); await to.WriteAsync(chunk, 0, chunk.Length); await to.FlushAsync(); } } }这里有个非常关键的细节解析器只是“看一眼”数据原始字节必须原封不动转发出去绝不能因为“这帧我不认识”就丢掉。老上位机对超时和响应缺失很敏感一旦丢包原通讯就可能断。安全第一我是先转发、再解析顺序不能反。还有一点TCP是字节流没有消息边界所以ReadAsync读到的数据可能包含半帧、也可能包含好几帧。这就是行业里常说的“粘包/半包”必须在解析层处理不能寄希望于“每次读到的刚好是一整帧”。4.3 帧解析器半包粘包就这么消化public class FrameParser { private readonly Listbyte _cache new Listbyte(); private readonly ListActionbyte[] _handlers new ListActionbyte[](); public void Subscribe(Actionbyte[] handler) { _handlers.Add(handler); } public void Feed(string direction, byte[] data) { _cache.AddRange(data); while (true) { // 先找帧头 0xAA 0x55把脏数据丢掉 int headIndex FindHead(); if (headIndex 0) { _cache.Clear(); return; } if (headIndex 0) { _cache.RemoveRange(0, headIndex); } // 帧头2 命令1 长度2 数据 校验2最小帧长按 6 算 if (_cache.Count 6) return; int payloadLen (_cache[3] 8) | _cache[4]; int totalLen 2 1 2 payloadLen 2; // 半包等后续数据 if (_cache.Count totalLen) return; var frame _cache.Take(totalLen).ToArray(); _cache.RemoveRange(0, totalLen); foreach (var handler in _handlers) { handler(frame); } } } private int FindHead() { for (int i 0; i _cache.Count - 1; i) { if (_cache[i] 0xAA _cache[i 1] 0x55) return i; } return _cache.Count 0 _cache[_cache.Count - 1] 0xAA ? _cache.Count - 1 : -1; } }这里长度字段的计算方式不是死的。有的协议长度字段表示“从命令字开始到校验之前”有的表示“整个帧的总长”。我是按照现场协议里04表示后续数据长度来写的如果你的协议不一样把totalLen的公式改掉就行。注释里一定写清楚不然一个月后你自己都看不懂。4.4 报警翻译器把老协议翻译成终端指令解析出老协议帧后报警翻译器负责两件事提取报警代码查映射表然后构造声光终端的字节帧。public class AlarmTranslator { private readonly Dictionaryushort, byte _alarmMap new Dictionaryushort, byte { { 0x0101, 0x01 }, { 0x0102, 0x02 }, { 0x0201, 0x03 } }; public byte[] TryTranslate(byte[] frame) { // 假设报警代码在 frame[5]、frame[6]大端 if (frame.Length 8) return null; ushort alarmCode (ushort)((frame[5] 8) | frame[6]); if (!_alarmMap.TryGetValue(alarmCode, out byte voiceNo)) { Console.WriteLine($[未映射] 报警代码: 0x{alarmCode:X4}); return null; } // 构造声光终端触发帧AA 55 命令 长度 数据 校验 var body new Listbyte { 0xAA, 0x55, 0x02, 0x04, 0x00, voiceNo }; var crc Crc16Helper.Modbus(body.ToArray(), 0, body.Count); body.Add((byte)(crc 8)); body.Add((byte)(crc 0xFF)); return body.ToArray(); } }CRC16_MODBUS的实现是工控老熟人直接贴出来public static class Crc16Helper { public static ushort Modbus(byte[] data, int offset, int count) { ushort crc 0xFFFF; for (int i offset; i offset count; i) { crc ^ data[i]; for (int j 0; j 8; j) { if ((crc 0x0001) ! 0) { crc 1; crc ^ 0xA001; } else { crc 1; } } } return crc; } }这里有个容易翻车的地方不同终端的CRC字节序不一样有的是低字节在前、高字节在后。我这次调试时终端手册写的是高字节在前就按(byte)(crc 8)先发高字节如果你的终端反过来交换一下两个字节的顺序即可。4.5 声光终端的连接管理注册、保活、断线重连声光终端我采用的是单独维护一条TCP连接的方式不占用老上位机的链路。程序里封装了一个VoiceTerminalClientpublic class VoiceTerminalClient { private readonly string _host; private readonly int _port; private TcpClient _client; private readonly object _lock new object(); public VoiceTerminalClient(string host, int port) { _host host; _port port; } public async Task EnsureConnectedAsync() { if (_client ! null _client.Connected) return; _client new TcpClient(); await _client.ConnectAsync(_host, _port); // 上电/连上后发注册帧 var registerFrame BuildRegisterFrame(); await SendRawAsync(registerFrame); Console.WriteLine([终端] 已连接并发送注册帧); } private byte[] BuildRegisterFrame() { var body new Listbyte { 0xAA, 0x55, 0x01, 0x01, 0x00 }; var crc Crc16Helper.Modbus(body.ToArray(), 0, body.Count); body.Add((byte)(crc 8)); body.Add((byte)(crc 0xFF)); return body.ToArray(); } public async Task SendAlarmAsync(byte alarmNo) { var body new Listbyte { 0xAA, 0x55, 0x02, 0x04, 0x00, alarmNo }; var crc Crc16Helper.Modbus(body.ToArray(), 0, body.Count); body.Add((byte)(crc 8)); body.Add((byte)(crc 0xFF)); await SendRawAsync(body.ToArray()); } }主程序最后把各模块串起来再挂一个定时器做终端断线重连。我把定时器这部分留给你按现场节奏自己写一般5秒重试一次就够了太快可能把终端搞懵。4.6 部署上线Windows服务、防火墙、开机自启程序写完只是开始上线部署有一堆脏活。控制台程序不能直接关机我通常用NSSM把exe注册成Windows服务设置开机自启和崩溃自动重启。命令大概是这样nssm install AlarmProxy D:\AlarmProxy\TcpAlarmProxy.exe nssm set AlarmProxy AppDirectory D:\AlarmProxy nssm set AlarmProxy Start SERVICE_AUTO_START nssm start AlarmProxy防火墙默认会拦端口监听装完服务要先放行。Windows下用netshnetsh advfirewall firewall add rule nameAlarmProxy dirin actionallow protocolTCP localport502如果代理部署在Linux服务器上则是firewall-cmd那一套firewall-cmd --zonepublic --add-port502/tcp --permanent firewall-cmd --reload别小看这步很多“代理跑不起来”的问题最后查出来都是端口没放行而不是程序逻辑。5. 上线排雷端口、防火墙、粘包半包与终端失联5.1 端口被占用bind地址只能用一次第一次在客户机器上启动代理就报了一个经典的错Only one usage of each socket address。翻译成人话就是代理想监听的502端口已经被占了。原因是我在原服务器上部署代理而原服务器自己的服务也监听502两个进程抢同一个端口。解决方式有两条一是把代理机换成独立工控机监听502老上位机配置指向代理机二是如果只能在同机部署就改原服务器的监听端口再用iptables或netsh端口转发把502映射到新端口。建议直接在独立机器上跑代理省去一堆麻烦。排查端口占用用这两条命令netstat -ano | findstr :502 tasklist | findstr PID5.2 连接建立不起来三次握手与防火墙老上位机连接代理时如果链路一直不通我会用Wireshark抓包看TCP三次握手的状态。SYN包发出去了代理没回SYN-ACK基本就是防火墙拦了或代理程序没在监听。如果代理回了SYN-ACK但上位机迟迟不发ACK那就要检查上位机到代理之间有没有设备做了访问控制。TCP三次握手这种底层的排查其实不难难的是现场有各种安全软件。我遇到过杀毒软件把代理程序当成疑似木马直接断网的情况解决方式是加白名单。还有一次是客户网络的VLAN隔离代理机和上位机根本不在一个广播域中间交换机没放行端口。所以说现场问题要先看网络拓扑再看程序逻辑。5.3 报警不触发解析偏移、大小端、防抖都是坑系统跑起来后原通讯一直稳定但声光终端就是不响。排查日志发现解析器确实从老链路里收到了帧但提取出的报警代码对不上。原因是报警代码并不是我一开始以为的frame[5]和frame[6]而是由于长度字段定义不同实际报警数据在frame[6]和frame[7]。这类问题只能靠真实报警日志反复比对没有捷径。还有一次是大小端搞反了。数据区01 01按大端读是0x0101按小端读是0x0101碰巧一样所以没踩坑但另一个报警代码02 01按小端读就成了0x0102直接映射到了错误音源。后来我统一成“抓一帧已知报文手工算一遍”来验证解析逻辑。防抖也很重要。老上位机在报警瞬间可能会连续发好几帧相同状态的报文如果不做防抖声光终端会连续触发语音听起来就是“报警报警报警”不断重复。我的处理方式是记录上一个报警状态只有状态发生变化时才发给终端报警保持期间不再重复触发直到状态复位后再准备下一次触发。5.4 声光终端失联注册机制与心跳保活终端上线后能正常触发几次但运行几个小时后又“哑”了。排查下来是终端把长时间不活动的TCP连接断掉了而我们这边没有及时感知。后来在VoiceTerminalClient里加了30秒一次的业务心跳保活并且心跳失败就立刻重连重连成功后重新发注册帧。这类工业终端大多有失联检测机制一断就自动释放资源。保活不能只在网络层做最好发业务帧或者终端手册里约定的心跳指令这个要在协议文档里找。千万别上来就用TcpClient.Connected判断连接状态那玩意儿在连接断开后经常仍然返回true骗了很多年青工程师。5.5 问题排查速查表现象可能原因处理方式上位机连不上代理代理没启动、防火墙拦截、VLAN不通检查监听状态抓包看三次握手监听端口起不来端口被占、权限不足netstat查看占用进程换端口或改服务端口报警不触发报警字段偏移错、大小端错、映射表缺失打日志对比真实报警帧手工验证解析规则终端不播报没发注册帧、注册后没等应答、心跳断了用TCP调试助手直连终端验证查注册和保活逻辑语音重复播报没有状态防抖增加状态变化判断只在边沿触发原上位机闪断代理丢字节、超时未转发确保原始字节原样转发不做任何截断或缓存等待写在最后的小经验这套改造从拿到需求到稳定运行前后差不多三周真正写代码只用了两三天大头全在协议分析和现场排雷。我个人最大的体会是老系统改造做的不是加法是减法。我们从头到尾没改老上位机一行代码只是在上位机和目标服务器之间加了一个“看得懂字节流的三通”。一切以可回退为前提上线前保留原链路凌晨切换跑满48小时才算验收。最后再分享一个很实用的习惯给代理程序加“全量日志开关”平时只记录帧摘要调试时能切换成完整十六进制落盘。这次很多疑难杂症都是靠完整日志硬生生比对出来的。如果你也正在被“旧上位机不肯改”折磨不妨先搭个哑巴代理听听数据等听懂了再动手八成能少走一半弯路。
返回列表