旧上位机零改动,基于TCP协议解析与透明代理的声光终端接入方案
2026/9/14 6:20:36 网站建设 项目流程

每次遇到那种“运行了五六年、源码都残缺不全”的老上位机,我都条件反射地紧张。再加上生产现场突然提出“把报警状态接到新装的声光语音终端上”,而原厂又不肯改程序,这种夹在中间的滋味,干工控的都懂。前阵子我就完整经历了这么一回改造:旧上位机是VS2019时代写的C#程序,内部走的是自定义TCP字节帧协议,和产线检测设备保持长连接;新设备是一台支持TCP控制的声光语音终端,需要接收报警指令后自动闪灯和播报语音。最后我没有动上位机一行代码,只在它和目标设备之间加了一层透明转发和帧解析服务,就把声光语音终端接进去了。整个过程涉及协议拆解、TCP流处理、触发状态去重,还有一堆现场才踩得到的坑,我把实录整理出来,给正在被“老系统绑架”的朋友做个参考。

1. 先看现场:为什么“旧上位机”不能动

1.1 老系统的真实状态

这台旧上位机严格来说不是“不能改”,而是“不敢改”。它负责采集产线上检测仪器的数据,再通过字节帧协议发送控制命令,中间还夹杂着设备状态查询、心跳保持和参数下发。整套逻辑已经稳定运行多年,生产部门对它的信任度非常高,几乎到了“谁动谁背锅”的程度。更要命的是,这套上位机的源码虽然名义上在合作方手里,合作方却只提供一个编译好的可执行程序,配置文件里能改的只有IP和端口;就算把源码拿回来,VS2019写的项目能不能在旧环境顺利编译都很难说,更别提重新集成一个终端控制逻辑。

我在现场先做了一次盘查,发现几个关键事实:上位机通过TCP主动连接检测设备的4001端口,连接建立后每2秒发一次心跳帧;当产线某个工位报警时,上位机向下发送一条命令帧,同时设备侧也会上报一个状态位。整个过程都是纯字节流,没有使用Modbus、OPC这类标准协议,而是厂家自定义的私有格式。没有协议文档,没有注释,唯一的“文档”就是Wireshark里抓到的一大堆十六进制报文。

这种场景在老旧产线里太常见了。你面对的不是一个“技术问题”,而是一个“组织问题”:原厂不愿意配合,现场不能停机,代码不能随意动。这时候硬去推动上位机改造,往往会把一个两天能干完的活拖到两周。所以我的判断很明确:不在源头上做文章,用外部中间层解决问题。

1.2 声光语音终端到底要接什么

新装的声光语音终端不是普通继电器式声光报警灯,它本身带网口,内置TCP服务端,可以通过网络接收控制帧。终端支持多路语音,每一路对应不同的语音内容,比如“设备故障”“请到三号工位”“压力超限”等;灯光部分支持红、黄、绿三种颜色和常亮、闪烁两种模式。现场要求:当上位机下发某个设备故障命令,或设备上报故障状态时,终端能立刻播放对应语音并闪红灯;故障恢复后,终端自动复位,停止播报并恢复绿灯。

如果按传统思路,一般会考虑在上位机软件里加一个串口控制模块,或者接PLC的IO点。但上位机改不了,产线又没有空闲的PLC点位,所以唯一可行的入口就是TCP链路。既然上位机本身已经用TCP和检测设备通信,那么我完全可以在链路上“插一脚”,看见报警字节帧就触发终端控制,这就是整个项目的大方向。

2. 改造方案选型与整体架构

2.1 可行方案对比

动手之前,我把能想的方案都列了一遍,简单做了个对比。第一种是改上位机源码,加一段Socket代码直接控制声光语音终端,但这条路被源码权限和排期卡死。第二种是在现场交换机上配置端口镜像,用另外一台电脑抓包分析报警帧,再发指令给终端。这个方案完全不动原链路,听起来很安全,但抓包分析程序要自己维护,在Windows服务里做持续抓包、规则匹配、终端联动,稳定性很难保证;而且报警是实时事件,镜像抓包在流量大的时候容易出现延迟或丢包。

第三种是在上位机和检测设备之间插入一个“TCP中转服务”。上位机原来直连检测设备IP:4001,现在改为连接中转服务的IP:4001,中转服务再作为TCP客户端去连检测设备。这样上位机无感知,原通信链路继续工作;中间层在双向转发数据的同时,顺手解析字节帧,发现报警条件就向声光语音终端发送控制帧。这是一个经典的透明代理思路,在应用层做,完全可控。

