Linux 網絡子系統

作者:xholic

鏈接:https://www.cnblogs.com/ypholic/p/14337328.html

今天分享一篇經典 Linux 協議棧文章,主要講解 Linux 網絡子系統,看完相信大家對協議棧又會加深不少,不光可以瞭解協議棧處理流程,方便定位問題,還可以學習一下怎麼去設計一個可擴展的子系統,屏蔽不同層次的差異。

===============================================================================================================

目錄

1.Linux 網絡子系統的分層

2.TCP/IP 分層模型

3.Linux 網絡協議棧

4.Linux 網卡收包時的中斷處理問題

5.Linux 網絡啓動的準備工作

6.Linux 網絡包:中斷到網絡層接收

  1. 總結

Linux 網絡子系統的分層

Linux 網絡子系統實現需要:

 

系統調用提供用戶的應用程序訪問內核的唯一途徑。協議無關接口由 socket layer 來實現的,其提供一組通用功能,以支持各種不同的協議。網絡協議層爲 socket 層提供具體協議接口——proto{},實現具體的協議細節。設備無關接口,提供一組通用函數供底層網絡設備驅動程序使用。設備驅動與特定網卡設備相關,定義了具體的協議細節,會分配一個 net_device 結構,然後用其必需的例程進行初始化。

TCP/IP 分層模型

在 TCP/IP 網絡分層模型裏,整個協議棧被分成了物理層、鏈路層、網絡層,傳輸層和應用層。物理層對應的是網卡和網線,應用層對應的是我們常見的 Nginx,FTP 等等各種應用。Linux 實現的是鏈路層、網絡層和傳輸層這三層。

在 Linux 內核實現中,鏈路層協議靠網卡驅動來實現,內核協議棧來實現網絡層和傳輸層。內核對更上層的應用層提供 socket 接口來供用戶進程訪問。我們用 Linux 的視角來看到的 TCP/IP 網絡分層模型應該是下面這個樣子的。

 

首先我們梳理一下每層模型的職責:

鏈路層:對 0 和 1 進行分組,定義數據幀,確認主機的物理地址,傳輸數據;

網絡層:定義 IP 地址,確認主機所在的網絡位置,並通過 IP 進行 MAC 尋址,對外網數據包進行路由轉發;

傳輸層:定義端口,確認主機上應用程序的身份,並將數據包交給對應的應用程序;

應用層:定義數據格式,並按照對應的格式解讀數據。

然後再把每層模型的職責串聯起來,用一句通俗易懂的話講就是:

當你輸入一個網址並按下回車鍵的時候,首先,應用層協議對該請求包做了格式定義; 緊接着傳輸層協議加上了雙方的端口號,確認了雙方通信的應用程序; 然後網絡協議加上了雙方的 IP 地址,確認了雙方的網絡位置; 最後鏈路層協議加上了雙方的 MAC 地址,確認了雙方的物理位置,同時將數據進行分組,形成數據幀,採用廣播方式,通過傳輸介質發送給對方主機。而對於不同網段,該數據包首先會轉發給網關路由器,經過多次轉發後,最終被髮送到目標主機。目標機接收到數據包後,採用對應的協議,對幀數據進行組裝,然後再通過一層一層的協議進行解析,最終被應用層的協議解析並交給服務器處理。

Linux 網絡協議棧

基於 TCP/IP 協議棧的 send/recv 在應用層,傳輸層,網絡層和鏈路層中具體函數調用過程已經有很多人研究,本文引用一張比較完善的圖如下:

 

以上說明基本大致說明了 TCP/IP 中 TCP,UDP 協議包在網絡子系統中的實現流程。本文主要在鏈路層中,即關於網卡收報觸發中斷到進入網絡層之間的過程探究。

Linux 網卡收包時的中斷處理問題

中斷,一般指硬件中斷,多由系統自身或與之鏈接的外設(如鍵盤、鼠標、網卡等)產生。中斷首先是處理器提供的一種響應外設請求的機制,是處理器硬件支持的特性。一個外設通過產生一種電信號通知中斷控制器,中斷控制器再向處理器發送相應的信號。處理器檢測到了這個信號後就會打斷自己當前正在做的工作,轉而去處理這次中斷(所以才叫中斷)。當然在轉去處理中斷和中斷返回時都有保護現場和返回現場的操作,這裏不贅述。

