资讯详情

资讯详情

C# TCP通信对接组态软件:粘包拆包、心跳重连与变量映射范例

简介面向工控上位机开发初学者及需要打通C#与昆仑通态MCGS通信的工程师这套范例以TCP协议为主线展示在Visual Studio 2013中编写的WinForms程序如何连接MCGS、读取其中的数据并配合看板人机界面完成联调适合用作通信入门与项目原型参考。压缩包共36个文件、约92KB包含9个C#源码文件以及解决方案、项目配置、资源文件、可执行程序、说明文本和人机界面工程等配套内容目录虽小但结构完整便于直接打开对照各模块职责。已有2507人学习下载说明其在工控通信学习场景中具有一定参考价值。读者可从中获得一套可运行的示例工程借助源码理解TCP连接建立、数据收发与读取处理的关键实现并参考看板界面的组织方式排查通信问题、搭建自己的上位机采集程序。1. C# TCP 通信对接组态软件一份范例代码先解决哪三个联调问题C# TCP 通信对接组态软件最怕的不是不会写 Socket而是两端对“谁连谁、一帧多长、超时算谁的”理解不一致。现场常见画面是C# 端日志显示连接已建立组态画面却一直是问号或者第一帧数值正常跑十分钟后全部错位。这份范例代码把服务端监听、粘包拆包、心跳重连和变量映射拆成可单独验证的几段适合做设备联调、上位机采集、产线数据回传的从业者也适合刚接手 C# 与组态软件对接、不想从零试错的人。它不承诺一套代码通吃所有版本但能把最容易翻车的几个参数先摆到桌面上监听端口、帧头长度、读超时、重连退避、变量地址偏移。先让一条最小链路跑通再往上加变量比一上来铺两百个通道更稳。2. 通信模型选型谁做服务端、帧长怎么定、端口怎么留后路TCP 通信对接组态软件第一步不是打开代码编辑器而是把角色定死。C# 端和组态软件端一个做服务端一个做客户端。服务端负责监听端口、接受连接、维护连接表客户端负责主动发起连接、断线重连。角色反了后面所有超时、心跳、重连参数都会拧巴。现场常见做法是把 C# 做成服务端组态软件做客户端C# 端日志好落盘升级时只换 C# 进程组态侧只改 IP 和端口。若现场组态版本只开放客户端角色再把 C# 改成客户端代码结构不变只替换连接入口。这一章先把三个选型问题讲透角色、帧格式、端口与超时。它们看起来像配置项实际决定了后面代码怎么写、组态侧怎么填、出问题时先看哪一层。2.1 先定角色C# 做服务端还是客户端两种模式都能跑差别在维护成本和现场权限。C# 做服务端时组态软件主动连过来C# 端只需要暴露一个固定端口并持续监听组态侧填远程 IP 和远程端口本地端口通常留 0 让系统分配。C# 做客户端时组态软件要开服务端C# 端负责重连适合组态软件已经占用了主站角色、现场不允许再加监听端口的场景。模式适用场景优点代价C# 服务端 组态客户端新建采集链路、C# 端可控制部署连接表清楚日志集中重启 C# 后组态侧自动重连需要固定监听端口防火墙要放行C# 客户端 组态服务端组态侧已有服务端配置、现场只允许单向发起C# 端可部署在边缘设备主动上报重连逻辑全压到 C# 端组态侧排查手段少我一般优先选 C# 服务端。原因很实际现场组态画面是给操作工看的C# 日志是给维护人员看的。连接建立、断开、收帧、回帧都落在 C# 日志里出了问题不用在组态软件里翻半天。服务端监听地址建议绑定具体网卡比如10.0.0.10不要图省事绑0.0.0.0。多网卡机器上绑0.0.0.0容易出现“组态连到了另一张网卡”的玄学问题日志里看本地端点才发现连错网段。端口选择也有讲究。示例用6000避开80、443、3389和现场已有的502。不是这些端口不能用而是它们容易被运维策略、安全软件或既有设备占用。端口一旦定下来C# 端、组态侧、防火墙规则、交接文档要同时改别只改一处。连接数上限先按 8 个客户端留实际产线通常只有一两台上位机留太多反而让连接表难清理。2.2 帧格式定长、分隔符还是 TLV帧格式决定组态侧配置难度和 C# 端拆包复杂度。定长帧最简单两端按固定字节数收发变量增减就要同时改两端分隔符文本帧最好调试直接看日志就能认出TAGvalue但字符串转义、换行拆包、编码不一致会带来新坑头长度负载的 TLV 结构扩展性最好组态侧配置门槛也最高适合变量多、命令多的场景。帧格式实现难度调试成本适合场景定长二进制低中变量固定、采样周期稳定的老设备分隔符文本低低首轮联调、变量少、需要肉眼读日志帧头长度负载中中多命令、变长数据、后续要扩展这份范例选“帧头AA 55 1 字节长度 1 字节命令 负载 2 字节 CRC16”。长度字段表示“命令字 负载”的字节数最大 255 字节够放几十个变量。变量多了就把长度改成 2 字节帧头不动。组态软件侧如果只支持字符串发送不要硬配二进制直接把协议换成TAGvalue\nC# 端保留按换行拆包的逻辑先把链路跑通再换二进制。文本帧的编码要写死 UTF-8长度按字节算不按字符算中文变量名尤其容易在这里翻车。注意帧头不要选0x00 0x00或0xFF 0xFF这类字节在缓冲区里太常见粘包时很难定位起始位置。2.3 端口、超时和重连参数先留余量参数不要拍脑袋。下面这组值适合首轮联调现场稳定后再按采样周期压缩参数建议值说明监听端口6000避开常用服务端口和现场已有 502连接超时3 s组态侧发起连接后 3 s 没建立就重试读超时6 s要大于心跳间隔否则心跳还没到就判死心跳间隔2 s空闲链路保活不要低于 500 ms重连退避1 s 起2 倍递增上限 10 s防止断网时重连风暴最大帧长1024 字节缓冲区上限超过直接丢帧并记日志采样周期200 ms 起变量多时先降频别一上来 50 ms读超时必须大于心跳间隔这条是血泪经验。曾经把读超时设成 2 s、心跳也设成 2 s网络稍微抖一下服务端先判超时断开客户端心跳才发出去日志里全是“刚连上就断”。后来把读超时拉到 6 s重连退避从 1 s 开始翻倍现场才稳下来。参数改完要同时改 C# 端和组态侧只改一边等于没改。3. C# 侧最小实现TcpListener、粘包拆包与心跳重连一把写完C# 侧不需要一上来就上通信框架。System.Net.Sockets自带TcpListener、TcpClient、NetworkStream控制台程序或 Worker Service 都能跑。先建一个最小工程把监听、收帧、回帧、重连四件事写清楚再考虑加日志组件、配置中心、依赖注入。工程名用TcpGatewayDemo命名空间和目录保持一致后面贴类文件不用再改引用。3.1 工程骨架与不引第三方通信库的取舍先建控制台工程不引额外通信包。这样部署时少一层依赖现场拷贝过去就能跑。命令只做一件事生成项目并进入目录。dotnet new console -n TcpGatewayDemo cd TcpGatewayDemo-n指定项目名生成的项目文件、命名空间、输出程序集名都会跟着走。控制台工程适合首轮联调因为Console.WriteLine直接能看到连接、收帧、异常。正式交付可以换成 Worker Service把日志接到文件或系统日志里但通信类不用重写。3.2 用 TcpListener 搭一个可复现的最小服务端下面这段是服务端核心监听、接受连接、按帧头拆包、回显命令字和负载。代码里保留了 CRC16 计算现场如果组态侧不方便算 CRC可以先把校验去掉但要在日志里标记“未校验”别悄悄放宽。using System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; namespace TcpGatewayDemo { public sealed class HmiTcpServer { private readonly TcpListener _listener; private readonly Dictionarystring, TcpClient _clients new(); private readonly CancellationTokenSource _cts new(); public HmiTcpServer(string ip, int port) { _listener new TcpListener(IPAddress.Parse(ip), port); } public async Task StartAsync() { _listener.Start(); Console.WriteLine($listen {_listener.LocalEndpoint}); _ AcceptLoopAsync(_cts.Token); await Task.Delay(Timeout.Infinite, _cts.Token); } private async Task AcceptLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { TcpClient client await _listener.AcceptTcpClientAsync(token); client.NoDelay true; // 小帧频繁收发时关掉 Nagle降低延迟 string key client.Client.RemoteEndPoint?.ToString() ?? Guid.NewGuid().ToString(N); _clients[key] client; _ HandleClientAsync(key, client, token); } } private async Task HandleClientAsync(string key, TcpClient client, CancellationToken token) { var buffer new byte[1024]; var cache new Listbyte(2048); try { using NetworkStream stream client.GetStream(); while (!token.IsCancellationRequested) { int n await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), token); if (n 0) break; cache.AddRange(buffer.AsSpan(0, n).ToArray()); while (TryTakeFrame(cache, out byte[] frame)) { byte[] reply BuildReply(frame); await stream.WriteAsync(reply, token); } } } catch (Exception ex) { Console.WriteLine(${key} closed: {ex.Message}); } finally { _clients.Remove(key); client.Dispose(); } } private static bool TryTakeFrame(Listbyte cache, out byte[] frame) { frame Array.Emptybyte(); if (cache.Count 4) return false; int head cache.IndexOf(0xAA); if (head 0 || cache.Count head 2 || cache[head 1] ! 0x55) { cache.Clear(); // 找不到帧头就清缓存避免脏数据越积越多 return false; } int len cache[head 2]; int total head 3 len 2; if (cache.Count total) return false; frame cache.GetRange(head, total - head).ToArray(); cache.RemoveRange(0, total); return true; } private static byte[] BuildReply(byte[] frame) { // 范例只回显命令字和负载现场按变量表替换 byte cmd frame[3]; byte[] body frame[4..^2]; var reply new byte[3 1 body.Length 2]; reply[0] 0xAA; reply[1] 0x55; reply[2] (byte)(1 body.Length); reply[3] cmd; body.CopyTo(reply, 4); ushort crc Crc16(reply, 0, reply.Length - 2); reply[^2] (byte)(crc 0xFF); reply[^1] (byte)(crc 8); return reply; } private static ushort Crc16(byte[] data, int start, int len) { ushort crc 0xFFFF; for (int i start; i start len; i) { crc ^ data[i]; for (int b 0; b 8; b) crc (crc 1) ! 0 ? (ushort)((crc 1) ^ 0xA001) : (ushort)(crc 1); } return crc; } public void Stop() { _cts.Cancel(); _listener.Stop(); foreach (var c in _clients.Values) c.Dispose(); } } }逻辑说明StartAsync启动监听后进入无限等待AcceptLoopAsync每接受一个连接就开一个读循环连接按远程端点存进字典。HandleClientAsync把收到的字节追加到cacheTryTakeFrame先找0xAA再确认下一个字节是0x55然后读长度字段长度够才取出一帧。BuildReply按同样的帧结构回包CRC16 用常见的 Modbus 多项式0xA001。参数说明buffer给 1024 字节是单次读的上限不是单帧上限cache初始 2048 字节变量多时可以调到 8192。长度字段是 1 字节所以单帧负载不要超过 255 字节。NoDelay true对 200 ms 级采样有用如果现场是 1 s 以上的慢采集关不关差异不大。CRC 校验要两端一致组态侧如果只发文本帧就把TryTakeFrame换成按\n切分CRC 那两字节去掉。3.3 粘包拆包与心跳超时的参数落点粘包不是“偶发问题”是 TCP 流的常态。组态侧连续发两帧服务端一次ReadAsync可能全收到网络抖动时一帧也可能拆成两次到。上面的cache就是干这个的先缓存再按长度取完整帧。不要在ReadAsync后面直接BitConverter.ToInt16(buffer, 0)那样第一帧正常、第二帧必错位。心跳放在客户端侧更自然。下面这段是 C# 做客户端时的重连骨架连接成功后每 2 s 发一次心跳帧失败按 1 s、2 s、4 s 退避到 10 s 封顶。public async Task RunClientAsync(string host, int port, CancellationToken token) { int delay 1000; while (!token.IsCancellationRequested) { using var client new TcpClient(); try { await client.ConnectAsync(host, port, token); client.NoDelay true; delay 1000; // 连上后重置退避下次断线仍从 1 秒起 using NetworkStream stream client.GetStream(); while (!token.IsCancellationRequested) { await Task.Delay(2000, token); // 示例心跳帧CRC 按第 3.2 节同一算法补齐 byte[] ping { 0xAA, 0x55, 0x01, 0x01, 0x00, 0x00 }; await stream.WriteAsync(ping, token); } } catch (Exception ex) when (!token.IsCancellationRequested) { Console.WriteLine($retry {delay}ms: {ex.Message}); await Task.Delay(delay, token); delay Math.Min(delay * 2, 10000); } } }逻辑说明外层while负责断线重连内层while负责心跳。ConnectAsync成功一次就把delay重置回 1 s避免一次长断网后永远停在 10 s。using确保TcpClient和NetworkStream在异常时释放。参数说明心跳 2000 ms、退避上限 10000 ms 是首轮联调值现场网络差时把上限调到 30000 ms但不要取消退避否则断网恢复瞬间会打出几十个连接请求。token要贯穿连接、延时、写入窗口关闭时才能干净退出。4. 组态软件侧配置设备窗口、变量映射与第一帧验证组态侧配置决定联调一半成败。不同版本的菜单名有差异按“设备窗口—父设备—子设备—变量连接”这条路径找基本不会偏。动手前先备份工程改端口、改 IP、改变量地址之前记下原值现场翻车时能退回去。4.1 TCP 父设备与子设备参数先建 TCP 父设备再挂通用 TCP 子设备。父设备管网络参数子设备管采集周期和变量通道。组态软件做客户端时远程 IP 填 C# 所在机器远程端口填 6000本地端口留 0让系统分配。连接超时填 3000 ms重连间隔填 1000 ms先跟 C# 端的退避起点对齐。配置项建议值说明通信角色客户端与 C# 服务端配对远程 IPC# 机器实际网卡地址多网卡时确认组态走的是同一网段远程端口6000与 C# 监听端口一致本地端口0由系统分配避免端口冲突连接超时3000 ms首轮联调值稳定后可降到 1000 ms重连间隔1000 ms不要低于 500 ms防止重连风暴子设备类型通用 TCP按字节收发不绑定具体协议配置完先保存再启动 C# 服务端。C# 控制台应打印listen 10.0.0.10:6000组态侧启动采集后C# 端应出现远程端点日志。看不到连接日志先查 IP、端口、防火墙不要急着改代码。4.2 变量地址与 C# 数据区的映射表变量地址不要拍脑袋。先定一张映射表C# 端按偏移打包组态侧按通道填地址。下面是一组示例首轮只上 4 个变量C# 帧内偏移数据类型组态通道变量含义备注4UInt16通道 0设备状态0 停止1 运行2 报警6Int32通道 1压力值小端单位 0.01 MPa10Float通道 2流量值小端IEEE75414Byte通道 3报警码按位定义先做 8 位偏移从帧头之后算还是从命令字之后算两端必须写死。上面这张表从命令字之后算C# 端打包时把状态放在body[0..2]压力放body[2..6]。组态侧填通道地址时不要按“第几个变量”猜按字节偏移填。大小端先统一小端Int32和Float最容易在这里翻车C# 的BitConverter默认小端组态侧如果按大端解析压力会变成几百万。注意首轮联调只上 4 个变量跑稳 30 分钟再加量。一次铺 200 个通道出错时根本不知道是地址错、类型错还是周期太快。4.3 第一帧联调顺序按下面顺序走每一步都有观察点在 C# 机器上确认监听控制台出现listen日志端口是 6000。在组态机器上pingC# 机器地址确认网段通。用端口测试工具连10.0.0.10:6000看 C# 端是否出现 accept 日志。启动组态采集观察 C# 端是否收到第一帧打印帧头和长度。在 C# 端手动回一帧固定数据看组态变量是否变化。改一个变量值看组态画面刷新周期是否跟采样周期一致。拔网线 10 s 再插回看组态侧是否自动重连C# 端连接表是否清理旧连接。第 3 步很关键。端口测试工具能连上说明监听和防火墙没问题问题在组态配置或帧格式连不上先查监听地址和防火墙不要翻 C# 业务代码。第 7 步是现场最容易被忽略的恢复测试很多工程首帧正常断线一次就再也连不上原因多半是旧连接没释放或重连退避卡死。4.4 采样周期与写回策略采样周期从 200 ms 起步。变量少、网络稳可以降到 100 ms变量多、组态画面多先保 500 ms。不要为了“看起来实时”把周期压到 20 msTCP 小帧高频收发会把 Nagle、日志、UI 刷新全部放大现场表现就是 CPU 上涨、画面卡顿、日志刷屏。写回策略要单独定命令字。读用0x01写用0x02写帧带 2 字节序号C# 端收到重复序号只执行一次。组态侧写操作不要挂在画面刷新事件里否则操作工拖一次滑块可能发几十帧。写回成功要回一个确认帧组态侧看到确认再清“待写入”标志避免网络丢帧后状态不一致。5. 联调排查六类翻车现场与抓包定位顺序TCP 联调的问题大多不是“代码不会写”而是“不知道先看哪一层”。这一章按现象、原因、解决三段式记录六类高频翻车再给一套从监听、连接、字节到业务的定位顺序。现场照着走比在代码里加Console.WriteLine碰运气快。5.1 六类翻车现场第一类端口能连上但组态变量一直是问号。现象是 C# 端能看到 accept 日志组态画面不刷新。原因多半是组态子设备没启动采集或者帧格式对不上组态侧发了请求但 C# 回帧长度不对。解决先在 C# 端打印收到的原始字节确认帧头是不是AA 55再手动回一帧固定长度数据看组态是否解析。如果 C# 端一字节都没收到查组态子设备的采集周期和启动状态不要只盯网络。第二类第一帧正常第二帧开始错位。现象是第一个变量对后面全乱重启后又好一阵。原因是ReadAsync一次收到多帧代码却按单帧解析。解决按第 3.2 节的cache缓存先找帧头再读长度长度够才取帧。每次取完从缓存移除不要清空整个缓存除非帧头彻底找不到。错位问题不要靠“加大延时”解决延时只能降低概率不能根治。第三类数值翻倍或变成 65535。现象是压力值显示 655.35 或者直接 65535。原因通常是大小端不一致或者UInt16和Int16类型填反。解决两端统一小端Int32、Float用BitConverter.ToSingle对照组态侧通道类型选“16 位无符号”还是“32 位浮点”要跟 C# 打包一致。先发一个已知值比如 1234看两端显示是否一致再上真实变量。第四类字符串乱码。现象是文本变量显示问号或方块。原因是编码不一致或者长度字段把结束符算进去了。解决C# 端固定 UTF-8组态侧如果有编码选项也选 UTF-8长度按字节算不按字符算。中文字符占 3 字节长度字段写 4 但实际发了 6 字节后面全部错位。首轮联调可以先不上字符串等数值变量稳了再加。第五类心跳导致频繁断线。现象是日志里“连接成功—读超时—重连”循环。原因是读超时小于等于心跳间隔或者组态侧采集周期比心跳还长。解决读超时设成心跳的 3 倍心跳 2 s 就读超时 6 s重连退避从 1 s 起翻倍上限 10 s。不要把心跳设成 200 ms小帧高频在弱网里只会放大抖动。第六类UI 卡死、内存缓慢上涨。现象是点按钮没反应任务管理器里内存曲线往上走。原因是在 UI 线程同步Read或者每次重连都 new 线程、new 字典项不清理。解决通信循环全部async/await连接断开后在finally里移除字典项并Dispose日志不要每条都拼大字符串。服务端跑一夜内存涨几十 MB多半是缓存或连接表没清。5.2 抓包与日志的定位顺序定位顺序固定为先看监听再看连接再看字节最后看业务。不要一上来就抓包先确认端口在听。ss -lntp | grep 6000 nc -vz 10.0.0.10 6000 tcpdump -i any -nn port 6000 -w hmi.pcapss -lntp看 6000 是否处于LISTEN以及监听地址是0.0.0.0还是具体网卡nc -vz从组态机器测端口通不通通不了先查防火墙和网段tcpdump抓 6000 端口的包-nn不做域名和端口名解析-w写文件后面用抓包工具打开看字节。抓包文件不要放系统盘跑久了容易把盘写满。日志侧至少打三类连接建立与断开、收到帧的十六进制、回帧长度。十六进制日志不要全量长期开联调期开稳定后降到只记录异常帧。回帧长度对不上时先对照抓包里的实际字节数再看代码里的长度字段十有八九是长度字段含义两端理解不同。6. 把范例跑成可交付三组验证与一条压测底线范例能跑通不等于能交付。我一般会做三组验证再压一条底线过了才敢放到现场。6.1 三组验证端口、单帧、连续运行验证项操作合格线端口连通从组态机器连 C# 监听端口3 s 内建立连接C# 日志出现远程端点单帧读写手动发一帧读命令改一个变量值组态显示与 C# 打包值一致误差为 0连续运行200 ms 周期跑 30 分钟无错位、无断线、内存无持续上涨单帧读写要选有代表性的类型一个UInt16、一个Int32、一个Float。三个都对再上字符串和位变量。连续运行期间故意拔一次网线10 s 后插回看组态是否自动重连、C# 连接表是否只剩一条。这个动作比看日志有用。6.2 压测底线100 ms 周期下看三件事把采样周期压到 100 ms跑 10 分钟观察三件事连接是否保持、周期抖动是否可接受、内存是否稳定。合格线可以按下面这张表卡指标合格线观察点连接保持10 分钟内不断线C# 连接表数量不增长周期抖动平均 100 ms最大不超过 300 ms日志时间戳差值内存增长10 分钟不超过 20 MB任务管理器或进程监控断线恢复拔插网线后 15 s 内恢复组态变量恢复刷新压测时不要开全量十六进制日志否则测的是日志性能不是通信性能。变量多就先把周期放到 500 ms跑稳再降。最后提醒一句从那以后我每次交付前都强制走一遍“拔网线再插回”的恢复测试哪怕现场催得再急因为首帧正常不代表链路能扛住断线。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →