Socket 程式設計

2022-10-17 06:00:53

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>

返 回 值:

  1. 呼叫成功後會返回一個小的非負整數(socket 描述符)。
  2. 呼叫失敗返回 -1,並置 errno 為相應的錯誤碼。。

引數描述:

  1. domain:即協定域,又稱為協定族(family)。常用的協定族有:

    協定族 說明
    AF_INET IPv4 協定
    AF_INET6 IPv6 協定

    協定族決定了socket 的地址型別,在通訊中必須採用對應的地址,如 AF_INET 決定了要用 32 位的 IPv4 地址與 16 位的號組合。

  2. type:指定 socket 型別。常用的 socket 型別有:

    socket 型別 說明
    SOCK_STREAM 位元組流通訊端
    SOCK_DGRAM 資料包通訊端
  3. 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 為相應的錯誤碼。

引數描述:

  1. sockfd:即 socket 描述字,它是通過 socket() 函數建立的、唯一標識一個socket。
  2. addr:socket 地址結構。
  3. 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
}
  1. sin_family:和 socket() 的第一個引數的含義相同,取值也要保持一致。
  2. sin_port:16 位埠號,長度為 2Byte。有關埠號的賦值,有兩點需要關注:
    • 理論上埠號的取值範圍為 0~65535,但 0~1023 的埠一般由系統分配給特定的服務程式,例如 web 服務的埠為 70,FTP 服務的埠為 21,所以我們的程式儘量在 1024~65536 之間分配埠號。
    • 通過 htons 函數將埠號轉化為網路位元組序。
  3. sin_addr:是 in_addr 結構體型別的變數,表示 32 位 IP 地址,並通過 inet_aton 函數將其轉化為網路位元組序。
  4. 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 為相應的錯誤碼。

引數描述:

  1. sockfd:即 socket 描述字,它是通過 socket() 函數建立的、唯一標識一個socket。
  2. addr:socket 地址結構。
  3. 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。

引數描述:

  1. sockfd 為需要進入監聽狀態的通訊端。
  2. 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. 呼叫成功後會返回一個小的非負整數(描述符)。
  2. 呼叫失敗返回 -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 為響應的錯誤碼。

引數描述:

  1. sockfd:對於伺服器端而言,傳入「已連線通訊端描述符」;對於使用者端而言,傳入通訊端描述符。
  2. buf:需要傳送的資料。
  3. len:指定要傳送的資料大小。
  4. flags:標誌位,一般置為 0。

2.8 recv()

函數原型:ssize_t recv(int sockfd, void *buf, size_t len, int flags);

頭 文 件:#include <sys/socket.h>

返 回 值:

  1. 成功返回接收到的字元個數。
  2. 失敗返回 -1,並置 errno 為響應的錯誤碼。
  3. 對端關閉則返回 0。

引數描述:

  1. sockfd:對於伺服器端而言,傳入「已連線通訊端描述符」;對於使用者端而言,傳入通訊端描述符。
  2. buf:指明一個緩衝區,該緩衝區用來存放接收到的資料;
  3. len:指定緩衝區 buf 的大小。
  4. 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 錯誤

執行環境:

  1. 虛擬機器器 CentOS 7 執行 Server
  2. 虛擬機器器 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。

參考資料