Socket通信協議解析(文章摘要)


參考網址: https://zhuanlan.zhihu.com/p/84800923

在計算機通信領域,socket 被翻譯為“套接字”,它是計算機之間進行通信的一種約定或一種方式。通過 socket 這種約定,一台計算機可以接收其他計算機的數據,也可以向其他計算機發送數據。

socket 的典型應用就是 Web 服務器和瀏覽器:瀏覽器獲取用戶輸入的URL,向服務器發起請求,服務器分析接收到的URL,將對應的網頁內容返回給瀏覽器,瀏覽器再經過解析和渲染,就將文字、圖片、視頻等元素呈現給用戶。

學習 socket,也就是學習計算機之間如何通信,並編寫出實用的程序。

IP地址(IP Address)

計算機分布在世界各地,要想和它們通信,必須要知道確切的位置。確定計算機位置的方式有多種,IP 地址是最常用的,例如,114.114.114.114 是國內第一個、全球第三個開放的 DNS 服務地址,127.0.0.1 是本機地址。

其實,我們的計算機並不知道 IP 地址對應的地理位置,當要通信時,只是將 IP 地址封裝到要發送的數據包中,交給路由器去處理。路由器有非常智能和高效的算法,很快就會找到目標計算機,並將數據包傳遞給它,完成一次單向通信。

目前大部分軟件使用 IPv4 地址,但 IPv6 也正在被人們接受,尤其是在教育網中,已經大量使用。

端口(Port)

有了 IP 地址,雖然可以找到目標計算機,但仍然不能進行通信。一台計算機可以同時提供多種網絡服務,例如Web服務、FTP服務(文件傳輸服務)、SMTP服務(郵箱服務)等,僅有 IP 地址,計算機雖然可以正確接收到數據包,但是卻不知道要將數據包交給哪個網絡程序來處理,所以通信失敗。

為了區分不同的網絡程序,計算機會為每個網絡程序分配一個獨一無二的端口號(Port Number),例如,Web服務的端口號是 80,FTP 服務的端口號是 21,SMTP 服務的端口號是 25。

端口(Port)是一個虛擬的、邏輯上的概念。可以將端口理解為一道門,數據通過這道門流入流出,每道門有不同的編號,就是端口號。如下圖所示:

 

 

協議(Protocol)

協議(Protocol)就是網絡通信的約定,通信的雙方必須都遵守才能正常收發數據。協議有很多種,例如 TCP、UDP、IP 等,通信的雙方必須使用同一協議才能通信。協議是一種規范,由計算機組織制定,規定了很多細節,例如,如何建立連接,如何相互識別等。

協議僅僅是一種規范,必須由計算機軟件來實現。例如 IP 協議規定了如何找到目標計算機,那么各個開發商在開發自己的軟件時就必須遵守該協議,不能另起爐灶。

所謂協議族(Protocol Family),就是一組協議(多個協議)的統稱。最常用的是 TCP/IP 協議族,它包含了 TCP、IP、UDP、Telnet、FTP、SMTP 等上百個互為關聯的協議,由於 TCP、IP 是兩種常用的底層協議,所以把它們統稱為 TCP/IP 協議族。

數據傳輸方式

計算機之間有很多數據傳輸方式,各有優缺點,常用的有兩種:SOCK_STREAM 和 SOCK_DGRAM。

1) SOCK_STREAM 表示面向連接的數據傳輸方式。數據可以准確無誤地到達另一台計算機,如果損壞或丟失,可以重新發送,但效率相對較慢。常見的 http 協議就使用 SOCK_STREAM 傳輸數據,因為要確保數據的正確性,否則網頁不能正常解析。

2) SOCK_DGRAM 表示無連接的數據傳輸方式。計算機只管傳輸數據,不作數據校驗,如果數據在傳輸中損壞,或者沒有到達另一台計算機,是沒有辦法補救的。也就是說,數據錯了就錯了,無法重傳。因為 SOCK_DGRAM 所做的校驗工作少,所以效率比 SOCK_STREAM 高。

