轉載來源:https://www.cnblogs.com/felixzh/p/8359066.html
一、TCP數據報結構以及三次握手
TCP(Transmission Control Protocol,傳輸控制協議)是一種面向連接的、可靠的、基於字節流的通信協議,數據在傳輸前要建立連接,傳輸完畢后還要斷開連接。
客戶端在收發數據前要使用 connect() 函數和服務器建立連接。建立連接的目的是保證IP地址、端口、物理鏈路等正確無誤,為數據的傳輸開辟通道。
TCP建立連接時要傳輸三個數據包,俗稱三次握手(Three-way Handshaking)。可以形象的比喻為下面的對話:
- [Shake 1] 套接字A:“你好,套接字B,我這里有數據要傳送給你,建立連接吧。”
- [Shake 2] 套接字B:“好的,我這邊已准備就緒。”
- [Shake 3] 套接字A:“謝謝你受理我的請求。”
TCP數據報結構
我們先來看一下TCP數據報的結構:
帶陰影的幾個字段需要重點說明一下:
1) 序號:Seq(Sequence Number)序號占32位,用來標識從計算機A發送到計算機B的數據包的序號,計算機發送數據時對此進行標記。
2) 確認號:Ack(Acknowledge Number)確認號占32位,客戶端和服務器端都可以發送,Ack = Seq + 1。
3) 標志位:每個標志位占用1Bit,共有6個,分別為 URG、ACK、PSH、RST、SYN、FIN,具體含義如下:
URG:緊急指針(urgent pointer)有效。
ACK:確認序號有效。
PSH:接收方應該盡快將這個報文交給應用層。
RST:重置連接。
SYN:建立一個新連接。
FIN:斷開一個連接。
對英文字母縮寫的總結:Seq 是 Sequence 的縮寫,表示序列;Ack(ACK) 是 Acknowledge 的縮寫,表示確認;SYN 是 Synchronous 的縮寫,願意是“同步的”,這里表示建立同步連接;FIN 是 Finish 的縮寫,表示完成。
連接的建立(三次握手)
使用 connect() 建立連接時,客戶端和服務器端會相互發送三個數據包,請看下圖:
客戶端調用 socket() 函數創建套接字后,因為沒有建立連接,所以套接字處於CLOSED
狀態;服務器端調用 listen() 函數后,套接字進入LISTEN
狀態,開始監聽客戶端請求。
這個時候,客戶端開始發起請求:
1) 當客戶端調用 connect() 函數后,TCP協議會組建一個數據包,並設置 SYN 標志位,表示該數據包是用來建立同步連接的。同時生成一個隨機數字 1000,填充“序號(Seq)”字段,表示該數據包的序號。完成這些工作,開始向服務器端發送數據包,客戶端就進入了SYN-SEND
狀態。
2) 服務器端收到數據包,檢測到已經設置了 SYN 標志位,就知道這是客戶端發來的建立連接的“請求包”。服務器端也會組建一個數據包,並設置 SYN 和 ACK 標志位,SYN 表示該數據包用來建立連接,ACK 用來確認收到了剛才客戶端發送的數據包。
服務器生成一個隨機數 2000,填充“序號(Seq)”字段。2000 和客戶端數據包沒有關系。
服務器將客戶端數據包序號(1000)加1,得到1001,並用這個數字填充“確認號(Ack)”字段。
服務器將數據包發出,進入SYN-RECV
狀態。
3) 客戶端收到數據包,檢測到已經設置了 SYN 和 ACK 標志位,就知道這是服務器發來的“確認包”。客戶端會檢測“確認號(Ack)”字段,看它的值是否為 1000+1,如果是就說明連接建立成功。
接下來,客戶端會繼續組建數據包,並設置 ACK 標志位,表示客戶端正確接收了服務器發來的“確認包”。同時,將剛才服務器發來的數據包序號(2000)加1,得到 2001,並用這個數字來填充“確認號(Ack)”字段。
客戶端將數據包發出,進入ESTABLISED
狀態,表示連接已經成功建立。
4) 服務器端收到數據包,檢測到已經設置了 ACK 標志位,就知道這是客戶端發來的“確認包”。服務器會檢測“確認號(Ack)”字段,看它的值是否為 2000+1,如果是就說明連接建立成功,服務器進入ESTABLISED
狀態。
至此,客戶端和服務器都進入了ESTABLISED
狀態,連接建立成功,接下來就可以收發數據了。
最后的說明
三次握手的關鍵是要確認對方收到了自己的數據包,這個目標就是通過“確認號(Ack)”字段實現的。計算機會記錄下自己發送的數據包序號 Seq,待收到對方的數據包后,檢測“確認號(Ack)”字段,看Ack = Seq + 1
是否成立,如果成立說明對方正確收到了自己的數據包。
二、TCP數據的傳輸過程

