简介:面向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个字节,响应帧也就是9+N个字节,解析逻辑一眼看到底,最容易把“拆包、组帧、超时、重连”这些通用骨架练扎实。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 Action<byte[]> DataReceived; public async Task<bool> 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 ConcurrentQueue<byte[]> _frameQueue = new ConcurrentQueue<byte[]>(); public event Action<byte[]> 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 List<byte> _buffer = new List<byte>(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 List<byte[]> PushAndSplit(byte[] data) { _buffer.AddRange(data); List<byte[]> frames = new List<byte[]>(); 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到稳定通信层的整理能帮到你,哪怕只是避掉其中一条坑,也值回这次阅读的时间。
本文还有配套的精品资源,点击获取