最终我选择了第三种。原因很直接:它不影响原软件,只需要改上位机配置文件里的IP地址;所有解析和联动逻辑都集中在自己写的服务里,后面想加规则、加终端都很方便;出了问题也能快速回退,把IP改回原设备地址即可,风险最小。

2.2 透明桥接网关的架构设计

网关的物理部署用了一台工控机,双网口,一个网口接上位机所在的局域网,另一个网口接检测设备侧网络。实际上生产中不一定需要双网口,只要IP能互通,单网卡也能跑,但双网口更稳妥,可以避免广播风暴和路由混淆。

逻辑上分成三层:最底层是TCP通信层,负责监听上位机连接、建立到检测设备的连接、双向转发数据;中间层是字节帧解析层,把TCP流按帧切分,识别帧头、帧长、命令字和设备号;最上层是联动控制层,当解析到满足条件的命令或状态后,把报警信息映射成声光语音终端的控制帧并发送出去。

整体链路变成这样:

  • 上位机 ——TCP连接——> 网关监听端口4001
  • 网关 ——TCP连接——> 检测设备原IP:4001
  • 网关 ——TCP连接——> 声光语音终端:9100

所有发给检测设备的原始字节,网关原样转发;检测设备返回的字节,网关也原样回给上位机。中间层在转发的同时做“复制一份”并解析。这样做的好处是上位机以为自己在和设备直接通信,实际上它的“设备”已经被替换成了网关。

2.3 可能遇到的协议风险

做这个方案前,我心里很清楚有两个风险点。一是网关必须正确模拟原设备的行为,至少TCP握手和连接保持不能让上位机发现异常;如果检测设备偶尔会主动断开连接,网关还要处理重连逻辑,不能把“设备掉线”这个状态暴露给上位机。二是帧解析不能出错,一旦误判报警位,就可能造成声光语音终端不断误报,产线操作员会被烦死。所以协议拆解阶段必须做扎实,宁可多花一天抓包,也不要在没弄清全部字段前就写解析逻辑。

3. 先啃硬骨头:拆解老协议的字节帧

3.1 用Wireshark确认帧格式

协议拆解最可靠的手段还是抓包。我把没有改造之前的网络流量抓下来,连续跑了半小时,覆盖正常状态、心跳、单点报警、多点报警、故障恢复等场景,然后逐个分析TCP负载里的数据特征。

老协议是这样的字节帧结构:

  • 帧头:2字节,固定为AA 55
  • 帧长:2字节,小端序,表示“从命令字开始到校验位之前”的长度
  • 命令字:1字节,比如0x01是读状态,0x02是报警启动,0x03是报警停止,0x04是心跳
  • 数据区:N字节,具体内容取决于命令字
  • CRC校验:2字节,CRC16校验
  • 帧尾:2字节,固定为0D 0A

比如一帧报警启动的报文,十六进制可能是:

AA 55 08 00 02 03 01 00 00 00 0D 0A

拆开看就是:AA 55是帧头;08 00代表后面跟了8个字节;02是报警启动命令;数据区第一字节03代表工位号;然后01代表故障类型;后面三个字节是保留位;最后0D 0A是帧尾。当然不同厂家的协议细节可能有出入,但大体的结构思路是一样的。

3.2 找到值得解析的触发条件

抓包后我发现,报警状态其实有两种表达方式。第一种是上位机主动下发的“报警启动”命令,帧里包含工位号和故障类型;第二种是检测设备周期上报的状态帧,在状态位里有一个字节专门表示故障标志,比如bit0就是1号工位故障、bit1是2号工位故障。如果只解析下行命令,可能会漏掉一些由设备侧主动上报的报警;如果只解析上行状态,又可能把心跳里的历史故障状态当成新报警。

所以我决定双向都解析。网关在收到下行数据时,检查命令字是不是0x02或0x03,如果是,就根据工位号和故障类型触发或复位终端;在收到上行数据时,检查状态帧里的故障标志位,并做“上升沿去重”,也就是只有故障标志从0变成1时才触发一次,从1变成0时复位一次,避免每个心跳周期重复播报。

这一步是整个项目最关键的地方。很多类似改造失败,都是因为没搞清“什么是真正的报警事件”,把持续存在的状态当成瞬间事件,最后终端一直响,现场没法干活。

3.3 声光语音终端的控制协议设计

新终端的控制协议其实很灵活,但厂家只给了一个简单的指令说明:终端作为TCP服务端监听9100端口,接收固定格式的指令帧。指令帧的格式是我这边和厂家协商后定的,为了和旧协议区分,我采用了另一套帧头:

  • 帧头:0x7E
  • 控制字:1字节,0x01播报语音,0x02停止播报,0x03灯光控制,0x04复位
  • 语音编号:1字节,对应预置在终端里的语音文件
  • 灯光颜色:1字节,0x01红,0x02黄,0x03绿
  • 灯光模式:1字节,0x01常亮,0x02闪烁
  • 校验:1字节,前面所有字节累加和取低8位
  • 帧尾:0xFF

例如想触发“1号工位设备故障”语音,同时亮红灯闪烁,指令帧可以写成:

7E 01 05 01 02 27 FF

其中05是语音编号,01是红色,02是闪烁模式。整个控制逻辑其实不算复杂,但因为对接的是第三方终端,指令码定义必须提前和厂家确认,不然调试阶段会来回折腾。

4. 网关代码的核心实现

4.1 TCP透明转发的骨架

网关我选择用C#写了一个Windows服务,因为现场环境是Windows Server,而且老上位机也是C#体系,后面维护的人更熟悉。核心逻辑不复杂,难的是要把“转发”和“解析”正确串起来。

透明转发层主要包含两个TcpListener/TcpClient对象。网关监听在上位机要连接的端口上,当上位机连上来后,网关立即去连接检测设备。双向数据用两个独立的异步循环转发:

public class RelayGateway { private TcpListener _listener; private TcpClient _upstream; private TcpClient _downstream; private FrameParser _parser; public async Task StartAsync() { _listener = new TcpListener(IPAddress.Any, Config.ListenPort); _listener.Start(); while (true) { _upstream = await _listener.AcceptTcpClientAsync(); _downstream = new TcpClient(); await _downstream.ConnectAsync(Config.TargetIP, Config.TargetPort); _ = Task.Run(() => RelayAsync(_upstream, _downstream, Direction.Down)); _ = Task.Run(() => RelayAsync(_downstream, _upstream, Direction.Up)); } } private async Task RelayAsync(TcpClient source, TcpClient target, Direction dir) { var buffer = new byte[4096]; var stream = source.GetStream(); while (true) { int read = await stream.ReadAsync(buffer, 0, buffer.Length); if (read <= 0) break; await target.GetStream().WriteAsync(buffer, 0, read); _parser.Feed(buffer, read, dir); } } }

这里要注意,_parser.Feed会先复制一份数据做解析,但绝对不影响原始数据的转发。转发要的是“透传”,解析要的是“旁路”,二者不能互相阻塞,否则可能出现上位机和设备通信延迟增大的问题。

4.2 字节帧解析与粘包半包处理

TCP是流协议,不是消息协议。上位机可能一次发送多个帧,也可能一帧分成好几段到来。所以解析器必须维护一个缓存队列,每次收到数据先追加到缓存,然后循环尝试从缓存里切出完整的一帧。

我的解析方法大概是这样的:

