Linux TCP IP Stack - ianchen0119/Introduce-to-5GC GitHub Wiki

本文目標:

  • 認識(或複習)socket programming
  • 了解 Linux 如何接收/發送封包

Recap: socket programming

漢語的「插座」是 socket 一詞在電氣領域的特化用語,但不代表 socket 就只翻譯為「插座」 —— socket 原本在英語就有多個意思,例如 eye socket 指眼眶,後者是顱骨的一個體腔,眼球就位於眼眶中。無論是解剖學還是在電器領域,socket 都有連接後,得以存取某種資源和支撐某個部分的寓意。由於「插座」在漢語已是特化用語,我們就不以「插座」來稱呼電腦網路領域的 socket。

會閱讀本系列文的讀者基本上都有開發網路程式設計的經驗,考慮到現在的程式語言大多提供了 high-level 的函式庫,或許有很多人沒有接觸過 socket programming,所以在介紹 IP Stack 之前,筆者會先簡單的講解一下如何使用 socket 開發一個 TCP Server:

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

int main(int argc, char **argv) {
    int s;
    int sock_fd = socket(AF_INET, SOCK_STREAM, 0);

    struct addrinfo hints, *result;
    memset(&hints, 0, sizeof (struct addrinfo));
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_flags = AI_PASSIVE;

    s = getaddrinfo(NULL, "1234", &hints, &result);
    if (s != 0) {
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(s));
        exit(1);
    }

    if (bind(sock_fd, result->ai_addr, result->ai_addrlen) != 0) {
        perror("bind()");
        exit(1);
    }

    if (listen(sock_fd, 10) != 0) {
        perror("listen()");
        exit(1);
    }
    
    struct sockaddr_in *result_addr = (struct sockaddr_in *) result->ai_addr;
    printf("Listening on file descriptor %d, port %d\n", sock_fd, ntohs(result_addr->sin_port));

    printf("Waiting for connection...\n");
    int client_fd = accept(sock_fd, NULL, NULL);
    printf("Connection made: client_fd=%d\n", client_fd);

    char buffer[1000];
    int len = read(client_fd, buffer, sizeof (buffer) - 1);
    buffer[len] = '\0';

    printf("Read %d chars\n", len);
    printf("===\n");
    printf("%s\n", buffer);

    return 0;
}

上方的範例程式碼是一個簡易的 TCP Server program,內容取自 UIUC 的 System programming 教材

不管是開發 TCP/UDP 的 Server/Client 應用,我們都會需要呼叫 socket() 這個 glibc 提供的 API,它會回傳一個指向 socket 的 file discriptor,讓我們可以對 socket 進行操作。

  • AF_INET 表示 internet family,也就是說這個 socket 會用來與網際網路中的服務進行通訊。
  • SOCK_STREAM 表示 TCP,如果傳入 SOCK_DGRAM 則代表 UDP。

image

圖片來源:http://zake7749.github.io/2015/03/17/SocketProgramming/

參考上圖,建立 socket 之後使用 getaddrinfo() 填入必備的資訊(像是 IP 位址、Port 等資訊),呼叫後我們會得到一個指向 addrinfo 結構指標的指標。

  • 上面的範例中沒有填入 IP 而是使用 NULL,這是因為我們在 hints.ai_flags = AI_PASSIVE; 告訴該函式請自動為我們填入 IP 相關的資訊到資料結構中。

這個回傳的資料提供我們 bind socket,bind() 幫助作業系統了解這個 socket 對應到的 IP、Port,經過這個步驟以後,當作業系統收到來自該 IP & Port 的封包時,我們才能在 User Space 順利的取得封包。

前面的步驟完成後,使用 listen() 讓系統開始監聽目標 port 是否有封包進來:

  • listen(sock_fd, 10) 第一個參數為 socket 的 file descriptor,第二個參數為封包 queue 的大小

