對於基於TCP開發的通訊程序,有個很重要的問題需要解決,就是封包和拆包。
一、為什么基於TCP的通訊程序需要進行封包和拆包.
TCP是個"流"協議,所謂流,就是沒有界限的一串數據。
大家可以想想河里的流水,是連成一片的,其間是沒有分界線的。但一般通訊程序開發是需要定義一個個相互獨立的數據包的,比如用於登陸的數據包,用於注銷的數據包。
由於TCP"流"的特性以及網絡狀況,在進行數據傳輸時會出現以下幾種情況:
假設我們連續調用兩次 send 分別發送兩段數據 data1 和 data2,在接收端有以下幾種接收情況(當然不止這幾種情況,這里只列出了有代表性的情況):
A.先接收到 data1,然后接收到 data2。
B.先接收到 data1 的部分數據,然后接收到 data1 余下的部分以及 data2 的全部。
C.先接收到了data1 的全部數據和 data2 的部分數據,然后接收到了 data2 的余下的數據。
D.一次性接收到了data1和data2的全部數據。
對於A這種情況正是我們需要的,在此不再做討論。
對於B,C,D的情況就是大家經常說的"粘包",就需要我們把接收到的數據進行拆包,拆成一個個獨立的數據包。為了拆包就必須在發送端進行封包。
另外要注意的是:對於UDP來說就不存在拆包的問題。因為UDP是個"數據包"協議,也就是兩段數據間是有界限的,在接收端要么接收不到數據要么就是接收一個完整的一段數據,不會少接收也不會多接收。
二、為什么會出現 B,C,D 的情況?
"粘包"可發生在發送端也可發生在接收端。
1、由Nagle算法造成的發送端的粘包:
Nagle算法是一種改善網絡傳輸效率的算法。簡單的說,當我們提交一段數據給TCP發送時,TCP並不立刻發送此段數據,而是等待一小段時間,看看在等待期間是否還有要發送的數據,若有則會一次把這兩段數據發送出去。這是對Nagle算法一個簡單的解釋,詳細的請看相關書籍。像 C 和 D 的情況就有可能是 Nagle 算法造成的。
2、接收端接收不及時造成的接收端粘包:
TCP會把接收到的數據存在自己的緩沖區中,然后通知應用層取數據。當應用層由於某些原因不能及時的把TCP的數據取出來,就會造成TCP緩沖區中存放了幾段數據。
三、怎樣封包和拆包?
最初遇到"粘包"的問題時,我是通過在兩次 send 之間調用 sleep 來休眠一小段時間來解決。
這個解決方法的缺點是顯而易見的:使傳輸效率大大降低,而且也並不可靠。后來就是通過應答的方式來解決,盡管在大多數時候是可行的,但是不能解決象 B 的那種情況,而且采用應答方式增加了通訊量,加重了網絡負荷(但是象FTP等協議采用的就是應答方式)。再后來就是對數據包進行封包和拆包的操作。
封包:
封包就是給一段數據加上包頭,這樣一來數據包就分為包頭和包體兩部分內容了(以后講過濾非法包時封包會加入"包尾"內容)。
包頭其實上是個大小固定的結構體,其中有個結構體成員變量表示包體的長度,這是個很重要的變量,其他的結構體成員可根據需要自己定義。根據包頭長度固定以及包頭中含有包體長度的變量就能正確的拆分出一個完整的數據包。
拆包:
對於拆包目前我最常用的是以下兩種方式:
1、動態緩沖區暫存方式。之所以說緩沖區是動態的是因為當需要緩沖的數據長度超出緩沖區的長度時會增大緩沖區長度。
大概過程描述如下:
A 為每一個連接動態分配一個緩沖區,同時把此緩沖區和 SOCKET 關聯,常用的是通過結構體關聯。
B 當接收到數據時首先把此段數據存放在緩沖區中。
C 判斷緩存區中的數據長度是否夠一個包頭的長度,如不夠,則不進行拆包操作。
D 根據包頭數據解析出里面代表包體長度的變量。
E 判斷緩存區中除包頭外的數據長度是否夠一個包體的長度,如不夠,則不進行拆包操作。
F 取出整個數據包,這里的"取"的意思是不光從緩沖區中拷貝出數據包,而且要把此數據包從緩存區中刪除掉。刪除的辦法就是把此包后面的數據移動到緩沖區的起始地址。
這種方法有兩個缺點:
1)為每個連接動態分配一個緩沖區增大了內存的使用;
2)有三個地方需要拷貝數據,一個地方是把數據存放在緩沖區,一個地方是把完整的數據包從緩沖區取出來,一個地方是把數據包從緩沖區中刪除。這種拆包的改進方法會解決和完善部分缺點。
下面給出相關代碼.
先看包頭結構定義
#pragma pack(push,1) //開始定義數據包, 采用字節對齊方式 /*----------------------包頭---------------------*/ typedef struct tagPACKAGEHEAD { BYTE Version; WORD Command; WORD nDataLen;//包體的長度 }PACKAGE_HEAD; #pragma pack(pop) //結束定義數據包, 恢復原來對齊方式
然后看存放數據和“取"數據函數:
/***************************************************************************** Description:添加數據到緩存 Input:pBuff[in]-待添加的數據;nLen[in]-待添加數據長度 Return: 如果當前緩沖區沒有足夠的空間存放pBuff則返回FALSE;否則返回TRUE。 ******************************************************************************/ bool CDataBufferPool::AddBuff( char *pBuff, int nLen ) { m_cs.Lock();///臨界區鎖 if ( nLen < 0 ) { m_cs.Unlock(); return false; } if(nLen <= GetFreeSize())///判斷剩余空間是否足夠存放nLen長的數據 { memcpy(m_pBuff + m_nOffset, pBuff, nLen); m_nOffset += nLen; } else///若不夠則擴充原有的空間 { char *p = m_pBuff; m_nSize += nLen*2;//每次增長2*nLen m_pBuff = new char[m_nSize]; memcpy(m_pBuff,p,m_nOffset); delete []p; memcpy(m_pBuff + m_nOffset, pBuff, nLen); m_nOffset += nLen; m_cs.Unlock(); return false; } m_cs.Unlock(); return true; }
/***************************************************************************** Description:獲取一個完整的包 Input:Buf[out]-獲取到的數據;nLen[out]-獲取到的數據長度 Return: 1、當前緩沖區不夠一個包頭的數據 2、當前緩沖區不夠一個包體的數據 ******************************************************************************/ int CDataBufferPool::GetFullPacket(char *Buf, int& nLen) { m_cs.Lock(); if(m_nOffset < m_PacketHeadLen)//當前緩沖區不夠一個包頭的數據 { m_cs.Unlock(); return 1; } PACKAGE_HEAD *p = (PACKAGE_HEAD *)m_pBuff; if((m_nOffset - m_PacketHeadLen) < (int)p->nDataLen)//當前緩沖區不夠一個包體的數據 { m_cs.Unlock(); return 2; } //判斷包的合法性 /* int IsIntegrallity = ValidatePackIntegrality(p); if( IsIntegrallity != 0 ) { m_cs.Unlock(); return IsIntegrallity; } */ nLen = m_PacketHeadLen+p->nDataLen; memcpy( Buf, m_pBuff, nLen ); m_nOffset -= nLen; memcpy( m_pBuff, m_pBuff+nLen, m_nOffset ); m_cs.Unlock(); return 0; }
前面提到過這種方法的缺點。下面給出一個改進辦法,即采用環形緩沖。但是這種改進方法還是不能解決第一個缺點以及第一個數據拷貝,只能解決第三個地方的數據拷貝(這個地方是拷貝數據最多的地方)。第2種拆包方式會解決這兩個問題。
環形緩沖實現方案是定義兩個指針,分別指向有效數據的頭和尾。在存放數據和刪除數據時只是進行頭尾指針的移動。
用代碼來說明。
注:下面的代碼是采用一個開源的游戲服務器的代碼,我對此代碼有所修改。
int CCircularBufferPool::PutData(TCHAR *pData, int len) { if( len <= 0 ) return 1; EnterCriticalSection(&m_cs); while(IsOverFlowCondition(len))///判斷緩沖區剩余空間是否夠存放len長的數據 { BufferResize(len);///若不夠,則擴充緩沖區. } if(IsIndexOverFlow(len))///判斷"尾"指針的位置. { int FirstCopyLen = m_iBufSize-m_iTailPos; int SecondCopyLen = len - FirstCopyLen; CopyMemory(m_pBuffer+m_iTailPos, pData, FirstCopyLen); if (SecondCopyLen) { CopyMemory(m_pBuffer, pData+FirstCopyLen, SecondCopyLen); m_iTailPos = SecondCopyLen; } else m_iTailPos = 0; } else { CopyMemory(m_pBuffer+m_iTailPos, pData, len); m_iTailPos += len; } LeaveCriticalSection(&m_cs); return 0; } void CCircularBufferPool::GetData(TCHAR *pData, int len, bool Delete) { if(len < m_iBufSize-m_iHeadPos) { CopyMemory(pData, m_pBuffer+m_iHeadPos, len); if(Delete==true) m_iHeadPos += len; } else { int fc, sc; fc = m_iBufSize-m_iHeadPos; sc = len - fc; CopyMemory(pData, m_pBuffer+m_iHeadPos, fc); if (sc) CopyMemory(pData+fc, m_pBuffer, sc); if(Delete==true) m_iHeadPos = sc; if(m_iHeadPos >= m_iBufSize) m_iHeadPos = 0; } } // //進行自定義包的解析 // int CCircularBufferPool::GetFullPacket( TCHAR *Buf, int &nLen ) { EnterCriticalSection(&m_cs); if(GetValidCount() < m_PacketHeadLen )//當前緩沖區不夠一個包頭的數據 { LeaveCriticalSection(&m_cs); return 1; } GetData(Buf,m_PacketHeadLen,false); PACKAGE_HEAD *p = (PACKAGE_HEAD *)Buf; if( (GetValidCount()-m_PacketHeadLen) < (int)p->nDataLen )//當前緩沖區不夠一個包體的數據 { LeaveCriticalSection(&m_cs); return 2; } //判斷包的合法性 int IsIntegrallity = ValidatePackIntegrality(p); if( IsIntegrallity != 0 ) { LeaveCriticalSection(&m_cs); return IsIntegrallity; } GetData(Buf,m_PacketHeadLen+p->nDataLen,true); nLen = m_PacketHeadLen+p->nDataLen; LeaveCriticalSection(&m_cs); return 0; }
int CCircularBufferPool::PutData(TCHAR *pData, int len) { if( len <= 0 ) return 1; EnterCriticalSection(&m_cs); while(IsOverFlowCondition(len))///判斷緩沖區剩余空間是否夠存放len長的數據 { BufferResize(len);///若不夠,則擴充緩沖區. } if(IsIndexOverFlow(len))///判斷"尾"指針的位置. { int FirstCopyLen = m_iBufSize-m_iTailPos; int SecondCopyLen = len - FirstCopyLen; CopyMemory(m_pBuffer+m_iTailPos, pData, FirstCopyLen); if (SecondCopyLen) { CopyMemory(m_pBuffer, pData+FirstCopyLen, SecondCopyLen); m_iTailPos = SecondCopyLen; } else m_iTailPos = 0; } else { CopyMemory(m_pBuffer+m_iTailPos, pData, len); m_iTailPos += len; } LeaveCriticalSection(&m_cs); return 0; } void CCircularBufferPool::GetData(TCHAR *pData, int len, bool Delete) { if(len < m_iBufSize-m_iHeadPos) { CopyMemory(pData, m_pBuffer+m_iHeadPos, len); if(Delete==true) m_iHeadPos += len; } else { int fc, sc; fc = m_iBufSize-m_iHeadPos; sc = len - fc; CopyMemory(pData, m_pBuffer+m_iHeadPos, fc); if (sc) CopyMemory(pData+fc, m_pBuffer, sc); if(Delete==true) m_iHeadPos = sc; if(m_iHeadPos >= m_iBufSize) m_iHeadPos = 0; } } // //進行自定義包的解析 // int CCircularBufferPool::GetFullPacket( TCHAR *Buf, int &nLen ) { EnterCriticalSection(&m_cs); if(GetValidCount() < m_PacketHeadLen )//當前緩沖區不夠一個包頭的數據 { LeaveCriticalSection(&m_cs); return 1; } GetData(Buf,m_PacketHeadLen,false); PACKAGE_HEAD *p = (PACKAGE_HEAD *)Buf; if( (GetValidCount()-m_PacketHeadLen) < (int)p->nDataLen )//當前緩沖區不夠一個包體的數據 { LeaveCriticalSection(&m_cs); return 2; } //判斷包的合法性 int IsIntegrallity = ValidatePackIntegrality(p); if( IsIntegrallity != 0 ) { LeaveCriticalSection(&m_cs); return IsIntegrallity; } GetData(Buf,m_PacketHeadLen+p->nDataLen,true); nLen = m_PacketHeadLen+p->nDataLen; LeaveCriticalSection(&m_cs); return 0; }
2、利用底層的緩沖區來進行拆包
由於TCP也維護了一個緩沖區,所以我們完全可以利用TCP的緩沖區來緩存我們的數據,這樣一來就不需要為每一個連接分配一個緩沖區了。另一方面我們知道 recv 或者 wsarecv 都有一個參數,用來表示我們要接收多長長度的數據。利用這兩個條件我們就可以對第一種方法進行優化了。
對於阻塞 SOCKET 來說,我們可以利用一個循環來接收包頭長度的數據,然后解析出代表包體長度的那個變量,再用一個循環來接收包體長度的數據。
相關代碼如下:
char PackageHead[1024]; char PackageContext[1024*20]; int len; PACKAGE_HEAD *pPackageHead; while( m_bClose == false ) { memset(PackageHead,0,sizeof(PACKAGE_HEAD)); len = m_TcpSock.ReceiveSize((char*)PackageHead,sizeof(PACKAGE_HEAD)); if( len == SOCKET_ERROR ) { break; } if(len == 0) { break; } pPackageHead = (PACKAGE_HEAD *)PackageHead; memset(PackageContext,0,sizeof(PackageContext)); if(pPackageHead->nDataLen>0) { len = m_TcpSock.ReceiveSize((char*)PackageContext,pPackageHead->nDataLen); } }
其中,m_TcpSock 是一個封裝了SOCKET的類的變量,其中的 ReceiveSize 用於接收一定長度的數據,直到接收了一定長度的數據或者網絡出錯才返回。
int winSocket::ReceiveSize( char* strData, int iLen ) { if( strData == NULL ) return ERR_BADPARAM; char *p = strData; int len = iLen; int ret = 0; int returnlen = 0; while( len > 0) { ret = recv( m_hSocket, p+(iLen-len), iLen-returnlen, 0 ); if ( ret == SOCKET_ERROR || ret == 0 ) { return ret; } len -= ret; return len += ret; } return returnlen;
對於非阻塞的 SOCKET,比如完成端口,我們可以提交接收包頭長度的數據的請求,當 GetQueuedCompletionStatus 返回時,我們判斷接收的數據長度是否等於包頭長度,若等於,則提交接收包體長度的數據的請求,若不等於則提交接收剩余數據的請求。當接收包體時,采用類似的方法。
下面給出相關代碼:
enum IOType { IOInitialize, IORead, IOWrite, IOIdle }; class OVERLAPPEDPLUS { public: OVERLAPPED m_ol; IOType m_ioType; bool m_bIsPackageHead;//當前接收的數據是否是包頭數據。 int m_count; WSABUF m_wsaBuffer; int m_RecvPos; char m_Buffer[1024*8];//此緩沖要盡可能大 OVERLAPPEDPLUS(IOType ioType) { ZeroMemory(this, sizeof(OVERLAPPEDPLUS)); m_ioType = ioType; } };
接收連接后發出的第一個請求,請求接收包頭大小的數據.
OVERLAPPEDPLUS *pOverlappedPlus = new OVERLAPPEDPLUS; pOverlappedPlus->m_wsaBuffer.buf = pOverlappedPlus->m_Buffer; pOverlappedPlus->m_wsaBuffer.len = PACKAGE_HEAD_LEN;///包頭的長度 pOverlappedPlus->m_bIsPackageHead = true; pOverlappedPlus->m_RecvPos = 0; pOverlappedPlus->m_ioType = IORead; DWORD RecvBytes; DWORD Flags; Flags = 0; if (WSARecv(clientSocket, &(pOverlappedPlus->m_wsaBuffer), 1, &RecvBytes, &Flags, &pOverlappedPlus->m_ol, NULL) == SOCKET_ERROR) { if (WSAGetLastError() != ERROR_IO_PENDING) { delete pOverlappedPlus; } else { ///相關的錯誤處理 } } else { ///相關的錯誤處理 }
在GetQueuedCompletionStatus所在的函數中:
if( pOverlapPlus->m_ioType== IORead) { if( pOverlapPlus->m_wsaBuffer.len == dwIoSize ) { if( pOverlapPlus->m_bIsPackageHead == true )///接收到的是包頭。 { PACKAGE_HEAD *pPackageHead = (PACKAGE_HEAD *)(pOverlapPlus->m_Buffer); if(pThis->IsLegalityPackageHead(pPackageHead)==false)///判斷是否是合法的包 { closesocket(lpClientContext->m_Socket); continue; } pOverlapPlus->m_bIsPackageHead = false; pOverlapPlus->m_wsaBuffer.len = pPackageHead->nDataLen; pOverlapPlus->m_RecvPos += dwIoSize; pOverlapPlus->m_wsaBuffer.buf = pOverlapPlus->m_Buffer+pOverlapPlus->m_RecvPos; } else///接收到的是包體 { pOverlapPlus->m_RecvPos += dwIoSize; ///這時pOverlapPlus->m_Buffer里就存放了一個完整的數據包,長度為pOverlapPlus->m_RecvPos ///繼續請求 請求下一個數據包的包頭 pOverlapPlus->m_wsaBuffer.buf = pOverlapPlus->m_Buffer; memset(pOverlapPlus->m_Buffer,0,sizeof(pOverlapPlus->m_Buffer)); pOverlapPlus->m_wsaBuffer.len = PACKAGE_HEAD_LEN; pOverlapPlus->m_bIsPackageHead = true; pOverlapPlus->m_RecvPos = 0; } } else///接收的數據還不完整 { pOverlapPlus->m_wsaBuffer.len -= dwIoSize; pOverlapPlus->m_RecvPos += dwIoSize; pOverlapPlus->m_wsaBuffer.buf = pOverlapPlus->m_Buffer+pOverlapPlus->m_RecvPos; } pOverlapPlus->m_ioType = IORead; state = WSARecv(lpClientContext->m_Socket, &(pOverlapPlus->m_wsaBuffer), 1, &RecvBytes, &Flags, &pOverlapPlus->m_ol, NULL); if ( state == SOCKET_ERROR) { if(WSAGetLastError() != ERROR_IO_PENDING) { //關閉套接字 釋放相應資源 continue; } } }
三、如何判斷包的合法性.
判斷包的合法性可以結合下面兩種方式來判斷.但是想100%的判定出非法包,只能通過信息安全中的知識來判定了,對這種方法這里不做闡述.
1、通過包頭的結構來判斷包的合法性.
最初的時候我是根據包頭來判斷包的合法性,比如判斷Command是否超出命令范圍,nDataLen是否大於最大包的長度.但是這種方法無法過濾掉非法包,當出現非法包時我們唯一能做的就是斷開連接,或許這也是最好的處理辦法.
我們可以給一個完整的包加上開始和結束標志,標志可以是個整數,也可以是一串字符串.以第一種拆包方式為例來說明.當要拆一個完整包時我們先從緩沖區有效數據頭指針地址搜索包的開始標志,搜索到后並且當前數據夠一個包頭數據,則判斷開始標志和包頭是否合法,若合法則根據代表數據長度的變量的值定位到包尾,判斷包尾標志是否與我們定義的一致,若一致則這個包是合法的包.若有一項不一致則繼續尋找下個包的開始標志,並把下個合法包的前面的數據全部舍棄.
2、通過邏輯層來判斷包的合法性.
當取出一個合法的包時,我們還要根據當前數據處理的邏輯來判斷包的合法性.比如說在登陸成功后的某段時間服務器又收到了同一個客戶端的登陸包,那我們就可以判斷這個包是非法的,簡單處理就是斷開連接.
