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 帧头 + 帧尾 + 校验和 自定义协议循环接收解析完整方案 

整体流程

  1. 全局私有缓冲区(List<byte> 最方便操作删除截取)接收所有 TCP 字节
  2. 循环扫描缓冲区,先匹配帧头
  3. 匹配到帧头后,判断剩余字节是否足够一整帧长度
  4. 够长度则截取完整帧 → 校验 CRC → 校验帧尾
  5. 校验通过:执行业务处理,从缓冲区删除已消费的所有字节
  6. 校验失败:删除当前帧头第一个字节,回到步骤 2 重新遍历(抗脏数据)
  7. 缓冲区剩余不足一帧,退出循环,等待下一次接收追加数据

关键规则

  • 缓冲区每个 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;
}
View Code

单连接缓冲区封装类(每个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
}
View Code

服务端接收循环调用示例

// 每一个客户端连接单独实例化一个解析器
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}");
            // 在这里解析载荷、执行业务逻辑
        }
    }
}
View Code

 

posted @ 2026-08-13 23:27  HelloWorld庄先生  阅读(14)  评论(0)    收藏  举报