Socket 編程
Socket 編程
一、前行必備
1.1 網路中進程之間如何通訊
網路進程間的通訊,首要解決的問題是如何唯一標識一個進程,否則通訊無從談起!
在本地可以通過進程 PID 來唯一標識一個進程,但是在網路中這是行不通的。其實 TCP/IP 協議族已經幫我們解決了這個問題,網路層的「IP 地址」可以唯一標識網路中的主機,而傳輸層的「協議 + 埠」可以唯一標識主機中的應用程式(進程)。
這樣利用三元組「IP 地址、協議、埠」就可以唯一標識網路的進程了,網路中的進程通訊就可以利用這個標誌與其它進程進行交互。
使用 TCP/IP 協議的應用程式通常採用應用編程介面——Socket,來實現網路進程之間的通訊。
1.2 文件描述符
在 Linux 中,一切皆文件。一個硬體設備也可以被映射為一個虛擬的文件,稱為設備文件。例如,stdin 稱為標準輸入文件,它對應的硬體設備一般是鍵盤,stdout 稱為標準輸出文件,它對應的硬體設備一般是顯示器。
「一切皆文件」的思想極大地簡化了程式設計師的理解和操作,使得對硬體設備的處理就像普通文件一樣。所有在 Linux 中創建的文件都有一個 int 類型的編號,稱為文件描述符(File Descriptor,簡稱 FD)。使用文件時,我們只需要知道文件描述符就可以,例如,stdin 的描述符為 0,stdout 的描述符為 1。
在Linux中,socket 也被認為是文件的一種,和普通文件的操作沒有區別,所以在網路數據傳輸過程中自然可以使用與文件 I/O 相關的函數。可以認為,兩台電腦之間的通訊,實際上是兩個 socket 文件的相互讀寫。
文件描述符有時也被稱為文件句柄(File Handle),但「句柄」主要是 Windows 中術語。
二、Socket 編程
2.1 總覽
在開始講解 Socket 編程前,先通過一張圖片瀏覽一下網路進程間通訊的流程:
- 伺服器首先啟動,稍後某個時刻客戶啟動,它試圖連接到伺服器。
- 客戶通過 send() 函數給伺服器發送一段數據,伺服器通過 recv() 函數接收客戶發送的數據,並處理該請求,之後通過 send() 函數給客戶發回一個響應。
- 這個過程一直持續下去,直到客戶關閉 Socket 連接,從而給伺服器發送一個 EOF(文件結束)通知。伺服器收到後接著也關閉與之相應的 Socket,然後結束運行或者等待新的客戶連接。
2.2 socket()
2.2.1 函數介紹
為了執行網路 I/O,一個進程必須做的第一件事就是調用 socket() 函數,指定期望的通訊協議類型(使用 IPv4 的 TCP、使用 IPv6 的 UDP 等)。
函數原型:int socket(int domain, int type, int protocol);
頭 文 件:#include <sys/socket.h>
返 回 值:
- 調用成功後會返回一個小的非負整數(socket 描述符)。
- 調用失敗返回 -1,並置 errno 為相應的錯誤碼。。
參數描述:
-
domain:即協議域,又稱為協議族(family)。常用的協議族有:
協議族 說明 AF_INET IPv4 協議 AF_INET6 IPv6 協議 協議族決定了socket 的地址類型,在通訊中必須採用對應的地址,如 AF_INET 決定了要用 32 位的 IPv4 地址與 16 位的埠號組合。
-
type:指定 socket 類型。常用的 socket 類型有:
socket 類型 說明 SOCK_STREAM 位元組流套接字 SOCK_DGRAM 數據報套接字 -
protocol:故名思意,就是指定協議。常用的協議有:
協議 說明 IPPROTO_TCP TCP 傳輸協議 IPPROTO_UDP UDP 傳輸協議 -
若將 protocol 置為 0,socket() 會自動選擇 domain 和 type 對應的默認協議:
\(domain \backslash^{type}\) SOCK_STREAM SOCK_DGRAM AF_INET IPPROTO_TCP IPPROTO_UDP AF_INET6 IPPROTO_TCP IPPROTO_UDP
-
2.2.2 函數使用
int main()
{
// 創建TCP套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == sockfd)
{
printf("fail to call socket, errno[%d, %s]\n", errno, strerror(errno));
exit(0);
}
}
int main()
{
// 創建UDP套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (-1 == sockfd)
{
printf("fail to call socket, errno[%d, %s]\n", errno, strerror(errno));
exit(0);
}
}
當我們調用 socket() 創建一個 socket 描述符時,返回的 socket 描述符存在於協議族(address family,AF_XXX)空間中,但還沒有一個具體的地址;如果想要給它賦值一個地址,就必須調用 bind() 函數。
2.3 bind()
2.3.1 函數介紹
socket() 函數用來創建套接字,確定套接字的各種屬性,然後服務端要用 bind() 函數將套接字與特定的 IP 地址和埠綁定起來,只有這樣,流經該 IP 地址和埠的數據才能交給套接字。
函數原型:int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
頭 文 件:#include <sys/socket.h>
返 回 值:調用成功返回 0;出錯返回 -1,並置 errno 為相應的錯誤碼。
參數描述:
- sockfd:即 socket 描述字,它是通過 socket() 函數創建的、唯一標識一個socket。
- addr:socket 地址結構。
- addrlen:地址的長度。
大多數套接字函數都需要一個指向套接字地址結構的指針(addr)作為參數。每個協議族都定義它自己的套接字地址結構,這些結構的名字均以 sockaddr_ 開頭,並以對應每個協議族的唯一後綴結尾。
2.3.2 函數使用
我們來看一個程式碼,將創建的套接字與 IP 地址 192.0.0.128、埠 8080 綁定:
#define IPADDR "192.0.0.128" /* IP 地址 */
#define PORT 8080 /* 埠號 */
int main()
{
// 將套接字與特定的IP地址和埠綁定起來
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
inet_aton(IPADDR, &addr.sin_addr);
int iBind = bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
if (-1 == iBind)
{
printf("fail to call bind, errno[%d, %s]\n", errno, strerror(errno));
close(sockfd);
exit(0);
}
}
2.3.3 sockaddr_in
在該程式碼中,我們使用了 IPv4 的地址結構 sockaddr_in,它的定義如下:
struct in_addr
{
in_addr_t s_addr; // network byte ordered
}
struct sockaddr_in
{
sa_family_t sin_family; // AF_INET
in_port_t sin_port; // 16-bit TCP or UDP port number
// network byte ordered
struct in_addr sin_addr; // 32-bit IPv4 address
// network byte ordered
char sin_zero[8]; // unused
}
- sin_family:和 socket() 的第一個參數的含義相同,取值也要保持一致。
- sin_port:16 位埠號,長度為 2Byte。有關埠號的賦值,有兩點需要關註:
- 理論上埠號的取值範圍為 0~65535,但 0~1023 的埠一般由系統分配給特定的服務程式,例如 web 服務的埠為 70,FTP 服務的埠為 21,所以我們的程式盡量在 1024~65536 之間分配埠號。
- 通過 htons 函數將埠號轉化為網路位元組序。
- sin_addr:是 in_addr 結構體類型的變數,表示 32 位 IP 地址,並通過 inet_aton 函數將其轉化為網路位元組序。
- sin_zero:剩餘的 8 個位元組,沒有用,一般使用 memset() 函數填充為0。
2.3.4 sockaddr
但函數 bind() 的第二個參數 sockaddr,程式碼中卻使用了 sockaddr_in,然後再強制轉換為 sockaddr,這是為什麼呢?在解釋之前,我們先來看一下 sockaddr 長什麼樣:
struct sockaddr
{
sa_family_t sa_family; // address family: AF_XXX
char sa_data[14]; // protocol-specific address
}
下圖是 sockaddr 與 sockaddr_in 的對比(括弧中的數字表示所佔用的位元組數):
sockaddr 和 sockaddr_in 的長度相同,都是16 個位元組,但是 sockaddr 的 sa_data 區域需要同時指定 IP 地址和埠號,例如「192.0.0.128:8080」。遺憾的是沒有相關函數將這個字元串轉換成需要的形式,也就很難給 sockaddr 類型的變數直接賦值,所以使用 sockaddr_in 來代替。這兩個結構體的長度相同,強制轉換類型時也不會丟失位元組,也沒有多於的位元組。
可以認為,sockaddr 是一個通用的套接字結構體,可以用來保存多種類型的 IP 地址和埠號,而 sockaddr_in 是專門用來保存 IPv4 地址的結構體。另外還有 sockaddr_in6,用來保存 IPv6 地址。
2.4 connect()
2.4.1 函數介紹
客戶端通過 connect() 函數來建立與服務端的連接
函數原型:int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
頭 文 件:#include <sys/socket.h>
返 回 值:調用成功返回 0;出錯返回 -1,並置 errno 為相應的錯誤碼。
參數描述:
- sockfd:即 socket 描述字,它是通過 socket() 函數創建的、唯一標識一個socket。
- addr:socket 地址結構。
- addrlen:地址的長度。
2.4.2 函數使用
#define IPADDR "192.0.0.128" /* IP 地址 */
#define PORT 8080 /* 埠號 */
int main()
{
// 將套接字與特定的IP地址和埠號建立連接
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
inet_aton(IPADDR, &addr.sin_addr);
int iConn = connect(sockfd, (struct sockaddr *)&addr, sizeof(addr));
if (-1 == iConn)
{
printf("fail to call connect, errno[%d, %s]\n", errno, strerror(errno));
close(sockfd);
exit(0);
}
}
有關地址結構的說明,參考 bind()。
2.5 listen()
2.5.1 函數介紹
函數原型:int listen(int sockfd, int backlog);
頭 文 件:#include <sys/socket.h>
返 回 值:調用成功返回 0,出錯返回 -1。
參數描述:
- sockfd 為需要進入監聽狀態的套接字。
- backlog 為請求隊列的最大長度。
- 當套接字正在處理客戶端請求時,如果有新的請求進來,套接字是沒法處理的,只能把它先放到緩衝區中,待當前請求處理完畢後,再從緩衝區中讀取出來處理。如果不斷有新的請求進來,它們就按照先後順序在緩衝區中排隊,直到緩衝區滿,而這個緩衝區,就稱為請求隊列。
listen() 函數僅由服務端調用,使套接字進入被動監聽狀態。所謂被動監聽,是指在沒有客戶端請求時,套接字處於「睡眠」狀態;只有當接收到客戶端的請求時,套接字才會被「喚醒」來響應請求。
緩衝區的長度(能存放多少個客戶端的請求)可以通過 listen() 函數的backlog參數指定,但究竟為多少並沒有什麼標準,根據你的需求來定。如果將 backlog 的值設置為 SOMAXCONN,就由系統來決定請求隊列長度,這個值一般比較大,可能是幾百或者更多。當請求隊列滿時,就不再接收新的請求;對於 linux,客戶端會受到 ECONNREFUSED 錯誤。
注意:listen()函數只是讓套接字處於監聽狀態,並沒有接收請求。接收請求需要使用accept()函數。
2.5.2 函數使用
#define BACKLOG 10
int main()
{
// 讓套接字進入被動監聽狀態
int iListen = listen(sockfd, BACKLOG);
if (-1 == iListen)
{
printf("fail to call listen, errno[%d, %s]\n", errno, strerror(errno));
close(sockfd);
exit(0);
}
}
2.6 accept()
2.6.1 函數介紹
函數原型:int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
頭 文 件:#include <sys/socket.h>
返 回 值:
- 調用成功後會返回一個小的非負整數(描述符)。
- 調用失敗返回 -1,並置 errno 為相應的錯誤碼。
參數描述:它的形參列表與 bind() 和 connect() 是相同的,區別在於該函數的 addr 返回的是已連接的對端的(客戶端)協議地址(IP 地址和埠號),如果我們對「對端的協議地址」不感興趣,可將 addr 和 addrlen 均置為 NULL。
accept() 函數由服務端調用。如果調用成功,將返回一個新的描述符,代表與所返回客戶(addr)的TCP連接。在討論 accept() 函數時,我們稱它的第一個參數為監聽套接字描述符(由 socket() 創建,隨後作用於 bind() 和 listen() 的第一個參數的描述符),稱它的返回值為已連接套接字描述符。
區分這兩個套接字非常重要:
- 一個伺服器通常僅僅創建一個「監聽套接字」,它在該伺服器的生命周期內一直存在。
- 內核為每個由伺服器進程接受的客戶連接創建一個「已連接套接字」,當伺服器完成對某個給定客戶的服務時,相應的「已連接套接字」就被關閉了。
2.6.2 函數使用
用法一:不關注帶對端的協議地址
int main()
{
// 當套接字處於監聽狀態時,可以通過 accept 函數來接收客戶端的請求
int acceptfd = accept(sockfd, NULL, NULL);
if (-1 == acceptfd)
{
printf("fail to call accept, errno[%d, %s]\n", errno, strerror(errno));
close(sockfd);
exit(0);
}
}
用法二:需要獲取對端的協議地址
#define STRING_LEN_16 16
/* 將 IP 地址的網路位元組序轉化為點分十進位字元串 */
char *_inet_ntoa(struct in_addr *addr, char *ipAddr, int len)
{
if (NULL == addr || NULL == ipAddr || 16 > len)
{
printf("invalid param\n");
return NULL;
}
unsigned char *tmp = (unsigned char*)addr;
snprintf(ipAddr, len, "%d.%d.%d.%d", tmp[0], tmp[1], tmp[2], tmp[3]);
return ipAddr;
}
int main()
{
// 當套接字處於監聽狀態時,可以通過 accept 函數來接收客戶端的請求
struct sockaddr_in peerAddr;
socklen_t peerAddrLen = sizeof(struct sockaddr_in);
int acceptfd = accept(sockfd, (struct sockaddr *)&peerAddr, &peerAddrLen);
if (-1 == acceptfd)
{
printf("fail to call accept, errno[%d, %s]\n", errno, strerror(errno));
close(sockfd);
exit(0);
}
char peerIPAddr[STRING_LEN_16];
_inet_ntoa(&peerAddr.sin_addr, peerIPAddr, STRING_LEN_16);
printf("peer client address [%s:%u]\n", peerIPAddr, ntohs(peerAddr.sin_port));
}
2.7 send()
函數原型:ssize_t send(int sockfd, const void *buf, size_t len, int flags);
頭 文 件:#include <sys/socket.h>
返 回 值:成功返回發送的位元組個數;失敗返回 -1,並置 errno 為響應的錯誤碼。
參數描述:
- sockfd:對於服務端而言,傳入「已連接套接字描述符」;對於客戶端而言,傳入套接字描述符。
- buf:需要發送的數據。
- len:指定要發送的數據大小。
- flags:標誌位,一般置為 0。
2.8 recv()
函數原型:ssize_t recv(int sockfd, void *buf, size_t len, int flags);
頭 文 件:#include <sys/socket.h>
返 回 值:
- 成功返回接收到的字元個數。
- 失敗返回 -1,並置 errno 為響應的錯誤碼。
- 對端關閉則返回 0。
參數描述:
- sockfd:對於服務端而言,傳入「已連接套接字描述符」;對於客戶端而言,傳入套接字描述符。
- buf:指明一個緩衝區,該緩衝區用來存放接收到的數據;
- len:指定緩衝區 buf 的大小。
- flags:標誌位,一般置為 0。
2.9 close()
函數原型:int close(int fd);
頭 文 件:#include <unistd.h>
功 能:關閉一個文件描述符。
三、一個完整的 Demo
3.1 Server
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <errno.h>
#include <unistd.h>
#define IPADDR "192.0.0.128" /* IP 地址 */
#define PORT 8080 /* 埠號 */
#define STRING_LEN_16 16
#define STRING_LEN_64 64
char *_inet_ntoa(struct in_addr *addr, char *ipAddr, int len)
{
if (NULL == addr || NULL == ipAddr || 16 > len)
{
printf("invalid param\n");
return NULL;
}
unsigned char *tmp = (unsigned char*)addr;
snprintf(ipAddr, len, "%d.%d.%d.%d", tmp[0], tmp[1], tmp[2], tmp[3]);
return ipAddr;
}
int main()
{
// 創建TCP套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == sockfd)
{
printf("fail to call socket, errno[%d, %s]\n", errno, strerror(errno));
exit(0);
}
// 將套接字與特定的IP地址和埠綁定起來
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
inet_aton(IPADDR, &addr.sin_addr);
int iBind = bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
if (-1 == iBind)
{
printf("fail to call bind, errno[%d, %s]\n", errno, strerror(errno));
close(sockfd);
exit(0);
}
// 讓套接字進入被動監聽狀態
int iListen = listen(sockfd, 10);
if (-1 == iListen)
{
printf("fail to call listen, errno[%d, %s]\n", errno, strerror(errno));
close(sockfd);
exit(0);
}
// 當套接字處於監聽狀態時,可以通過 accept 函數來接收客戶端的請求
struct sockaddr_in peerAddr;
socklen_t peerAddrLen = sizeof(struct sockaddr_in);
int connfd = accept(sockfd, (struct sockaddr *)&peerAddr, &peerAddrLen);
if (-1 == connfd)
{
printf("fail to call accept, errno[%d, %s]\n", errno, strerror(errno));
close(sockfd);
exit(0);
}
char peerIPAddr[STRING_LEN_16];
_inet_ntoa(&peerAddr.sin_addr, peerIPAddr, STRING_LEN_16);
printf("peer client address [%s:%u]\n", peerIPAddr, ntohs(peerAddr.sin_port));
while (1)
{
// 讀取客戶端發送的數據
char buf[STRING_LEN_64];
int n = recv(connfd, buf, STRING_LEN_64 - 1, 0);
buf[n] = '\0';
if (0 == n) // n為0表示對端關閉
{
printf("peer close\n");
break;
}
printf("recv msg from client : %s\n", buf);
sleep(2);
// 向客戶端發送數據
char str[] = "recved~";
printf("send msg to client : %s\n", str);
send(connfd, str, strlen(str), 0);
}
// 交互結束,關閉套接字
close(connfd);
close(sockfd);
return 0;
}
3.2 Client
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <errno.h>
#include <unistd.h>
#define IPADDR "192.0.0.128" /* 服務端 IP 地址 */
#define PORT 8080 /* 服務端 埠號 */
#define STRING_LEN_64 64
int main()
{
// 創建TCP套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
printf("sockfd = %d\n", sockfd);
if (-1 == sockfd)
{
printf("fail to call socket, errno[%d, %s]\n", errno, strerror(errno));
exit(0);
}
// 將套接字與特定的IP地址和埠號建立連接
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
inet_aton(IPADDR, &addr.sin_addr);
int iConn = connect(sockfd, (struct sockaddr *)&addr, sizeof(addr));
if (-1 == iConn)
{
printf("fail to call connect, errno[%d, %s]\n", errno, strerror(errno));
close(sockfd);
exit(0);
}
// 向服務端發送數據
char str[] = "hello world";
printf("send msg to server : %s\n", str);
send(sockfd, str, strlen(str), 0);
// 接收服務端相應的數據
char buf[STRING_LEN_64];
int n = recv(sockfd, buf, STRING_LEN_64 - 1, 0);
buf[n] = '\0';
printf("recv msg from server : %s\n", buf);
// 交互結束,關閉套接字
close(sockfd);
return 0;
}
3.3 客戶端 connect 調用報 113 錯誤
運行環境:
- 虛擬機 CentOS 7 運行 Server
- 虛擬機 CentOS 6 運行 Client
當 CentOS 6 啟動 Client 時,運行到 connect 函數報錯:fail to call connect, errno[113, No route to host]。排查了一下,報這個錯誤的原因是運行服務端的 CentOS 7 未關閉防火牆,相關指令如下:
-
firewall-cmd --state
:查看防火牆狀態-
running 表示防火牆處於開啟狀態
-
not running 表示防火牆處於關閉狀態
-
-
systemctl stop firewalld.service
:關閉防火牆 -
systemctl start firewalld.service
:打開防火牆
CentOS 6,相關指令如下:
service iptables status
:查看防火牆狀態- 如果防火牆處於關閉狀態,則提示:iptables:未運行防火牆。
service iptables stop
:關閉防火牆service iptables start
:打開防火牆
四、POSIX 規範要求的數據類型
最後,附上 POSIX 規範要求的數據類型。
頭文件:#include <netinet/in.h>
數據類型 | 說明 | 大小 |
---|---|---|
int8_t | 帶符號的 8 位整數 | 1 Byte |
uint8_t | 無符號的 8 位整數 | 1 Byte |
int16_t | 帶符號的 16 位整數 | 2 Byte |
uint16_t | 無符號的 16 位整數 | 2 Byte |
int32_t | 帶符號的 32 位整數 | 4 Byte |
uint32_t | 無符號的 32 位整數 | 4 Byte |
sa_family_t | 套接字地址結構的地址族 | 2 Byte |
socklen_t | 套接字地址結構的長度,一般為 uint32_t | 4 Byte |
in_addr_t | IPv4 地址,一般為 uint32_t | 4 Byte |
in_port_t | TCP 或 UDP 埠號,一般為 uint16_t | 2 Byte |
ssize_t | 有符號整型;在 32位 機器上等同與 int,在 64 位機器上等同與 long int。 |