理解 TCP(六):網路程式設計介面

JerryC發表於2017-03-02

更好閱讀體驗:《理解 TCP 和 UDP》— By Gitbook

一切皆 Socket

我們已經知道網路中的程式是通過 socket 來通訊的,那什麼是 socket 呢?
socket 起源於 UNIX,而 UNIX/Linux 基本哲學之一就是「一切皆檔案」,都可以用「open → write/read → close」模式來操作。
socket 其實就是該模式的一個實現,socket 即是一種特殊的檔案,一些 socket 函式就是對其進行的操作。

使用 TCP/IP 協議的應用程式通常採用系統提供的程式設計介面:UNIX BSD 的套接字介面(Socket Interfaces)
以此來實現網路程式之間的通訊。
就目前而言,幾乎所有的應用程式都是採用 socket,所以說現在的網路時代,網路中程式通訊是無處不在,一切皆 socket

套接字介面 Socket Interfaces

套接字介面是一組函式,由作業系統提供,用以建立網路應用。
大多數現代作業系統都實現了套接字介面,包括所有 Unix 變種,Windows 和 Macintosh 系統。

套接字介面的起源
套接字介面是加州大學伯克利分校的研究人員在 20 世紀 80 年代早起提出的。
伯克利的研究者使得套接字介面適用於任何底層的協議,第一個實現就是針對 TCP/IP 協議,他們把它包括在 Unix 4.2 BSD 的核心裡,並且分發給許多學校和實驗室。
這在因特網的歷史成為了一個重大事件。
—— 《深入理解計算機系統》

從 Linux 核心的角度來看,一個套接字就是通訊的一個端點。
從 Linux 程式的角度來看,套接字是一個有相應描述符的檔案。
普通檔案的開啟操作返回一個檔案描述字,而 socket() 用於建立一個 socket 描述符,唯一標識一個 socket。
這個 socket 描述字跟檔案描述字一樣,後續的操作都有用到它,把它作為引數,通過它來進行一些操作。

常用的函式有:

  • socket()
  • bind()
  • listen()
  • connect()
  • accept()
  • write()
  • read()
  • close()

Socket 的互動流程

理解 TCP(六):網路程式設計介面
socket 互動過程.png

圖中展示了 TCP 協議的 socket 互動流程,描述如下:

  1. 伺服器根據地址型別、socket 型別、以及協議來建立 socket。
  2. 伺服器為 socket 繫結 IP 地址和埠號。
  3. 伺服器 socket 監聽埠號請求,隨時準備接收客戶端發來的連線,這時候伺服器的 socket 並沒有全部開啟。
  4. 客戶端建立 socket。
  5. 客戶端開啟 socket,根據伺服器 IP 地址和埠號試圖連線伺服器 socket。
  6. 伺服器 socket 接收到客戶端 socket 請求,被動開啟,開始接收客戶端請求,知道客戶端返回連線資訊。這時候 socket 進入阻塞狀態,阻塞是由於 accept() 方法會一直等到客戶端返回連線資訊後才返回,然後開始連線下一個客戶端的連線請求。
  7. 客戶端連線成功,向伺服器傳送連線狀態資訊。
  8. 伺服器 accept() 方法返回,連線成功。
  9. 伺服器和客戶端通過網路 I/O 函式進行資料的傳輸。
  10. 客戶端關閉 socket。
  11. 伺服器關閉 socket。

這個過程中,伺服器和客戶端建立連線的部分,就體現了 TCP 三次握手的原理。

下面詳細講一下 socket 的各函式。

Socket 介面

socket 是系統提供的介面,而作業系統大多數都是用 C/C++ 開發的,自然函式庫也是 C/C++ 程式碼。

socket 函式

該函式會返回一個套接字描述符(socket descriptor),但是該描述符僅是部分開啟的,還不能用於讀寫。
如何完成開啟套接字的工作,取決於我們是客戶端還是伺服器。

函式原型

#include <sys/socket.h>

int socket(int domain, int type, int protocol);複製程式碼

引數說明

