旧上位机零改动,基于TCP协议解析与透明代理的声光终端接入方案

发布时间:2026/9/14 6:20:36
旧上位机零改动,基于TCP协议解析与透明代理的声光终端接入方案
每次遇到那种“运行了五六年、源码都残缺不全”的老上位机我都条件反射地紧张。再加上生产现场突然提出“把报警状态接到新装的声光语音终端上”而原厂又不肯改程序这种夹在中间的滋味干工控的都懂。前阵子我就完整经历了这么一回改造旧上位机是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 Dictionaryint, bool _alarmStates new Dictionaryint, 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改回检测设备原地址整个系统就应该立即恢复原始通信状态。我在网关里加了一个总开关当开关关闭时网关只做透明转发完全不做解析和联动。这个开关在出问题时救了我好几次现场只需要改一行配置就能回退不至于因为一个通知功能让整条产线趴窝。如果你需要在通知基础上再加更多设备比如把报警同步到生产看板、钉钉机器人或者数据库这套框架也能直接扩展。因为核心的帧解析模块已经拆出来了后面新增触发源或通知通道都是往配置表和事件处理里加内容的事情。旧系统的改造不一定非要推倒重来很多时候在边界上给它加一个聪明的“中间人”反而更稳。