TCP的粘包、拆包问题及其解决方法产生粘包和拆包的原因自

最近花了挺多时间跟TCP的粘包问题死磕,终于也算有了一些进展,在这里记录一下我是如何解决TCP的粘包问题的,也希望看到这篇文章的你如果有更好的方法可以和我交流~

产生粘包和拆包的原因

TCP协议的特点

TCP协议是面向字节流的协议。TCP中的“”(stream)指的是流入到进程或从进程流出的字节序列。

面向字节流的含义是:虽然应用程序和TCP的交互是一次一个数据块(大小不等),但是,TCP把应用程序交付下来的数据仅仅看成是一串无结构的字节流,TCP并不知道所传送的字节流的含义。对于应用程序来说,它看到的数据之间没有边界,也无法得知一条报文从哪里开始,到哪里结束,每条报文有多少字节。

而UDP是面向消息的协议,每个UDP段都是一条消息,应用程序必须以消息为单位提取数据,不能一次提取任意字节的数据。因此,UDP通信不会发生粘包问题。

导致粘包的情况

连续发送较短数据
在发送数据时,TCP会根据nagle算法,将数据量小的,且时间间隔较短的数据一次性发给对方。也就是说,如果发送端连续发送了好几个数据包,经过nagle算法的优化,这些小的数据包就可能被封装成一个大的数据包,一次性发送给接收端,而TCP是面向字节流的通信,没有消息保护边界,所以就产生了粘包问题。
接收端没有及时接收数据
还有一种情况会产生粘包,就是接收方没有及时接收数据。可能发送端发来了一段数据,但接收端只接收了部分数据,剩下的小部分数据还遗留在接收缓冲区。那么在下一次接收时,接收缓冲区上不但有上一次遗留的数据,还可能有来自其它报文数据,它们作为一个整体被接收端接收了,这就也造成了粘包。

综上所述,在接收缓冲区上的粘包表现形式主要有以下三种:

1)packet1和packet2被合并起来一起发送
在这里插入图片描述
2)packet1发生了拆包,packet2与packet1的部分数据被合并起来一起发送
在这里插入图片描述
3)packet2的部分数据没有被及时接收,留在缓冲区与packet1合并起来一起发送
在这里插入图片描述

导致拆包的情况

如果发送端缓冲区的长度大于网卡的MTU时,TCP会将这次发送的数据拆成几个数据包发送出去。也就是说,发送端可能只发送了一次数据,接收端却要分好几次才能收到完整的数据。

自定义协议解决粘包和拆包问题

构造自定义协议

虽然我们无法决定TCP会如何处理发送端发出来的数据,但我们可以借助序列化和反序列化的思想,人为地为数据添加边界。比如,在发送端给待发送的数据加上自定义协议作为报文头,在接收端接收数据时,再把数据还原成我们想要的样子。

举个例子,报文头可以按如下方式构造:
在这里插入图片描述
协议头(head)是我们在接收缓冲区中识别本程序所需报文的基本依据;
控制码(cmd)用来标识程序中不同报文的作用;
报文数据长度(len)是一个数据报中真实数据的长度,当然也可以是一个数据报的完整长度;
校验码(crc)一般在head、cmd、len的基础上生成,为了进一步确保之前通过协议头判断的数据报是我们要的,(单凭协议头判断目标报文是否存在是不够严谨的,报数据部分也有出现协议头序列的可能)
报文数据(data)就是发送端真正需要发送的数据,也是接收端经过反序列化后,需要得到的数据。

了解了如何构造一个带协议的数据报之后,就该考虑在接收端如何解析数据报了。

解析报文

接收端调用一次readAll(),会把当前接收缓冲区上的所有数据读取出来,这时候的接收缓冲区上可能有如下几种情况:
在这里插入图片描述
不包含协议头
对于这种情况,还需要进行进一步判断:

  • 是目标报文的数据部分。说明数据发送的过程中发生了拆包,需要多次接收数据,直到所接收的数据总长度达到协议中指定的报文数据长度。
  • 不是目标报文的数据部分。可以直接丢弃。

包含一个或多个完整的协议头
对于这种情况,还需要进行进一步判断:

  • 能通过CRC校验。说明当前缓冲区发生了粘包,需要进行循环处理。

  • 不能通过CRC校验。也分两种情况:

    • 如果读取到的是长度完整的协议,但仍不通过CRC校验,说明当前缓冲区的数据不是目标报文的数据,可以直接丢弃。
    • 如果读取到的是长度不完整的协议(比如协议在拆包时被截断),才导致没有通过CRC校验,就不能判断接下来读取的数据不是目标报文的数据。【对于这种情况,要特殊处理】

包含不完整的协议头

对于这种情况,首先肯定是无法通过是否包含协议头的判断的,但如果直接丢弃这段数据,就会造成丢包。所以,要防止这种情况的发生,就要防止在读取数据时,读取过短的数据。