做完 bind() 以及 listen() 後,這時作業系統已經知道 user space 存在一個應用程式在等到來自網際網路的 TCP 封包,當作業系統收到封包後我們還需要做點額外的工作才能讀取到封包的內容:

  • int client_fd = accept(sock_fd, NULL, NULL);accept() 會在 socket 收到封包時回傳指向封包本身的 file descriptor 並往下執行。
  • 得到指向封包本身的 file descriptor,我們就可以使用 read() 這一類的 API 讀取封包內容了!

作者補充:

  • accept() 會在收到封包後往下執行,換句話說 accept() 在收到封包之前會持續的 blocking 系統的運作,如果你的應用程式不止要做接收封包的動作,應配合 select() 或是採用 concurrent programming 的方式處理這類問題。
  • socket 對於作業系統來說跟一般的檔案差不多,我們在 user space 對 socket 進行任何操作時,對 kernel space 來說就跟操作一個 file 是一樣的。

進入正題

在先前的文章中,我們已經探討過以下內容:

  • 基本網路概念
  • Linux 網路系統簡介(包含 xdp、dpdk 等獵奇的技術)

為了讓各位讀者更完整的認識 Linux 的網路實作,這篇文章的重點會放在 TCP/IP(或是 UDP/IP)的封包從 user space 離開以後到底經歷了多少的工作。

image

對於一個支援網路功能的作業系統來說,它可能會需要處理各個 layer 的封包,甚至是在每一個 layer 中依據不同 protocol 的類型特別處理這些封包,以一個 TCP/IP 的封包為例:

  • 在我們透過 user space 的 socket 發送封包以後,作業系統會把封包的資料 copy 到 kernel space。
  • 當封包進入 kernel space 後,作業系統需要先處理 TCP header,處理完畢後再向下處理 IP protocol。
    • 如果我們每處理完一層網路協定就需要對封包進行複製,這樣會對系統造成很大的負擔!
  • 為了解決這個問題,Linux 會在 user space 複製到 kernel space 時就先使用 kmalloc() 分配一個比封包本身要大的記憶體空間(也就是上圖左下角的一塊連續記憶體)。

有了這塊空間後,會碰到一個問題:我要怎麼知道 TCP header 或是 IP header 在哪裡?

image

圖片出處:https://www.researchgate.net/figure/Packet-encapsulation-TCP-IP-architecture-encapsulates-the-data-from-the-upper-layer-by_fig4_49288737

聰明的你或許已經想到:我們可以使用一個資料結構來記錄這個封包的資訊呀!是的,Linux 正是使用這個方式紀錄每一個 protocol 封包的起始、終止位址,這個結構叫做 sk_buff(socket buffer),我們也可以把它想成是封包的 metedata,因為它記錄了:

  • Packet 資料的位址
  • 封包屬於哪一個裝置(dev)
  • checksum
  • 封包對應到的 socket(它是從哪送出,或是該送去哪)

每一個 sk_buff 都代表一個網路封包,在系統運作時 kernel space 可能會存放多到數不清的 sk_buff。為了方便管理,Linux 將它設計成 linked-list 的節點:

  • sock 資料結構紀錄 socket 的相關資訊,並且指向 socket 以及 sk_buff_head
  • sk_buff_head 是 sk_buff linked-list 的 head
  • socket 資料結構就像是一個檔案,它包含了一般檔案具備的元素(如:inodeops 等等)

了解 Linux 如何利用 socket buffer 避免繁瑣的複製以及管理封包處理後,來看看當我們在 user space 呼叫 write()(對應系統呼叫 sys_write())以後,作業系統到底使用多少函式對 socket buffer 進行操作:

image

image

圖片出處:https://friday.plus/archives/linux%E7%BD%91%E7%BB%9C%E5%8D%8F%E8%AE%AE%E6%A0%88

參考上圖,當封包存 User space 的 socket 傳出,會先經過 TCP 的處理,等到 TCP 的部份處理完畢後再交給 IP 相關的函式。 當一切準備就緒後,就可以使用 DMA 之類的方式將封包複製到網路卡的記憶體上傳送(MAC 相關的部分通常由硬體處理)。

image

