首页 / 资讯中心 / 文章详情

C#上位机与组态软件TCP通信:粘包处理、断线重连与地址映射

C#上位机与组态软件TCP通信:粘包处理、断线重连与地址映射 ★ FEATURED ARTICLE
简介C#与MCGS昆仑通态TCP通信范例代码面向工控上位机开发者、自动化工程师及相关专业学生针对C#桌面程序与昆仑通态触摸屏、组态软件之间建立TCP连接并读取屏内实时数据的典型需求给出可运行的参考实现。代码基于Visual Studio 2013编写可直接打开解决方案对照调试。压缩包为rar格式共36个文件、约92KB其中9个cs文件承载窗体界面与通信逻辑的主体代码3个exe为编译产物config、settings等配置项保存运行参数resx、manifest、sln、csproj等共同构成完整的VS工程结构另有若干txt说明帮助快速上手。已有2507人学习下载。读者可从中获取连接建立、报文收发、数据解析到界面刷新的完整链路示例以及指示看板类例程的工程组织方式既能改造成自己的设备通信模块也可作为排查连接失败、数据错位等问题的对照参考。1. 一条网线两端C# 上位机和组态软件为什么要用 TCP 对话产线上那台工控机跑着组态画面旁边的工控机跑着 C# 上位机两边谁也不认识谁中间就靠一条 TCP 连接把数据递过去。这份范例代码要解决的就是这件事C# 端做 Socket 通信组态软件端开一个 TCP 服务或者客户端双方按约定好的报文格式收发把现场的液位、温度、启停信号搬到 C# 界面上再把操作指令写回去。它适合刚接手上位机对接、手上只有一份组态变量表、不知道第一条报文该怎么拼的开发者也适合已经在用但被粘包、断线、乱码折腾过一轮的人。代码本身不大难点全在协议约定和边界处理上下面按“骨架—报文—映射—排查—改造”的顺序拆开看。2. 通信骨架角色怎么定、工程怎么摆、连接怎么保2.1 角色分工谁监听、谁主动连TCP 通信第一步不是写代码是定角色。组态软件和 C# 上位机之间必须有一个是服务端监听端口、等别人连另一个是客户端主动发起连接。这件事在很多现场是靠“谁先上线”拍脑袋决定的结果就是调试时两边都在等对方连一条报文都发不出去。常见做法是把组态端当服务端。原因很实际组态软件通常跑在触摸屏或工控机上开机即运行、长期不关IP 相对固定C# 上位机可能部署在办公电脑上网段会变、会重启、会被人为关掉。让稳定的一端监听、易变的一端主动连重连逻辑只需要在 C# 里写一份组态端不用改任何脚本。另一种做法反过来C# 做服务端。适合的场景是现场有多台组态设备都要往同一台 C# 服务器上报数据或者 C# 端需要同时接好几台设备。这时候 C# 要维护一个客户端列表每个连接一个接收缓冲区管理成本会高一些范例里用Dictionarystring, ClientContext按RemoteEndPoint建索引就能撑住。提示角色一旦定下来两边的端口号、IP 就要写进配置项不要硬编码在代码里。现场改 IP 是常态改一次编译一次很容易出错。定完角色还要定“连接方向上的数据流”。组态端一般有“设备窗口”的概念里面配的是通信驱动你要在驱动里指定协议类型、目标 IP、端口、超时时间、采集周期。这几个参数和 C# 端必须一一对应尤其是端口和超时。组态端默认的采集周期可能是 200msC# 端如果按 1s 才读一次数据看着就会“跳变”现场容易误判成通信丢包。2.2 工程目录与文件职责范例工程按职责分了四层通信层完全不碰界面界面层也不碰 Socket中间靠事件传递。这个分层不是为了好看是为了后面换协议的时候能只改一个文件。TcpDemo/ ├─ TcpDemo.sln ├─ TcpDemo/ │ ├─ Program.cs // 入口启动主窗体 │ ├─ Forms/ │ │ ├─ MainForm.cs // 界面逻辑连接、发送、日志 │ │ └─ MainForm.Designer.cs │ ├─ Comm/ │ │ ├─ TcpClientService.cs // 连接管理、收发循环、重连 │ │ ├─ FrameCodec.cs // 报文拼装与解析协议集中在这里 │ │ └─ ReconnectPolicy.cs // 退避策略避免疯狂重连 │ ├─ Mapping/ │ │ ├─ TagItem.cs // 单个变量的定义 │ │ └─ TagTable.cs // 地址表加载与查找 │ └─ Config/ │ └─ tags.csv // 变量表地址、类型、名称各文件的职责边界很清晰FrameCodec只认字符串和字节数组不认 SocketTcpClientService只负责“把字节收发好”不关心报文里写的是什么TagTable只做地址到字段的映射不关心数据怎么来的。真要做二次开发改协议只动FrameCodec加点位只动tags.csv和TagItem。文件改动频率典型改动场景FrameCodec.cs低对方协议换了分隔符、加了校验位TcpClientService.cs低加重连次数上限、加心跳tags.csv高现场增减变量、改地址MainForm.cs中改界面布局、加按钮2.3 连接、心跳与断线重连的三段式连接管理最容易写成“连一次断了就崩”。工业现场的网络不是机房网络交换机断电、网线被叉车压断、组态软件重启任何一件都会让连接断掉。范例里把连接拆成三段建链、保活、重连。建链负责“连上”保活负责“知道还连着”重连负责“断了能自己回来”。三段分开写每段都能单独打日志出问题时看日志就知道卡在哪一段。// ReconnectPolicy.cs public class ReconnectPolicy { private readonly int _maxDelayMs; private int _currentDelayMs 500; public ReconnectPolicy(int maxDelayMs 30000) { _maxDelayMs maxDelayMs; } // 每次失败后调用延迟翻倍封顶 maxDelayMs public int NextDelay() { var delay _currentDelayMs; _currentDelayMs Math.Min(_currentDelayMs * 2, _maxDelayMs); return delay; } // 连接成功后调用把延迟复位 public void Reset() _currentDelayMs 500; }NextDelay()用的是指数退避500ms、1s、2s、4s……一直涨到 30s 封顶。为什么不用固定 3 秒重试因为组态端重启的时候如果 C# 端每 3 秒猛敲一次服务端的连接队列会被打满反而更连不上。指数退避给了对端喘息时间也避免日志被刷爆。Reset()必须在连接成功回调里调用否则第一次断线后延迟就永久停在 30 秒现场表现是“断了之后要等半分钟才恢复”很容易被当成 bug 报上来。保活这一段常见做法是定时发一帧心跳内容可以是固定的字符串也可以是读一个固定地址。心跳的作用有两个一是让中间的交换机、防火墙别把空闲连接回收掉二是 C# 端能通过“心跳有没有回”判断链路是否真的通。光靠TcpClient.Connected是不可靠的这个属性反映的是上一次 IO 的状态对端拔了网线它照样返回 true这个坑后面还会细说。3. 核心代码逐段拆一条报文从拼装到落地的完整链路3.1 建链TcpClient 的连接与超时控制TcpClient.Connect是同步阻塞的对端不在线的时候会卡住默认 20 秒左右。放在 UI 线程里调用界面直接白屏。范例里用ConnectAsync加CancellationToken的方式把超时控制在自己手里。// TcpClientService.cs片段 private TcpClient _client; private NetworkStream _stream; public async Taskbool ConnectAsync(string ip, int port, int timeoutMs, CancellationToken token) { try { _client new TcpClient(); // 关闭 Nagle 算法小报文立即发出避免几十毫秒的合并延迟 _client.NoDelay true; using var cts CancellationTokenSource.CreateLinkedTokenSource(token); cts.CancelAfter(timeoutMs); // 超时由这里兜底 await _client.ConnectAsync(ip, port, cts.Token); _stream _client.GetStream(); return true; } catch (OperationCanceledException) { // 超时或外部取消 CloseQuietly(); return false; } catch (SocketException ex) { // 端口不通、被拒绝、DNS 失败 Log($connect failed: {ex.SocketErrorCode}); CloseQuietly(); return false; } }NoDelay true这行值得单独说。TCP 默认开着 Nagle 算法会把小包攒一攒再发攒的窗口可能是几十毫秒。通信频率低的时候无所谓但如果你的采集周期是 50ms攒包会让数据看起来“一卡一卡”的。工业协议报文都很短关掉 Nagle 基本没有副作用。CancelAfter是超时的关键。ConnectAsync本身不带超时参数靠CancellationToken打断。注意CreateLinkedTokenSource的用法它把外部传进来的 token 和自己的超时 token 合并任何一边触发都能取消这样窗口关闭时也能立刻中断正在进行的连接。CloseQuietly()是个小工具方法里面try { _stream?.Close(); } catch { }、try { _client?.Close(); } catch { }异常全部吞掉。关闭 socket 的时候抛异常太正常了没必要往上冒。3.2 发帧定界符协议怎么拼报文格式这件事范例用的是一套很朴素的可读协议字段用逗号分隔整帧用\r\n结尾。读RD,DB1.DBW10,2\r\n 写WR,DB1.DBW10,1234\r\n 回OK,1234,5678\r\n 错ER,03\r\n选这套格式不是因为优雅是因为调试成本最低。抓包工具里直接能看懂组态端的脚本里拼字符串也方便出问题时不用翻协议文档。// FrameCodec.cs public static class FrameCodec { public const string Terminator \r\n; // 读命令RD,地址,数量 public static byte[] BuildRead(string address, int count) { var text $RD,{address},{count}{Terminator}; return Encoding.ASCII.GetBytes(text); // 协议里只有 ASCII 字符 } // 写命令WR,地址,值 public static byte[] BuildWrite(string address, string value) { var text $WR,{address},{value}{Terminator}; return Encoding.ASCII.GetBytes(text); } // 心跳固定字符串对端原样回 public static byte[] BuildHeartbeat() Encoding.ASCII.GetBytes($HB{Terminator}); }Encoding.ASCII而不是UTF-8是因为协议里只允许数字、字母、逗号和回车换行。用 ASCII 的好处是每个字符固定一字节长度计算不会出错如果哪天要传中文必须整条链路一起换成 UTF-8而且要和组态端确认字符串是按字节算长度还是按字符算这一点后面避坑章节会展开。发送端必须加锁。多个线程同时往一个NetworkStream写字节会交错对端收到的就是一帧半。范例里用一个专门的_sendLock对象所有写操作都包在lock里而且写完之后立刻FlushNetworkStream默认不缓冲这一步其实是习惯动作但换成BufferedStream就必须有。private readonly object _sendLock new object(); public void Send(byte[] frame) { lock (_sendLock) { if (_stream null) throw new InvalidOperationException(not connected); _stream.Write(frame, 0, frame.Length); } }这个锁的粒度要小心。锁里只做写不要在里面等响应、不要在里面打日志到文件、不要在里面更新界面否则发送会被拖慢严重的时候会把接收线程也堵住。3.3 收帧缓冲区解决粘包半包TCP 是字节流不保证一次Read就是一帧。可能一次读到两帧粘在一起也可能一帧被拆成两次读到。范例用一个Listbyte当累积缓冲区每次读到的字节追加进去然后循环找定界符找到就切一帧出来。private readonly Listbyte _buffer new Listbyte(); private readonly byte[] _terminator { 0x0D, 0x0A }; // \r\n private void ReceiveLoop() { var chunk new byte[4096]; while (_running) { int n; try { n _stream.Read(chunk, 0, chunk.Length); // 阻塞读 } catch (IOException) { break; // 连接被对端关闭 } if (n 0) break; lock (_buffer) { _buffer.AddRange(chunk.Take(n)); } DrainFrames(); } } private void DrainFrames() { while (true) { string frameText; lock (_buffer) { int idx IndexOfTerminator(_buffer); if (idx 0) return; // 还没凑齐一帧等下次 var frameBytes _buffer.GetRange(0, idx).ToArray(); _buffer.RemoveRange(0, idx _terminator.Length); frameText Encoding.ASCII.GetString(frameBytes); } OnFrame(frameText); // 放到锁外处理 } }IndexOfTerminator就是从缓冲区里找连续的0x0D 0x0A逐字节比对即可。这里有个细节RemoveRange的下标是idx _terminator.Length把定界符一起去掉否则下一轮会从一个\r开始永远匹配不上。OnFrame放在锁外面调用是因为处理逻辑里可能要更新界面、可能要写日志这些操作耗时不确定放锁里会把接收线程拖住缓冲区越积越大最后表现为“延迟越来越高”。缓冲区本身要有上限。如果对端发了一堆没有定界符的脏数据_buffer会无限增长跑几个小时内存就上去了。常见做法是设一个MaxBufferSize比如 64KB超过就清空并记一条错误日志同时主动断开重连把状态拉回干净。3.4 回到 UI跨线程更新与日志接收循环跑在后台线程往TextBox里追加日志必须切回 UI 线程否则会抛InvalidOperationException或者更糟——不抛异常但界面偶发错乱。范例统一走BeginInvoke。private void OnFrame(string text) { if (InvokeRequired) { // 异步切回 UI 线程不阻塞接收循环 BeginInvoke(new Actionstring(OnFrame), text); return; } _logBox.AppendText(${DateTime.Now:HH:mm:ss.fff} {text}{Environment.NewLine}); if (_logBox.TextLength 200_000) // 防止日志控件无限膨胀 _logBox.Clear(); HandleFrame(text); // 真正的业务解析 }用BeginInvoke不用Invoke是因为Invoke会同步等待 UI 线程处理完如果 UI 线程正好卡在别的事情上比如弹了个模态框接收线程就跟着卡住。BeginInvoke是投递消息投完就走。日志控件要有上限。TextBox文本太长之后追加会明显变慢几万行就能感觉到卡顿。范例里超过 20 万字符清一次简单粗暴但有效。更讲究的做法是用ListView的虚拟模式或者只保留最近 N 条。HandleFrame里做协议解析如果是OK,开头就拆字段更新对应变量如果是ER,开头就记错误码其他的一律当日志。解析失败不要抛异常往上冒接收线程挂了整个通信就停了正确做法是记一条日志然后丢弃这一帧。4. 地址映射把组态端的变量表翻译成 C# 强类型字段4.1 地址表一份 CSV 撑起整个映射组态端的变量是“地址 类型 名称”三件套C# 端要把它变成可以直接绑定的字段。范例把这份对应关系放在tags.csv里程序启动时加载改点位不用重新编译。Name,Address,Type,Unit,Description TankLevel,DB1.DBW10,short,%,1号罐液位 TankTemp,DB1.DBD12,float,℃,1号罐温度 PumpRun,DB1.DBX20.0,bool,,1号泵运行状态 BatchNo,DB1.DBW30,string,,批次号加载逻辑就是一个File.ReadAllLines加循环跳过表头按类型实例化TagItem。这里要处理两个边界一是文件里有多余的空格Trim()一下二是类型字符串大小写不统一统一ToLowerInvariant()再比较。看着琐碎但现场拿到手的表经常是人工填的格式不会那么规整。// TagTable.cs public void Load(string path) { foreach (var line in File.ReadLines(path).Skip(1)) { if (string.IsNullOrWhiteSpace(line)) continue; var parts line.Split(,); if (parts.Length 3) continue; // 列数不够跳过并记录 var item new TagItem { Name parts[0].Trim(), Address parts[1].Trim(), Type parts[2].Trim().ToLowerInvariant(), }; _map[item.Name] item; } }用Dictionarystring, TagItem按名称索引界面绑定的时候直接_map[TankLevel]。如果想按地址反查收到一帧按地址更新再加一个Dictionarystring, TagItem按Address建索引两个字典指向同一个对象内存开销可以忽略。4.2 解析字符串到 int/float/bool 的转换边界报文里的数值都是字符串落到 C# 里要转成具体类型。转换这一步最容易翻车因为组态端发出来的格式和Parse期望的格式不一定对得上。public static object ParseValue(string raw, string type) { raw raw.Trim(); switch (type) { case short: return short.Parse(raw, CultureInfo.InvariantCulture); case int: return int.Parse(raw, CultureInfo.InvariantCulture); case float: // 现场可能发逗号小数点也可能发科学计数法 return float.Parse(raw, NumberStyles.Float, CultureInfo.InvariantCulture); case bool: // 常见三种写法都兼容一下 if (raw 1 || raw.Equals(true, StringComparison.OrdinalIgnoreCase)) return true; if (raw 0 || raw.Equals(false, StringComparison.OrdinalIgnoreCase)) return false; return null; // 无法识别交给调用方记日志 default: return raw; // string 类型原样返回 } }CultureInfo.InvariantCulture这行不能省。有些工控机的系统区域设置是欧洲格式小数点用逗号表示float.Parse(3,14)在当前区域下能过但如果报文里真的是逗号分隔符解析就会错位。固定用不变区域行为才稳定。bool的兼容写法是从实际对接里攒出来的。有的组态端发1/0有的发true/false有的发ON/OFF。与其在协议文档里吵不如在解析层兼容掉识别不了返回null调用方看到null就记一条“未知布尔值”比直接抛异常友好。浮点数还有个精度问题。float只有 7 位有效数字如果现场传的是累计流量这种大数用float会丢精度这种情况直接改成double。范例里float够用但TagItem的Type字段是字符串加一个double分支就行。4.3 写入序号回执与失败重试写操作比读麻烦因为要确认“到底写进去没有”。范例的做法是给每一帧写命令带一个序号对端回执里原样带回序号C# 端用TaskCompletionSource等待回执超时就重试。private readonly ConcurrentDictionaryint, TaskCompletionSourcestring _pending new ConcurrentDictionaryint, TaskCompletionSourcestring(); private int _seq; public async Taskbool WriteAsync(string address, string value, int timeoutMs 1000) { int seq Interlocked.Increment(ref _seq); var tcs new TaskCompletionSourcestring(TaskCreationOptions.RunContinuationsAsynchronously); _pending[seq] tcs; Send(FrameCodec.BuildWrite(address, value, seq)); using var cts new CancellationTokenSource(timeoutMs); using (cts.Token.Register(() tcs.TrySetCanceled())) { try { await tcs.Task; return true; } catch (TaskCanceledException) { _pending.TryRemove(seq, out _); return false; // 超时交给上层决定要不要重试 } } }TaskCreationOptions.RunContinuationsAsynchronously这个参数很关键。不加的话TrySetResult会在调用线程上同步执行后续逻辑而调用线程正是接收线程等于把接收线程拖去做业务处理。加上之后续体被扔到线程池接收线程立刻返回继续读下一帧。序号用Interlocked.Increment保证多线程下不重复_pending用ConcurrentDictionary免锁。收到OK,seq,value这样的回执时_pending.TryRemove(seq, out var t)然后t.TrySetResult(value)。重试策略要克制。写命令不是幂等的尤其是“启动泵”这种动作超时重试可能导致重复执行。范例里WriteAsync只返回成功/失败重试交给业务层判断而且对“写失败”的处理是记日志 界面提示不自动重发。这个决定是保守的但工业场景里保守比激进安全。注意如果对端不支持序号回执就别硬套这套机制。可以先发一帧读命令读回来比对一下值用“写后读”代替回执虽然慢一点但兼容性好。5. 现场排查五个最容易翻车的点5.1 本机 ping 得通、telnet 也通程序就是连不上现象是命令行telnet 192.168.x.x 5000能连上关掉 telnet 后跑 C# 程序ConnectAsync一直返回 false。原因通常有两个。一是组态端的 TCP 服务只允许一个客户端连接telnet 那次连接还没完全释放端口被占着C# 连上去直接被拒。二是组态软件配的是“客户端模式”它在等你连它而你也在这边等它连你两边都在等。解决方式先用netstat -ano | findstr :5000看端口状态确认LISTENING那一行对应的进程是不是组态软件telnet 测试完等十几秒再跑程序最后回组态软件的设备配置里确认一次角色是“服务端”还是“客户端”别只看 IP 和端口对上了就以为没问题。5.2 数据偶发错位重启程序就好现象是跑一段时间后数值开始乱液位显示成温度或者干脆显示成上一个批次的数据。原因基本都是粘包处理不干净。常见写法是“一次Read当成一帧”网络状况好的时候看不出来一旦对端连续发两帧或者一帧被拆成两半数据处理就错位了。解决方式是老老实实做缓冲区追加字节、查找定界符、切帧、循环。定界符要选对端绝对不会出现在数据里的字符组合\r\n是比较稳的选择但要注意如果数据里可能含中文编码统一成 UTF-8 之后\r\n的字节序不会变仍然可用。5.3 界面假死点按钮没反应现象是连接成功后界面还能动跑几分钟就卡住鼠标点上去转圈。原因是在 UI 线程里做了阻塞操作。最常见的是把_stream.Read直接写在按钮事件里或者用Invoke同步版更新界面而接收线程持有锁两边互等。解决方式分两步接收循环一律放后台线程Task.Run或者new Thread都行界面更新一律用BeginInvoke另外把lock的范围压到最小锁里只做内存操作不做 IO、不碰控件。5.4 中文和字符串乱码现象是数字都对批次号显示成问号或者一串怪字符。原因是两端编码不一致。C# 端用Encoding.ASCII遇到中文直接变成?改成UTF-8之后如果组态端按 GBK 发还是乱。解决方式是先确认组态端的字符串是按字节还是按字符算长度再确认它用的编码。两边统一成 UTF-8 最省事但要注意 UTF-8 下一个中文占 3 字节如果协议里用“固定长度字符串”字段长度要按字节算否则会被截断。实在统一不了就在 C# 端做一次转码Encoding.GetEncoding(GBK).GetString(bytes)但要求目标机器装了对应代码页。5.5 跑一晚上就断白天好好的现象是白天调试一切正常第二天早上来发现连接断了日志停在凌晨某个时间点。原因是没有心跳中间的网络设备把空闲连接回收了。也可能是组态软件那边配了“通信超时断开”一段时间没有数据交互就主动踢掉。解决方式是加心跳固定周期比如 5 秒发一帧对端原样回。同时 C# 端要记录“最后一次收到数据的时间”超过三个心跳周期没收到就主动关闭重连。不要依赖TcpClient.Connected判断这个属性对拔网线、对端宕机都不敏感判断链路是否活着只能靠实际收发。6. 二次开发换协议、加点位、做回环验证拿到这份范例之后真正要改的地方通常只有三处按改动频率从高到低排改动点改哪个文件注意事项增减变量Config/tags.csv地址格式要和组态端变量表逐字对齐别自己补前导零换报文格式Comm/FrameCodec.cs只改拼装和解析收发逻辑不用动换连接角色Comm/TcpClientService.cs从客户端改服务端要多维护一个客户端列表换协议是最常见也最容易出问题的改造。如果对方用的是 Modbus TCP不建议在FrameCodec里手搓字节直接引一个成熟的 Modbus 库把TcpClientService的NetworkStream交给它读写就行注意 Modbus 的寄存器地址有两种写法从 0 开始和从 1 开始对不上会整体偏移一位。如果对方是自定义的二进制协议FrameCodec里要加长度前缀别再用定界符二进制数据里出现\r\n的概率不低。我自己改这类项目有个固定习惯先在 C# 里写一个“假服务端”用TcpListener起在本地按协议回几帧固定数据把整个收发、解析、界面更新链路跑通再去连真的组态设备。这一步能挡掉八成的低级错误——编码不对、定界符写错、序号没对上在本地就能暴露不用跑到现场去猜。假服务端不用写多复杂能回OK,1234就够// 仅调试用本地假服务端模拟组态端行为 var listener new TcpListener(IPAddress.Loopback, 5000); listener.Start(); var client await listener.AcceptTcpClientAsync(); var s client.GetStream(); var buf new byte[1024]; while (true) { int n s.Read(buf, 0, buf.Length); if (n 0) break; var req Encoding.ASCII.GetString(buf, 0, n).Trim(); // 收到读命令就回一帧固定数据方便验证解析逻辑 var resp req.StartsWith(RD) ? OK,1234,56.7\r\n : OK\r\n; var bytes Encoding.ASCII.GetBytes(resp); s.Write(bytes, 0, bytes.Length); }假服务端跑通之后再改一个参数把 IP 从127.0.0.1换成现场设备地址其它代码一行不动。如果这时连不上问题一定在网络和组态配置上不在代码里排查范围一下子就小了。还有一点是日志。范例里的日志只输出到界面实际项目建议同时落文件按天切分格式里带上方向和原始报文。现场出问题时一份带时间戳的收发记录比任何口头描述都管用。我习惯在每条日志里加一个连接序号重连一次换一个号这样能把“断线前”和“断线后”的数据分开看。从那以后我每次对接新的组态设备都强制先走一遍本机假服务端把协议跑通再上现场宁可多花半小时也不愿意在车间里对着一屏幕乱码猜是哪边的问题。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站