知道缓冲区可能有以上这几种情况,有助于我们对症下药,下面来梳理接收端处理数据的流程:在这里插入图片描述
解析报文的流程:
在这里插入图片描述
部分代码:

//收数据
void CDataRecver::slotDataArrived(QByteArray array)
{
    m_buff.recevData.append(array);   //添加到内存接收缓冲区
    //判断是否有文件头
    checkBufferHasHead(m_buff);

    //每个数据报接收完毕就进行解析,否则,等待下一次信号来临继续接收
    quint32 size=m_buff.recevData.size();
    if(size>=m_buff.totalLen)
    {
        parseBufferData(m_buff,m_recvDataVector);   //解析数据报,并用一个向量保存接收到的数据报
    }
    qDebug()<<"current recv:"<<array.size()<<"total size:"<<m_buff.recevData.size();
}

void CDataRecver::checkBufferHasHead(BufferData &bufferData)
{
    //判断缓冲区是否包含报文头

    //缓冲区原来有报文头,只需要继续读取数据
    if(bufferData.hasHead)
    {
        return;
    }

    //之前没有报文头,加上这次读取的数据,再进行判断
    int index=bufferData.recevData.indexOf(PRIVATE_HEAD);
    if(index == -1)
    {
        //说明这次没有报文头
        bufferData.recevData.clear();
        return;
    }

    //这次有报文头
    if(index>0)
    {
        bufferData.recevData.remove(0,index);
    }


    //可以确定缓冲区含有报文头,进行CRC校验
    //取出协议中的各个分量
    QDataStream out(&bufferData.recevData,QIODevice::ReadOnly);
    quint32 header,cmd,len,crc;
    QByteArray data;
    out>>header>>cmd>>len>>crc>>data;

    //如果没有通过CRC校验,说明数据仍然不是我们要的,清楚缓冲区所有内容
    if(checkCRC(header,cmd,len,crc)==false)
    {
        cout<<"wrong crc";
        bufferData.recevData.clear();
        bufferData.totalLen=0;
        bufferData.hasHead=false;
        return;
    }

    //包含文件头并且通过CRC校验
    bufferData.hasHead=true;
    bufferData.totalLen=len;
}

//CRC校验
bool CDataRecver::checkCRC(quint32 header, quint32 cmd, quint32 len, quint32 crc)
{
    bool isOK=false;
    quint32 rightCRC=0;
    rightCRC=header+cmd+len-PROTOCOL_LENGTH;
    if(rightCRC==crc)
    {
        isOK=true;
    }
    return isOK;
}

//解析缓冲区的数据
void CDataRecver::parseBufferData(BufferData &bufferData, QVector<CProtocalData> &vector)
{
    //检查缓冲区是否含有报文头
    int index=bufferData.recevData.indexOf(PRIVATE_HEAD);
    if(bufferData.recevData.size()==0)
    {
        return;
    }
    if(index==-1)
    {
        cout<<"can't find header when parsing";
        return;
    }
    //如果有多个数据报在缓冲区中
    while(index != -1)
    {
        if(index>0)
        {
            bufferData.recevData.remove(0,index);
        }
        //缓冲区当前数据长度小于协议长度,退出循环等待下一次读取
        if(bufferData.recevData.size()<PROTOCOL_LENGTH)
        {
            break;
        }
        //进行CRC校验
        QDataStream out(&bufferData.recevData,QIODevice::ReadOnly);
        quint32 header,cmd,len,crc;
        QByteArray data;
        out>>header>>cmd>>len>>crc>>data;
        if(checkCRC(header,cmd,len,crc)==false)
        {
            qDebug()<<"wrong crc";
            bufferData.recevData.clear();
            bufferData.hasHead=false;
            bufferData.totalLen=0;
            break;
        }
        //若当前读取到的数据长度小于文件数据实际长度,退出循环等待下一次读取
        quint32 dataSize=data.size()+PROTOCOL_LENGTH;
        if(len>dataSize)
        {
            break;
        }
        //当前数据报接收完毕,转化成ProtocalData,添加到用来保存 接收到的数据报的向量中,并从缓冲区冲移除
        vector.append(CProtocal::toProtocalData((bufferData.recevData.mid(0,dataSize))));
        bufferData.recevData.remove(0,dataSize);

        //为下一个数据报的读取做准备工作
        bufferData.hasHead=false;
        index=bufferData.recevData.indexOf(PRIVATE_HEAD);

        //如果报文头被截断,退出循环等待下一次读取
        if(bufferData.recevData.size()-index<4)
        {
            break;
        }
    }
}
复制代码

为了测试解析报文的方法是否正确,用一个小demo模拟发送端和接收端的行为,由于在实际的数据传输过程中,网卡可能在任何位置给数据包进行拆包,因此本demo也模拟了发送端的数据在任意位置被截断的情况。注释也写得很详细了,应该不难理解~

demo源码 【若积分不够,可以在评论区留下邮箱,看到会回的】

P.S:如有错误,欢迎指正~