public void Feed(byte[] data, int length, Direction dir) { _cache.AddRange(data.Take(length)); while (_cache.Count >= 8) { if (_cache[0] != 0xAA || _cache[1] != 0x55) { _cache.RemoveAt(0); continue; } int frameLen = BitConverter.ToUInt16(_cache.ToArray(), 2); int total = 4 + frameLen + 2; // 帧头+长度字段+数据+校验+帧尾 if (_cache.Count < total) break; var frame = _cache.GetRange(0, total).ToArray(); if (VerifyCrc(frame)) { OnFrameReceived(frame, dir); } _cache.RemoveRange(0, total); } }

核心思想就是先找帧头,再读长度字段,如果缓存不够一帧长度就继续等下一段数据;校验不通过就丢弃这个可疑起点,继续往后找。这是处理粘包半包最实用的套路,比依赖TCP接收次数可靠得多。

4.3 报警联动与状态去重

解析出帧以后,就进入联动控制模块。我定义了一个事件,当收到报警启动帧时,根据工位号和故障类型查配置文件里的映射表,得到语音编号和灯光颜色;然后通过一个独立的TCP连接发送给声光语音终端。

为了防止持续状态帧导致终端重复播报,我加了一个状态表:

private Dictionary<int, bool> _alarmStates = new Dictionary<int, bool>(); private void OnFrameReceived(byte[] frame, Direction dir) { if (TryParseAlarmInfo(frame, dir, out var point, out var isAlarm)) { bool oldState = _alarmStates.TryGetValue(point, out var old) && old; if (isAlarm && !oldState) { _ = SoundLightController.TriggerAlarmAsync(point, GetVoiceId(point)); } else if (!isAlarm && oldState) { _ = SoundLightController.ResetAlarmAsync(point); } _alarmStates[point] = isAlarm; } }

上升沿触发的逻辑能保证每条报警只触发一次,直到故障恢复后再报警时再次触发。这个细节不写清楚,上线后大概率会被现场投诉“终端响个不停”。

4.4 配置与部署

整个服务全部参数都放在App.config里,包括监听端口、检测设备地址、声光语音终端地址、触发命令字、状态帧设备号偏移等。部署的时候只需要把上位机配置文件里原本指向检测设备的IP,改成网关所在工控机的IP,然后启动服务,其余不用动。

我还写了一个简单的自检功能:网关启动时主动向声光语音终端发送一条测试帧,如果终端返回成功,就在日志里记录“终端连接正常”;否则输出告警日志。这样现场维护人员不用懂代码,也能快速判断是不是终端网络断了。

5. 现场调试实录与常见坑

5.1 粘包半包问题比预想严重

刚开始联调时,我把解析器接到真实流量里,发现经常有报警命令解析不出来。排查下来不是协议不对,而是TCP分包太乱:上位机在一个TCP包内连续发了两帧心跳和一帧报警,而解析器的缓存处理不够健壮,导致第二帧找不到帧头。后来我调整了循环逻辑,并在缓存移除时使用精确的帧长度,才算稳定下来。

建议大家在调试阶段做一个“帧记录”功能,把每次解析出来的帧和原始hex都打印到日志里。不要在凭感觉改代码,先看日志里帧头位置是否连续,基本一眼就能定位是分包问题还是协议理解错了。

5.2 触发条件误判和重复播报

这个坑最严重。第一次上线时,我直接按帧里的报警位状态来触发,结果因为设备每2秒上报一次状态帧,报警位在0和1之间跳变了几次,终端也跟着反复响。后来我仔细看了抓包,发现状态帧里的故障标志在报警期间其实是常1,不是跳变;问题出在我把“报警启动命令”和“状态上报”两个来源都接进了同一个状态表,命令和状态产生了重复逻辑。

解决办法是明确触发来源:报警启动命令负责“立即触发”,状态上报中的故障位负责“维持确认”,两个来源做或运算,但触发动作只在上升沿发生。这样终端只在真正报警时响,不会因为状态帧重复上报而反复触发。

5.3 上位机重连与设备掉线

调试中还发现一个问题:检测设备偶尔会因为网络抖动断开连接,网关直接把这个断开状态透传给了上位机,上位机判断设备离线后开始弹窗报警。放在以前,设备恢复后上位机也会重连,但生产现场不希望看到这种中间瞬断。

我的处理是给网关增加“保持连接”逻辑:当检测设备连接断开时,网关不立即断开上位机,而是尝试在后台重新连接检测设备,期间继续保留上位机连接,并把接收到的上位机数据暂存在队列里,等下游恢复后按顺序补发。这样上位机几乎感知不到副链路断过。这也是中间层最大的价值所在——可以自己做故障隔离,不让底层网络问题直接冲击上位机。

5.4 排查技巧速查表

现象可能原因排查思路
上位机连接不上网关端口未监听/防火墙拦截在网关机器上netstat -ano看端口,临时关防火墙测试
数据能转发但报警不触发帧头/命令字配置不对打开帧记录日志,检查解析出来的命令码
终端不停播报缺少状态去重检查状态表是否在上升沿才触发
终端偶尔播报但滞后TCP连接每次都重新建立改成连接池,保持长连接
网关进程崩溃异步异常未捕获用全局异常处理器把异常写入日志,并自动重启服务
上位机重启后无法连旧设备网关未自动连接下游在上位机连接事件里主动Connect下游设备

这些坑都不算高深,但每一个都能让一次看似简单的改造变得很难受。尤其是状态去重和重连隔离,属于“不遇到根本想不到”的问题。

6. 给同样处境的朋友几个建议

最后说几句实战体会。如果你也遇到旧上位机不肯改、但又要接新设备的项目,我建议第一步永远不是写代码,而是抓包和画时序图。把原始报文弄清楚,把触发条件定义清楚,再考虑中间层的实现,这样能少走很多弯路。

还有一个小技巧:中间层服务一定要设计成可以随时“旁路”。也就是说,只要把上位机的目标IP改回检测设备原地址,整个系统就应该立即恢复原始通信状态。我在网关里加了一个总开关,当开关关闭时,网关只做透明转发,完全不做解析和联动。这个开关在出问题时救了我好几次,现场只需要改一行配置就能回退,不至于因为一个通知功能让整条产线趴窝。

如果你需要在通知基础上再加更多设备,比如把报警同步到生产看板、钉钉机器人或者数据库,这套框架也能直接扩展。因为核心的帧解析模块已经拆出来了,后面新增触发源或通知通道,都是往配置表和事件处理里加内容的事情。旧系统的改造不一定非要推倒重来,很多时候,在边界上给它加一个聪明的“中间人”,反而更稳。

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

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

立即咨询