多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C# Socket通讯源码实践:粘包拆包、心跳机制与断线重连全解析

C# Socket通讯源码实践:粘包拆包、心跳机制与断线重连全解析 简介这是一份基于C#的Socket网络通讯完整源码面向初步接触网络编程的C#开发者适合用来快速理解客户端与服务器之间的通信原理。代码采用WinForm界面分为server和client两个独立项目结构简洁支持文本消息发送与接收直接打开即可运行关键逻辑放在Form1.cs中便于对照理解。压缩包共61个文件以14个cs源代码文件为主另含4个exe可执行程序、4个pdb调试符号文件、4个resx界面资源文件以及项目配置文件等整体体积仅1.17MB轻量易用。目前已有2914人学习下载使用者既可从中掌握Socket建立连接、收发消息的基本流程也能在此基础上进一步扩展多客户端通信、消息协议设计等功能。作者留出私信渠道读者遇到问题或想延伸功能时可直接交流适合作为入门Socket编程的参考起点。1. C# Socket 网络通讯源码真正要解决的是什么做上位机或者设备联调的人大概都经历过这种场景手里有一台 PLC、一张读码器或者一块温控表厂商给的协议文档只有几页 A4 纸写满了字节偏移和校验算法。你在 C# 里新建了一个TcpClient满怀信心地连过去——然后收到一个10061错误远程主机积极拒绝。更麻烦的是就算连上了收到的数据总是对不上明明约定好的帧头偏偏就缺了几个字节。这篇文章说的 C# Socket 网络通讯完整源码不是那种动辄几十个类、接口套接口的框架而是你拿到手能看懂、能改、能在产线上跑起来的一套最小通信骨架。它把服务端、客户端、收包缓存、断线重连和协议解析这些最常踩的环节拆成一个个独立的模块每段代码干了什么参数为什么这么设都会讲清楚。适合刚入门 C# 上位机的开发者也适合被各种通讯库折腾到想自己轮子的老手。下面不废话直接上代码。2. 先定通讯模型同步阻塞比异步更适合多数设备对接2.1 为什么设备通讯不首选 async 全异步C# 里做 Socket 通讯有两种主流姿势一种是TcpClientNetworkStream的同步阻塞读写另一种是基于SocketAsyncEventArgs的纯异步模型。很多教程一上来就推异步理由是线程利用率高。但设备对接的场景并发量往往是个位数一次只和三五台设备通讯每台设备的报文频率也就是几百毫秒一条。这种量级下同步阻塞的代码可读性要比异步高出一个档次。我见过不少同事被异步回调里的上下文切换搞晕SocketAsyncEventArgs的缓冲区复用稍有不慎就会出现上一包数据和下一包数据串了的问题。而同步阻塞模型一个客户端对应一个线程线程里写一个while循环读数据逻辑上就是「收到什么、处理什么」出问题也好定位。线程开销这一点单台设备一个线程十台设备也就十个线程对 Windows 和现代 .NET 运行时来说完全不是压力。另外设备通讯协议大多数是半双工式的上位机发请求设备回响应。这种一问一答的模式用同步阻塞再合适不过。所以结论是先别被「异步性能好」这句话带偏设备对接场景优先考虑同步模型代码简单排查容易。2.2 一个能直接落地的 TCP 服务端骨架服务端的主要职责是监听端口、接收客户端连接、为每个客户端单独开一个线程处理收发。下面这段代码是核心监听部分支持多客户端同时接入每个客户端连接后输出到控制台的日志里带着远程 IP 和端口方便联调时确认设备真的连上来了。using System.Net; using System.Net.Sockets; public class TcpServer { private TcpListener _listener; private readonly int _port; private readonly CancellationTokenSource _cts new(); public TcpServer(int port) { _port port; } public void Start() { _listener new TcpListener(IPAddress.Any, _port); _listener.Start(); Console.WriteLine($[服务端] 监听 {_port} 端口等待设备连接...); Task.Run(AcceptLoopAsync); } private async Task AcceptLoopAsync() { while (!_cts.IsCancellationRequested) { TcpClient client await _listener.AcceptTcpClientAsync(_cts.Token); Console.WriteLine($[连接] {client.Client.RemoteEndPoint} 已接入); Task.Run(() HandleClient(client)); } } private void HandleClient(TcpClient client) { using (client) { var stream client.GetStream(); var buffer new byte[4096]; try { while (true) { int readCount stream.Read(buffer, 0, buffer.Length); if (readCount 0) break; string received System.Text.Encoding.UTF8.GetString(buffer, 0, readCount); Console.WriteLine($[收] {client.Client.RemoteEndPoint}: {received}); // 原样回给客户端确认链路通 byte[] response System.Text.Encoding.UTF8.GetBytes(OK: received); stream.Write(response, 0, response.Length); } } catch (IOException ex) { Console.WriteLine($[断开] {client.Client.RemoteEndPoint}: {ex.Message}); } finally { Console.WriteLine($[离线] {client.Client.RemoteEndPoint} 连接关闭); } } } public void Stop() { _cts.Cancel(); _listener.Stop(); } }AcceptTcpClientAsync负责等待设备接入每个设备连接后立即扔到一个独立线程里执行HandleClient。HandleClient里的stream.Read是阻塞调用设备不发数据时线程就在这里挂着不占 CPU。readCount 0表示对端关闭连接这是网络编程里最基础的断开判定比捕获异常判断来得可靠。代码里用的是 4096 字节的接收缓冲区对绝大多数设备报文足够了。如果设备单帧数据超过 4KB比如某些相机传图就需要改成动态扩容或大缓冲区后面收到数据时再按协议截取。2.3 多客户端会话管理别用静态字典存 Socket上面的代码能跑但生产环境里还需要一个「会话管理器」用来记录当前有哪些设备在线、每个设备最后一次活跃时间、以及主动给某个设备发指令的功能。常见的做法是定义一个ClientSession类把TcpClient、设备标识、收发缓冲区都封装进去。public class ClientSession { public string DeviceId { get; set; } public TcpClient Client { get; set; } public NetworkStream Stream { get; set; } public DateTime LastActiveTime { get; set; } public void Send(string message) { byte[] data System.Text.Encoding.UTF8.GetBytes(message); Stream.Write(data, 0, data.Length); LastActiveTime DateTime.Now; } }有了这个会话类服务端就能用一个ConcurrentDictionarystring, ClientSession来管理所有在线设备。为什么用ConcurrentDictionary而不是普通Dictionary因为设备接入和断开是异步发生的多个线程可能同时修改集合普通字典会抛Collection was modified异常。加锁也能解决但并发字典在读写比例高、写操作少的场景下性能更好代码也更干净。注册到会话表、从会话表移除这两步一定要放在连接建立和连接关闭的try/finally里避免设备异常断线后会话表里残留死连接。2.4 同步与异步的取舍清单判断项选同步阻塞选异步 SocketAsyncEventArgs并发连接数建议 50 以内上千连接才需要报文交互模式一问一答持续高频推送协议复杂度定长或简单长度头流式不定长、频繁复用缓冲代码可维护性天然线性逻辑回调整理难度大设备通讯绝大多数落在左列这也是为什么这种「简单清楚」的源码值得一看。先把同步阻塞吃透后续真有高并发需求框架里再局部替换成异步实现也不迟。3. 客户端封装与数据收发把粘包拆包一次讲透3.1 客户端连接与断开重连的完整写法服务端能收数据了客户端也不能写得稀烂。很多源码示例里的客户端就一个Connect方法连不上就抛异常然后让调用方处理这在正式项目里完全不行。现场设备的网络环境经常不稳定——网线松了、交换机断电、设备重启这些情况都要在客户端里自己兜住。一个可用的客户端封装核心是两步连接成功后的KeepAlive设置以及断线后的自动重连。KeepAlive是 TCP 层的心跳机制由操作系统内核维护默认情况下 socket 空闲两小时才探测一次对设备通讯来说太慢了需要手动调小探测间隔。using System.Net.Sockets; public class TcpClientHelper { private TcpClient _client; private readonly string _host; private readonly int _port; public TcpClientHelper(string host, int port) { _host host; _port port; } public bool Connect() { try { _client new TcpClient(); _client.SendTimeout 3000; _client.ReceiveTimeout 3000; // 开启 TCP KeepAlive探测空闲连接 _client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); _client.Connect(_host, _port); Console.WriteLine($[客户端] 已连接 {_host}:{_port}); return true; } catch (SocketException ex) { Console.WriteLine($[客户端] 连接失败: {ex.SocketErrorCode} - {ex.Message}); return false; } } public void Close() { _client?.Close(); } }连接失败时捕获的是SocketException并且通过SocketErrorCode区分失败原因。ConnectionRefused和TimedOut的应对策略不一样前者说明设备在线但端口没开或者服务没起后者说明 IP 不通。联调时把错误码打出来看一眼就能定位方向比只看一个「无法连接」有用得多。3.2 接收数据的核心问题你会收到半包服务端和客户端都建立了最影响「简单清楚」体验的就是拆包。TCP 是流式协议它只保证字节按序到达不保证一次Send的数据在接收端也恰好一次Read拿到。设备发过来的 100 个字节可能被拆成 4060 两段到达也可能和下一帧数据黏在一起变成 180 字节一次性到达。前者叫半包后者叫粘包。网上很多源码在读取数据时直接按缓冲区里的全部字节转字符串处理这在报文边界恰好在一次Read内闭合的简单测试里能跑通但放到现场十包数据里至少有一包会解析错。解决的办法有三个层次定长协议、特殊分隔符、长度头。设备通讯里最可靠也最通用的是长度头方案尤其是和设备厂定私有协议时几乎都是这种结构。3.3 长度头拆包的完整实现4 字节长度 内容下面这段代码是「边收边拆」的经典实现。维护一个内存缓冲每次读到新数据先追加进去然后循环尝试从缓冲里截取一帧完整报文。框架逻辑是先读前 4 个字节作为长度头网络字节序如果缓冲里不足 4 个字节就等下一包够 4 个字节后算出整帧长度再看缓冲里数据量够不够完整帧够了就取出来交给业务层。public class PacketParser { private readonly Listbyte _buffer new(); public event Actionbyte[] PacketReceived; public void Append(byte[] data, int count) { for (int i 0; i count; i) { _buffer.Add(data[i]); } Parse(); } private void Parse() { while (true) { // 长度头占 4 字节不足则等待更多数据 if (_buffer.Count 4) return; // 读取长度头按网络字节序处理 byte[] lengthBytes _buffer.GetRange(0, 4).ToArray(); if (BitConverter.IsLittleEndian) { Array.Reverse(lengthBytes); } int bodyLength BitConverter.ToInt32(lengthBytes, 0); // 校验长度非法时清空缓冲避免死循环 if (bodyLength 0 || bodyLength 1024 * 1024) { Console.WriteLine([拆包] 非法长度清空缓冲区); _buffer.Clear(); return; } // 数据不足一帧继续等待 if (_buffer.Count 4 bodyLength) return; // 取出一帧完整数据 byte[] packet _buffer.GetRange(4, bodyLength).ToArray(); _buffer.RemoveRange(0, 4 bodyLength); PacketReceived?.Invoke(packet); } } }Append收到的每段数据先追加进_buffer然后进入Parse循环。BitConverter.IsLittleEndian判断是必要的——C# 跑在 x86/x64 机器上默认小端但 TCP 协议里约定的长度头通常是网络字节序即大端不反转就会算出几百万的假长度。长度校验一定要做设备如果发来一个 0 或者负数不加判断的话GetRange会直接抛异常。Parse里的while (true)是为了处理「一次 Read 含多帧」的情况。第一次截完一帧后缓冲区里可能还剩半帧数据继续循环解析直到不足一帧才跳出。这样无论网络上怎么切分业务层拿到的始终是完整帧。3.4 字符协议和十六进制调试设备联调的必经阶段长度头方案适合自定义协议但很多现成设备用的是字符协议比如以\n结尾的 ASCII 报文。这时拆包方式换成ReadLine即可但要注意设备回车的实际形式——有的发\n有的发\r\n还有的混着发。简单处理是收到数据后先转字符串用Trim()去掉首尾空白再判协议。调试阶段我习惯把接收到的原始字节同时以十六进制打印一份方便确认设备发的到底是什么。下面这段代码放在PacketReceived回调里即可string hex BitConverter.ToString(packet).Replace(-, ); Console.WriteLine($[HEX] {hex}); Console.WriteLine($[STR] {Encoding.ASCII.GetString(packet)});十六进制打印要放在PacketReceived回调里这一步定位问题时极好用。很多协议字段在 ASCII 下是不可见字符转成字符串后看起来是一堆乱码但十六进制下能清楚地看到帧头AA 55和帧尾校验字节。4. 三个必调的通信参数缓冲、超时与心跳4.1 缓冲区大小与 Backlog先看速查表Socket 通讯的很多「玄学」问题最后查出来都是缓冲区设得不合理。以下三个参数是设备对接时最常需要调的参数默认值建议值调整依据TcpClient.ReceiveBufferSize81924096~65536报文最大长度 余量避免大包被多次 Read 拆碎TcpClient.SendBufferSize8192同上和设备协商的写入数据量TcpListener.Start(backlog)未指定10~100同时排队的未处理连接数设备批量上线时调大缓冲区不是越大越好。接收缓冲区开太大小报文反而积累在内存里不动拆包逻辑依赖长度头的话还没影响但依赖ReadTimeout的场景容易误判超时。我一般先按设备单帧最大长度的两倍设比如设备最大报文 512 字节ReceiveBufferSize就设 2048够用且有冗余。4.2 超时设置连接超时与收发超时分开配TcpClient.Connect默认没有超时概念连不上的话可能卡十几秒才返回。设置连接超时的一个常见做法是异步连接加等待IAsyncResult result _client.BeginConnect(_host, _port, null, null); bool success result.AsyncWaitHandle.WaitOne(3000, false); if (!success) { throw new TimeoutException(连接超时); } _client.EndConnect(result);WaitOne(3000)表示最多等 3 秒3 秒内连上就返回true连不上就抛超时异常。这个 3 秒按现场环境调整走局域网内部设备建议 1~2 秒走 4G 或跨网段可以放到 5 秒。ReceiveTimeout和SendTimeout设在NetworkStream上超时后下次读写会抛IOException内部包裹着SocketException错误码是TimedOut。读超时捕获时别吞掉异常这是设备「在线但没回复」的唯一线索。4.3 心跳机制设备死了代码不知道怎么办TCP 断开的发现是有延迟的。设备断电、网线拔掉这些情况本机不会立刻知道可能是几分钟后系统才发 KeepAlive 探测失败。对交互型设备来说这个延迟太久所以应用层通常自己加心跳——上位机定时发一个Ping指令设备回Pong连续几次没回应就判定设备离线。心跳间隔的选取要平衡占用和设备实际表现。间隔太短比如 200ms 一次设备端如果是不带缓冲的串口转网口模块可能会因为频繁收到数据而干扰正常通讯。我一般推荐 3~5 秒一个心跳连续 3 次无回应主动断开重连。这个策略在产线上经过验证既能及时感知设备离线又不会给设备增加明显负担。public class HeartBeatMonitor { private readonly Action _sendPing; private readonly Action _onTimeout; private readonly int _intervalMs; private readonly int _maxMissCount; public HeartBeatMonitor(Action sendPing, Action onTimeout, int intervalMs 3000, int maxMissCount 3) { _sendPing sendPing; _onTimeout onTimeout; _intervalMs intervalMs; _maxMissCount maxMissCount; } public async Task StartAsync() { int missCount 0; while (true) { await Task.Delay(_intervalMs); try { _sendPing(); missCount 0; } catch { missCount; if (missCount _maxMissCount) { Console.WriteLine([心跳] 设备无响应触发离线回调); _onTimeout(); return; } } } } }这段心搏监测器只管定时发指令和计数。_sendPing委托里执行实际发送如果发送抛异常说明连接已经断了连续三次就触发_onTimeout回调。心跳任务是在Task里跑的不影响主流程收发数据。4.4 断线重连的退避策略不要无脑 sleep 1 秒断线重连是一个看起来简单但细节很多的点。粗暴的写法是连接失败后Thread.Sleep(1000)再重试这在现场会遇到一个问题设备刚好在重启可能要 10 秒这期间程序会以每秒一次的频率疯狂尝试连接把设备的端口队列占满反而拖慢设备恢复。更稳妥的是指数退避——第一次失败等 1 秒第二次等 2 秒第三次等 4 秒到最大间隔 15 秒就封顶。这样设备恢复后第一次可连接窗口程序大概率能连上连不上也不会刷屏。public async Task ReconnectLoopAsync(TcpClientHelper client, int maxRetrySeconds 15) { int retryCount 0; while (true) { if (client.Connect()) { retryCount 0; // 连接成功的处理例如启动心跳、订阅事件 return; } retryCount; int delaySeconds Math.Min((int)Math.Pow(2, retryCount), maxRetrySeconds); Console.WriteLine($[重连] 第 {retryCount} 次失败{delaySeconds} 秒后重试); await Task.Delay(TimeSpan.FromSeconds(delaySeconds)); } }Math.Pow算出的延迟随着重试次数指数增长到了上限就保持最大间隔。要注意的是这个循环是异步的不会阻塞界面线程适合 WPF 上位机里作为后台任务挂着。重连成功后要记得把retryCount清零否则下次断线又要从很长的等待开始。5. 常见问题排查ERROR 10061、粘包乱码与内存泄漏5.1 远程主机积极拒绝10061现象客户端Connect抛SocketException (10061)提示目标计算机积极拒绝无法连接。原因目标主机的指定端口上没有服务在监听或者防火墙把端口拦了。两种情况要区分端口没监听可能是服务端程序没启动、监听线程崩了、端口被占用换过了防火墙拦截则表现为 ping 得通但端口不通本机命令行telnet IP 端口可以直接验证。解决先在本机netstat -ano | findstr 端口号确认服务端确实在监听再检查防火墙入站规则是否放行了该端口。对现场设备来说还要确认设备端的 IP 和端口确实配套很多设备的通讯参数是拨码开关或网页配置的改过之后要重启设备才生效。5.2 连接被远程主机强制关闭10053 / 10054现象通讯运行一段时间后stream.Read抛异常错误码是10053或10054程序崩溃或通讯中断。原因最常见的是设备端因为某种原因主动关了连接——设备程序崩溃、看门狗复位、固件 bug。还有一种情况是应用层长时间不通讯中间交换机或设备端内核帮我们断开了空闲连接导致对端发 RST 包回来。这个在跨网段或走运营商网络的场景里出现频率很高。解决把 10054 当作正常断线事件处理走断线重连逻辑不要当作致命异常。重连成功后的第一件事发一个查询指令确认设备状态是否正常。如果想减少被交换机断开就把 5.3 节说的应用层心跳加上保活频率高于 NAT 超时时间即可。5.3 粘包半包导致解析错乱现象收到的数据偶尔少几个字节或者两条报文拼在一起解析后字段错位CRC 校验经常失败。原因就是前面详细说过的 TCP 流式特性。半包是因为缓冲区太小设备一帧数据超过 4KB 时被拆成多次Read粘包是因为设备连续发两帧上位机一次Read全收到了。只做「收到多少处理多少」必然出问题。解决接收数据一律进缓冲按长度头或分隔符拆帧后再交给业务层。缓冲区用Listbyte追加虽然简单但频繁RemoveRange会有复制开销数据量大了之后改成Queuebyte或者带偏移的byte[]更高效。产线高频通讯时关注一下拆包效率能省不少 CPU。5.4 句柄泄漏与内存持续上涨现象程序运行一整天后任务管理器里句柄数一路涨到几千内存也跟着往上飙通讯响应越来越卡。原因TcpClient没有正确释放。最常见的错误是只关了NetworkStream没关TcpClient或者Accept返回的客户端在异常路径里跳过了Close。Socket 底层是操作系统句柄不释放的话句柄表撑爆新连接进不来。解决所有TcpClient的创建和使用要么using包裹要么在finally里Close并手动调用Dispose。会话管理的移除逻辑要放在finally而非catch之后确保任何路径都会清理。按上面的ClientSession封装来写通常不会出现这种问题。5.5 收到的中文乱码编码不统一现象设备发来的中文文本显示为「锟斤拷」之类或者十六进制和预期对不上。原因设备端用的编码和上位机不一致。设备端可能是GB2312、GBK、ASCII、UTF-8上位机统一用Encoding.UTF8.GetString去解自然全乱。特别是很多老设备手册上写着「ASCII 编码」实际实现是GB2312要以抓包实测为准。解决抓取真实数据包和文档对照确认编码。可以通过抓包工具先查看原始字节内容。中文报文的设备优先尝试Encoding.GetEncoding(GB2312)。要灵活一点的做法是收到数据后先按某种编码解解出来不可读字符太多就切换编码重解但这属于应急手段正常开发还是定好一种编码两边统一。6. 验证手段与进阶方向从能跑到跑得稳6.1 并发连接压测用一个小脚本冒充多设备服务端写完不能只用一个客户端测试至少模拟 3~5 台设备同时连接才能暴露多线程收发的竞态问题。最快的方式是写一个简单的并发连接脚本开多个 socket 同时连上去每两秒发一条报文人var tasks new ListTask(); for (int i 0; i 5; i) { int deviceId i; tasks.Add(Task.Run(() { var client new TcpClient(); client.Connect(127.0.0.1, 9000); var stream client.GetStream(); var data Encoding.UTF8.GetBytes($Device-{deviceId}-{DateTime.Now:T}); for (int j 0; j 100; j) { stream.Write(data, 0, data.Length); Thread.Sleep(500); } })); } Task.WaitAll(tasks.ToArray());这个脚本跑起来观察服务端控制台日志每个客户端接入和断开的日志是否成对出现收到的数据有没有乱、有没有丢。再配合任务管理器观察句柄数压测期间应该保持稳定不涨不减。这一步做过之后基本可以排除代码层面的线程安全问题。6.2 关键路径日志埋点三年后你能靠日志定位问题Socket 通讯程序最怕「偶发问题」。客户说设备十分钟后连不上了你跑过去可能两小时都复现不了。这时候日志就是唯一的后悔药。除了开始和断开时的日志建议把下面几类事件也打点客户端每次连接成功记录 IP端口、每次断线记录原因和错误码、每次重连记录当前次数、每次异常丢弃记录起始字节和长度。日志要带时间戳精确到毫秒因为网络问题经常要按时间轴对多端日志。不要嫌日志多一个正常运行的通讯模块一天下来的日志量不会超过几十 MB按天切割保留一周完全可行。排查时把上位机日志和设备端日志按时间对齐问题基本一眼就看穿。6.3 更好的结构把协议编解码从通讯逻辑里剥出来最后给出一个重构建议。当你的通讯源码要对接多种设备时塞在PacketReceived回调里的一堆if会变得不可维护。更清晰的结构是把协议解析独立成类只暴露一个方法输入原始字节输出业务对象。通讯层只负责「完整帧的收发」协议层负责「弄懂这帧什么意思」上下层之间用接口隔开。public interface IProtocolParser { bool TryParse(byte[] rawData, out object message); } public class ModbusRtuParser : IProtocolParser { public bool TryParse(byte[] rawData, out object message) { // 校验 CRC解析地址码功能码数据体 message null; return false; } }这样每接一台新设备只要写一个新的协议解析类通讯层的收发和断线重连逻辑完全不用动。用这种方式这套简单清楚的 Socket 源码就不再只是一个玩具示例而是能支撑一个车间级设备接入项目的地基。我自己最早写的通讯代码就是吃了协议和通讯混在一起的亏改一个设备会影响另一台的收发后期花了两周才拆干净。希望这篇文章能帮你少走那段弯路先跑通这个骨架再在实践里一层一层加固希望帮到你。本文还有配套的精品资源点击获取
返回列表