上圖是 netfilter 的架構圖,透過這張圖我們可以清楚的了解到 netfilter 每一個 hook 分別在哪些 kernel space API 前/後進行處理,然後它們又對應到 iptables 的哪些 chain:

  • PREROUTING:由 NF_IP_PRE_ROUTING 觸發,接在 ip_rcv() 之後。
  • INPUT:由 NF_IP_LOCAL_IN 觸發,接在 ip_local_deliver() 之前。
  • FORWARD:由 NF_IP_FORWARD 觸發,接在 ip_rcv() 之後,經過 routing 判斷後交給 dev_queue_xmit()
  • OUTPUT:由 NF_IP_LOCAL_OUT 觸發,接在 ip_queue_xmit() 之後。
  • POSTROUTING:由 NF_IP_POST_ROUTING 觸發,接在 dev_queue_xmit() 之後。

Linux 如何接收封包

在前面我們探討了封包如何從 User space 一路經過各個 protocol 的處理,再由網路卡送出。 當網路卡收到封包後,會向 CPU 發送中斷訊號(Interrupt Request),作業系統會在能夠被中斷時將封包複製到 kernel space,再由對應的 ISR(Interrupt Service Routine)進行處理。

image

圖片來源:https://qiita.com/IK_PE/items/4d868e8940885f46e0da

如果封包屬於 IP protocol 的封包,那麼會由 ip_rcv() 將封包收進來處理,待處理完畢後,會由 ip_locol_deliver() 將封包交給更上層的協定繼續處理:

/*
 *  Deliver IP Packets to the higher protocol layers.
 */ 
int ip_local_deliver(struct sk_buff *skb)
{
    /*
     *  Reassemble IP fragments.
     */
    /* IP flags. */
    //#define IP_CE     0x8000      /* Flag: "Congestion"       */
    //#define IP_DF     0x4000      /* Flag: "Don't Fragment"   */
    //#define IP_MF     0x2000      /* Flag: "More Fragments"   */
    //#define IP_OFFSET 0x1FFF      /* "Fragment Offset" part   */
    if (skb->nh.iph->frag_off & htons(IP_MF|IP_OFFSET)) {
        // defragment
        skb = ip_defrag(skb, IP_DEFRAG_LOCAL_DELIVER);
        if (!skb)
            return 0;
    }

    // 對應到 Netfilter 的 hook,ip_local_deliver_finish() 會被呼叫
    return NF_HOOK(PF_INET, NF_IP_LOCAL_IN, skb, skb->dev, NULL,
               ip_local_deliver_finish);
}

ip_local_deliver_finish()

static inline int ip_local_deliver_finish(struct sk_buff *skb)
{
    int protocol = skb->nh.iph->protocol;
    int hash;
    struct net_protocol *ipprot;

  resubmit:
    hash = protocol & (MAX_INET_PROTOS - 1);

    // 取得上層協議的 protocol type
    ipprot = rcu_dereference(inet_protos[hash]));

    // ipprot->handler 表示 transport layer 的 entry function
    // TCP 對應到 tcp_v4_rcv()
    // UDP 則對應 udp_rcv()
    int ret = ipprot->handler(skb);
    if (ret < 0) {
        protocol = -ret;
        goto resubmit;
    }
    return 0;
}

假設這邊的封包數於 TCP protocol 的網路封包,那麼這個封包會經過以下函式:

在不探討 TCP 如何處理 connection 狀態的改變、重傳、fast/slow path 的情況下,經過層層處理後,位於 user space 的 socket 就能收到來自網卡的網路封包了!

總結

本篇文章帶大家複習了基本的 socket programming,並藉由 socket 探討網路封包的處理與收送,相信這篇文章可以讓大家更加了解當我們在高階程式語言寫下簡單的 receive() 或是 send() 函式後,作業系統在開發者的背後默默的進行哪些處理、網路協定之間的關係是什麼。

礙於篇幅的關係,這邊沒有完整的介紹 TCP 的機制,以及 Linux 如何優化網路封包處理的速度(包含 soft_irq 以及用於優化 TCP 的 fast path 機制),有興趣的讀者可以自行研究。

References

⚠️ **GitHub.com Fallback** ⚠️