圖1:TCP 套接字的數據交換過程
上圖給出了主機A分2次(分2個數據包)向主機B傳遞200字節的過程。首先,主機A通過1個數據包發送100個字節的數據,數據包的 Seq 號設置為 1200。主機B為了確認這一點,向主機A發送 ACK 包,並將 Ack 號設置為 1301。
為了保證數據准確到達,目標機器在收到數據包(包括SYN包、FIN包、普通數據包等)包后必須立即回傳ACK包,這樣發送方才能確認數據傳輸成功。
此時 Ack 號為 1301 而不是 1201,原因在於 Ack 號的增量為傳輸的數據字節數。假設每次 Ack 號不加傳輸的字節數,這樣雖然可以確認數據包的傳輸,但無法明確100字節全部正確傳遞還是丟失了一部分,比如只傳遞了80字節。因此按如下的公式確認 Ack 號:
Ack號 = Seq號 + 傳遞的字節數 + 1
與三次握手協議相同,最后加 1 是為了告訴對方要傳遞的 Seq 號。
下面分析傳輸過程中數據包丟失的情況,如下圖所示:

圖2:TCP套接字數據傳輸過程中發生錯誤
上圖表示通過 Seq 1301 數據包向主機B傳遞100字節的數據,但中間發生了錯誤,主機B未收到。經過一段時間后,主機A仍未收到對於 Seq 1301 的ACK確認,因此嘗試重傳數據。
為了完成數據包的重傳,TCP套接字每次發送數據包時都會啟動定時器,如果在一定時間內沒有收到目標機器傳回的 ACK 包,那么定時器超時,數據包會重傳。
上圖演示的是數據包丟失的情況,也會有 ACK 包丟失的情況,一樣會重傳。
重傳超時時間(RTO, Retransmission Time Out)
這個值太大了會導致不必要的等待,太小會導致不必要的重傳,理論上最好是網絡 RTT 時間,但又受制於網絡距離與瞬態時延變化,所以實際上使用自適應的動態算法(例如 Jacobson 算法和 Karn 算法等)來確定超時時間。
往返時間(RTT,Round-Trip Time)表示從發送端發送數據開始,到發送端收到來自接收端的 ACK 確認包(接收端收到數據后便立即確認),總共經歷的時延。
重傳次數
TCP數據包重傳次數根據系統設置的不同而有所區別。有些系統,一個數據包只會被重傳3次,如果重傳3次后還未收到該數據包的 ACK 確認,就不再嘗試重傳。但有些要求很高的業務系統,會不斷地重傳丟失的數據包,以盡最大可能保證業務數據的正常交互。
建立連接需要三次握手,斷開連接需要四次握手,可以形象的比喻為下面的對話:
- [Shake 1] 套接字A:“任務處理完畢,我希望斷開連接。”
- [Shake 2] 套接字B:“哦,是嗎?請稍等,我准備一下。”
- 等待片刻后……
- [Shake 3] 套接字B:“我准備好了,可以斷開連接了。”
- [Shake 4] 套接字A:“好的,謝謝合作。”
下圖演示了客戶端主動斷開連接的場景:
建立連接后,客戶端和服務器都處於ESTABLISED
狀態。這時,客戶端發起斷開連接的請求:
1) 客戶端調用 close() 函數后,向服務器發送 FIN 數據包,進入FIN_WAIT_1
狀態。FIN 是 Finish 的縮寫,表示完成任務需要斷開連接。
2) 服務器收到數據包后,檢測到設置了 FIN 標志位,知道要斷開連接,於是向客戶端發送“確認包”,進入CLOSE_WAIT
狀態。
注意:服務器收到請求后並不是立即斷開連接,而是先向客戶端發送“確認包”,告訴它我知道了,我需要准備一下才能斷開連接。
3) 客戶端收到“確認包”后進入FIN_WAIT_2
狀態,等待服務器准備完畢后再次發送數據包。
4) 等待片刻后,服務器准備完畢,可以斷開連接,於是再主動向客戶端發送 FIN 包,告訴它我准備好了,斷開連接吧。然后進入LAST_ACK
狀態。
5) 客戶端收到服務器的 FIN 包后,再向服務器發送 ACK 包,告訴它你斷開連接吧。然后進入TIME_WAIT
狀態。
6) 服務器收到客戶端的 ACK 包后,就斷開連接,關閉套接字,進入CLOSED
狀態。
關於 TIME_WAIT 狀態的說明
客戶端最后一次發送 ACK包后進入 TIME_WAIT 狀態,而不是直接進入 CLOSED 狀態關閉連接,這是為什么呢?
TCP 是面向連接的傳輸方式,必須保證數據能夠正確到達目標機器,不能丟失或出錯,而網絡是不穩定的,隨時可能會毀壞數據,所以機器A每次向機器B發送數據包后,都要求機器B”確認“,回傳ACK包,告訴機器A我收到了,這樣機器A才能知道數據傳送成功了。如果機器B沒有回傳ACK包,機器A會重新發送,直到機器B回傳ACK包。
客戶端最后一次向服務器回傳ACK包時,有可能會因為網絡問題導致服務器收不到,服務器會再次發送 FIN 包,如果這時客戶端完全關閉了連接,那么服務器無論如何也收不到ACK包了,所以客戶端需要等待片刻、確認對方收到ACK包后才能進入CLOSED狀態。那么,要等待多久呢?
數據包在網絡中是有生存時間的,超過這個時間還未到達目標主機就會被丟棄,並通知源主機。這稱為報文最大生存時間(MSL,Maximum Segment Lifetime)。TIME_WAIT 要等待 2MSL 才會進入 CLOSED 狀態。ACK 包到達服務器需要 MSL 時間,服務器重傳 FIN 包也需要 MSL 時間,2MSL 是數據包往返的最大時間,如果 2MSL 后還未收到服務器重傳的 FIN 包,就說明服務器已經收到了 ACK 包。

