车间那台旧上位机已经稳定跑了快十年,突然要接一台声光语音终端,厂家给出的对接方式是“原生 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 重连,采集模块依然在正常攒数据,不会丢报警。我用的是简单的ConcurrentQueue<T>加后台任务的方式,没有引入重量级消息队列,毕竟一个网关程序没必要那么复杂,够用就行。
3. 字节帧协议拆包组包:从帧格式到粘包半包处理
3.1 终端私有协议的帧格式怎么定
设计协议前,我拿到了终端厂家的通讯协议文档,里面规定的帧格式大致如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2字节 | 固定 0xAA 0x55 |
| 命令字 | 1字节 | 0x01 触发报警 / 0x02 解除报警 / 0x03 心跳 / 0x04 查询状态 |
| 数据长度 | 2字节 | 小端序,表示数据体的字节数 |
| 数据体 | N字节 | 具体参数 |
| CRC16 | 2字节 | 从命令字到数据体结束的 CRC16 校验值 |
| 帧尾 | 2字节 | 固定 0x0D 0x0A |
数据体的定义根据命令字不同而不同。以触发报警为例,数据体是 8 个字节:
| 偏移 | 字段 | 说明 |
|---|---|---|
| 0 | 报警通道号 | 1字节,对应终端上的1~8号报警灯 |
| 1 | 灯光颜色 | 1字节,0=红,1=黄,2=绿,3=蓝 |
| 2 | 蜂鸣器模式 | 1字节,0=关闭,1=连续,2=间断 |
| 3 | 语音播报ID | 2字节,小端序,对应终端内置语音编号 |
| 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字段,查询时只取Processed=0的记录,成功入队后直接更新为 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 上位机数据源采集失败与丢失报警
数据库轮询方案有个隐藏问题:如果网关程序重启,或者数据库连接池满了,可能把报警记录漏掉。解决方式是查询时不要只查Processed=0,要把查询条件加上时间范围,比如最近 5 分钟内的记录,防止跨重启丢失。
另外旧上位机写库的时间可能不是实时的,如果它批量补写历史数据,网关可能把一条几天前的记录当成新报警发出去。我的做法是在表里增加一个WriteTime字段,网关只处理WriteTime在最近 2 秒内的记录,从而过滤掉批量回填数据。
常见问题速查表如下:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 终端不收帧 | CRC 错误或字节序不对 | 抓包对比厂家报文 |
| 终端闪断 | 心跳超时 | 独立心跳任务并缩短周期 |
| 乱码/串帧 | 半包粘包处理不当 | 缓冲区状态机拆包 |
| 报警重复 | 数据库标记失效 | 检查 Processed 字段 |
| 报警丢失 | 网关重启时间快照丢失 | 增加时间范围过滤 |
| 偶发无响应 | 队列拥塞 | 监控队列长度,增加持久化 |
6. 改装完我最想提醒你的几件事
踩过这次坑之后,我最大的体会是:旧系统改造的关键节点往往不在代码,而在通信链路的几个边界上。数据从哪里来、谁来保证不丢、断线了怎么恢复、终端不响应要不要重发,这几个问题想清楚了,代码只是把它们串起来而已。
另外,现场调试一定要养成打印十六进制报文的习惯。我在网关里加了一个开关,需要排查时打开日志,能看到每一帧的内容、CRC 校验结果、队列长度和连接状态。这个习惯帮我省了大量盲猜的时间。
如果你做的项目也遇到类似的旧上位机对接问题,建议先花半天时间跟终端厂家把协议文档每个细节确认清楚,特别是 CRC 范围、字节序、心跳超时时间,再动手写代码。文档里含糊不清的地方,直接找厂家要一份他们自己抓包的样例报文,比对报文的字节结构比看十页文档都有效。这样可以避免到现场后反复试错,省下的时间和精力,远比提前沟通的成本要多。