ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#上位机与PLC通信:从Socket异步收发到粘包拆包与断线重连的完整实战指南

C#上位机与PLC通信:从Socket异步收发到粘包拆包与断线重连的完整实战指南 简介面向C#上位机开发与工业自动化学习者围绕基于Socket与PLC服务器的TCP通信展开承接前一篇TIA PORTAL V15.1与S7-PLCSIM ADVANCED V3.0的组态配置重点讲解Visual Studio 2019环境下的C#编码实现。资源为单个docx文档容量约365KB内容按登录窗体、Socket连接、数据接收与解析逐步展开包含可直接参考的代码片段和界面设计说明。文中演示了在Form1中添加IP地址默认192.168.1.20与端口号默认2000文本框并给出连接按钮的Click事件处理代码涵盖Socket实例化、IPEndPoint解析、Connect连接及失败提示。随后讲解StartReceive异步接收与缓冲区字节数组处理利用BitConverter、Encoding按协议取出整数、浮点数、字符串并提示大小端、字段位置、序列化方案以及心跳检测、断线重连等工程要点帮助读者避开常见坑点。已有966人学习该文档适合需要从组态到编码落地、快速搭建上位机通信原型的入门至中级开发者。1. 从“连上PLC”到“能放心用”TCP通信这一层到底还有多少活要干C#上位机这个方向最容易卡住人的反而不是界面也不是数据库而是Socket这一层看起来早就该会的东西。上一篇把和PLC的TCP连接跑通了connect一发就通报文也发得出去但真把程序丢到现场用三天就会露馅PLC一个扫描周期没回界面直接假死连续读几个DB块报文错位设备一断电程序从此再也没有捞回来。这篇要补齐的就是连上之后的另一半——把Socket收发的原始字节流变成上位机项目里真正能信赖的通信底座协议帧怎么拼、异步收发模型怎么搭、粘包半包怎么拆、断线和心跳怎么处理。适合正在做上位机开发、从“能联调”走向“要稳定”的工程师也适合把Socket网络编程刚学完但不知道往哪落的人。读完你可以直接把代码摘走改而不是再翻一遍MSDN。2. 先立协议再写代码PLC这端的报文要先变成C#能拆的字节流2.1 先定协议再定Socket西门子S7、Modbus TCP与私有协议怎么选很多做了两三个上位机项目的人见了PLC还是只问“它IP多少”。IP和端口只是最后一公里真正决定通信代码怎么写的是应用层协议。做C#上位机编程常见的PLC通信协议有三类西门子S7协议跑在102端口报文里包含PDU号、功能嵌套读写DB块、I区、M区都靠它Modbus TCP通用性强端口502帧结构干净清楚几乎所有PLC和IO模块都支持剩下就是厂商私有协议比如三菱MC、台达、汇川通常是“指令头正文校验”的结构。对做C#上位机基础学习的人来说我的建议是先用Modbus TCP把通信层写稳。原因很简单一个读请求帧就12个字节响应帧也就是9N个字节解析逻辑一眼看到底最容易把“拆包、组帧、超时、重连”这些通用骨架练扎实。S7虽然实际项目中占比更高但S7的报文里还藏着一层TPKT、一层S7Header初学时很容易在拆包上摔跤分不清哪一层粘了包。协议一旦定下来后面所有通信层代码都围绕“帧的编解码”来写。这一点特别重要你写的那套Socket收发模型、拆包器、重连逻辑和Modbus还是S7没关系之后真要切西门子换的只是怎么把读写请求组装成字节数组、怎么从响应字节里抠出数据。所以不要一上来就去找“S7的DLL”先把协议选型和自己的通信框架分层想清楚这才是c#上位机通用框架该有的样子。2.2 用类映射报文结构不要在代码里到处写byte[]魔法数字现场维护最怕的一种代码就是解析报文时直接按“buffer[8] 8 | buffer[9]”硬写每个数字都代表什么只有写的人自己知道。换个PLC型号、改个协议版本整段代码就要推倒重来。我一般会把报文结构映射成C#类每个字段对应着写在属性上组帧和拆帧都成为类的方法。下面这段是Modbus TCP读保持寄存器的请求帧构建字段顺序和TCP传输的字节流完全一致public class ModbusReadRequest { public ushort TransactionId { get; set; } // 事务标识区分同一连接上的多次请求 public ushort ProtocolId { get; set; } 0; // 协议标识Modbus固定为0 public ushort Length { get; set; } // 长度指“单元ID之后所有字节”的数量 public byte UnitId { get; set; } 1; // 单元ID通常对应Modbus从站地址 public byte FunctionCode { get; set; } 3; // 功能码03代表读保持寄存器 public ushort StartAddress { get; set; } // PLC里的起始寄存器地址 public ushort Quantity { get; set; } // 要读多少个寄存器 public byte[] Build() { Length 6; // UnitId 1 FunctionCode 1 StartAddress 2 Quantity 2 byte[] buffer new byte[12]; PutBigEndian(buffer, 0, TransactionId); PutBigEndian(buffer, 2, ProtocolId); PutBigEndian(buffer, 4, Length); buffer[6] UnitId; buffer[7] FunctionCode; PutBigEndian(buffer, 8, StartAddress); PutBigEndian(buffer, 10, Quantity); return buffer; } private static void PutBigEndian(byte[] buffer, int offset, ushort value) { buffer[offset] (byte)(value 8); // 高字节在前 buffer[offset 1] (byte)(value 0xFF); } }这里的核心是Length字段。很多初学者会把Length误写成12实际Modbus TCP规定Length是指“从单元ID开始到报文结束的字节数”所以固定是6。这个数字错了PLC会直接丢弃请求或者返回异常。还有所有多字节整型都用大端序传输也就是高字节在前C#里的BitConverter默认在Windows上按小端处理直接拿去用会得到完全相反的结果所以这里必须手动移位拼字节。把组帧收进类里有另一个好处测试方便。你可以在单元测试里new一个ModbusReadRequest把字节数组打出来和Wireshark抓到的报文逐字节比。不要靠眼睛去数buffer[8]这种位置你总有一天会看花眼。2.3 解析响应时先把字节序和数据类型对齐再谈业务发出去的请求是12个字节PLC回的报文就复杂一点事务ID、协议ID、长度、单元ID、功能码、字节数后面才是寄存器数据。解析响应时最容易犯的错是把“寄存器数量”和“字节数”搞混。读5个保持寄存器返回的字节数是10不是5。解码代码我习惯写成这样public static ushort[] ParseReadHoldingRegisters(byte[] frame) { // 最小长度MBAP头6 单元ID1 功能码1 字节数1 if (frame.Length 9) throw new ArgumentException(响应帧长度不足); byte byteCount frame[8]; // 第8个字节是跟随其后的数据字节数 ushort[] values new ushort[byteCount / 2]; for (int i 0; i values.Length; i) { int index 9 i * 2; // PLC返回的是大端序必须手工还原成ushort values[i] (ushort)((frame[index] 8) | frame[index 1]); } return values; }byteCount字段很关键它告诉你后面到底跟了多少字节数据这个数值必须和请求里的Quantity对得上。如果PLC返回的byteCount是0多半是寄存器地址越界或者功能码不被支持。还有一点要注意Modbus TCP里超过2字节的数据类型比如32位浮点数、32位整数在PLC侧的高低字顺序经常和C#的字节序又不一样这是另一层坑——有的PLC高字在前有的低字在前。你要是直接把4个字节塞给BitConverter.ToSingle数据大概率是错的必须先按协议规定把字序调整回C#能理解的样子。3. 把收发从“请求一次读一次”改成“事件驱动的异步循环”3.1 同步阻塞为什么会在PLC面前翻车新手写Socket通信最容易写出来的结构是连接成功之后开一个Thread里面对Socket.Receive做死等。这在两台电脑之间做Demo没问题一旦对端是PLC问题就来了。PLC的扫描周期从几毫秒到几十毫秒不等加上现场网络里可能还有中继、交换机、偶发延迟你那个线程会长期阻塞在Receive调用上。阻塞本身不是大问题问题在于它占用了你唯一的接收通道。你没法同时发起多个请求也没法在超时后主动收手。更棘手的是TCP连接异常时Receive并不一定立刻返回有些PLC的通信板在链路已经断掉的情况下也不会关Socket你的线程就这么永远睡下去。所以要改成异步接收模型让Socket操作交给IO完成端口数据到了才触发回调没有数据时不占用任何线程。3.2 用SocketAsyncEventArgs搭一个可复用的异步接收循环下面是我在C#上位机项目里常用的一段异步接收核心只保留最关键的骨架public class PlcChannel { private Socket _socket; private readonly byte[] _buffer new byte[4096]; public event Actionbyte[] DataReceived; public async Taskbool ConnectAsync(string ip, int port) { _socket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) { NoDelay true, // 关闭Nagle算法降低小报文的发送延迟 SendTimeout 3000, ReceiveTimeout 3000 }; try { await _socket.ConnectAsync(ip, port); } catch (SocketException) { return false; } StartReceive(); return true; } private void StartReceive() { SocketAsyncEventArgs args new SocketAsyncEventArgs(); args.SetBuffer(_buffer, 0, _buffer.Length); args.Completed OnReceiveCompleted; // 如果ReceiveAsync返回false表示操作同步完成了也要走同一套处理 if (!_socket.ReceiveAsync(args)) OnReceiveCompleted(_socket, args); } private void OnReceiveCompleted(object sender, SocketAsyncEventArgs e) { if (e.SocketError ! SocketError.Success || e.BytesTransferred 0) { HandleDisconnect(); // 报错或对端关闭都需要走重连流程 return; } byte[] chunk new byte[e.BytesTransferred]; Array.Copy(_buffer, 0, chunk, 0, e.BytesTransferred); DataReceived?.Invoke(chunk); // 把原始字节交给上层拆包器不在这个回调里做业务解析 StartReceive(); // 一次接收完成立刻挂起下一次监听 } public void Send(byte[] data) { if (_socket null || !_socket.Connected) return; _socket.Send(data, 0, data.Length, SocketFlags.None); } }这段代码有三个地方值得细看。第一NoDelay设为true是为了避免小报文在Nagle算法下被缓存合并PLC通信的请求很多都是12字节这种短消息你不想让一个读请求在客户端里多等40毫秒。第二ConnectAsync不是只做一次每次连接成功就必须马上调用StartReceive后续所有数据都是“到了再通知”而不是循环读。第三OnReceiveCompleted回调里没有直接调用拆包解析只是把原始字节发布出去这样通信层和协议层可以各自独立修改。3.3 回调跑在哪个线程别让UI线程背锅SocketAsyncEventArgs的Completed事件回调通常不是在你的主线程上执行而是由线程池里的IO线程触发。这意味着在DataReceived里直接更新listView或者textBox轻则界面闪烁重则直接抛出跨线程访问异常。上位机开发里这种翻车极其常见解决办法也不是没得选我一般在事件回调里把完整帧丢进一个ConcurrentQueue让UI层自己的Timer定时去取。// 通信层内部 private readonly ConcurrentQueuebyte[] _frameQueue new ConcurrentQueuebyte[](); public event Actionbyte[] FrameReady; public void EnqueueFrame(byte[] frame) { _frameQueue.Enqueue(frame); FrameReady?.Invoke(frame); // 通知UI层去取 }UI启动一个500毫秒的Timer在Timer回调里while尝试Dequeue取到就更新控件。这样即使一秒钟来了几十帧界面也能平滑消费不会因为一次刷新太久卡掉整个定时器。通信层本身完全感知不到UI的存在这也是上位机通用框架里通信层和界面层解耦的标准手法。4. 粘包和半包把收到的字节流直接交给拆包器别拿到就解析4.1 现象一帧变成两帧两帧合成一帧还有读了一半的半包Socket是流协议TCP不保证你每发一次就收到一次完整报文。PLC侧连续返回两帧响应中间没有任何分隔符本端可能一次Receive就同时拿到两帧也可能因为网络抖动只拿到一帧的前半部分。现场最常见的一幕上位机里显示“寄存器值乱跳”一看日志数据长度对不上再多读几次数值就错位了。很多C#上位机编程新手会直接拿接收到的字节数组去调ParseReadHoldingRegisters头都做烂了也没想起来问题根本不在解析而是在边界根本没切对。处理粘包半包是通信层的基本功这一点和具体协议无关你要做的是在一层“拆包器”里先按帧边界把字节流切成一个一个独立完整的帧再把完整帧交给协议解析。4.2 定长帧和变长帧按字段算总长别按固定值拍脑袋拆包有两种情况。定长帧最简单直接按固定字节数切切完剩下的留在缓冲里继续等变长帧要靠帧里的长度字段来判断一帧到底有多长。Modbus TCP属于变长帧长度字段在MBAP头的偏移4和5它表示“从单元ID开始还剩多少字节”。所以完整一帧的总字节数是6加上这个长度字段的值这里的6是事务ID2、协议ID2、长度2一共占掉的MBAP头。这个“长度字段是部分长度”的细节是两个非常容易搞混的坑点。有的协议长度字段包含整个帧头有的不包含你必须先看协议文档再写拆包器。错一个数字帧边界就全错了后面解析全是白费。4.3 一个能直接落地的缓冲拆包类下面这个类用List 做累计缓冲只要收到新数据就往里追加然后循环从缓冲里切出完整帧public class FrameSplitter { private readonly Listbyte _buffer new Listbyte(4096); private readonly int _maxFrameLength; private readonly int _lengthFieldOffset; // 长度字段距离帧头的位置 private readonly int _lengthFieldLength; // 长度字段占几个字节 private readonly int _lengthFieldBase; // 长度字段是否包含长度字段本身之前的字节 public FrameSplitter(int maxFrameLength 1024, int offset 4, int length 2, int baseOffset 6) { _maxFrameLength maxFrameLength; _lengthFieldOffset offset; _lengthFieldLength length; _lengthFieldBase baseOffset; } public Listbyte[] PushAndSplit(byte[] data) { _buffer.AddRange(data); Listbyte[] frames new Listbyte[](); while (_buffer.Count _lengthFieldOffset _lengthFieldLength) { int declaredLength ReadLengthField(); int totalFrameLength declaredLength _lengthFieldBase; if (totalFrameLength _maxFrameLength) { _buffer.Clear(); throw new InvalidDataException($帧长度超限{totalFrameLength}); } if (_buffer.Count totalFrameLength) break; // 半包继续等待剩余字节 byte[] frame _buffer.GetRange(0, totalFrameLength).ToArray(); _buffer.RemoveRange(0, totalFrameLength); frames.Add(frame); } return frames; } private int ReadLengthField() { if (_lengthFieldLength 2) return (_buffer[_lengthFieldOffset] 8) | _buffer[_lengthFieldOffset 1]; return _buffer[_lengthFieldOffset]; // 单字节长度字段 } }这段代码的关键参数都在构造方法里暴露出来maxFrameLength是保护阀防止PLC那边发来一个超大长度值把缓冲撑爆或者陷入死循环lengthFieldBase是上文说的那个容易搞错的偏置Modbus TCP里设成6最合适。每次收到数据后要反复循环切分因为一次Receive可能带过来好几帧完整报文切完一帧还要再检查缓冲里剩余字节是否足够下一帧。拆包器还有一个容易被忽略的好处它是处理“异常奇数字节”的天然防线。现场如果出现一帧数据中间多了一个字节你解析一定会错有了长度字段校验一旦总长超出缓冲区能对上的范围马上抛异常并要求重连程序不会带病运行。这是我做上位机项目时最强调的一条宁可断开重连也不要带着脏数据继续跑。5. 通信不上线心跳、断线重连与五条现场踩坑记录5.1 心跳设计低频探活别把PLC的扫描周期当背景板上位机和PLC之间就算没有业务请求也要有活着的证据。常见做法是每一个心跳周期发一条读请求读PLC里的一个固定寄存器或系统秒字上位机只要收到合法响应就认为链路健康。重点在频率PLC的扫描周期一般几十毫秒但心跳没必要跟上这个节奏1到3秒一次完全够。频率太高反而占用PLC的通信资源影响正常读写。心跳还要和超时判定配合连续2到3个心跳周期没有响应就判定为断线触发重连。5.2 断线重连指数退避比高频硬连更靠谱重连不能写一个while循环里疯狂Connect那会让PLC的通信板卡吃不消也会把日志刷成一片垃圾。我一般用指数退避第一次重连延迟1秒第二次2秒第三次4秒最大30秒封顶。每次重连前把旧的Socket释放缓冲清空心跳队列清空否则那边还残留着上次未发送的命令。private async Task ReconnectLoop(string ip, int port, CancellationToken token) { int delay 1000; while (!token.IsCancellationRequested) { bool ok await _channel.ConnectAsync(ip, port); if (ok) { Log(重连成功); break; } await Task.Delay(delay, token); delay Math.Min(delay * 2, 30000); // 最多退避到30秒 } }重连期间如果有界面按钮要给一个明确的“离线”状态不要显示一个永远绿的连接灯。很多现场事故不是没重连是重连成功了但界面显示还是断开操作工看到绿灯灭了直接去把PLC断电重启反而把事情搞大。5.3 五条现场踩坑记录按“现象→原因→解决”对齐一条一条对着看全是我在排障时见过且亲自踩过的雷。第一现象上位机解析出来的寄存器值全是乱码数值忽大忽小偶尔又完全正确。原因接收到的字节没有先过拆包器把半包或合并的两帧强行去解析数据边界错位。解决必须在DataReceived回调后立刻走FrameSplitter只允许完整帧进入解析层。同一个位置如果还出现奇数字节优先检查是不是长度字段的base偏移设错了。第二现象Socket.Connected属性还是true但上位机发什么PLC都不回答过了很久才返回失败。原因TCP底层链路还在但PLC侧的通信任务已经认为这个会话超时把两端状态丢掉了而C#的Socket.Connected只反映最后一次IO的状态根本不能用来判断对端是否活着。解决以心跳响应为准连续N个心跳周期无响应主动Close旧Socket并触发重连。第三现象WinForms程序一收到大量数据界面就像死了一样拖窗口都费劲。原因接收事件回调直接调用了Invoke同步更新控件高频数据把UI线程堵死。解决先用ConcurrentQueue缓存完整帧UI层定时器批量刷新。只要涉及1秒超过几十帧的数据这个方案必须提前做。第四现象程序运行一段时间后发送命令时报“无法将数据写入传输连接: 远程主机强迫关闭了一个现有的连接”。原因PLC侧发生总线错误或程序复位把TCP连接重置了而本端还拿着旧Socket继续发送。解决发送方法里捕获SocketException一旦捕获立即把连接标记为失效清空所有待发送命令触发重连。不要把异常吞掉吞掉就是下一轮崩溃。第五现象后台线程偶尔报一个未处理的异常程序直接退出没有任何提示。原因接收回调里没有try-catch任何解析异常都会顺着回调抛到线程池上把进程带崩。解决所有IO回调和异步方法外层都包try-catch异常统一写进日志文件。通信层在任何情况下都不应该让异常飞出边界。这五条做完通信层就基本稳了。剩下的才是业务逻辑比如命令排队、读写分离、批量轮询那些依赖具体项目不做统一展开。6. 再往前一步把通信层并入上位机通用框架用模拟器完整验证6.1 用接口隔离协议以后换PLC只换编解码现在通信链路已经通了下一步是给整个通信层套一个稳定的对外接口。我建议定义IProtocolChannel这样的接口里面只放ConnectAsync、Send、RegisterFrameHandler、Disconnect四个方法所有业务层只依赖这个接口不关心底层是S7还是Modbus。这样你新接一台PLC只需要实现一个协议适配器把帧的编解码和拆包参数填进去上层界面一行都不用改。常见的上位机通用框架几乎都是这个结构通信层、协议层、业务层、界面层一层一层的依赖关系单向向下。6.2 现场验证的最小组合模拟器、抓包、日志三级确认没有PLC在现场的背景下我用Modbus模拟器把整套代码验证过无数次你说不值得做模拟器验证那是没踩过去现场才发现功能码都拼错的尴尬。跑通的第一步是用模拟器绑定TCP端口用同一套代码去读保持寄存器第二级用抓包工具对比报文字节确认类里拼出来的请求帧和标准抓包一致第三级才是接真实PLC。现场调试时日志必须带时间、帧方向、原始十六进制报文和解析结果这一条习惯能救回你很多个熬夜修bug的晚上。这块通信层是我所有上位机上位机项目里复用率最高的部分最后一次重构它就是因为发现现场换PLC要改的地方太多了。现在的习惯是每接一种新设备先写协议适配器再用模拟器跑一整个状态机最后才上真机。整个过程里最花时间的永远不是Socket本身而是把一个不可靠的字节流打磨成稳定的帧序列。通信这层稳了后面的数据处理、报表、MES对接才有意义。希望这段从Socket到稳定通信层的整理能帮到你哪怕只是避掉其中一条坑也值回这次阅读的时间。本文还有配套的精品资源点击获取
返回列表