圖1:close()/closesocket() 斷開連接
上圖演示了兩台正在進行雙向通信的主機。主機A發送完數據后,單方面調用 close()/closesocket() 斷開連接,之后主機A、B都不能再接受對方傳輸的數據。實際上,是完全無法調用與數據收發有關的函數。
一般情況下這不會有問題,但有些特殊時刻,需要只斷開一條數據傳輸通道,而保留另一條。
使用 shutdown() 函數可以達到這個目的,它的原型為:
- int shutdown(int sock, int howto); //Linux
- int shutdown(SOCKET s, int howto); //Windows
sock 為需要斷開的套接字,howto 為斷開方式。
howto 在 Linux 下有以下取值:
- SHUT_RD:斷開輸入流。套接字無法接收數據(即使輸入緩沖區收到數據也被抹去),無法調用輸入相關函數。
- SHUT_WR:斷開輸出流。套接字無法發送數據,但如果輸出緩沖區中還有未傳輸的數據,則將傳遞到目標主機。
- SHUT_RDWR:同時斷開 I/O 流。相當於分兩次調用 shutdown(),其中一次以 SHUT_RD 為參數,另一次以 SHUT_WR 為參數。
howto 在 Windows 下有以下取值:
- SD_RECEIVE:關閉接收操作,也就是斷開輸入流。
- SD_SEND:關閉發送操作,也就是斷開輸出流。
- SD_BOTH:同時關閉接收和發送操作。
至於什么時候需要調用 shutdown() 函數,下節我們會以文件傳輸為例進行講解。
close()/closesocket()和shutdown()的區別
確切地說,close() / closesocket() 用來關閉套接字,將套接字描述符(或句柄)從內存清除,之后再也不能使用該套接字,與C語言中的 fclose() 類似。應用程序關閉套接字后,與該套接字相關的連接和緩存也失去了意義,TCP協議會自動觸發關閉連接的操作。
shutdown() 用來關閉連接,而不是套接字,不管調用多少次 shutdown(),套接字依然存在,直到調用 close() / closesocket() 將套接字從內存清除。
調用 close()/closesocket() 關閉套接字時,或調用 shutdown() 關閉輸出流時,都會向對方發送 FIN 包。FIN 包表示數據傳輸完畢,計算機收到 FIN 包就知道不會再有數據傳送過來了。
默認情況下,close()/closesocket() 會立即向網絡中發送FIN包,不管輸出緩沖區中是否還有數據,而shutdown() 會等輸出緩沖區中的數據傳輸完畢再發送FIN包。也就意味着,調用 close()/closesocket() 將丟失輸出緩沖區中的數據,而調用 shutdown() 不會。
編寫這個程序需要注意兩個問題:
1) 文件大小不確定,有可能比緩沖區大很多,調用一次 write()/send() 函數不能完成文件內容的發送。接收數據時也會遇到同樣的情況。
要解決這個問題,可以使用 while 循環,例如:
- //Server 代碼
- int nCount;
- while( (nCount = fread(buffer, 1, BUF_SIZE, fp)) > 0 ){
- send(sock, buffer, nCount, 0);
- }
- //Client 代碼
- int nCount;
- while( (nCount = recv(clntSock, buffer, BUF_SIZE, 0)) > 0 ){
- fwrite(buffer, nCount, 1, fp);
- }
對於 Server 端的代碼,當讀取到文件末尾,fread() 會返回 0,結束循環。
對於 Client 端代碼,有一個關鍵的問題,就是文件傳輸完畢后讓 recv() 返回 0,結束 while 循環。
注意:讀取完緩沖區中的數據 recv() 並不會返回 0,而是被阻塞,直到緩沖區中再次有數據。
- Client 端如何判斷文件接收完畢,也就是上面提到的問題——何時結束 while 循環。
最簡單的結束 while 循環的方法當然是文件接收完畢后讓 recv() 函數返回 0,那么,如何讓 recv() 返回 0 呢?recv() 返回 0 的唯一時機就是收到FIN包時。
FIN 包表示數據傳輸完畢,計算機收到 FIN 包后就知道對方不會再向自己傳輸數據,當調用 read()/recv() 函數時,如果緩沖區中沒有數據,就會返回 0,表示讀到了”socket文件的末尾“。
這里我們調用 shutdown() 來發送FIN包:server 端直接調用 close()/closesocket() 會使輸出緩沖區中的數據失效,文件內容很有可能沒有傳輸完畢連接就斷開了,而調用 shutdown() 會等待輸出緩沖區中的數據傳輸完畢。
本節以Windows為例演示文件傳輸功能,Linux與此類似,不再贅述。請看下面完整的代碼。
服務器端 server.cpp:
- #include <stdio.h>
- #include <stdlib.h>
- #include <winsock2.h>
- #pragma comment (lib, "ws2_32.lib") //加載 ws2_32.dll
- #define BUF_SIZE 1024
- int main(){
- //先檢查文件是否存在
- char *filename = "D:\\send.avi"; //文件名
- FILE *fp = fopen(filename, "rb"); //以二進制方式打開文件
- if(fp == NULL){
- printf("Cannot open file, press any key to exit!\n");
- system("pause");
- exit(0);
- }
- WSADATA wsaData;
- WSAStartup( MAKEWORD(2, 2), &wsaData);
- SOCKET servSock = socket(AF_INET, SOCK_STREAM, 0);
- sockaddr_in sockAddr;
- memset(&sockAddr, 0, sizeof(sockAddr));
- sockAddr.sin_family = PF_INET;
- sockAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
- sockAddr.sin_port = htons(1234);
- bind(servSock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR));
- listen(servSock, 20);
- SOCKADDR clntAddr;
- int nSize = sizeof(SOCKADDR);
- SOCKET clntSock = accept(servSock, (SOCKADDR*)&clntAddr, &nSize);
- //循環發送數據,直到文件結尾
- char buffer[BUF_SIZE] = {0}; //緩沖區
- int nCount;
- while( (nCount = fread(buffer, 1, BUF_SIZE, fp)) > 0 ){
- send(clntSock, buffer, nCount, 0);
- }
- shutdown(clntSock, SD_SEND); //文件讀取完畢,斷開輸出流,向客戶端發送FIN包
- recv(clntSock, buffer, BUF_SIZE, 0); //阻塞,等待客戶端接收完畢
- fclose(fp);
- closesocket(clntSock);
- closesocket(servSock);
- WSACleanup();
- system("pause");
- return 0;
- }
客戶端代碼:
- #include <stdio.h>
- #include <stdlib.h>
- #include <WinSock2.h>
- #pragma comment(lib, "ws2_32.lib")
- #define BUF_SIZE 1024
- int main(){
- //先輸入文件名,看文件是否能創建成功
- char filename[100] = {0}; //文件名
- printf("Input filename to save: ");
- gets(filename);
- FILE *fp = fopen(filename, "wb"); //以二進制方式打開(創建)文件
- if(fp == NULL){
- printf("Cannot open file, press any key to exit!\n");
- system("pause");
- exit(0);
- }
- WSADATA wsaData;
- WSAStartup(MAKEWORD(2, 2), &wsaData);
- SOCKET sock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
- sockaddr_in sockAddr;
- memset(&sockAddr, 0, sizeof(sockAddr));
- sockAddr.sin_family = PF_INET;
- sockAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
- sockAddr.sin_port = htons(1234);
- connect(sock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR));
- //循環接收數據,直到文件傳輸完畢
- char buffer[BUF_SIZE] = {0}; //文件緩沖區
- int nCount;
- while( (nCount = recv(sock, buffer, BUF_SIZE, 0)) > 0 ){
- fwrite(buffer, nCount, 1, fp);
- }
- puts("File transfer success!");
- //文件接收完畢后直接關閉套接字,無需調用shutdown()
- fclose(fp);
- closesocket(sock);
- WSACleanup();
- system("pause");
- return 0;
- }
在D盤中准備好send.avi文件,先運行 server,再運行 client:
Input filename to save: D:\recv.avi↙
//稍等片刻后
File transfer success!
打開D盤就可以看到 recv.avi,大小和 send.avi 相同,可以正常播放。
注意 server.cpp 第42行代碼,recv() 並沒有接收到 client 端的數據,當 client 端調用 closesocket() 后,server 端會收到FIN包,recv() 就會返回,后面的代碼繼續執行。