QQ 視頻聊天和語音聊天就使用 SOCK_DGRAM 傳輸數據,因為首先要保證通信的效率,盡量減小延遲,而數據的正確性是次要的,即使丟失很小的一部分數據,視頻和音頻也可以正常解析,最多出現噪點或雜音,不會對通信質量有實質的影響。

注意:SOCK_DGRAM 沒有想象中的糟糕,不會頻繁的丟失數據,數據錯誤只是小概率事件。

有可能多種協議使用同一種數據傳輸方式,所以在 socket 編程中,需要同時指明數據傳輸方式和協議。

綜上所述:IP地址和端口能夠在廣袤的互聯網中定位到要通信的程序,協議和數據傳輸方式規定了如何傳輸數據,有了這些,兩台計算機就可以通信了。

socket緩沖區

每個 socket 被創建后,都會分配兩個緩沖區,輸入緩沖區和輸出緩沖區。

write()/send() 並不立即向網絡中傳輸數據,而是先將數據寫入緩沖區中,再由TCP協議將數據從緩沖區發送到目標機器。一旦將數據寫入到緩沖區,函數就可以成功返回,不管它們有沒有到達目標機器,也不管它們何時被發送到網絡,這些都是TCP協議負責的事情。

TCP協議獨立於 write()/send() 函數,數據有可能剛被寫入緩沖區就發送到網絡,也可能在緩沖區中不斷積壓,多次寫入的數據被一次性發送到網絡,這取決於當時的網絡情況、當前線程是否空閑等諸多因素,不由程序員控制。

read()/recv() 函數也是如此,也從輸入緩沖區中讀取數據,而不是直接從網絡中讀取。

 


圖:TCP套接字的I/O緩沖區示意圖

 

這些I/O緩沖區特性可整理如下:

  • I/O緩沖區在每個TCP套接字中單獨存在;
  • I/O緩沖區在創建套接字時自動生成;
  • 即使關閉套接字也會繼續傳送輸出緩沖區中遺留的數據;
  • 關閉套接字將丟失輸入緩沖區中的數據。

 

輸入輸出緩沖區的默認大小一般都是 8K,可以通過 getsockopt() 函數獲取:

 

unsigned optVal;
int optLen = sizeof(int);
getsockopt(servSock, SOL_SOCKET, SO_SNDBUF, (char*)&optVal, &optLen);
printf("Buffer length: %d\n", optVal);

 

運行結果:

Buffer length: 8192

這里僅給出示例,后面會詳細講解。

阻塞模式

對於TCP套接字(默認情況下),當使用 write()/send() 發送數據時:
1) 首先會檢查緩沖區,如果緩沖區的可用空間長度小於要發送的數據,那么 write()/send() 會被阻塞(暫停執行),直到緩沖區中的數據被發送到目標機器,騰出足夠的空間,才喚醒 write()/send() 函數繼續寫入數據。

2) 如果TCP協議正在向網絡發送數據,那么輸出緩沖區會被鎖定,不允許寫入,write()/send() 也會被阻塞,直到數據發送完畢緩沖區解鎖,write()/send() 才會被喚醒。

3) 如果要寫入的數據大於緩沖區的最大長度,那么將分批寫入。

4) 直到所有數據被寫入緩沖區 write()/send() 才能返回。

當使用 read()/recv() 讀取數據時:
1) 首先會檢查緩沖區,如果緩沖區中有數據,那么就讀取,否則函數會被阻塞,直到網絡上有數據到來。

2) 如果要讀取的數據長度小於緩沖區中的數據長度,那么就不能一次性將緩沖區中的所有數據讀出,剩余數據將不斷積壓,直到有 read()/recv() 函數再次讀取。

3) 直到讀取到數據后 read()/recv() 函數才會返回,否則就一直被阻塞。

這就是TCP套接字的阻塞模式。所謂阻塞,就是上一步動作沒有完成,下一步動作將暫停,直到上一步動作完成后才能繼續,以保持同步性。

我們講到了socket緩沖區和數據的傳遞過程,可以看到數據的接收和發送是無關的,read()/recv() 函數不管數據發送了多少次,都會盡可能多的接收數據。也就是說,read()/recv() 和 write()/send() 的執行次數可能不同。

