旧上位机不改?TCP透明代理实现报警语音联动改造实录
2026/9/15 7:30:31 网站建设 项目流程

“旧上位机不肯改怎么办?”这个问题,最近半年我已经被问过不止一次。这次接手的项目特别典型:现场跑着一套设备厂商用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 555A A555 AA68 04等等。在Wireshark或十六进制编辑器里搜一下,如果某个字节组合周期性出现,那基本就是帧头。这一步能把“哪里有边界”感觉出来。

第二板斧是“找长度字段”。帧头之后,通常会有一个或两个字节表示长度。要特别注意的是,长度字段的“单位”到底是整个帧的长度,还是从某个偏移到校验位之间的长度,还是仅仅数据区的长度。同一个协议,不同厂家定义能差出三四个字节去。判断方法是拿两帧不同长度的报文比对,看长度字段的变化跟整个帧长的变化是否一致。

第三板斧是“找校验字段”。常见的有CRC16_MODBUS、CRC16_CCITT、累加和、异或校验。用已知帧反推,CRC校验占了绝大多数。算完校验如果对得上,帧边界基本就锁死了。

举个例子,我在现场抓到的报警相关帧长这样:

AA 55 01 04 00 01 00 00 0C 3B
  • AA 55:帧头
  • 01:命令字(状态查询)
  • 04:负载长度,说明后面4个字节是数据
  • 00 01 00 00:数据区,其中报警代码是00 01
  • 0C 3B:CRC16校验

当然,每个现场的协议细节都不一样,但“找头、找长、找校验”这套流程是通用的。

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 List<byte> _cache = new List<byte>(); private readonly List<Action<byte[]>> _handlers = new List<Action<byte[]>>(); public void Subscribe(Action<byte[]> 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 Dictionary<ushort, byte> _alarmMap = new Dictionary<ushort, 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 List<byte> { 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连接的方式,不占用老上位机的链路。程序里封装了一个VoiceTerminalClient

public 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 List<byte> { 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 List<byte> { 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下用netsh:

netsh advfirewall firewall add rule name="AlarmProxy" dir=in action=allow protocol=TCP localport=502

如果代理部署在Linux服务器上,则是firewall-cmd那一套:

firewall-cmd --zone=public --add-port=502/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 <PID>

5.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小时才算验收。

最后再分享一个很实用的习惯:给代理程序加“全量日志开关”,平时只记录帧摘要,调试时能切换成完整十六进制落盘。这次很多疑难杂症,都是靠完整日志硬生生比对出来的。如果你也正在被“旧上位机不肯改”折磨,不妨先搭个哑巴代理听听数据,等听懂了再动手,八成能少走一半弯路。

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

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

立即咨询