那軟中斷又是什麼呢?我們知道在中斷處理時 CPU 沒法處理其它事物,對於網卡來說,如果每次網卡收包時中斷的時間都過長,那很可能造成丟包的可能性。當然我們不能完全避免丟包的可能性,以太包的傳輸是沒有 100% 保證的,所以網絡纔有協議棧,通過高層的協議來保證連續數據傳輸的數據完整性(比如在協議發現丟包時要求重傳)。但是即使有協議保證,那我們也不能肆無忌憚的使用中斷,中斷的時間越短越好,儘快放開處理器,讓它可以去響應下次中斷甚至進行調度工作。基於這樣的考慮,我們將中斷分成了上下兩部分,上半部分就是上面說的中斷部分,需要快速及時響應,同時需要越快結束越好。而下半部分就是完成一些可以推後執行的工作。對於網卡收包來說,網卡收到數據包,通知內核數據包到了,中斷處理將數據包存入內存這些都是急切需要完成的工作,放到上半部完成。而解析處理數據包的工作則可以放到下半部去執行。

軟中斷就是下半部使用的一種機制,它通過軟件模仿硬件中斷的處理過程,但是和硬件沒有關係,單純的通過軟件達到一種異步處理的方式。其它下半部的處理機制還包括 tasklet,工作隊列等。依據所處理的場合不同,選擇不同的機制,網卡收包一般使用軟中斷。對應 NET_RX_SOFTIRQ 這個軟中斷,軟中斷的類型如下:

enum
{
        HI_SOFTIRQ=0,
        TIMER_SOFTIRQ,
        NET_TX_SOFTIRQ,
        NET_RX_SOFTIRQ,
        BLOCK_SOFTIRQ,
        IRQ_POLL_SOFTIRQ,
        TASKLET_SOFTIRQ,
        SCHED_SOFTIRQ,
        HRTIMER_SOFTIRQ,
        RCU_SOFTIRQ,    /* Preferable RCU should always be the last softirq */
        NR_SOFTIRQS
};

通過以上可以瞭解到,Linux 中斷註冊顯然應該包括網卡的硬中斷,包處理的軟中斷兩個步驟。

註冊網卡中斷

我們以一個具體的網卡驅動爲例,比如 e1000。其模塊初始化函數就是:

static int __init e1000_init_module(void)
{
        int ret;
        pr_info("%s - version %s\n", e1000_driver_string, e1000_driver_version);
        pr_info("%s\n", e1000_copyright);
        ret = pci_register_driver(&e1000_driver);
...
        return ret;
}

其中 e1000_driver 這個結構體是一個關鍵,這個結構體中很主要的一個方法就是. probe 方法,也就是 e1000_probe():

/**                                                  
 * e1000_probe - Device Initialization Routine         
 * @pdev: PCI device information struct                    
 * @ent: entry in e1000_pci_tbl     
 *                                
 * Returns 0 on success, negative on failure                                                                               
 *                                                                                                               
 * e1000_probe initializes an adapter identified by a pci_dev structure.                                                               
 * The OS initialization, configuring of the adapter private structure,                                                                  
 * and a hardware reset occur.                                                      
 **/
static int e1000_probe(struct pci_dev *pdev, const struct pci_device_id *ent)
{
...
...
        netdev->netdev_ops = &e1000_netdev_ops;
        e1000_set_ethtool_ops(netdev);
...
...
}

這個函數很長,我們不都列出來,這是 e1000 主要的初始化函數,即使從註釋都能看出來。我們留意其註冊了 netdev 的 netdev_ops,用的是 e1000_netdev_ops 這個結構體:

static const struct net_device_ops e1000_netdev_ops = {
        .ndo_open               = e1000_open,
        .ndo_stop               = e1000_close,
        .ndo_start_xmit         = e1000_xmit_frame,
        .ndo_set_rx_mode        = e1000_set_rx_mode,
        .ndo_set_mac_address    = e1000_set_mac,
        .ndo_tx_timeout         = e1000_tx_timeout,
...
...
};

這個 e1000 的方法集裏有一個重要的方法,e1000_open,我們要說的中斷的註冊就從這裏開始:

