最近花了挺多时间跟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校验。也分两种情况:
包含不完整的协议头
对于这种情况,首先肯定是无法通过是否包含协议头的判断的,但如果直接丢弃这段数据,就会造成丢包。所以,要防止这种情况的发生,就要防止在读取数据时,读取过短的数据。
知道缓冲区可能有以上这几种情况,有助于我们对症下药,下面来梳理接收端处理数据的流程:
解析报文的流程:
部分代码:
//收数据
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:如有错误,欢迎指正~