例如,write()/send() 重復執行三次,每次都發送字符串"abc",那么目標機器上的 read()/recv() 可能分三次接收,每次都接收"abc";也可能分兩次接收,第一次接收"abcab",第二次接收"cabc";也可能一次就接收到字符串"abcabcabc"。

假設我們希望客戶端每次發送一位學生的學號,讓服務器端返回該學生的姓名、住址、成績等信息,這時候可能就會出現問題,服務器端不能區分學生的學號。例如第一次發送 1,第二次發送 3,服務器可能當成 13 來處理,返回的信息顯然是錯誤的。

這就是數據的“粘包”問題,客戶端發送的多個數據包被當做一個數據包接收。也稱數據的無邊界性,read()/recv() 函數不知道數據包的開始或結束標志(實際上也沒有任何開始或結束標志),只把它們當做連續的數據流來處理。

下面的代碼演示了粘包問題,客戶端連續三次向服務器端發送數據,服務器端卻一次性接收到所有數據。

服務器端代碼 server.cpp:

 

#include <stdio.h>
#include <windows.h>
#pragma comment (lib, "ws2_32.lib") //加載 ws2_32.dll

#define BUF_SIZE 100

int main(){
 WSADATA wsaData;
 WSAStartup( MAKEWORD(2, 2), &wsaData);

 //創建套接字
 SOCKET servSock = socket(AF_INET, SOCK_STREAM, 0);

 //綁定套接字
 sockaddr_in sockAddr;
 memset(&sockAddr, 0, sizeof(sockAddr)); //每個字節都用0填充
    sockAddr.sin_family = PF_INET; //使用IPv4地址
    sockAddr.sin_addr.s_addr = inet_addr("127.0.0.1"); //具體的IP地址
    sockAddr.sin_port = htons(1234); //端口
 bind(servSock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR));

 //進入監聽狀態
 listen(servSock, 20);

 //接收客戶端請求
 SOCKADDR clntAddr;
 int nSize = sizeof(SOCKADDR);
 char buffer[BUF_SIZE] = {0}; //緩沖區
 SOCKET clntSock = accept(servSock, (SOCKADDR*)&clntAddr, &nSize);

 Sleep(10000); //注意這里,讓程序暫停10秒

 //接收客戶端發來的數據,並原樣返回
 int recvLen = recv(clntSock, buffer, BUF_SIZE, 0);
 send(clntSock, buffer, recvLen, 0);

 //關閉套接字並終止DLL的使用
 closesocket(clntSock);
 closesocket(servSock);
 WSACleanup();

 return 0;
}

 

 

客戶端代碼 client.cpp:

 

#include <stdio.h>
#include <stdlib.h>
#include <WinSock2.h>
#include <windows.h>
#pragma comment(lib, "ws2_32.lib") //加載 ws2_32.dll

#define BUF_SIZE 100

int main(){
 //初始化DLL
 WSADATA wsaData;
 WSAStartup(MAKEWORD(2, 2), &wsaData);

 //向服務器發起請求
 sockaddr_in sockAddr;
 memset(&sockAddr, 0, sizeof(sockAddr)); //每個字節都用0填充
    sockAddr.sin_family = PF_INET;
    sockAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
    sockAddr.sin_port = htons(1234);

 //創建套接字
 SOCKET sock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
 connect(sock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR));

 //獲取用戶輸入的字符串並發送給服務器
 char bufSend[BUF_SIZE] = {0};
 printf("Input a string: ");
 gets(bufSend);
 for(int i=0; i<3; i++){
 send(sock, bufSend, strlen(bufSend), 0);
 }
 //接收服務器傳回的數據
 char bufRecv[BUF_SIZE] = {0};
 recv(sock, bufRecv, BUF_SIZE, 0);
 //輸出接收到的數據
 printf("Message form server: %s\n", bufRecv);

 closesocket(sock); //關閉套接字
 WSACleanup(); //終止使用 DLL

 system("pause");
 return 0;
}

 

 

先運行 server,再運行 client,並在10秒內輸入字符串"abc",再等數秒,服務器就會返回數據。運行結果如下:
Input a string: abc
Message form server: abcabcabc

本程序的關鍵是 server.cpp 第31行的代碼Sleep(10000);

