Socket丢包粘包的处理方案
一、粘包
粘包产生的原因:TCP 是流式协议,无消息边界,操作系统缓冲区会积攒数据一次性发送,UDP就不会。
粘包的原因:
1.发送方连续多次 Send,内核缓冲区合并成一个 TCP 包发出。
2.接收方一次 Recv 读取到多条消息拼接在一起。
3.发送数据包过小,Nagle 算法自动合并小包。
针对不同场景有不同的解决方法:
针对第一个,一次发送完成之后,等对方回复,同时建立超时机制,设置一个阈值,假如超时3次以内可以重发,3次以上对方没有回复则报警。
针对第二个,首先双方定义交互的报文协议,一种是有帧头,帧尾+校验位;或是加上“特殊分隔符”。
针对第三个,关闭Nagle算法(减少主动粘包,会略微增加网络包数量,大数据文件传输建议开启:合并报文,减少包数量,提升吞吐量)
// TcpNoDelay=true 关闭Nagle,小包立即发送 socket.NoDelay = true;
二、丢包
TCP 底层有重传、校验、滑动窗口,不会丢字节,一般都是业务层丢包:
丢包的原因:
1.接收缓冲区读取不全,只读了一部分数据,剩余数据被丢弃
2.多线程并发处理逻辑错误、缓冲区覆盖、异步回调被冲掉(独立缓冲区)
3.网络中断、断开未捕获异常,残留数据丢失(这个时候就要报警,日志做好记录)。
发送方:如果字节内容很长,需要循环发送。
View Code接收方
建立2个缓冲区:一个是list<>的滑动缓冲区;一个是单次循环接收的byte数组;
TCP 帧头 + 帧尾 + 校验和 自定义协议循环接收解析完整方案
整体流程
- 全局私有缓冲区(
List<byte>最方便操作删除截取)接收所有 TCP 字节 - 循环扫描缓冲区,先匹配帧头
- 匹配到帧头后,判断剩余字节是否足够一整帧长度
- 够长度则截取完整帧 → 校验 CRC → 校验帧尾
- 校验通过:执行业务处理,从缓冲区删除已消费的所有字节
- 校验失败:删除当前帧头第一个字节,回到步骤 2 重新遍历(抗脏数据)
- 缓冲区剩余不足一帧,退出循环,等待下一次接收追加数据
关键规则
- 缓冲区每个 Socket 连接独立一个,多客户端不能共用
- 校验不通过直接丢弃 1 字节回溯查找,防止死循环卡死
- 禁止用
MemoryStream频繁重建,List<byte>插入删除性能更友好
极简口诀
- 每连接独立
List<byte>滑动缓冲区,TCP 流全部往里堆; - 循环找帧头,裁掉前面垃圾字节;
- 读长度算整帧总大小,缓冲区够长再截取;
- 先验帧尾再验 CRC,双校验防篡改、防错包;
- 校验成功删缓冲区已消费数据,失败删一字节回溯重找;
- 发送端严格对齐帧头、长度、校验、帧尾四段结构。
协议常量定义
using System; using System.Collections.Generic; using System.Linq; public static class ProtocolDef { // 帧头 2字节 AA BB public static readonly byte[] Header = { 0xAA, 0xBB }; // 帧尾 2字节 CC DD public static readonly byte[] Tail = { 0xCC, 0xDD }; // 帧头长度 public const int HeaderLen = 2; // 长度字段占用2字节 public const int LengthFieldLen = 2; // CRC校验占用2字节 CRC16 public const int CrcLen = 2; // 帧尾长度 public const int TailLen = 2; // 单帧最小固定头部+尾部开销 = 2+2+2+2=8字节(不含载荷) public const int FixedOverhead = HeaderLen + LengthFieldLen + CrcLen + TailLen; }
单连接缓冲区封装类(每个ClientSocket实例化一个对象)
/// <summary> /// 带帧头帧尾CRC校验的TCP粘包解析器 /// </summary> public class FrameParser { // 滑动接收缓冲区 private readonly List<byte> _recvBuf = new List<byte>(); // 单次读取临时数组 private readonly byte[] _tempBuf = new byte[4096]; #region 外部调用入口:塞入TCP收到的字节,返回解析成功的完整帧列表 public List<byte[]> AppendDataAndParse(Socket socket) { List<byte[]> resultFrames = new List<byte[]>(); try { // 读取TCP数据 int readCount = socket.Receive(_tempBuf); if (readCount <= 0) return resultFrames; // 连接关闭 lock (_recvBuf) { // 写入缓冲区 _recvBuf.AddRange(_tempBuf.Take(readCount)); // 循环反复解析,直到拆不出完整帧 while (ParseOneFrame(out byte[] frame)) { resultFrames.Add(frame); } } } catch (Exception ex) { Console.WriteLine($"接收异常:{ex.Message}"); } return resultFrames; } #endregion #region 核心单帧解析方法 /// <summary> /// 尝试解析一帧,成功返回true,并输出完整帧原始字节 /// </summary> private bool ParseOneFrame(out byte[] fullFrame) { fullFrame = null; lock (_recvBuf) { int bufLen = _recvBuf.Count; // 1. 缓冲区不足最小固定开销,直接返回 if (bufLen < ProtocolDef.FixedOverhead) return false; // ========== 步骤1:查找帧头 AA BB ========== int headerIndex = FindHeaderIndex(); if (headerIndex == -1) { // 没有找到帧头,清空缓冲区无效脏数据 _recvBuf.Clear(); return false; } // 移除帧头前面所有无效垃圾字节 if (headerIndex > 0) { _recvBuf.RemoveRange(0, headerIndex); } // 修剪后重新获取长度 bufLen = _recvBuf.Count; // 帧头占2字节,接下来至少还要 2(长度)+2(CRC)+2(尾) =6 if (bufLen < ProtocolDef.FixedOverhead) return false; // ========== 步骤2:读取载荷长度(第2、3位两个字节) ========== byte[] lenBytes = new byte[2]; lenBytes[0] = _recvBuf[2]; lenBytes[1] = _recvBuf[3]; // 网络统一大端序 ushort payloadLen = BitConverter.ToUInt16(lenBytes.Reverse().ToArray(), 0); // 计算本帧总字节数:帧头2 + 长度2 + 载荷N + CRC2 + 帧尾2 int totalFrameLen = ProtocolDef.FixedOverhead + payloadLen; // 缓冲区不够完整一帧,等待下次接收 if (bufLen < totalFrameLen) return false; // ========== 步骤3:截取完整一帧 ========== fullFrame = _recvBuf.Take(totalFrameLen).ToArray(); // ========== 步骤4:校验帧尾 ========== bool tailOk = CheckFrameTail(fullFrame, totalFrameLen); if (!tailOk) { // 帧尾错误,丢弃当前帧第一个字节,重新查找帧头 _recvBuf.RemoveAt(0); fullFrame = null; return false; } // ========== 步骤5:校验CRC校验和 ========== bool crcOk = CheckCrc(fullFrame, payloadLen); if (!crcOk) { _recvBuf.RemoveAt(0); fullFrame = null; return false; } // ========== 全部校验通过,删除缓冲区已消费数据 ========== _recvBuf.RemoveRange(0, totalFrameLen); return true; } } #endregion #region 辅助私有方法 // 在缓冲区查找帧头AA BB起始位置 private int FindHeaderIndex() { for (int i = 0; i <= _recvBuf.Count - ProtocolDef.HeaderLen; i++) { if (_recvBuf[i] == ProtocolDef.Header[0] && _recvBuf[i + 1] == ProtocolDef.Header[1]) { return i; } } return -1; } // 校验帧尾是否为CC DD private bool CheckFrameTail(byte[] frame, int totalLen) { int tailStart = totalLen - ProtocolDef.TailLen; return frame[tailStart] == ProtocolDef.Tail[0] && frame[tailStart + 1] == ProtocolDef.Tail[1]; } // CRC16校验示例(根据你的设备实际校验算法替换) private bool CheckCrc(byte[] frame, ushort payloadLen) { // CRC所在位置:帧头2 + 长度2 + 载荷N = 偏移位置 int crcOffset = ProtocolDef.HeaderLen + ProtocolDef.LengthFieldLen + payloadLen; byte[] crcBytes = new byte[2] { frame[crcOffset], frame[crcOffset + 1] }; // 参与CRC计算的区域:长度字段 + 载荷数据 int calcDataLen = ProtocolDef.LengthFieldLen + payloadLen; byte[] calcData = new byte[calcDataLen]; Array.Copy(frame, ProtocolDef.HeaderLen, calcData, 0, calcDataLen); // 此处替换为你真实的CRC16算法(Modbus CRC16/Xmodem等) ushort calcCrc = CalcCRC16(calcData); ushort recvCrc = BitConverter.ToUInt16(crcBytes.Reverse().ToArray(), 0); return calcCrc == recvCrc; } // 示例Modbus CRC16算法 private ushort CalcCRC16(byte[] data) { ushort crc = 0xFFFF; for (int i = 0; i < data.Length; i++) { crc ^= data[i]; for (int j = 0; j < 8; j++) { if ((crc & 1) != 0) crc = (ushort)((crc >> 1) ^ 0xA001); else crc >>= 1; } } return crc; } #endregion }
服务端接收循环调用示例
// 每一个客户端连接单独实例化一个解析器 private void ClientReceiveLoop(Socket clientSock) { FrameParser parser = new FrameParser(); while (true) { // 塞入Socket数据,自动解析出所有完整合法帧 var frames = parser.AppendDataAndParse(clientSock); foreach (var frame in frames) { // frame 就是校验通过的完整一帧原始字节数组 Console.WriteLine($"收到合法帧,长度:{frame.Length}"); // 在这里解析载荷、执行业务逻辑 } } }
浙公网安备 33010602011771号