/**           
 * e1000_open - Called when a network interface is made active  
 * @netdev: network interface device structure            
 *                                                 
 * Returns 0 on success, negative value on failure     
 *     
 * The open entry point is called when a network interface is made                                                                                                    
 * active by the system (IFF_UP).  At this point all resources needed                                                                            
 * for transmit and receive operations are allocated, the interrupt                                                     
 * handler is registered with the OS, the watchdog task is started,                                                                                                     
 * and the stack is notified that the interface is ready.                                                                                                             
 **/
int e1000_open(struct net_device *netdev)
{
        struct e1000_adapter *adapter = netdev_priv(netdev);
        struct e1000_hw *hw = &adapter->hw;
...
...
        err = e1000_request_irq(adapter);
...
}

e1000 在這裏註冊了中斷:

static int e1000_request_irq(struct e1000_adapter *adapter)
{
        struct net_device *netdev = adapter->netdev;
        irq_handler_t handler = e1000_intr;
        int irq_flags = IRQF_SHARED;
        int err;
        err = request_irq(adapter->pdev->irq, handler, irq_flags, netdev->name,
...
...
}

如上所示,這個被註冊的中斷處理函數,也就是 handler,就是 e1000_intr()。我們不展開這個中斷處理函數看了,我們知道中斷處理函數在這裏被註冊了,在網絡包來的時候會觸發這個中斷函數。

註冊軟中斷

內核初始化期間,softirq_init 會註冊 TASKLET_SOFTIRQ 以及 HI_SOFTIRQ 相關聯的處理函數。

void __init softirq_init(void)
{
    ......
    open_softirq(TASKLET_SOFTIRQ, tasklet_action);
    open_softirq(HI_SOFTIRQ, tasklet_hi_action);
}
網絡子系統分兩種soft IRQ。NET_TX_SOFTIRQ和NET_RX_SOFTIRQ,分別處理發送數據包和接收數據包。這兩個soft IQ在net_dev_init函數(net/core/dev.c)中註冊:
   open_softirq(NET_TX_SOFTIRQ, net_tx_action);
   open_softirq(NET_RX_SOFTIRQ, net_rx_action);

收發數據包的軟中斷處理函數被註冊爲 net_rx_action 和 net_tx_action。其中 open_softirq 實現爲:

void open_softirq(int nr, void (*action)(struct softirq_action *))
{
    softirq_vec[nr].action = action;
}

 

Linux 網絡啓動的準備工作

首先在開始收包之前,Linux 要做許多的準備工作:

  1. 創建 ksoftirqd 線程,爲它設置好它自己的線程函數,後面就指望着它來處理軟中斷呢。

  2. 協議棧註冊,linux 要實現許多協議,比如 arp,icmp,ip,udp,tcp,每一個協議都會將自己的處理函數註冊一下,方便包來了迅速找到對應的處理函數

  3. 網卡驅動初始化,每個驅動都有一個初始化函數,內核會讓驅動也初始化一下。在這個初始化過程中,把自己的 DMA 準備好,把 NAPI 的 poll 函數地址告訴內核

  4. 啓動網卡,分配 RX,TX 隊列,註冊中斷對應的處理函數

創建 ksoftirqd 內核線程

Linux 的軟中斷都是在專門的內核線程(ksoftirqd)中進行的,因此我們非常有必要看一下這些進程是怎麼初始化的,這樣我們才能在後面更準確地瞭解收包過程。該進程數量不是 1 個,而是 N 個,其中 N 等於你的機器的核數。

系統初始化的時候在 kernel/smpboot.c 中調用了 smpboot_register_percpu_thread, 該函數進一步會執行到 spawn_ksoftirqd(位於 kernel/softirq.c)來創建出 softirqd 進程。

 

相關代碼如下:

//file: kernel/softirq.c
static struct smp_hotplug_thread softirq_threads = {
    .store          = &ksoftirqd,
    .thread_should_run  = ksoftirqd_should_run,
    .thread_fn      = run_ksoftirqd,
    .thread_comm        = "ksoftirqd/%u",
};

當 ksoftirqd 被創建出來以後,它就會進入自己的線程循環函數 ksoftirqd_should_run 和 run_ksoftirqd 了。不停地判斷有沒有軟中斷需要被處理。這裏需要注意的一點是,軟中斷不僅僅只有網絡軟中斷,還有其它類型。

創建 ksoftirqd 內核線程

 

linux 內核通過調用 subsys_initcall 來初始化各個子系統,在源代碼目錄裏你可以 grep 出許多對這個函數的調用。這裏我們要說的是網絡子系統的初始化,會執行到 net_dev_init 函數。

 

在這個函數里,會爲每個 CPU 都申請一個softnet_data數據結構,在這個數據結構裏的poll_list是等待驅動程序將其 poll 函數註冊進來,稍後網卡驅動初始化的時候我們可以看到這一過程。

另外 open_softirq 註冊了每一種軟中斷都註冊一個處理函數。NET_TX_SOFTIRQ 的處理函數爲 net_tx_action,NET_RX_SOFTIRQ 的爲 net_rx_action。繼續跟蹤open_softirq後發現這個註冊的方式是記錄在softirq_vec變量裏的。後面 ksoftirqd 線程收到軟中斷的時候,也會使用這個變量來找到每一種軟中斷對應的處理函數。

協議棧註冊

內核實現了網絡層的 ip 協議,也實現了傳輸層的 tcp 協議和 udp 協議。這些協議對應的實現函數分別是 ip_rcv(),tcp_v4_rcv() 和 udp_rcv()。和我們平時寫代碼的方式不一樣的是,內核是通過註冊的方式來實現的。Linux 內核中的 fs_initcall 和 subsys_initcall 類似,也是初始化模塊的入口。fs_initcall 調用 inet_init 後開始網絡協議棧註冊。通過 inet_init,將這些函數註冊到了 inet_protos 和 ptype_base 數據結構中

相關代碼如下

//file: net/ipv4/af_inet.c
static struct packet_type ip_packet_type __read_mostly = {
    .type = cpu_to_be16(ETH_P_IP),
    .func = ip_rcv,
};
static const struct net_protocol udp_protocol = {
    .handler =  udp_rcv,
    .err_handler =  udp_err,
    .no_policy =    1,
    .netns_ok = 1,
};
static const struct net_protocol tcp_protocol = {
    .early_demux    =   tcp_v4_early_demux,
    .handler    =   tcp_v4_rcv,
    .err_handler    =   tcp_v4_err,
    .no_policy  =   1,
    .netns_ok   =   1,
};

擴展一下,如果看一下 ip_rcv 和 udp_rcv 等函數的代碼能看到很多協議的處理過程。例如,ip_rcv 中會處理 netfilter 和 iptable 過濾,如果你有很多或者很複雜的 netfilter 或 iptables 規則,這些規則都是在軟中斷的上下文中執行的,會加大網絡延遲。再例如,udp_rcv 中會判斷 socket 接收隊列是否滿了。對應的相關內核參數是 net.core.rmem_max 和 net.core.rmem_default。如果有興趣,建議大家好好讀一下 inet_init 這個函數的代碼。

網卡驅動初始化

每一個驅動程序(不僅僅只是網卡驅動)會使用 module_init 向內核註冊一個初始化函數,當驅動被加載時,內核會調用這個函數。比如 igb 網卡驅動的代碼位於 drivers/net/ethernet/intel/igb/igb_main.c

驅動的 pci_register_driver 調用完成後,Linux 內核就知道了該驅動的相關信息,比如 igb 網卡驅動的 igb_driver_name 和 igb_probe 函數地址等等。當網卡設備被識別以後,內核會調用其驅動的 probe 方法(igb_driver 的 probe 方法是 igb_probe)。驅動 probe 方法執行的目的就是讓設備 ready,對於 igb 網卡,其 igb_probe 位於 drivers/net/ethernet/intel/igb/igb_main.c 下。主要執行的操作如下:

 

第 5 步中我們看到,網卡驅動實現了 ethtool 所需要的接口,也在這裏註冊完成函數地址的註冊。當 ethtool 發起一個系統調用之後,內核會找到對應操作的回調函數。對於 igb 網卡來說,其實現函數都在 drivers/net/ethernet/intel/igb/igb_ethtool.c 下。相信你這次能徹底理解 ethtool 的工作原理了吧?這個命令之所以能查看網卡收發包統計、能修改網卡自適應模式、能調整 RX 隊列的數量和大小,是因爲 ethtool 命令最終調用到了網卡驅動的相應方法,而不是 ethtool 本身有這個超能力。

第 6 步註冊的 igb_netdev_ops 中包含的是 igb_open 等函數,該函數在網卡被啓動的時候會被調用。

//file: drivers/net/ethernet/intel/igb/igb_main.
......
static const struct net_device_ops igb_netdev_ops = {
  .ndo_open               = igb_open,
  .ndo_stop               = igb_close,
  .ndo_start_xmit         = igb_xmit_frame,
  .ndo_get_stats64        = igb_get_stats64,
  .ndo_set_rx_mode        = igb_set_rx_mode,
  .ndo_set_mac_address    = igb_set_mac,
  .ndo_change_mtu         = igb_change_mtu,
  .ndo_do_ioctl           = igb_ioctl,......
}

第 7 步中,在 igb_probe 初始化過程中,還調用到了 igb_alloc_q_vector。他註冊了一個 NAPI 機制所必須的 poll 函數,對於 igb 網卡驅動來說,這個函數就是 igb_poll, 如下代碼所示。

static int igb_alloc_q_vector(struct igb_adapter *adapter,
                  int v_count, int v_idx,
                  int txr_count, int txr_idx,
                  int rxr_count, int rxr_idx)
{
    ......
    /* initialize NAPI */
    netif_napi_add(adapter->netdev, &q_vector->napi,
               igb_poll, 64);
}


啓動網卡

當上面的初始化都完成以後,就可以啓動網卡了。回憶前面網卡驅動初始化時,我們提到了驅動向內核註冊了 structure net_device_ops 變量,它包含着網卡啓用、發包、設置 mac 地址等回調函數(函數指針)。當啓用一個網卡時(例如,通過 ifconfig eth0 up),net_device_ops 中的 igb_open 方法會被調用。它通常會做以下事情:

 

//file: drivers/net/ethernet/intel/igb/igb_main.c
static int __igb_open(struct net_device *netdev, bool resuming)
{
    /* allocate transmit descriptors */
    err = igb_setup_all_tx_resources(adapter);
    /* allocate receive descriptors */
    err = igb_setup_all_rx_resources(adapter);
    /* 註冊中斷處理函數 */
    err = igb_request_irq(adapter);
    if (err)
        goto err_req_irq;
    /* 啓用NAPI */
    for (i = 0; i < adapter->num_q_vectors; i++)
        napi_enable(&(adapter->q_vector[i]->napi));
    ......
}

在上面__igb_open 函數調用了 igb_setup_all_tx_resources, 和 igb_setup_all_rx_resources。在 igb_setup_all_rx_resources 這一步操作中,分配了 RingBuffer,並建立內存和 Rx 隊列的映射關係。(Rx Tx 隊列的數量和大小可以通過 ethtool 進行配置)。我們再接着看中斷函數註冊 igb_request_irq:

static int igb_request_irq(struct igb_adapter *adapter)
{
    if (adapter->msix_entries) {
        err = igb_request_msix(adapter);
        if (!err)
            goto request_done;
        ......
    }
}
static int igb_request_msix(struct igb_adapter *adapter)
{
    ......
    for (i = 0; i < adapter->num_q_vectors; i++) {
        ...
        err = request_irq(adapter->msix_entries[vector].vector,
                  igb_msix_ring, 0, q_vector->name,
    }

在上面的代碼中跟蹤函數調用, __igb_open => igb_request_irq => igb_request_msix, 在 igb_request_msix 中我們看到了,對於多隊列的網卡,爲每一個隊列都註冊了中斷,其對應的中斷處理函數是 igb_msix_ring(該函數也在 drivers/net/ethernet/intel/igb/igb_main.c 下)。我們也可以看到,msix 方式下,每個 RX 隊列有獨立的 MSI-X 中斷,從網卡硬件中斷的層面就可以設置讓收到的包被不同的 CPU 處理。(可以通過 irqbalance ,或者修改 /proc/irq/IRQ_NUMBER/smp_affinity 能夠修改和 CPU 的綁定行爲)。

到此準備工作完成。

Linux 網絡包:中斷到網絡層接收

網卡收包從整體上是網線中的高低電平轉換到網卡 FIFO 存儲再拷貝到系統主內存(DDR3)的過程,其中涉及到網卡控制器,CPU,DMA,驅動程序,在 OSI 模型中屬於物理層和鏈路層,如下圖所示。

中斷處理

物理網卡收到數據包的處理流程如上圖左半部分所示,詳細步驟如下:

  1. 網卡收到數據包,先將高低電平轉換到網卡 fifo 存儲,網卡申請 ring buffer 的描述,根據描述找到具體的物理地址,從 fifo 隊列物理網卡會使用 DMA 將數據包寫到了該物理地址,,其實就是 skb_buffer 中.

  2. 這個時候數據包已經被轉移到 skb_buffer 中,因爲是 DMA 寫入,內核並沒有監控數據包寫入情況,這時候 NIC 觸發一個硬中斷,每一個硬件中斷會對應一箇中斷號,且指定一個 vCPU 來處理,如上圖 vcpu2 收到了該硬件中斷.

  3. 硬件中斷的中斷處理程序,調用驅動程序完成,a. 啓動軟中斷

  4. 硬中斷觸發的驅動程序會禁用網卡硬中斷,其實這時候意思是告訴 NIC,再來數據不用觸發硬中斷了,把數據 DMA 拷入系統內存即可

  5. 硬中斷觸發的驅動程序會啓動軟中斷,啓用軟中斷目的是將數據包後續處理流程交給軟中斷慢慢處理,這個時候退出硬件中斷了,但是注意和網絡有關的硬中斷,要等到後續開啓硬中斷後,纔有機會再次被觸發

  6. NAPI 觸發軟中斷,觸發 napi 系統

  7. 消耗 ringbuffer 指向的 skb_buffer

  8. NAPI 循環處理 ringbuffer 數據,處理完成

  9. 啓動網絡硬件中斷,有數據來時候就可以繼續觸發硬件中斷,繼續通知 CPU 來消耗數據包.

其實上述過程過程簡單描述爲:網卡收到數據包,DMA 到內核內存,中斷通知內核數據有了,內核按輪次處理消耗數據包,一輪處理完成後,開啓硬中斷。其核心就是網卡和內核其實是生產和消費模型,網卡生產,內核負責消費,生產者需要通知消費者消費;如果生產過快會產生丟包,如果消費過慢也會產生問題。也就說在高流量壓力情況下,只有生產消費優化後,消費能力夠快,此生產消費關係纔可以正常維持,所以如果物理接口有丟包計數時候,未必是網卡存在問題,也可能是內核消費的太慢。

關於 CPU 與 ksoftirqd 的關係可以描述如下:

 

網卡收到的數據寫入到內核內存

NIC 在接收到數據包之後,首先需要將數據同步到內核中,這中間的橋樑是 rx ring buffer。它是由 NIC 和驅動程序共享的一片區域,事實上,rx ring buffer 存儲的並不是實際的 packet 數據,而是一個描述符,這個描述符指向了它真正的存儲地址,具體流程如下:

  1. 驅動在內存中分配一片緩衝區用來接收數據包,叫做 sk_buffer;

  2. 將上述緩衝區的地址和大小(即接收描述符),加入到 rx ring buffer。描述符中的緩衝區地址是 DMA 使用的物理地址;

  3. 驅動通知網卡有一個新的描述符;

  4. 網卡從 rx ring buffer 中取出描述符,從而獲知緩衝區的地址和大小;

  5. 網卡收到新的數據包;

  6. 網卡將新數據包通過 DMA 直接寫到 sk_buffer 中。

當驅動處理速度跟不上網卡收包速度時,驅動來不及分配緩衝區,NIC 接收到的數據包無法及時寫到 sk_buffer,就會產生堆積,當 NIC 內部緩衝區寫滿後,就會丟棄部分數據,引起丟包。這部分丟包爲 rx_fifo_errors,在 /proc/net/dev 中體現爲 fifo 字段增長,在 ifconfig 中體現爲 overruns 指標增長。

中斷下半部分

ksoftirqd 內核線程處理軟中斷,即中斷下半部分軟中斷處理過程:

1.NAPI(以 e1000 網卡爲例):net_rx_action() -> e1000_clean() -> e1000_clean_rx_irq() -> e1000_receive_skb() -> netif_receive_skb()

  1. 非 NAPI(以 dm9000 網卡爲例):net_rx_action() -> process_backlog() -> netif_receive_skb()

最後網卡驅動通過 netif_receive_skb() 將 sk_buff 上送協議棧。

 

內核線程初始化的時候,我們介紹了 ksoftirqd 中兩個線程函數 ksoftirqd_should_run 和 run_ksoftirqd。其中 ksoftirqd_should_run 代碼如下:

 

#define local_softirq_pending() \

__IRQ_STAT(smp_processor_id(), __softirq_pending)

這裏看到和硬中斷中調用了同一個函數 local_softirq_pending。使用方式不同的是硬中斷位置是爲了寫入標記,這裏僅僅只是讀取。如果硬中斷中設置了 NET_RX_SOFTIRQ, 這裏自然能讀取的到。接下來會真正進入線程函數中 run_ksoftirqd 處理:

static void run_ksoftirqd(unsigned int cpu)
{
    local_irq_disable();
    if (local_softirq_pending()) {
        __do_softirq();
        rcu_note_context_switch(cpu);
        local_irq_enable();
        cond_resched();
        return;
    }
    local_irq_enable();
}

在__do_softirq 中,判斷根據當前 CPU 的軟中斷類型,調用其註冊的 action 方法。

asmlinkage void __do_softirq(void)

 

在網絡子系統初始化小節,我們看到我們爲 NET_RX_SOFTIRQ 註冊了處理函數 net_rx_action。所以 net_rx_action 函數就會被執行到了。

這裏需要注意一個細節,硬中斷中設置軟中斷標記,和 ksoftirq 的判斷是否有軟中斷到達,都是基於 smp_processor_id() 的。這意味着只要硬中斷在哪個 CPU 上被響應,那麼軟中斷也是在這個 CPU 上處理的。所以說,如果你發現你的 Linux 軟中斷 CPU 消耗都集中在一個核上的話,做法是要把調整硬中斷的 CPU 親和性,來將硬中斷打散到不通的 CPU 核上去。

我們再來把精力集中到這個核心函數 net_rx_action 上來。

static void net_rx_action(struct softirq_action *h)
{
    struct softnet_data *sd = &__get_cpu_var(softnet_data);
    unsigned long time_limit = jiffies + 2;
    int budget = netdev_budget;
    void *have;
    local_irq_disable();
    while (!list_empty(&sd->poll_list)) {
        ......
        n = list_first_entry(&sd->poll_list, struct napi_struct, poll_list);
        work = 0;
        if (test_bit(NAPI_STATE_SCHED, &n->state)) {
            work = n->poll(n, weight);
            trace_napi_poll(n);
        }
        budget -= work;
    }
}

函數開頭的 time_limit 和 budget 是用來控制 net_rx_action 函數主動退出的,目的是保證網絡包的接收不霸佔 CPU 不放。等下次網卡再有硬中斷過來的時候再處理剩下的接收數據包。其中 budget 可以通過內核參數調整。這個函數中剩下的核心邏輯是獲取到當前 CPU 變量 softnet_data,對其 poll_list 進行遍歷, 然後執行到網卡驅動註冊到的 poll 函數。對於 igb 網卡來說,就是 igb 驅動力的 igb_poll 函數了。

/**
 *  igb_poll - NAPI Rx polling callback
 *  @napi: napi polling structure
 *  @budget: count of how many packets we should handle
 **/
static int igb_poll(struct napi_struct *napi, int budget)
{
    ...
if (q_vector->tx.ring)
        clean_complete = igb_clean_tx_irq(q_vector);
if (q_vector->rx.ring)
        clean_complete &= igb_clean_rx_irq(q_vector, budget);
    ...
}

在讀取操作中,igb_poll 的重點工作是對 igb_clean_rx_irq 的調用。

static bool igb_clean_rx_irq(struct igb_q_vector *q_vector, const int budget)
{
    ...
do {
/* retrieve a buffer from the ring */
        skb = igb_fetch_rx_buffer(rx_ring, rx_desc, skb);
/* fetch next buffer in frame if non-eop */
if (igb_is_non_eop(rx_ring, rx_desc))
continue;
        }
/* verify the packet layout is correct */
if (igb_cleanup_headers(rx_ring, rx_desc, skb)) {
            skb = NULL;
continue;
        }
/* populate checksum, timestamp, VLAN, and protocol */
        igb_process_skb_fields(rx_ring, rx_desc, skb);
        napi_gro_receive(&q_vector->napi, skb);
}

igb_fetch_rx_buffer 和 igb_is_non_eop 的作用就是把數據幀從 RingBuffer 上取下來。爲什麼需要兩個函數呢?因爲有可能幀要佔多多個 RingBuffer,所以是在一個循環中獲取的,直到幀尾部。獲取下來的一個數據幀用一個 sk_buff 來表示。收取完數據以後,對其進行一些校驗,然後開始設置 sbk 變量的 timestamp, VLAN id, protocol 等字段。接下來進入到 napi_gro_receive 中:

//file: net/core/dev.c
gro_result_t napi_gro_receive(struct napi_struct *napi, struct sk_buff *skb)
{
    skb_gro_reset_offset(skb);
return napi_skb_finish(dev_gro_receive(napi, skb), skb);
}

dev_gro_receive 這個函數代表的是網卡 GRO 特性,可以簡單理解成把相關的小包合併成一個大包就行,目的是減少傳送給網絡棧的包數,這有助於減少 CPU 的使用量。我們暫且忽略,直接看 napi_skb_finish, 這個函數主要就是調用了 netif_receive_skb。

//file: net/core/dev.c
static gro_result_t napi_skb_finish(gro_result_t ret, struct sk_buff *skb)
{
switch (ret) {
case GRO_NORMAL:
if (netif_receive_skb(skb))
            ret = GRO_DROP;
break;
    ......
}

在 netif_receive_skb 中,數據包將被送到協議棧中,接下來在網絡層協議層的處理流程便不再贅述。

總結

send 發包過程

1、網卡驅動創建 tx descriptor ring(一致性 DMA 內存),將 tx descriptor ring 的總線地址寫入網卡寄存器 TDBA

2、協議棧通過 dev_queue_xmit() 將 sk_buff 下送網卡驅動

3、網卡驅動將 sk_buff 放入 tx descriptor ring,更新 TDT

4、DMA 感知到 TDT 的改變後,找到 tx descriptor ring 中下一個將要使用的 descriptor

5、DMA 通過 PCI 總線將 descriptor 的數據緩存區複製到 Tx FIFO

6、複製完後,通過 MAC 芯片將數據包發送出去

7、發送完後,網卡更新 TDH,啓動硬中斷通知 CPU 釋放數據緩存區中的數據包

recv 收包過程

1、網卡驅動創建 rx descriptor ring(一致性 DMA 內存),將 rx descriptor ring 的總線地址寫入網卡寄存器 RDBA

2、網卡驅動爲每個 descriptor 分配 sk_buff 和數據緩存區,流式 DMA 映射數據緩存區,將數據緩存區的總線地址保存到 descriptor

3、網卡接收數據包,將數據包寫入 Rx FIFO

4、DMA 找到 rx descriptor ring 中下一個將要使用的 descriptor

5、整個數據包寫入 Rx FIFO 後,DMA 通過 PCI 總線將 Rx FIFO 中的數據包複製到 descriptor 的數據緩存區

6、複製完後,網卡啓動硬中斷通知 CPU 數據緩存區中已經有新的數據包了,CPU 執行硬中斷函數:

NAPI(以 e1000 網卡爲例):e1000_intr() -> __napi_schedule() -> __raise_softirq_irqoff(NET_RX_SOFTIRQ)

非 NAPI(以 dm9000 網卡爲例):dm9000_interrupt() -> dm9000_rx() -> netif_rx() -> napi_schedule() -> __napi_schedule() -> __raise_softirq_irqoff(NET_RX_SOFTIRQ)

7、ksoftirqd 執行軟中斷函數 net_rx_action():

NAPI(以 e1000 網卡爲例):net_rx_action() -> e1000_clean() -> e1000_clean_rx_irq() -> e1000_receive_skb() -> netif_receive_skb()

非 NAPI(以 dm9000 網卡爲例):net_rx_action() -> process_backlog() -> netif_receive_skb()

8、網卡驅動通過 netif_receive_skb() 將 sk_buff 上送協議棧

 

Linux 網絡子系統的分層

Linux 網絡子系統實現需要:

系統調用

系統調用提供用戶的應用程序訪問內核的唯一途徑。協議無關接口由 socket layer 來實現的,其提供一組通用功能,以支持各種不同的協議。網絡協議層爲 socket 層提供具體協議接口——proto{},實現具體的協議細節。設備無關接口,提供一組通用函數供底層網絡設備驅動程序使用。設備驅動與特定網卡設備相關,定義了具體的協議細節,會分配一個 net_device 結構,然後用其必需的例程進行初始化。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/AjjaUn5ouhj8EcqXig6jEQ