,它讓程序暫停執行10秒。在這段時間內,client 連續三次發送字符串"abc",由於 server 被阻塞,數據只能堆積在緩沖區中,10秒后,server 開始運行,從緩沖區中一次性讀出所有積壓的數據,並返回給客戶端。

另外還需要說明的是 client.cpp 第34行代碼。client 執行到 recv() 函數,由於輸入緩沖區中沒有數據,所以會被阻塞,直到10秒后 server 傳回數據才開始執行。用戶看到的直觀效果就是,client 暫停一段時間才輸出 server 返回的結果。

client 的 send() 發送了三個數據包,而 server 的 recv() 卻只接收到一個數據包,這很好的說明了數據的粘包問題。

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是否成立,如果成立說明對方正確收到了自己的數據包。

建立連接后,兩台主機就可以相互傳輸數據了。如下圖所示:

 


圖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 包。

我們來完成 socket 文件傳輸程序,這是一個非常實用的例子。要實現的功能為:client 從 server 下載一個文件並保存到本地。

編寫這個程序需要注意兩個問題:
1) 文件大小不確定,有可能比緩沖區大很多,調用一次 write()/send() 函數不能完成文件內容的發送。接收數據時也會遇到同樣的情況。

要解決這個問題,可以使用 while 循環,例如:

 

  1. //Server 代碼
  2. int nCount;
  3. while( (nCount = fread(buffer, 1, BUF_SIZE, fp)) > 0 ){
  4. send(sock, buffer, nCount, 0);
  5. }
  6. //Client 代碼
  7. int nCount;
  8. while( (nCount = recv(clntSock, buffer, BUF_SIZE, 0)) > 0 ){
  9. fwrite(buffer, nCount, 1, fp);
  10. }

 

對於 Server 端的代碼,當讀取到文件末尾,fread() 會返回 0,結束循環。

對於 Client 端代碼,有一個關鍵的問題,就是文件傳輸完畢后讓 recv() 返回 0,結束 while 循環。

注意:讀取完緩沖區中的數據 recv() 並不會返回 0,而是被阻塞,直到緩沖區中再次有數據。

2) 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() 就會返回,后面的代碼繼續執行。

不同CPU中,4字節整數1在內存空間的存儲方式是不同的。4字節整數1可用2進制表示如下:

00000000 00000000 00000000 00000001

有些CPU以上面的順序存儲到內存,另外一些CPU則以倒序存儲,如下所示:

00000001 00000000 00000000 00000000

若不考慮這些就收發數據會發生問題,因為保存順序的不同意味着對接收數據的解析順序也不同。

大端序和小端序

CPU向內存保存數據的方式有兩種:

  • 大端序(Big Endian):高位字節存放到低位地址(高位字節在前)。
  • 小端序(Little Endian):高位字節存放到高位地址(低位字節在前)。

 

僅憑描述很難解釋清楚,不妨來看一個實例。假設在 0x20 號開始的地址中保存4字節 int 型數據 0x12345678,大端序CPU保存方式如下圖所示:

 


圖1:整數 0x12345678 的大端序字節表示

 

對於大端序,最高位字節 0x12 存放到低位地址,最低位字節 0x78 存放到高位地址。小端序的保存方式如下圖所示:

 


圖2:整數 0x12345678 的小端序字節表示

 

不同CPU保存和解析數據的方式不同(主流的Intel系列CPU為小端序),小端序系統和大端序系統通信時會發生數據解析錯誤。因此在發送數據前,要將數據轉換為統一的格式——網絡字節序(Network Byte Order)。網絡字節序統一為大端序。

主機A先把數據轉換成大端序再進行網絡傳輸,主機B收到數據后先轉換為自己的格式再解析。

網絡字節序轉換函數

在《使用bind()和connect()函數》一節中講解了 sockaddr_in 結構體,其中就用到了網絡字節序轉換函數,如下所示:

 

//創建sockaddr_in結構體變量
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr)); //每個字節都用0填充
serv_addr.sin_family = AF_INET; //使用IPv4地址
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); //具體的IP地址
serv_addr.sin_port = htons(1234); //端口號

 

