ARTICLE DETAIL

资讯详情

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

C# Socket网络通讯实战:上位机TCP粘包拆包与异步收发源码解析

C# Socket网络通讯实战:上位机TCP粘包拆包与异步收发源码解析 简介这套C# Socket网络通讯源码面向初步接触网络编程的开发者以服务端与客户端两个完整项目演示Socket通信基本原理代码结构简单、逻辑清晰打开即用适合用于课程设计或通讯功能扩展。压缩包共61个文件以C#源文件cs为主并包含可执行程序exe、调试符号pdb、界面资源resx及解决方案文件sln等整体仅1.17MB便于快速下载与本地运行。已有2915人学习使用口碑较为可靠。通过服务端与客户端的分项目组织读者可直观理解连接建立、消息收发等关键环节并在此基础上自定义协议或增加多客户端管理等扩展功能。全部源码均为原创风格直白便于二次改造。1. C#Socket网络通讯是什么上位机场景里绕不开的最后一条路如果你做过C#上位机大概率有过这样的经历设备方只甩给你一个IP和端口说“连上去收数据就行”。所谓C#Socket网络通讯就是这时候用来把两个进程之间的字节流打通的那套基础能力——TCP也好、UDP也好绕不开监听、连接、收发和断开。完整源码听起来唬人拆开看核心不过三个点你等谁、你发什么、你怎么知道一条消息从哪儿断到哪儿。这篇文章按“先立模型、再给源码、后讲避坑”的顺序把这条路走通适合刚接触socket网络编程的C#上位机开发者也适合协议对接做到一半发现粘包问题的人。2. 动手前先把模型立住同步阻塞、异步回调与粘包拆包很多人拿不到能跑的源码不是因为不会写收发而是没想清楚自己要的是哪种通信模型写出来的代码跟场景是拧着的。这一章先把模型掰开后面再看源码就不会一头雾水。2.1 先分清服务端和客户端TcpListener、TcpClient、Socket怎么选Socket是最底层的抽象TcpClient和TcpListener是对它的一层包装。TcpListener管“监听”TcpClient管“连接后的通信”它们内部都持有Socket对象。在C#上位机开发里选择逻辑很简单你需要等待别人来连你就用TcpListener你需要主动去连别人就用TcpClient。两个都不满足才考虑直接操作Socket比如要设置很细的IO选项或者同时挂多个连接时自己控缓冲区。绝大多数项目用前两者就够了。一个容易搞反的点是很多设备扫码器、称重仪表、读卡器本身是服务端开机后监听一个固定端口上位机反而要作为客户端去连接它。工控现场常见的Modbus TCP就是上位机做客户端连PLC的502端口。反过来如果设备会把数据主动推给上位机比如RFID读写器把标签数据上报过来那上位机就是服务端设备掉线重连也不影响接收。设计代码前先把角色定死不然写到一半发现角色反了整个收发流程都要返工。2.2 同步阻塞与BeginReceive回调为什么设备主动上报必须用异步同步模式下程序调用Receive就一直停在那里等数据直到有数据或者对端关闭才返回。对于“发一条指令—等一条应答”的请求应答型交互比如上位机给PLC下发一条复位命令同步模式最简单也最直白不容易出错。但上位机往往同时要和十几台设备通信每台设备都开一个专职线程阻塞等待线程数量一多调度开销和资源占用就成了问题UI线程更不能这么干。老项目里最常见的异步写法是BeginReceive回调。调用的套路是先准备一个byte[]缓冲区和SocketAsync对象调用socket.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, 回调方法, state)然后立即返回。数据到达时框架在线程池线程上调用你传进去的回调方法回调里调EndReceive拿到实际收到的字节数。这里的坑在于同一时刻只允许一个未完成的BeginReceive如果回调还没处理完又发了第二次BeginReceive会抛异常所以一般用异步状态对象把缓冲区、Socket、消息累积器打包传进去err个跌跌撞撞。.NET后来提供的async/await模式本质上也是异步回调只不过编译器帮你生成了状态机代码写起来像同步可读性好很多。下一篇源码就基于async/await新项目建议直接用这套老代码实在不能动再补BeginReceive封装。2.3 粘包、半包、断包TCP是字节流不是消息流TCP不保消息边界它只保证字节顺序。你一次Send了100字节对端可能一次收到100字节也可能先收到40字节再收到60字节极端情况下还可能一次收到200字节——里面有两条消息粘在一起。这就是工控里天天说的粘包和半包问题。很多socket网络编程出问题的项目根源都是直接把收到的字节流当完整消息解析收到半个包就解码解码失败就当成设备乱发数据整个上位机界面直接崩掉。治这个问题的标准做法是给每条消息加边界。常见三种固定长度、特殊结束符、包头带长度。二进制数据里固定长度最简单但不灵活特殊结束符比如\r\n需要处理数据里出现同样字符的情况要转义麻烦我在C#上位机里最常用的是“4字节长度前缀消息体”发送端先把消息体长度写成4字节int拼在消息体前面一起发接收端先读4字节拿到长度再按这个长度去读消息体。这样无论网络怎么拆包都能从字节流里把整条消息重新抠出来。3. 完整源码落地TCP服务端客户端的最小可运行方案这套源码的设计就一句话发的时候先发4字节长度再发内容收的时候先收满4字节再按长度收内容。剩下的全是循环和异常处理。这一章给的是能直接运行的完整Program.cs新建一个.NET 6以上的控制台项目就能跑。3.1 服务端源码监听、Accept、读满与拆包服务端先启动监听每来一个客户端就用一个独立Task处理不阻塞主循环。拆包逻辑集中在ReadExactlyAsync里——必须把指定字节数读满才算一次完整读取少了就继续读这就是处理半包的唯一办法。看完整代码。using System.Net; using System.Net.Sockets; using System.Text; TcpListener listener new TcpListener(IPAddress.Any, 9000); listener.Start(); Console.WriteLine($服务端已启动监听端口 {9000}); while (true) { TcpClient client await listener.AcceptTcpClientAsync(); _ HandleClientAsync(client); // 每个客户端一个独立任务不阻塞Accept } static async Task HandleClientAsync(TcpClient client) { Console.WriteLine($客户端接入: {client.Client.RemoteEndPoint}); NetworkStream stream client.GetStream(); byte[] header new byte[4]; // 长度头 byte[] buffer new byte[64 * 1024]; // 消息体缓冲按业务调整 try { while (true) { // 先读满4字节长度头 if (!await ReadExactlyAsync(stream, header, header.Length)) break; // 客户端正常关闭 int bodyLen BitConverter.ToInt32(header, 0); if (bodyLen 0 || bodyLen buffer.Length) { Console.WriteLine($非法消息长度: {bodyLen}主动断开); break; } // 再按长度读消息体 if (!await ReadExactlyAsync(stream, buffer, bodyLen)) break; string text Encoding.UTF8.GetString(buffer, 0, bodyLen); Console.WriteLine($[{DateTime.Now:HH:mm:ss}] 收到: {text}); byte[] echo Encoding.UTF8.GetBytes($echo: {text}); await WriteFrameAsync(stream, echo); } } catch (Exception ex) { Console.WriteLine($连接异常: {ex.Message}); } finally { client.Close(); Console.WriteLine($客户端断开: {client.Client.RemoteEndPoint}); } } // 把buffer读满count字节才算成功解决半包 static async Taskbool ReadExactlyAsync(NetworkStream stream, byte[] buffer, int count) { int offset 0; while (offset count) { int read await stream.ReadAsync(buffer, offset, count - offset); if (read 0) return false; // 连接关闭 offset read; } return true; } static async Task WriteFrameAsync(NetworkStream stream, byte[] body) { byte[] header BitConverter.GetBytes(body.Length); await stream.WriteAsync(header, 0, header.Length); await stream.WriteAsync(body, 0, body.Length); }这段代码里有两个参数值得重点说明。第一个是IPAddress.Any它表示监听本机所有网卡地址工控机上设备可能从有线网卡、无线网卡任何一个网卡进来绑定Any才不会漏接如果你只回环测试可以改成IPAddress.Loopback。第二个是buffer的64KB上限这是给消息体设的保险丝防止对端发一个超大的长度值比如几十GB让你的程序直接OutOfMemory崩溃实际项目里按最大协议帧来定就行比如Modbus TCP一般给4096都够。_ HandleClientAsync(client)这行是丢任务模式主循环不await它客户端连接后立刻能继续Accept下一个。代价是异常没有被观察者捕获不过HandleClientAsync内部已经把异常全拦住了外面没有抛出来的机会。如果业务里还有未捕获异常建议在Task外层再加一个日志兜底记录是哪台设备的IP断开或者异常。3.2 客户端源码连接、发帧、收帧的对称写法客户端的收发逻辑必须跟服务端完全对称先发4字节长度再发消息体接收时先读4字节再读对应长度的消息体。双方只要有一边把长度算错或者没用相同的大小端就会出现消息解析错位。using System.Net.Sockets; using System.Text; using TcpClient client new TcpClient(); await client.ConnectAsync(127.0.0.1, 9000); Console.WriteLine(已连接服务端); NetworkStream stream client.GetStream(); byte[] header new byte[4]; byte[] buffer new byte[64 * 1024]; for (int i 0; i 100; i) { string msg $hello from client {i}; byte[] body Encoding.UTF8.GetBytes(msg); await WriteFrameAsync(stream, body); if (!await ReadExactlyAsync(stream, header, header.Length)) break; int bodyLen BitConverter.ToInt32(header, 0); if (bodyLen 0 || bodyLen buffer.Length) break; if (!await ReadExactlyAsync(stream, buffer, bodyLen)) break; string reply Encoding.UTF8.GetString(buffer, 0, bodyLen); Console.WriteLine($服务端回复: {reply}); } client.Close(); Console.WriteLine(客户端退出); static async Taskbool ReadExactlyAsync(NetworkStream stream, byte[] buffer, int count) { int offset 0; while (offset count) { int read await stream.ReadAsync(buffer, offset, count - offset); if (read 0) return false; offset read; } return true; } static async Task WriteFrameAsync(NetworkStream stream, byte[] body) { byte[] header BitConverter.GetBytes(body.Length); await stream.WriteAsync(header, 0, header.Length); await stream.WriteAsync(body, 0, body.Length); }这里的ConnectAsync第二个参数是端口号要和服务端监听的一致否则会抛SocketException连接失败。客户端把缓冲区和服务端一样设成64KB这里有个隐患我在第4章会细说如果服务端发来的消息体大于这个缓冲区读半截就会出错。设置好后发送和接收都通过同一个帧结构走代码结构和服务端几乎镜像这也是刻意保持的逻辑越对称越不容易出幺蛾子。3.3 跑通链路两个控制台实例先把回环测通把服务端代码存成Server项目的Program.cs客户端代码存成一个Client项目。两个项目分别启动或者打开两个终端窗口同时dotnet run。先启动服务端再启动客户端正确的结果是客户端每发一条服务端打印一条收到消息客户端收到一条echo回复循环100次后两边都正常退出。这个测试通过就说明链路通、帧对齐。如果出问题先按这个顺序排查第一步ping 127.0.0.1确认网络栈正常第二步看服务端控制台有没有打印“服务端已启动”没有就说明端口被占用参见第4章第三步看客户端有没有“已连接服务端”连不上先查防火墙和端口最后再看消息收发。本机回环是最干净的测试环境没有防火墙干扰也没有丢包能把代码逻辑和网络环境问题有效隔离。4. Socket通讯排查与避坑端口冲突、UI卡死、断开检测的血泪记录源码能跑通只是第一步落地到产线上才是真正的考验。这些坑都是我在C#上位机项目里踩过的每一条都按现象、原因、解决的顺序写清楚照着排查能少走很多弯路。4.1 端口被占用绑定报错“每个套接字地址只允许使用一次”现象启动服务端时抛出SocketException错误信息是“通常每个套接字地址(协议/网络地址/端口)只允许使用一次。”如果用了Java或者Python技术栈的设备端还会看到类似的“failed to create server shutdown socket on address [localhost] and port [802...”字样本质都是端口冲突。原因要么是别的进程已经占用了这个端口要么是你自己上一个程序没有完全退出Socket还处在TIME_WAIT状态没释放。调试时按了停止按钮但进程没真正结束是C#控制台程序里最常见的翻车场景。解决先用下面命令查端口被谁占用然后杀掉对应进程或者干脆换一个端口。netstat -ano | findstr :9000 taskkill /PID 12345 /F第一行会列出监听9000端口的进程PID第二行强制结束它。如果是程序自己残留去任务管理器确认没有同名进程再启动。工控现场还有种情况是设备厂商给的固定端口不能改解决不了占用就只能改监听IP或者协议层加转发别硬顶。4.2 回调线程里更新UI跨线程控件访问会把上位机搞崩现象在Receive回调里写textBox1.AppendText(msg)运行时抛出InvalidOperationException提示“线程间操作无效从不是创建控件的线程访问它”。或者不报错但界面卡死过一会儿无响应。原因异步回调在线程池线程上执行不是UI线程WinForms和WPF都不允许直接跨线程更新控件。卡死的情况多半是回调里弹了MessageBox把线程阻塞后又占用了UI资源。这也是C#上位机面试里经常追问的地方问的就是明白不明白线程模型。解决用Control.BeginInvoke把UI更新操作调度回UI线程回调在哪个线程跑都不受影响。this.BeginInvoke(new Action(() { textBox1.AppendText(msg Environment.NewLine); }));注意要优先用BeginInvoke而不是InvokeBeginInvoke不等待UI线程执行完就返回Invoke会同步等待万一UI线程正在处理别的耗时操作接收线程就被拖住了。更规范的做法是给UI更新建一个队列接收线程只往队列里塞数据UI线程用Timer去取这样就算一秒来几千条消息也不会卡界面。4.3 断网后代码不报错TCP不会主动告诉你对端走了现象网线被拔了或者设备断电上位机界面还显示“已连接”发指令也“成功”了没有任何异常。过几分钟再操作才突然收到一个异常断开。原因TCP没有在应用层探活。只要对端没有发RST包操作系统就认为连接还在你往缓冲区里写数据也只进入系统内存不会立刻报错。很多新手同学在这个阶段会怀疑是不是自己代码写错了其实是TCP本身的机制。解决两条腿走路。一是应用层心跳每3到5秒发一帧心跳指令超过N秒没收到对端响应就判定断开走重连逻辑二是设置Socket的KeepAlive和超时参数但操作系统的KeepAlive默认要两个小时才探测一次不调整的话远水救不了近火。还有个有用的信号ReadAsync返回0表示对端正常关闭了连接这是TCP协议里最明确的优雅关闭信号收到就立刻清理连接。代码层面可以这样设置底层超时client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); client.Client.ReceiveTimeout 10000; // 10秒没数据就超时4.4 粘包半包导致的乱码拆包逻辑必须和发送端严格对齐现象客户端发送的消息偶尔能正常解析偶尔两条内容拼在一起偶尔一条消息被截成两半解析出来是乱码或者直接抛异常。原因这就是2.3里说的TCP字节流特性。只要收发双方有一方没按长度前缀拆包或者把编码搞错了UTF-8和GB2312混用就会出现这种间歇性故障。最气人的是这种问题在回环测试里可能跑一百次都遇不到一到现场网络稍微卡一下就开始时不时冒出来。解决坚持用第3章的帧结构发送端写长度头时用BitConverter.GetBytes接收端也用BitConverter.ToInt32两边必须一致。还要注意大小端如果设备端是Python或者Java默认用大端C#的BitConverter是小端不一致的话收到的长度值可能是16777216这种天文数字直接触发我们设的长度校验。遇到这种情况要么在网络层反转要么自定义一个大小端转换函数统一处理。5. 验证与进阶压测通过才算这套C#Socket通讯真正能用代码写完先别急着上产线用一轮简单的验证把拆包和断线逻辑的真实状态暴露出来。5.1 用回环压测验证拆包正确性把第3章客户端的循环次数从100改成10000服务端每收到一条就计数。跑完看两边计数是否一致一致说明拆包逻辑基本可靠。第二步做乱序测试客户端开多个并发任务同时发每个任务连续发1000条这能暴露缓冲区重用和共用变量的问题。第三步拔网线测试在客户端发送循环中间人为断开网络观察服务端是否在预期时间内判定断开程序有没有崩溃或者内存上涨。我在做这类压测时会顺手把服务端收到的毫秒时间戳也打印出来看相邻两条消息的间隔。如果出现突然200毫秒以上的断档多半是网络抖动不是代码问题别改成重连逻辑太激进。真正的生产环境建议配合网络模拟工具模拟丢包和延迟比回环测试更能暴露问题。5.2 再往前一步帧格式、心跳与资源回收的进阶做法当前这套长度前缀方案只适合内部协议如果要对接真正的设备帧格式设计得更完善一些头部可以扩展成“消息序号协议ID长度CRC16校验”消息序号用来检测丢帧和重复帧CRC用来校验内容是否被篡改这在弱网环境里几乎是必须的。资源回收方面用using包裹TcpClient和NetworkStream连接断开时调用Dispose避免句柄泄漏。高并发场景可以把byte[]换成MemoryPool循环租借或者改用SocketAsyncEventArgs走IOCP压测时吞吐量能提升不少。我现在的习惯是每台设备接入先记一条日志每次异常断开把堆栈和收发的最后一段数据一起存下来这样设备厂扯皮的时候手里有证据自己排查也能少花半天时间。网络通讯这行没有多少玄学绝大多数问题都是帧格式不对称、缓冲区不够、断线没检测这三类框架搭对了后面就是正常维护的事。希望帮到你。本文还有配套的精品资源点击获取
返回列表