domain:
協議域,決定了 socket 的地質型別,在通訊中必須採用對應的地址。
常用的協議族有:AF_INET(ipv4地址與埠號的組合)、AF_INET6(ipv6地址與埠號的組合)、AF_LOCAL(絕對路徑名作為地址)。
該值的常量定義在 sys/socket.h 檔案中。

type:
指定 socket 型別。
常用的型別有:SOCK_STREAMSOCK_DGRAMSOCK_RAWSOCK_PACKETSOCK_SEQPACKET等。
其中 SOCK_STREAM 表示提供面向連線的穩定資料傳輸,即 TCP 協議。
該值的常量定義在 sys/socket.h 檔案中。

protocol:
指定協議。
常用的協議有:IPPROTO_TCP(TCP協議)、IPPTOTO_UDP(UDP協議)、IPPROTO_SCTP(STCP協議)。
當值位 0 時,會自動選擇 type 型別對應的預設協議。

bind 函式

由服務端呼叫,把一個地址族中的特定地址和 socket 聯絡起來。

函式原型

#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);複製程式碼

引數說明

sockfd:
即 socket 描述字,由 socket() 函式建立。

*addr
一個 const struct sockaddr 指標,指向要繫結給 sockfd 的協議地址。
這個地址結構根據地址建立 socket 時的地址協議族不同而不同,例如 ipv4 對應 sockaddr_in,ipv6 對應 sockaddr_in6.
這幾個結構體在使用的時候,都可以強制轉換成 sockaddr
下面是這幾個結構體對應的所在的標頭檔案:

  1. sockaddrsys/socket.h
  2. sockaddr_innetinet/in.h
  3. sockaddr_in6netinet6/in.h

_in 字尾意義:網際網路絡(internet)的縮寫,而不是輸入(input)的縮寫。

listen 函式

伺服器呼叫,將 socket 從一個主動套接字轉化為一個監聽套接字(listening socket), 該套接字可以接收來自客戶端的連線請求。
在預設情況下,作業系統核心會認為 socket 函式建立的描述符對應於主動套接字(active socket)。

函式原型

#include <sys/socket.h>
int listen(int sockfd, int backlog);複製程式碼

引數說明

sockfd:
即 socket 描述字,由 socket() 函式建立。

backlog:
指定在請求佇列中的最大請求數,進入的連線請求將在佇列中等待 accept() 它們。

connect 函式

由客戶端呼叫,與目的伺服器的套接字建立一個連線。

函式原型

#include <sys/socket.h>
int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);複製程式碼

引數說明

clientfd:
目的伺服器的 socket 描述符

*addr:
一個 const struct sockaddr 指標,包含了目的伺服器 IP 和埠。

addrlen
協議地址的長度,如果是 ipv4 的 TCP 連線,一般為 sizeof(sockaddr_in);

accept 函式

伺服器呼叫,等待來自客戶端的連線請求。
當客戶端連線,accept 函式會在 addr 中會填充上客戶端的套接字地址,並且返回一個已連線描述符(connected descriptor),這個描述符可以用來利用 Unix I/O 函式與客戶端通訊。

函式原型

#indclude <sys/socket.h>
int accept(int listenfd, struct sockaddr *addr, int *addrlen);複製程式碼

引數說明

listenfd:
伺服器的 socket 描述字,由 socket() 函式建立。

*addr:
一個 const struct sockaddr 指標,用來存放提出連線請求客戶端的主機的資訊

*addrlen:
協議地址的長度,如果是 ipv4 的 TCP 連線,一般為 sizeof(sockaddr_in)

close 函式

在資料傳輸完成之後,手動關閉連線。

函式原型

#include <sys/socket.h>
#include <unistd.h>
int close(int fd);複製程式碼

引數說明

fd:
需要關閉的連線 socket 描述符

網路 I/O 函式

當客戶端和伺服器建立連線後,可以使用網路 I/O 進行讀寫操作。
網路 I/O 操作有下面幾組:

  1. read()/write()
  2. recv()/send()
  3. readv()/writev()
  4. recvmsg()/sendmsg()
  5. recvfrom()/sendto()

最常用的是 read()/write()
他們的原型是:

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);複製程式碼

鑑於該文是側重於描述 socket 的工作原理,就不再詳細描述這些函式了。

實現一個簡單 TCP 互動

服務端

// socket_server.cpp

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>

#define MAXLINE 4096 // 4 * 1024

int main(int argc, char **argv)
{
    int listenfd, // 監聽埠的 socket 描述符
        connfd;   // 連線端 socket 描述符
    struct sockaddr_in servaddr;
    char buff[MAXLINE];
    int n;

    // 建立 socket,並且進行錯誤處理
    if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
    {
        printf("create socket error: %s(errno: %d)\n", strerror(errno), errno);
        return 0;
    }

    // 初始化 sockaddr_in 資料結構
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(6666);

    // 繫結 socket 和 埠
    if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1)
    {
        printf("bind socket error: %s(errno: %d)\n", strerror(errno), errno);
        return 0;
    }

    // 監聽連線
    if (listen(listenfd, 10) == -1)
    {
        printf("listen socket error: %s(errno: %d)\n", strerror(errno), errno);
        return 0;
    }

    printf("====== Waiting for client's request======\n");

    // 持續接收客戶端的連線請求
    while (true)
    {
        if ((connfd = accept(listenfd, (struct sockaddr *)NULL, NULL) == -1))
        {
            printf("accept socket error: %s(errno: %d)\n", strerror(errno), errno);
            continue;
        }

        n = recv(connfd, buff, MAXLINE, 0);
        buff[n] = '\0';
        printf("recv msg from client: %s\n", buff);
        close(connfd);
    }

    close(listenfd);
    return 0;
}複製程式碼

客戶端

// socket_client.cpp

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

#define MAXLINE 4096

int main(int argc, char **argv)
{
    int sockfd, n;
    char recvline[4096], sendline[4096];
    struct sockaddr_in servaddr;

    if (argc != 2)
    {
        printf("usage: ./client <ipaddress>\n");
        return 0;
    }

    // 建立 socket 描述符
    if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
    {
        printf("create socket error: %s(errno: %d)\n", strerror(errno), errno);
        return 0;
    }

    // 初始化目標伺服器資料結構
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(6666);
    // 從引數中讀取 IP 地址
    if (inet_pton(AF_INET, argv[1], &servaddr.sin_addr) <= 0)
    {
        printf("inet_pton error for %s\n", argv[1]);
        return 0;
    }

    // 連線目標伺服器,並和 sockfd 聯絡起來。
    if (connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0)
    {
        printf("connect error: %s(errno: %d)\n", strerror(errno), errno);
        return 0;
    }

    printf("send msg to server: \n");

    // 從標準輸入流中讀取資訊
    fgets(sendline, 4096, stdin);

    // 通過 sockfd,向目標伺服器傳送資訊
    if (send(sockfd, sendline, strlen(sendline), 0) < 0)
    {
        printf("send msg error: %s(errno: %d)\n", strerror(errno), errno);
        return 0;
    }

    // 資料傳輸完畢,關閉 socket 連線
    close(sockfd);
    return 0;
}複製程式碼

Run

首先建立 makefile 檔案

all:server client
server:socket_server.o
    g++ -g -o socket_server socket_server.o
client:socket_client.o
    g++ -g -o socket_client socket_client.o
socket_server.o:socket_server.cpp
    g++ -g -c socket_server.cpp
socket_client.o:socket_client.cpp
    g++ -g -c socket_client.cpp
clean:all
    rm all複製程式碼

然後使用命令:

$ make複製程式碼

會生成兩個可執行檔案:

  1. socket_server
  2. socket_client

分別開啟兩個終端,執行:

  1. ./socket_server
  2. ./socket_client 127.0.0.1

然後在 socket_client 中鍵入傳送內容,可以再 socket_server 接收到同樣的資訊。

參考

《後臺開發 核心技術與應用實踐》
《計算機網路》

相關文章