htons() 用來將當前主機字節序轉換為網絡字節序,其中h代表主機(host)字節序,n代表網絡(network)字節序,s

代表short,htons 是 h、to、n、s 的組合,可以理解為”將short型數據從當前主機字節序轉換為網絡字節序“。

常見的網絡字節轉換函數有:

  • htons():host to network short,將short類型數據從主機字節序轉換為網絡字節序。
  • ntohs():network to host short,將short類型數據從網絡字節序轉換為主機字節序。
  • htonl():host to network long,將long類型數據從主機字節序轉換為網絡字節序。
  • ntohl():network to host long,將long類型數據從網絡字節序轉換為主機字節序。

 

通常,以s為后綴的函數中,s代表2個字節short,因此用於端口號轉換;以l為后綴的函數中,l

代表4個字節的long,因此用於IP地址轉換。

舉例說明上述函數的調用過程:

 

#include <stdio.h>
#include <stdlib.h>
#include <WinSock2.h>
#pragma comment(lib, "ws2_32.lib")

int main(){
 unsigned short host_port = 0x1234, net_port;
 unsigned long host_addr = 0x12345678, net_addr;

    net_port = htons(host_port);
    net_addr = htonl(host_addr);

 printf("Host ordered port: %#x\n", host_port);
 printf("Network ordered port: %#x\n", net_port);
 printf("Host ordered address: %#lx\n", host_addr);
 printf("Network ordered address: %#lx\n", net_addr);

 system("pause");
 return 0;
}

 

運行結果:
Host ordered port: 0x1234
Network ordered port: 0x3412
Host ordered address: 0x12345678
Network ordered address: 0x78563412

另外需要說明的是,sockaddr_in 中保存IP地址的成員為32位整數,而我們熟悉的是點分十進制表示法,例如 127.0.0.1,它是一個字符串,因此為了分配IP地址,需要將字符串轉換為4字節整數。

inet_addr() 函數可以完成這種轉換。inet_addr() 除了將字符串轉換為32位整數,同時還進行網絡字節序轉換。請看下面的代碼:

 

#include <stdio.h>
#include <stdlib.h>
#include <WinSock2.h>
#pragma comment(lib, "ws2_32.lib")

int main(){
 char *addr1 = "1.2.3.4";
 char *addr2 = "1.2.3.256";

 unsigned long conv_addr = inet_addr(addr1);
 if(conv_addr == INADDR_NONE){
 puts("Error occured!");
 }else{
 printf("Network ordered integer addr: %#lx\n", conv_addr);
 }

    conv_addr = inet_addr(addr2);
 if(conv_addr == INADDR_NONE){
 puts("Error occured!");
 }else{
 printf("Network ordered integer addr: %#lx\n", conv_addr);
 }

 system("pause");
 return 0;
}

 

運行結果:
Network ordered integer addr: 0x4030201
Error occured!

從運行結果可以看出,inet_addr() 不僅可以把IP地址轉換為32位整數,還可以檢測無效IP地址。

注意:為 sockaddr_in 成員賦值時需要顯式地將主機字節序轉換為網絡字節序,而通過 write()/send() 發送數據時TCP協議會自動轉換為網絡字節序,不需要再調用相應的函數。

TCP 是面向連接的傳輸協議,建立連接時要經過三次握手,斷開連接時要經過四次握手,中間傳輸數據時也要回復ACK包確認,多種機制保證了數據能夠正確到達,不會丟失或出錯。

UDP 是非連接的傳輸協議,沒有建立連接和斷開連接的過程,它只是簡單地把數據丟到網絡中,也不需要ACK包確認。

UDP 傳輸數據就好像我們郵寄包裹,郵寄前需要填好寄件人和收件人地址,之后送到快遞公司即可,但包裹是否正確送達、是否損壞我們無法得知,也無法保證。UDP 協議也是如此,它只管把數據包發送到網絡,然后就不管了,如果數據丟失或損壞,發送端是無法知道的,當然也不會重發。

既然如此,TCP應該是更加優質的傳輸協議吧?

如果只考慮可靠性,TCP的確比UDP好。但UDP在結構上比TCP更加簡潔,不會發送ACK的應答消息,也不會給數據包分配Seq序號,所以UDP的傳輸效率有時會比TCP高出很多,編程中實現UDP也比TCP簡單。

