旧上位机不改动,C#网关实现声光语音终端TCP字节帧接入
车间那台旧上位机已经稳定跑了快十年突然要接一台声光语音终端厂家给出的对接方式是“原生 TCP 字节帧”。旧系统是早年用组态软件做的代码早没源码了别说加协议驱动连换个按钮都得找原厂加钱。我当时的想法很简单这个终端就是一台带网络口的报警盒子支持 TCP 长连接自己定义一套二进制帧协议我只要把旧系统里需要的报警数据取出来再翻译成它认得的字节帧发过去就行。这篇文章就把我这次改造的完整过程整理出来包括怎么绕过旧上位机拿数据、原生 TCP 字节帧如何拆包组包、C# 网关程序怎么写、现场又踩了哪些坑给准备做同类对接的朋友一个参考。不管你做的是 SCADA 还是普通上位机只要碰到这种“老系统不能动、新设备必须接”的场景思路都是相通的别跟黑盒死磕入口在它的数据出口处搭一座字节翻译桥。1. 为什么旧上位机“改不动”以及声光语音终端到底是个什么东西1.1 先看看这次改造的对象声光语音终端这类设备在工厂里一般用来做三级报警设备故障亮红灯、关键参数越限亮黄灯、正常状态亮绿灯同时支持蜂鸣器鸣叫和语音播报指定内容。它本质上是一个工业 IO 控制器加音频功放的组合体供电之后只要上位机通过网络给它下发指令它就能按指令组合声、光、语音的输出状态。这类终端一般提供的接口有几种要么是 Modbus TCP 这种标准协议要么是厂家自定义的字节帧协议还有少部分支持 HTTP 接口。我们这次遇到的是最常见的自定义字节帧帧头固定、命令字、数据长度、数据区再加 CRC 校验和帧尾。这种设计在工控私有协议里非常普遍别看它没有 Modbus 那么标准但结构简单、处理开销小只要把帧格式解析对后面的控制逻辑就顺理成章了。1.2 改旧上位机的三种阻力旧上位机“不肯改”通常不是技术问题而是现实阻力。我在现场碰到的主要是三种情况一是源码缺失。很多旧系统是第三方公司早年定制的项目验收之后源码根本没有交付或者交付了但是开发环境早已不可用。你连代码都拿不到谈什么集成。二是验证成本太高。就算有源码改动之后需要重新做全流程测试涉及产线联调、数据准确性验证、停机窗口牵一发而动全身。为了接一个终端去动一个稳定运行的旧系统项目经理第一个不同意。三是协议不支持。旧上位机当时的通信对象是 PLC 和采集卡根本没有给外部设备提供 TCP 转发功能。它要么只输出到本地数据库要么只往串口写数据网络能力几乎是空白。在这种局面下最合理的路线不是在旧系统内部做文章而是在旧系统旁边加一个“翻译官”也就是我们说的协议转换网关一把抓走旧系统已有的数据一把把数据翻译成终端需要的 TCP 字节帧。旧系统没有任何感知终端也完全不用改。2. 不改旧机也能接入中间代理“协议翻译器”的整体设计2.1 方案选型为什么绕开上位机走“数据桥”明确了“不动旧上位机”的原则后剩下的关键问题就是数据从哪里拿怎么拿。我先对比了几种常见方案。最直接的是在旧上位机里装 OPC UA 或 OPC DA 服务端然后网关上用 OPC 客户端去订阅。这个方案看着专业但老组态软件要么版本太低不支持 OPC UA要么 DCOM 配置能把人折腾到崩溃而且还要动上位机的安全策略和账户权限风险不小。另一种是串口转发。如果旧上位机本身在往串口发送调试信息或向下位机写指令我可以用一个串口服务器把它发的原始字节流镜像出来再从中间截取有效字段。这个方案不改上位机但问题在于串口数据格式不可控解析起来容易跟报文时序耦合维护成本偏高。我最终采用的是数据库中间表方案旧上位机原本就支持把报警记录和设备状态写入本地数据库我把网关程序部署在同一台工控机上或者能访问该数据库的机器上程序定时轮询报警表的新增记录发现新报警就翻译成终端的 TCP 字节帧发出去。这样旧系统完全不知道新设备的存在操作量最小回滚也容易只要停掉网关进程就恢复原状。这个思路虽然朴素但很实用。你可以理解为旧上位机是一个只会说方言的老员工终端是一个只听得懂普通话的新同事我做的网关就是坐在两者之间的翻译员老员工把要说的话写进便签数据库表翻译员定时看便签再用普通话念给新同事听。2.2 数据从哪儿来先解决旧上位机的数据出口数据库中间表方案听起来简单现场落地时要先确认三件事一是旧上位机是否有写库能力。绝大多数组态软件都有数据归档或者 SQL 功能比如组态王可以通过 ODBC 写外部数据库WinCC 可以用脚本往 SQL Server 插入记录。确认有写库能力之后再确认它写的是哪个表、字段含义是什么、写入频率如何。二是报警数据的表结构。旧系统里往往有一张报警记录表字段通常包括报警时间、设备编号、报警类型、报警级别、报警内容描述。我需要在边上新增一张“待下发表”让旧上位机通过视图或者直接脚本将需要上报的报警记录“推送”出来。如果旧上位机连自定义脚本都跑不了还有一个更笨但有效的办法直接监控它写入历史报警表的新记录把这条新记录作为触发源。也就是说不需要旧系统感知到终端的存在只需要它按原有方式记录报警网关自己去发现。三是数据同步的延迟。数据库轮询天然存在轮询周期通常是 200ms 到 1s 不等。对于声光语音终端的报警场景来说1 秒以内的延迟完全可接受。但如果是对时间敏感的设备动作控制数据库中间表的方案就不够看了那时候得考虑从 PLC 侧直接取数或者用串口镜像实时截获。2.3 网关程序的运行结构与线程模型网关我把它叫做“数据桥”程序整体上分成三个模块数据采集模块、协议转换模块、终端通信模块。三者之间用队列解耦避免数据采集阻塞终端收发。数据采集模块负责定时读取数据库的新增记录把报警原始信息封装成一个内部对象扔进阻塞队列。协议转换模块从队列里取出内部对象查表翻译成终端的命令字和参数按帧格式组装成字节数组。终端通信模块维护与声光语音终端的 TCP 长连接把字节数组发出去同时接收终端返回的应答帧做超时重发和状态确认。这个结构最关键的一点是队列解耦。如果数据采集慢不会拖慢帧发送如果网络抖动导致 TCP 重连采集模块依然在正常攒数据不会丢报警。我用的是简单的ConcurrentQueueT加后台任务的方式没有引入重量级消息队列毕竟一个网关程序没必要那么复杂够用就行。3. 字节帧协议拆包组包从帧格式到粘包半包处理3.1 终端私有协议的帧格式怎么定设计协议前我拿到了终端厂家的通讯协议文档里面规定的帧格式大致如下字段长度说明帧头2字节固定 0xAA 0x55命令字1字节0x01 触发报警 / 0x02 解除报警 / 0x03 心跳 / 0x04 查询状态数据长度2字节小端序表示数据体的字节数数据体N字节具体参数CRC162字节从命令字到数据体结束的 CRC16 校验值帧尾2字节固定 0x0D 0x0A数据体的定义根据命令字不同而不同。以触发报警为例数据体是 8 个字节偏移字段说明0报警通道号1字节对应终端上的1~8号报警灯1灯光颜色1字节0红1黄2绿3蓝2蜂鸣器模式1字节0关闭1连续2间断3语音播报ID2字节小端序对应终端内置语音编号5持续时间2字节小端序单位秒0表示一直保持7预留1字节拿到之后我先拿厂家提供的调试工具把各条命令跑了一遍确认了字节序是小端、CRC算法是标准 CRC16/Modbus多项式 0x8005初始值 0xFFFF输入输出都异或。这一步非常重要不同厂家的 CRC 细节差别很大查表法也好、逐位法也好算出来对不上就是白搭。3.2 拆包状态机的实现要点TCP 是流式协议没有消息边界所以收数据时会遇到两个经典问题粘包和半包。粘包就是一次 recv 到了好几帧数据半包就是一帧数据还没收完。解决办法不是依赖 recv 的时序而是维护一个“接收缓冲区 状态查找”的拆包逻辑。我的经验是先把收到的字节追加到内存缓冲然后循环查找帧头 0xAA 0x55找到之后判断当前缓冲长度是否够一个完整帧帧头 2 命令字 1 长度 2 数据长度 N CRC 2 帧尾 2如果不够就退出等待更多数据如果够就解析长度和 CRC校验通过就切走一帧继续循环处理下一帧。private byte[] _recvBuffer new byte[4096]; private int _recvLen 0; private void ParseReceiveBuffer() { int offset 0; while (offset 9 _recvLen) { if (_recvBuffer[offset] 0xAA _recvBuffer[offset 1] 0x55) { int dataLen _recvBuffer[offset 3] | (_recvBuffer[offset 4] 8); int frameLen 2 1 2 dataLen 2 2; if (offset frameLen _recvLen) { break; } byte[] frame new byte[frameLen]; Array.Copy(_recvBuffer, offset, frame, 0, frameLen); if (VerifyCrc16(frame, 2, 2 1 2 dataLen - 2)) { HandleFrame(frame); offset frameLen; } else { offset 2; } } else { offset; } } if (offset 0) { int remain _recvLen - offset; Array.Copy(_recvBuffer, offset, _recvBuffer, 0, remain); _recvLen remain; } }这套循环处理的核心技巧是校验失败时只跳过帧头两字节继续找不要丢弃整个缓冲否则碰到干扰字节容易把后续正常帧也丢掉。3.3 组包与CRC校验组包就是按照协议把命令参数填进去然后计算 CRC。CRC 计算用查表法性能好代码也简单。下面是 C# 里标准的 CRC16/Modbus 实现private static ushort Crc16Modbus(byte[] buffer, int start, int length) { ushort crc 0xFFFF; for (int i start; i start length; i) { crc ^ buffer[i]; for (int j 0; j 8; j) { if ((crc 0x0001) ! 0) { crc (ushort)((crc 1) ^ 0xA001); } else { crc 1; } } } return crc; }组帧函数最终返回一个完整的字节数组发送前可以打印十六进制日志方便排查private byte[] BuildTriggerFrame(byte channel, byte color, byte soundMode, ushort voiceId, ushort duration) { byte[] payload new byte[8]; payload[0] channel; payload[1] color; payload[2] soundMode; payload[3] (byte)(voiceId 0xFF); payload[4] (byte)(voiceId 8); payload[5] (byte)(duration 0xFF); payload[6] (byte)(duration 8); payload[7] 0; byte[] frame new byte[2 1 2 payload.Length 2 2]; frame[0] 0xAA; frame[1] 0x55; frame[2] 0x01; frame[3] (byte)(payload.Length 0xFF); frame[4] (byte)(payload.Length 8); Array.Copy(payload, 0, frame, 5, payload.Length); ushort crc Crc16Modbus(frame, 2, 1 2 payload.Length); frame[5 payload.Length] (byte)(crc 0xFF); frame[6 payload.Length] (byte)(crc 8); frame[7 payload.Length] 0x0D; frame[8 payload.Length] 0x0A; return frame; }这里一个容易踩坑的点是 CRC 计算范围。有的厂家计算范围是从命令字开始到数据体结束有的包含帧头还有的包含帧尾协议文档如果不写清楚只能逐个试。我现场就是先用终端自带的调试软件抓了一帧正常报文然后在网关里把同样内容算了一遍 CRC 才确认计算范围。4. 实操记录C#实现一个“数据桥”网关4.1 从旧上位机拿报警数据以SQLite轮询为例现场旧上位机用的数据库是 SQL Server Express但为了演示方便我把数据采集这部分抽象成了统一的轮询逻辑实际用 SQLite 也可以跑同样的流程Section 里的写法完全一致。简化后的报警表结构如下CREATE TABLE PendingAlarm ( Id INTEGER PRIMARY KEY AUTOINCREMENT, DeviceNo TEXT, AlarmType INTEGER, Level INTEGER, VoiceId INTEGER, AlarmTime TEXT );网关里用一个后台定时任务每 500ms 查一次这张表取出未处理记录放入发送队列然后标记为已处理。之所以要“标记已处理”是为了防止重启网关后重复报警。我的做法是在原表里加一个Processed字段查询时只取Processed0的记录成功入队后直接更新为 1。private async Task PollAlarmLoopAsync(CancellationToken ct) { using var conn new SqliteConnection(_connString); while (!ct.IsCancellationRequested) { try { var alarms await QueryPendingAlarmsAsync(conn); foreach (var a in alarms) { _queue.Enqueue(a); await MarkAlarmProcessedAsync(conn, a.Id); } } catch (Exception ex) { _logger.LogError(ex, poll alarm failed); } await Task.Delay(500, ct); } }注意这里我用的是await Task.Delay(500, ct)而不用Thread.Sleep因为网关程序要保持异步响应不能把线程池线程占死。4.2 建立TCP连接与断线重连终端是 TCP 服务端监听 8000 端口网关作为 TCP 客户端主动连接。这种模式下终端那边一般配置固定 IP 和端口网关需要实现断线重连。我先用最简单的指数退避重连逻辑失败后等 1 秒重试连续失败则拉长等待时间最大 30 秒一旦连接成功立即把等待时间重置回 1 秒。private async Task ConnectLoopAsync(CancellationToken ct) { int retryDelayMs 1000; while (!ct.IsCancellationRequested) { try { using var tcp new TcpClient(); await tcp.ConnectAsync(_terminalIp, _terminalPort); retryDelayMs 1000; await HandleConnectionAsync(tcp, ct); } catch (Exception ex) { _logger.LogWarning(connect failed: {Message}, ex.Message); } await Task.Delay(retryDelayMs, ct); if (retryDelayMs 30000) { retryDelayMs Math.Min(30000, retryDelayMs * 2); } } }重连逻辑里有个细节HandleConnectionAsync要等到连接断开才返回这样ConnectLoopAsync才能进入重试周期。如果 TCP 层出现半开连接比如网线断了但操作系统还没感知需要在HandleConnectionAsync里加心跳超时检测下面会讲到。4.3 心跳、应答与超时处理声光语音终端一般都要求在连接建立后周期性发送心跳帧否则它会在设定时间内主动断开。根据厂家文档心跳周期是 5 秒超过 15 秒没收到数据就断开。我的做法是在HandleConnectionAsync里启动两个独立任务一个发送任务负责心跳和报警命令一个接收任务负责解析应答帧。发送心跳使用Timer即可注意Timer的回调里不要做耗时操作直接把帧写入网络流就行。接收任务则持续读流把读到的字节喂给拆包逻辑。为了检测半开连接我设置了一个“最后收到数据时间”的变量如果超过 20 秒没有收到任何数据主动把TcpClient关掉触发外层重连。private async Task HandleConnectionAsync(TcpClient tcp, CancellationToken ct) { using var stream tcp.GetStream(); using var cts CancellationTokenSource.CreateLinkedTokenSource(ct); var sendTask SendLoopAsync(stream, cts.Token); var recvTask ReceiveLoopAsync(tcp, stream, cts.Token); await Task.WhenAny(sendTask, recvTask); cts.Cancel(); }SendLoopAsync里除了定时发心跳还负责从队列里取报警帧发送。发送前检查队列里是否有在等待确认的报警帧如果没有就直接发如果有则等待终端应答后再发下一条。这保证了报警的时序性不会因为多条报警并发导致终端响应混乱。5. 现场调试与常见问题排查实录5.1 连接正常但终端不动作字节序/校验问题我遇到的问题表现是网关成功连上终端也发了触发报警帧但是终端上的红灯不亮、语音不播。当时第一反应是数据体参数不对但用厂家调试软件对比了半天也没发现差异。后来我把网关发出的原始十六进制报文抓出来跟厂家工具发的报文一行行对比才发现 CRC 高低字节顺序反了。终端要求的是 CRC 低字节在前我一开始按习惯先发高字节再发低字节导致终端校验失败整帧被丢弃。这类问题终端通常不会返回错误码它只会悄无声息地丢弃非法帧排查起来特别费劲。排查思路很简单先确认 TCP 连接通不通再抓包对比报文内容。把网关发出的每一帧都打到日志里跟厂家调试工具抓出来的标准帧比对重点检查帧头、长度、CRC、帧尾四部分。字节序不匹配是最常见的坑小端和大端不要想当然必须以设备文档或实测抓包为准。5.2 粘包半包引发的“串帧”异常终端在启动时或者批量下发报警时可能出现一帧还没收完下一帧就来了的情况。如果拆包逻辑写得简陋比如每次 recv 都直接当一帧解析就会出现“串帧”帧头找不到、长度混乱、CRC 全错。我刚开始用的是简单的“每次收 14 字节”的写法因为普通报警帧长度是固定的 14 字节后来终端升级协议支持可变长度的语音播报内容直接翻车。改成缓冲区 状态机解析后无论粘包有多严重都能正确切帧。这里特别注意长度字段一定是从帧里解析出来的不能假设固定长度否则协议一变你的程序就要重写。5.3 终端频繁掉线的排查调试时发现终端连接几分钟就掉线一次重连后又正常反复循环。一开始我以为网络不稳后来查日志发现是心跳发送逻辑写在了主发送循环里而主循环有时候被发送报警帧阻塞了导致心跳延迟。终端检测到心跳超时主动断开了连接。解决方式很简单把心跳发送独立成一个定时任务不要和报警发送混合在同一个网络写操作序列里。后来我把Timer的周期设为 3 秒低于终端的 15 秒超时阈值留足余量掉线问题再没出现。5.4 上位机数据源采集失败与丢失报警数据库轮询方案有个隐藏问题如果网关程序重启或者数据库连接池满了可能把报警记录漏掉。解决方式是查询时不要只查Processed0要把查询条件加上时间范围比如最近 5 分钟内的记录防止跨重启丢失。另外旧上位机写库的时间可能不是实时的如果它批量补写历史数据网关可能把一条几天前的记录当成新报警发出去。我的做法是在表里增加一个WriteTime字段网关只处理WriteTime在最近 2 秒内的记录从而过滤掉批量回填数据。常见问题速查表如下现象可能原因排查方法终端不收帧CRC 错误或字节序不对抓包对比厂家报文终端闪断心跳超时独立心跳任务并缩短周期乱码/串帧半包粘包处理不当缓冲区状态机拆包报警重复数据库标记失效检查 Processed 字段报警丢失网关重启时间快照丢失增加时间范围过滤偶发无响应队列拥塞监控队列长度增加持久化6. 改装完我最想提醒你的几件事踩过这次坑之后我最大的体会是旧系统改造的关键节点往往不在代码而在通信链路的几个边界上。数据从哪里来、谁来保证不丢、断线了怎么恢复、终端不响应要不要重发这几个问题想清楚了代码只是把它们串起来而已。另外现场调试一定要养成打印十六进制报文的习惯。我在网关里加了一个开关需要排查时打开日志能看到每一帧的内容、CRC 校验结果、队列长度和连接状态。这个习惯帮我省了大量盲猜的时间。如果你做的项目也遇到类似的旧上位机对接问题建议先花半天时间跟终端厂家把协议文档每个细节确认清楚特别是 CRC 范围、字节序、心跳超时时间再动手写代码。文档里含糊不清的地方直接找厂家要一份他们自己抓包的样例报文比对报文的字节结构比看十页文档都有效。这样可以避免到现场后反复试错省下的时间和精力远比提前沟通的成本要多。