UDP 的可靠性雖然比不上TCP,但也不會像想象中那么頻繁地發生數據損毀,在更加重視傳輸效率而非可靠性的情況下,UDP是一種很好的選擇。比如視頻通信或音頻通信,就非常適合采用UDP協議;通信時數據必須高效傳輸才不會產生“卡頓”現象,用戶體驗才更加流暢,如果丟失幾個數據包,視頻畫面可能會出現“雪花”,音頻可能會夾帶一些雜音,這些都是無妨的。

與UDP相比,TCP的生命在於流控制,這保證了數據傳輸的正確性。

最后需要說明的是:TCP的速度無法超越UDP,但在收發某些類型的數據時有可能接近UDP。例如,每次交換的數據量越大,TCP 的傳輸速率就越接近於 UDP。

前面的文章中我們給出了幾個TCP的例子,對於UDP而言,只要能理解前面的內容,實現並非難事。

UDP中的服務器端和客戶端沒有連接

UDP不像TCP,無需在連接狀態下交換數據,因此基於UDP的服務器端和客戶端也無需經過連接過程。也就是說,不必調用 listen() 和 accept() 函數。UDP中只有創建套接字的過程和數據交換的過程。

UDP服務器端和客戶端均只需1個套接字

TCP中,套接字是一對一的關系。如要向10個客戶端提供服務,那么除了負責監聽的套接字外,還需要創建10套接字。但在UDP中,不管是服務器端還是客戶端都只需要1個套接字。之前解釋UDP原理的時候舉了郵寄包裹的例子,負責郵寄包裹的快遞公司可以比喻為UDP套接字,只要有1個快遞公司,就可以通過它向任意地址郵寄包裹。同樣,只需1個UDP套接字就可以向任意主機傳送數據。

基於UDP的接收和發送函數

創建好TCP套接字后,傳輸數據時無需再添加地址信息,因為TCP套接字將保持與對方套接字的連接。換言之,TCP套接字知道目標地址信息。但UDP套接字不會保持連接狀態,每次傳輸數據都要添加目標地址信息,這相當於在郵寄包裹前填寫收件人地址。

發送數據使用 sendto() 函數:

 

  1. ssize_t sendto(int sock, void *buf, size_t nbytes, int flags, struct sockaddr *to, socklen_t addrlen); //Linux
  2. int sendto(SOCKET sock, const char *buf, int nbytes, int flags, conststruct sockadr *to, int addrlen); //Windows

 

Linux和Windows下的 sendto() 函數類似,下面是詳細參數說明:

  • sock:用於傳輸UDP數據的套接字;
  • buf:保存待傳輸數據的緩沖區地址;
  • nbytes:帶傳輸數據的長度(以字節計);
  • flags:可選項參數,若沒有可傳遞0;
  • to:存有目標地址信息的 sockaddr 結構體變量的地址;
  • addrlen:傳遞給參數 to 的地址值結構體變量的長度。

 

UDP 發送函數 sendto() 與TCP發送函數 write()/send() 的最大區別在於,sendto() 函數需要向他傳遞目標地址信息。

接收數據使用 recvfrom() 函數:

 

  1. ssize_t recvfrom(int sock, void *buf, size_t nbytes, int flags, struct sockadr *from, socklen_t *addrlen); //Linux
  2. int recvfrom(SOCKET sock, char *buf, int nbytes, int flags, conststruct sockaddr *from, int *addrlen); //Windows

 

由於UDP數據的發送端不不定,所以 recvfrom() 函數定義為可接收發送端信息的形式,具體參數如下:

  • sock:用於接收UDP數據的套接字;
  • buf:保存接收數據的緩沖區地址;
  • nbytes:可接收的最大字節數(不能超過buf緩沖區的大小);
  • flags:可選項參數,若沒有可傳遞0;
  • from:存有發送端地址信息的sockaddr結構體變量的地址;
  • addrlen:保存參數 from 的結構體變量長度的變量地址值。

基於UDP的回聲服務器端/客戶端

下面結合之前的內容實現回聲客戶端。需要注意的是,UDP不同於TCP,不存在請求連接和受理過程,因此在某種意義上無法明確區分服務器端和客戶端,只是因為其提供服務而稱為服務器端,希望各位讀者不要誤解。

下面給出Windows下的代碼,Linux與此類似,不再贅述。

服務器端 server.cpp:

 

#include <stdio.h>
#include <winsock2.h>
#pragma comment (lib, "ws2_32.lib") //加載 ws2_32.dll

#define BUF_SIZE 100

int main(){
 WSADATA wsaData;
 WSAStartup( MAKEWORD(2, 2), &wsaData);

 //創建套接字
 SOCKET sock = socket(AF_INET, SOCK_DGRAM, 0);

 //綁定套接字
 sockaddr_in servAddr;
 memset(&servAddr, 0, sizeof(servAddr)); //每個字節都用0填充
    servAddr.sin_family = PF_INET; //使用IPv4地址
    servAddr.sin_addr.s_addr = htonl(INADDR_ANY); //自動獲取IP地址
    servAddr.sin_port = htons(1234); //端口
 bind(sock, (SOCKADDR*)&servAddr, sizeof(SOCKADDR));

 //接收客戶端請求
 SOCKADDR clntAddr; //客戶端地址信息
 int nSize = sizeof(SOCKADDR);
 char buffer[BUF_SIZE]; //緩沖區
 while(1){
 int strLen = recvfrom(sock, buffer, BUF_SIZE, 0, &clntAddr, &nSize);
 sendto(sock, buffer, strLen, 0, &clntAddr, nSize);
 }

 closesocket(sock);
 WSACleanup();
 return 0;
}

 

代碼說明:
1) 第12行代碼在創建套接字時,向 socket() 第二個參數傳遞 SOCK_DGRAM,以指明使用UDP協議。

2) 第18行代碼中使用htonl(INADDR_ANY)

來自動獲取IP地址。

利用常數 INADDR_ANY 自動獲取IP地址有一個明顯的好處,就是當軟件安裝到其他服務器或者服務器IP地址改變時,不用再更改源碼重新編譯,也不用在啟動軟件時手動輸入。而且,如果一台計算機中已分配多個IP地址(例如路由器),那么只要端口號一致,就可以從不同的IP地址接收數據。所以,服務器中優先考慮使用INADDR_ANY;而客戶端中除非帶有一部分服務器功能,否則不會采用。

客戶端 client.cpp:

 

#include <stdio.h>
#include <WinSock2.h>
#pragma comment(lib, "ws2_32.lib") //加載 ws2_32.dll

#define BUF_SIZE 100

int main(){
 //初始化DLL
 WSADATA wsaData;
 WSAStartup(MAKEWORD(2, 2), &wsaData);

 //創建套接字
 SOCKET sock = socket(PF_INET, SOCK_DGRAM, 0);

 //服務器地址信息
 sockaddr_in servAddr;
 memset(&servAddr, 0, sizeof(servAddr)); //每個字節都用0填充
    servAddr.sin_family = PF_INET;
    servAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
    servAddr.sin_port = htons(1234);

 //不斷獲取用戶輸入並發送給服務器,然后接受服務器數據
 sockaddr fromAddr;
 int addrLen = sizeof(fromAddr);
 while(1){
 char buffer[BUF_SIZE] = {0};
 printf("Input a string: ");
 gets(buffer);
 sendto(sock, buffer, strlen(buffer), 0, (struct sockaddr*)&servAddr, sizeof(servAddr));
 int strLen = recvfrom(sock, buffer, BUF_SIZE, 0, &fromAddr, &addrLen);
        buffer[strLen] = 0;
 printf("Message form server: %s\n", buffer);
 }

 closesocket(sock);
 WSACleanup();
 return 0;
}

 

先運行 server,再運行 client,client 輸出結果為:

Input a string: C語言中文網
Message form server: C語言中文網
Input a string:  Founded in 2012
Message form server:  Founded in 2012
Input a string:

 

從代碼中可以看出,server.cpp 中沒有使用 listen() 函數,client.cpp 中也沒有使用 connect() 函數,因為 UDP 不需要連接。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM