設(shè)置
  • 日夜間
    隨系統(tǒng)
    淺色
    深色
  • 主題色

圖解 Linux 網(wǎng)絡(luò)包接收過程

開發(fā)內(nèi)功修煉 2022/10/10 20:40:15 責(zé)編:子非

本文來自微信公眾號:開發(fā)內(nèi)功修煉 (ID:kfngxl),作者:張彥飛 allen

因?yàn)橐獙Π偃f、千萬、甚至是過億的用戶提供各種網(wǎng)絡(luò)服務(wù),所以在一線互聯(lián)網(wǎng)企業(yè)里面試和晉升后端開發(fā)同學(xué)的其中一個重點(diǎn)要求就是要能支撐高并發(fā),要理解性能開銷,會進(jìn)行性能優(yōu)化。而很多時候,如果你對 Linux 底層的理解不深的話,遇到很多線上性能瓶頸你會覺得狗拿刺猬,無從下手。

我們今天用圖解的方式,來深度理解一下在 Linux 下網(wǎng)絡(luò)包的接收過程。還是按照慣例來借用一段最簡單的代碼開始思考。為了簡單起見,我們用 udp 來舉例,如下:

int main(){
int serverSocketFd = socket(AF_INET, SOCK_DGRAM, 0);
bind(serverSocketFd, );
char buff[BUFFSIZE];
int readCount = recvfrom(serverSocketFd, buff, BUFFSIZE, 0, );
buff[readCount] = '\0';
printf("Receive from client:%s\n", buff);
}

上面代碼是一段 udp server 接收收據(jù)的邏輯。當(dāng)在開發(fā)視角看的時候,只要客戶端有對應(yīng)的數(shù)據(jù)發(fā)送過來,服務(wù)器端執(zhí)行 recv_from 后就能收到它,并把它打印出來。我們現(xiàn)在想知道的是,當(dāng)網(wǎng)絡(luò)包到達(dá)網(wǎng)卡,直到我們的 recvfrom 收到數(shù)據(jù),這中間,究竟都發(fā)生過什么?

通過本文,你將深入理解 Linux 網(wǎng)絡(luò)系統(tǒng)內(nèi)部是如何實(shí)現(xiàn)的,以及各個部分之間如何交互。相信這對你的工作將會有非常大的幫助。本文基于 Linux 3.10,源代碼參見 https://mirrors.edge.kernel.org/ pub / linux / kernel / v3.x/,網(wǎng)卡驅(qū)動采用 Intel 的 igb 網(wǎng)卡舉例。

友情提示,本文略長,可以先 Mark 后看!

一、Linux 網(wǎng)絡(luò)收包總覽

在 TCP / IP 網(wǎng)絡(luò)分層模型里,整個協(xié)議棧被分成了物理層、鏈路層、網(wǎng)絡(luò)層,傳輸層和應(yīng)用層。物理層對應(yīng)的是網(wǎng)卡和網(wǎng)線,應(yīng)用層對應(yīng)的是我們常見的 Nginx,F(xiàn)TP 等等各種應(yīng)用。Linux 實(shí)現(xiàn)的是鏈路層、網(wǎng)絡(luò)層和傳輸層這三層。

在 Linux 內(nèi)核實(shí)現(xiàn)中,鏈路層協(xié)議靠網(wǎng)卡驅(qū)動來實(shí)現(xiàn),內(nèi)核協(xié)議棧來實(shí)現(xiàn)網(wǎng)絡(luò)層和傳輸層。內(nèi)核對更上層的應(yīng)用層提供 socket 接口來供用戶進(jìn)程訪問。我們用 Linux 的視角來看到的 TCP / IP 網(wǎng)絡(luò)分層模型應(yīng)該是下面這個樣子的。

圖 1 Linux 視角的網(wǎng)絡(luò)協(xié)議棧

在 Linux 的源代碼中,網(wǎng)絡(luò)設(shè)備驅(qū)動對應(yīng)的邏輯位于 driver / net / ethernet, 其中 intel 系列網(wǎng)卡的驅(qū)動在 driver / net / ethernet / intel 目錄下。協(xié)議棧模塊代碼位于 kernel 和 net 目錄。

內(nèi)核和網(wǎng)絡(luò)設(shè)備驅(qū)動是通過中斷的方式來處理的。當(dāng)設(shè)備上有數(shù)據(jù)到達(dá)的時候,會給 CPU 的相關(guān)引腳上觸發(fā)一個電壓變化,以通知 CPU 來處理數(shù)據(jù)。對于網(wǎng)絡(luò)模塊來說,由于處理過程比較復(fù)雜和耗時,如果在中斷函數(shù)中完成所有的處理,將會導(dǎo)致中斷處理函數(shù)(優(yōu)先級過高)將過度占據(jù) CPU,將導(dǎo)致 CPU 無法響應(yīng)其它設(shè)備,例如鼠標(biāo)和鍵盤的消息。因此 Linux 中斷處理函數(shù)是分上半部和下半部的。上半部是只進(jìn)行最簡單的工作,快速處理然后釋放 CPU,接著 CPU 就可以允許其它中斷進(jìn)來。剩下將絕大部分的工作都放到下半部中,可以慢慢從容處理。2.4 以后的內(nèi)核版本采用的下半部實(shí)現(xiàn)方式是軟中斷,由 ksoftirqd 內(nèi)核線程全權(quán)處理。和硬中斷不同的是,硬中斷是通過給 CPU 物理引腳施加電壓變化,而軟中斷是通過給內(nèi)存中的一個變量的二進(jìn)制值以通知軟中斷處理程序。

好了,大概了解了網(wǎng)卡驅(qū)動、硬中斷、軟中斷和 ksoftirqd 線程之后,我們在這幾個概念的基礎(chǔ)上給出一個內(nèi)核收包的路徑示意:

圖 2 Linux 內(nèi)核網(wǎng)絡(luò)收包總覽

當(dāng)網(wǎng)卡上收到數(shù)據(jù)以后,Linux 中第一個工作的模塊是網(wǎng)絡(luò)驅(qū)動。網(wǎng)絡(luò)驅(qū)動會以 DMA 的方式把網(wǎng)卡上收到的幀寫到內(nèi)存里。再向 CPU 發(fā)起一個中斷,以通知 CPU 有數(shù)據(jù)到達(dá)。第二,當(dāng) CPU 收到中斷請求后,會去調(diào)用網(wǎng)絡(luò)驅(qū)動注冊的中斷處理函數(shù)。網(wǎng)卡的中斷處理函數(shù)并不做過多工作,發(fā)出軟中斷請求,然后盡快釋放 CPU。ksoftirqd 檢測到有軟中斷請求到達(dá),調(diào)用 poll 開始輪詢收包,收到后交由各級協(xié)議棧處理。對于 UDP 包來說,會被放到用戶 socket 的接收隊列中。

我們從上面這張圖中已經(jīng)從整體上把握到了 Linux 對數(shù)據(jù)包的處理過程。但是要想了解更多網(wǎng)絡(luò)模塊工作的細(xì)節(jié),我們還得往下看。

二、Linux 啟動

Linux 驅(qū)動,內(nèi)核協(xié)議棧等等模塊在具備接收網(wǎng)卡數(shù)據(jù)包之前,要做很多的準(zhǔn)備工作才行。比如要提前創(chuàng)建好 ksoftirqd 內(nèi)核線程,要注冊好各個協(xié)議對應(yīng)的處理函數(shù),網(wǎng)絡(luò)設(shè)備子系統(tǒng)要提前初始化好,網(wǎng)卡要啟動好。只有這些都 Ready 之后,我們才能真正開始接收數(shù)據(jù)包。那么我們現(xiàn)在來看看這些準(zhǔn)備工作都是怎么做的。

2.1 創(chuàng)建 ksoftirqd 內(nèi)核線程

Linux 的軟中斷都是在專門的內(nèi)核線程(ksoftirqd)中進(jìn)行的,因此我們非常有必要看一下這些進(jìn)程是怎么初始化的,這樣我們才能在后面更準(zhǔn)確地了解收包過程。該進(jìn)程數(shù)量不是 1 個,而是 N 個,其中 N 等于你的機(jī)器的核數(shù)。

系統(tǒng)初始化的時候在 kernel / smpboot.c 中調(diào)用了 smpboot_register_percpu_thread,該函數(shù)進(jìn)一步會執(zhí)行到 spawn_ksoftirqd(位于 kernel / softirq.c)來創(chuàng)建出 softirqd 進(jìn)程。

圖 3 創(chuàng)建 ksoftirqd 內(nèi)核線程

相關(guān)代碼如下:

//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",};
static __init int spawn_ksoftirqd(void){
register_cpu_notifier(&cpu_nfb);
BUG_ON(smpboot_register_percpu_thread(&softirq_threads));
return 0;
}
early_initcall(spawn_ksoftirqd);

當(dāng) ksoftirqd 被創(chuàng)建出來以后,它就會進(jìn)入自己的線程循環(huán)函數(shù) ksoftirqd_should_run 和 run_ksoftirqd 了。不停地判斷有沒有軟中斷需要被處理。這里需要注意的一點(diǎn)是,軟中斷不僅僅只有網(wǎng)絡(luò)軟中斷,還有其它類型。

//file: include/linux/interrupt.h
enum{
HI_SOFTIRQ=0,
TIMER_SOFTIRQ,
NET_TX_SOFTIRQ,
NET_RX_SOFTIRQ,
BLOCK_SOFTIRQ,
BLOCK_IOPOLL_SOFTIRQ,
TASKLET_SOFTIRQ,
SCHED_SOFTIRQ,
HRTIMER_SOFTIRQ,
RCU_SOFTIRQ,  
};

2.2 網(wǎng)絡(luò)子系統(tǒng)初始化

圖 4 網(wǎng)絡(luò)子系統(tǒng)初始化

linux 內(nèi)核通過調(diào)用 subsys_initcall 來初始化各個子系統(tǒng),在源代碼目錄里你可以 grep 出許多對這個函數(shù)的調(diào)用。這里我們要說的是網(wǎng)絡(luò)子系統(tǒng)的初始化,會執(zhí)行到 net_dev_init 函數(shù)。

//file: net/core/dev.c
static int __init net_dev_init(void){

for_each_possible_cpu(i) {
struct softnet_data *sd = &per_cpu(softnet_data, i);
memset(sd, 0, sizeof(*sd));
skb_queue_head_init(&sd-input_pkt_queue);
skb_queue_head_init(&sd-process_queue);
sd-completion_queue = NULL;
INIT_LIST_HEAD(&sd-poll_list);

}

open_softirq(NET_TX_SOFTIRQ, net_tx_action);
open_softirq(NET_RX_SOFTIRQ, net_rx_action);
}
subsys_initcall(net_dev_init);

在這個函數(shù)里,會為每個 CPU 都申請一個 softnet_data 數(shù)據(jù)結(jié)構(gòu),在這個數(shù)據(jù)結(jié)構(gòu)里的 poll_list 是等待驅(qū)動程序?qū)⑵?poll 函數(shù)注冊進(jìn)來,稍后網(wǎng)卡驅(qū)動初始化的時候我們可以看到這一過程。

另外 open_softirq 注冊了每一種軟中斷都注冊一個處理函數(shù)。NET_TX_SOFTIRQ 的處理函數(shù)為 net_tx_action,NET_RX_SOFTIRQ 的為 net_rx_action。繼續(xù)跟蹤 open_softirq 后發(fā)現(xiàn)這個注冊的方式是記錄在 softirq_vec 變量里的。后面 ksoftirqd 線程收到軟中斷的時候,也會使用這個變量來找到每一種軟中斷對應(yīng)的處理函數(shù)。

//file: kernel/softirq.c
void open_softirq(int nr, void (*action)(struct softirq_action *)){
softirq_vec[nr].action = action;
}

2.3 協(xié)議棧注冊

內(nèi)核實(shí)現(xiàn)了網(wǎng)絡(luò)層的 ip 協(xié)議,也實(shí)現(xiàn)了傳輸層的 tcp 協(xié)議和 udp 協(xié)議。這些協(xié)議對應(yīng)的實(shí)現(xiàn)函數(shù)分別是 ip_rcv (),tcp_v4_rcv () 和 udp_rcv ()。和我們平時寫代碼的方式不一樣的是,內(nèi)核是通過注冊的方式來實(shí)現(xiàn)的。Linux 內(nèi)核中的 fs_initcall 和 subsys_initcall 類似,也是初始化模塊的入口。fs_initcall 調(diào)用 inet_init 后開始網(wǎng)絡(luò)協(xié)議棧注冊。通過 inet_init,將這些函數(shù)注冊到了 inet_protos 和 ptype_base 數(shù)據(jù)結(jié)構(gòu)中了。如下圖:

圖 5 AF_INET 協(xié)議棧注冊

相關(guān)代碼如下

//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,
};
static int __init inet_init(void){
......
if (inet_add_protocol(&icmp_protocol, IPPROTO_ICMP) < 0)
pr_crit("%s: Cannot add ICMP protocol\n", __func__);
if (inet_add_protocol(&udp_protocol, IPPROTO_UDP) < 0)
pr_crit("%s: Cannot add UDP protocol\n", __func__);
if (inet_add_protocol(&tcp_protocol, IPPROTO_TCP) < 0)
pr_crit("%s: Cannot add TCP protocol\n", __func__);
......
dev_add_pack(&ip_packet_type);
}

上面的代碼中我們可以看到,udp_protocol 結(jié)構(gòu)體中的 handler 是 udp_rcv,tcp_protocol 結(jié)構(gòu)體中的 handler 是 tcp_v4_rcv,通過 inet_add_protocol 被初始化了進(jìn)來。

int inet_add_protocol(const struct net_protocol *prot, unsigned char protocol){
if (!prot-netns_ok) {
pr_err("Protocol %u is not namespace aware, cannot register.\n",
protocol);
return -EINVAL;
}
return !cmpxchg((const struct net_protocol **)&inet_protos[protocol],
NULL, prot) ? 0 : -1;
}

inet_add_protocol 函數(shù)將 tcp 和 udp 對應(yīng)的處理函數(shù)都注冊到了 inet_protos 數(shù)組中了。再看 dev_add_pack (&ip_packet_type); 這一行,ip_packet_type 結(jié)構(gòu)體中的 type 是協(xié)議名,func 是 ip_rcv 函數(shù),在 dev_add_pack 中會被注冊到 ptype_base 哈希表中。

//file: net/core/dev.c
void dev_add_pack(struct packet_type *pt){
struct list_head *head = ptype_head(pt);

}
static inline struct list_head *ptype_head(const struct packet_type *pt){
if (pt-type == htons(ETH_P_ALL))
return &ptype_all;
else
return &ptype_base[ntohs(pt-type) & PTYPE_HASH_MASK];
}

這里我們需要記住 inet_protos 記錄著 udp,tcp 的處理函數(shù)地址,ptype_base 存儲著 ip_rcv () 函數(shù)的處理地址。后面我們會看到軟中斷中會通過 ptype_base 找到 ip_rcv 函數(shù)地址,進(jìn)而將 ip 包正確地送到 ip_rcv () 中執(zhí)行。在 ip_rcv 中將會通過 inet_protos 找到 tcp 或者 udp 的處理函數(shù),再而把包轉(zhuǎn)發(fā)給 udp_rcv () 或 tcp_v4_rcv () 函數(shù)。

擴(kuò)展一下,如果看一下 ip_rcv 和 udp_rcv 等函數(shù)的代碼能看到很多協(xié)議的處理過程。例如,ip_rcv 中會處理 netfilter 和 iptable 過濾,如果你有很多或者很復(fù)雜的 netfilter 或 iptables 規(guī)則,這些規(guī)則都是在軟中斷的上下文中執(zhí)行的,會加大網(wǎng)絡(luò)延遲。再例如,udp_rcv 中會判斷 socket 接收隊列是否滿了。對應(yīng)的相關(guān)內(nèi)核參數(shù)是 net.core.rmem_max 和 net.core.rmem_default。如果有興趣,建議大家好好讀一下 inet_init 這個函數(shù)的代碼。

2.4 網(wǎng)卡驅(qū)動初始化

每一個驅(qū)動程序(不僅僅只是網(wǎng)卡驅(qū)動)會使用 module_init 向內(nèi)核注冊一個初始化函數(shù),當(dāng)驅(qū)動被加載時,內(nèi)核會調(diào)用這個函數(shù)。比如 igb 網(wǎng)卡驅(qū)動的代碼位于 drivers / net / ethernet / intel / igb / igb_main.c

//file: drivers/net/ethernet/intel/igb/igb_main.c
static struct pci_driver igb_driver = {
.name     = igb_driver_name,
.id_table = igb_pci_tbl,
.probe    = igb_probe,
.remove   = igb_remove,

};
static int __init igb_init_module(void){

ret = pci_register_driver(&igb_driver);
return ret;
}

驅(qū)動的 pci_register_driver 調(diào)用完成后,Linux 內(nèi)核就知道了該驅(qū)動的相關(guān)信息,比如 igb 網(wǎng)卡驅(qū)動的 igb_driver_name 和 igb_probe 函數(shù)地址等等。當(dāng)網(wǎng)卡設(shè)備被識別以后,內(nèi)核會調(diào)用其驅(qū)動的 probe 方法(igb_driver 的 probe 方法是 igb_probe)。驅(qū)動 probe 方法執(zhí)行的目的就是讓設(shè)備 ready,對于 igb 網(wǎng)卡,其 igb_probe 位于 drivers / net / ethernet / intel / igb / igb_main.c 下。主要執(zhí)行的操作如下:

圖 6 網(wǎng)卡驅(qū)動初始化

第 5 步中我們看到,網(wǎng)卡驅(qū)動實(shí)現(xiàn)了 ethtool 所需要的接口,也在這里注冊完成函數(shù)地址的注冊。當(dāng) ethtool 發(fā)起一個系統(tǒng)調(diào)用之后,內(nèi)核會找到對應(yīng)操作的回調(diào)函數(shù)。對于 igb 網(wǎng)卡來說,其實(shí)現(xiàn)函數(shù)都在 drivers / net / ethernet / intel / igb / igb_ethtool.c 下。相信你這次能徹底理解 ethtool 的工作原理了吧?這個命令之所以能查看網(wǎng)卡收發(fā)包統(tǒng)計、能修改網(wǎng)卡自適應(yīng)模式、能調(diào)整 RX 隊列的數(shù)量和大小,是因?yàn)?ethtool 命令最終調(diào)用到了網(wǎng)卡驅(qū)動的相應(yīng)方法,而不是 ethtool 本身有這個超能力。

第 6 步注冊的 igb_netdev_ops 中包含的是 igb_open 等函數(shù),該函數(shù)在網(wǎng)卡被啟動的時候會被調(diào)用。

//file: drivers/net/ethernet/intel/igb/igb_main.c
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 初始化過程中,還調(diào)用到了 igb_alloc_q_vector。他注冊了一個 NAPI 機(jī)制所必須的 poll 函數(shù),對于 igb 網(wǎng)卡驅(qū)動來說,這個函數(shù)就是 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);
}

2.5 啟動網(wǎng)卡

當(dāng)上面的初始化都完成以后,就可以啟動網(wǎng)卡了。回憶前面網(wǎng)卡驅(qū)動初始化時,我們提到了驅(qū)動向內(nèi)核注冊了 structure net_device_ops 變量,它包含著網(wǎng)卡啟用、發(fā)包、設(shè)置 mac 地址等回調(diào)函數(shù)(函數(shù)指針)。當(dāng)啟用一個網(wǎng)卡時(例如,通過 ifconfig eth0 up),net_device_ops 中的 igb_open 方法會被調(diào)用。它通常會做以下事情:

圖 7 啟動網(wǎng)卡
//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);
/* 注冊中斷處理函數(shù) */
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 函數(shù)調(diào)用了 igb_setup_all_tx_resources, 和 igb_setup_all_rx_resources。在 igb_setup_all_rx_resources 這一步操作中,分配了 RingBuffer,并建立內(nèi)存和 Rx 隊列的映射關(guān)系。(Rx Tx 隊列的數(shù)量和大小可以通過 ethtool 進(jìn)行配置)。我們再接著看中斷函數(shù)注冊 igb_request_irq:

static int igb_request_irqstruct 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_irqadapter-msix_entries[vector].vector,
igb_msix_ring, 0, q_vector-name,
}

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

當(dāng)做好以上準(zhǔn)備工作以后,就可以開門迎客(數(shù)據(jù)包)了!

三、迎接數(shù)據(jù)的到來

3.1 硬中斷處理

首先當(dāng)數(shù)據(jù)幀從網(wǎng)線到達(dá)網(wǎng)卡上的時候,第一站是網(wǎng)卡的接收隊列。網(wǎng)卡在分配給自己的 RingBuffer 中尋找可用的內(nèi)存位置,找到后 DMA 引擎會把數(shù)據(jù) DMA 到網(wǎng)卡之前關(guān)聯(lián)的內(nèi)存里,這個時候 CPU 都是無感的。當(dāng) DMA 操作完成以后,網(wǎng)卡會像 CPU 發(fā)起一個硬中斷,通知 CPU 有數(shù)據(jù)到達(dá)。

圖 8 網(wǎng)卡數(shù)據(jù)硬中斷處理過程

注意:當(dāng) RingBuffer 滿的時候,新來的數(shù)據(jù)包將給丟棄。ifconfig 查看網(wǎng)卡的時候,可以里面有個 overruns,表示因?yàn)榄h(huán)形隊列滿被丟棄的包。如果發(fā)現(xiàn)有丟包,可能需要通過 ethtool 命令來加大環(huán)形隊列的長度。

在啟動網(wǎng)卡一節(jié),我們說到了網(wǎng)卡的硬中斷注冊的處理函數(shù)是 igb_msix_ring。

//file: drivers/net/ethernet/intel/igb/igb_main.c
static irqreturn_t igb_msix_ring(int irq, void *data){
struct igb_q_vector *q_vector = data;
/* Write the ITR value calculated from the previous interrupt. */
igb_write_itr(q_vector);
napi_schedule(&q_vector-napi);
return IRQ_HANDLED;
}

igb_write_itr 只是記錄一下硬件中斷頻率(據(jù)說目的是在減少對 CPU 的中斷頻率時用到)。順著 napi_schedule 調(diào)用一路跟蹤下去,__napi_schedule=>____napi_schedule

/* Called with irq disabled */
static inline void ____napi_schedule(struct softnet_data *sd,
struct napi_struct *napi){
list_add_tail(&napi-poll_list, &sd-poll_list);
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

這里我們看到,list_add_tail 修改了 CPU 變量 softnet_data 里的 poll_list,將驅(qū)動 napi_struct 傳過來的 poll_list 添加了進(jìn)來。其中 softnet_data 中的 poll_list 是一個雙向列表,其中的設(shè)備都帶有輸入幀等著被處理。緊接著__raise_softirq_irqoff 觸發(fā)了一個軟中斷 NET_RX_SOFTIRQ,這個所謂的觸發(fā)過程只是對一個變量進(jìn)行了一次或運(yùn)算而已。

void __raise_softirq_irqoffunsigned int nr
trace_softirq_raisenr;
or_softirq_pending1UL  nr;
}
//file: include/linux/irq_cpustat.h
#define or_softirq_pendingx  (local_softirq_pending) |= (x)

我們說過,Linux 在硬中斷里只完成簡單必要的工作,剩下的大部分的處理都是轉(zhuǎn)交給軟中斷的。通過上面代碼可以看到,硬中斷處理過程真的是非常短。只是記錄了一個寄存器,修改了一下下 CPU 的 poll_list,然后發(fā)出個軟中斷。就這么簡單,硬中斷工作就算是完成了。

3.2 ksoftirqd 內(nèi)核線程處理軟中斷

圖 9 ksoftirqd 內(nèi)核線程

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

static int ksoftirqd_should_run(unsigned int cpu){
return local_softirq_pending();
}
#define local_softirq_pending() \    __IRQ_STAT(smp_processor_id(), __softirq_pending)

這里看到和硬中斷中調(diào)用了同一個函數(shù) local_softirq_pending。使用方式不同的是硬中斷位置是為了寫入標(biāo)記,這里僅僅只是讀取。如果硬中斷中設(shè)置了 NET_RX_SOFTIRQ, 這里自然能讀取的到。接下來會真正進(jìn)入線程函數(shù)中 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 中,判斷根據(jù)當(dāng)前 CPU 的軟中斷類型,調(diào)用其注冊的 action 方法。

asmlinkage void __do_softirq(void){
do {
if (pending & 1) {
unsigned int vec_nr = h - softirq_vec;
int prev_count = preempt_count();

trace_softirq_entry(vec_nr);
h-action(h);
trace_softirq_exit(vec_nr);

}
h++;
pending >= 1;
} while (pending);
}

在網(wǎng)絡(luò)子系統(tǒng)初始化小節(jié),我們看到我們?yōu)?NET_RX_SOFTIRQ 注冊了處理函數(shù) net_rx_action。所以 net_rx_action 函數(shù)就會被執(zhí)行到了。

這里需要注意一個細(xì)節(jié),硬中斷中設(shè)置軟中斷標(biāo)記,和 ksoftirq 的判斷是否有軟中斷到達(dá),都是基于 smp_processor_id () 的。這意味著只要硬中斷在哪個 CPU 上被響應(yīng),那么軟中斷也是在這個 CPU 上處理的。所以說,如果你發(fā)現(xiàn)你的 Linux 軟中斷 CPU 消耗都集中在一個核上的話,做法是要把調(diào)整硬中斷的 CPU 親和性,來將硬中斷打散到不同的 CPU 核上去。

我們再來把精力集中到這個核心函數(shù) 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;
}
}

函數(shù)開頭的 time_limit 和 budget 是用來控制 net_rx_action 函數(shù)主動退出的,目的是保證網(wǎng)絡(luò)包的接收不霸占 CPU 不放。等下次網(wǎng)卡再有硬中斷過來的時候再處理剩下的接收數(shù)據(jù)包。其中 budget 可以通過內(nèi)核參數(shù)調(diào)整。這個函數(shù)中剩下的核心邏輯是獲取到當(dāng)前 CPU 變量 softnet_data,對其 poll_list 進(jìn)行遍歷,然后執(zhí)行到網(wǎng)卡驅(qū)動注冊到的 poll 函數(shù)。對于 igb 網(wǎng)卡來說,就是 igb 驅(qū)動力的 igb_poll 函數(shù)了。

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 的重點(diǎn)工作是對 igb_clean_rx_irq 的調(diào)用。

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 的作用就是把數(shù)據(jù)幀從 RingBuffer 上取下來。為什么需要兩個函數(shù)呢?因?yàn)橛锌赡軒级喽鄠€ RingBuffer,所以是在一個循環(huán)中獲取的,直到幀尾部。獲取下來的一個數(shù)據(jù)幀用一個 sk_buff 來表示。收取完數(shù)據(jù)以后,對其進(jìn)行一些校驗(yàn),然后開始設(shè)置 sbk 變量的 timestamp, VLAN id, protocol 等字段。接下來進(jìn)入到 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 這個函數(shù)代表的是網(wǎng)卡 GRO 特性,可以簡單理解成把相關(guān)的小包合并成一個大包就行,目的是減少傳送給網(wǎng)絡(luò)棧的包數(shù),這有助于減少 CPU 的使用量。我們暫且忽略,直接看 napi_skb_finish, 這個函數(shù)主要就是調(diào)用了 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 中,數(shù)據(jù)包將被送到協(xié)議棧中。聲明,以下的 3.3, 3.4, 3.5 也都屬于軟中斷的處理過程,只不過由于篇幅太長,單獨(dú)拿出來成小節(jié)。

3.3 網(wǎng)絡(luò)協(xié)議棧處理

netif_receive_skb 函數(shù)會根據(jù)包的協(xié)議,假如是 udp 包,會將包依次送到 ip_rcv (),udp_rcv () 協(xié)議處理函數(shù)中進(jìn)行處理。

圖 10 網(wǎng)絡(luò)協(xié)議棧處理

//file: net/core/dev.c
int netif_receive_skb(struct sk_buff *skb)
//RPS 處理邏輯,先忽略    
return __netif_receive_skb(skb);
}
static int __netif_receive_skb(struct sk_buff *skb)
  
ret = __netif_receive_skb_core(skb, false);}static int __netif_receive_skb_core(struct sk_buff *skb, bool pfmemalloc){

//pcap 邏輯,這里會將數(shù)據(jù)送入抓包點(diǎn)。tcpdump 就是從這個入口獲取包的    list_for_each_entry_rcu (ptype, &ptype_all, list) {
if (!ptype->dev  ptype->dev == skb->dev) {
if (pt_prev)
ret = deliver_skb(skb, pt_prev, orig_dev);
pt_prev = ptype;
}
}

list_for_each_entry_rcu(ptype,
&ptype_base[ntohs(type) & PTYPE_HASH_MASK], list) {
if (ptype->type == type &&
(ptype->dev == null_or_dev  ptype->dev == skb->dev 
ptype->dev == orig_dev)) {
if (pt_prev)
ret = deliver_skb(skb, pt_prev, orig_dev);
pt_prev = ptype;
}
}
}

在__netif_receive_skb_core 中,我看著原來經(jīng)常使用的 tcpdump 的抓包點(diǎn),很是激動,看來讀一遍源代碼時間真的沒白浪費(fèi)。接著__netif_receive_skb_core 取出 protocol,它會從數(shù)據(jù)包中取出協(xié)議信息,然后遍歷注冊在這個協(xié)議上的回調(diào)函數(shù)列表。ptype_base 是一個 hash table,在協(xié)議注冊小節(jié)我們提到過。ip_rcv 函數(shù)地址就是存在這個 hash table 中的。

//file: net/core/dev.c
static inline int deliver_skb(struct sk_buff *skb,
struct packet_type *pt_prev,
struct net_device *orig_dev){

return pt_prev-func(skb, skb-dev, pt_prev, orig_dev);
}

pt_prev->func 這一行就調(diào)用到了協(xié)議層注冊的處理函數(shù)了。對于 ip 包來講,就會進(jìn)入到 ip_rcv(如果是 arp 包的話,會進(jìn)入到 arp_rcv)。

3.4 IP 協(xié)議層處理

我們再來大致看一下 linux 在 ip 協(xié)議層都做了什么,包又是怎么樣進(jìn)一步被送到 udp 或 tcp 協(xié)議處理函數(shù)中的。

//file: net/ipv4/ip_input.c
int ip_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt, struct net_device *orig_dev){

return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, skb, dev, NULL,
ip_rcv_finish);
}

這里 NF_HOOK 是一個鉤子函數(shù),當(dāng)執(zhí)行完注冊的鉤子后就會執(zhí)行到最后一個參數(shù)指向的函數(shù) ip_rcv_finish。

static int ip_rcv_finish(struct sk_buff *skb){

if (!skb_dst(skb)) {
int err = ip_route_input_noref(skb, iph-daddr, iph-saddr,
iph-tos, skb-dev);

}

return dst_input(skb);
}

跟蹤 ip_route_input_noref 后看到它又調(diào)用了 ip_route_input_mc。在 ip_route_input_mc 中,函數(shù) ip_local_deliver 被賦值給了 dst.input, 如下:

//file: net/ipv4/route.c
static int ip_route_input_mc(struct sk_buff *skb, __be32 daddr, __be32 saddr,u8 tos, struct net_device *dev, int our){
if (our) {
rth-dst.input= ip_local_deliver;
rth-rt_flags |= RTCF_LOCAL;
}
}

所以回到 ip_rcv_finish 中的 return dst_input (skb);。

/* Input packet from network to transport.  */
static inline int dst_input(struct sk_buff *skb){
return skb_dst(skb)-input(skb);
}

skb_dst (skb)->input 調(diào)用的 input 方法就是路由子系統(tǒng)賦的 ip_local_deliver。

//file: net/ipv4/ip_input.c
int ip_local_deliver(struct sk_buff *skb){
/*     *  Reassemble IP fragments.     */
if (ip_is_fragment(ip_hdr(skb))) {
if (ip_defrag(skb, IP_DEFRAG_LOCAL_DELIVER))
return 0;
}
return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, skb, skb->dev, NULL,
ip_local_deliver_finish);
}
static int ip_local_deliver_finish(struct sk_buff *skb){
......
int protocol = ip_hdr(skb)->protocol;
const struct net_protocol *ipprot;
ipprot = rcu_dereference(inet_protos[protocol]);
if (ipprot != NULL) {
ret = ipprot->handler(skb);
}
}

如協(xié)議注冊小節(jié)看到 inet_protos 中保存著 tcp_rcv () 和 udp_rcv () 的函數(shù)地址。這里將會根據(jù)包中的協(xié)議類型選擇進(jìn)行分發(fā),在這里 skb 包將會進(jìn)一步被派送到更上層的協(xié)議中,udp 和 tcp。

3.5 UDP 協(xié)議層處理

在協(xié)議注冊小節(jié)的時候我們說過,udp 協(xié)議的處理函數(shù)是 udp_rcv。

//file: net/ipv4/udp.c
int udp_rcv(struct sk_buff *skb){
return __udp4_lib_rcv(skb, &udp_table, IPPROTO_UDP);
}
int __udp4_lib_rcv(struct sk_buff *skb, struct udp_table *udptable,
int proto)
sk = __udp4_lib_lookup_skbskb, uh-source, uh-dest, udptable);
if (sk != NULL) 
int ret = udp_queue_rcv_skbsk, skb
}
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0);
}

__udp4_lib_lookup_skb 是根據(jù) skb 來尋找對應(yīng)的 socket,當(dāng)找到以后將數(shù)據(jù)包放到 socket 的緩存隊列里。如果沒有找到,則發(fā)送一個目標(biāo)不可達(dá)的 icmp 包。

//file: net/ipv4/udp.c
int udp_queue_rcv_skb(struct sock *sk, struct sk_buff *skb){  

if (sk_rcvqueues_full(sk, skb, sk-sk_rcvbuf))
goto drop;
rc = 0;
ipv4_pktinfo_prepare(skb);
bh_lock_sock(sk);
if (!sock_owned_by_user(sk))
rc = __udp_queue_rcv_skb(sk, skb);
else if (sk_add_backlog(sk, skb, sk-sk_rcvbuf)) {
bh_unlock_sock(sk);
goto drop;
}
bh_unlock_sock(sk);
return rc;
}

sock_owned_by_user 判斷的是用戶是不是正在這個 socker 上進(jìn)行系統(tǒng)調(diào)用(socket 被占用),如果沒有,那就可以直接放到 socket 的接收隊列中。如果有,那就通過 sk_add_backlog 把數(shù)據(jù)包添加到 backlog 隊列。當(dāng)用戶釋放的 socket 的時候,內(nèi)核會檢查 backlog 隊列,如果有數(shù)據(jù)再移動到接收隊列中。

sk_rcvqueues_full 接收隊列如果滿了的話,將直接把包丟棄。接收隊列大小受內(nèi)核參數(shù) net.core.rmem_max 和 net.core.rmem_default 影響。

四、recvfrom 系統(tǒng)調(diào)用

花開兩朵,各表一枝。上面我們說完了整個 Linux 內(nèi)核對數(shù)據(jù)包的接收和處理過程,最后把數(shù)據(jù)包放到 socket 的接收隊列中了。那么我們再回頭看用戶進(jìn)程調(diào)用 recvfrom 后是發(fā)生了什么。我們在代碼里調(diào)用的 recvfrom 是一個 glibc 的庫函數(shù),該函數(shù)在執(zhí)行后會將用戶進(jìn)行陷入到內(nèi)核態(tài),進(jìn)入到 Linux 實(shí)現(xiàn)的系統(tǒng)調(diào)用 sys_recvfrom。在理解 Linux 對 sys_revvfrom 之前,我們先來簡單看一下 socket 這個核心數(shù)據(jù)結(jié)構(gòu)。這個數(shù)據(jù)結(jié)構(gòu)太大了,我們只把對和我們今天主題相關(guān)的內(nèi)容畫出來,如下:

圖 11 socket 內(nèi)核數(shù)據(jù)機(jī)構(gòu)

socket 數(shù)據(jù)結(jié)構(gòu)中的 const struct proto_ops 對應(yīng)的是協(xié)議的方法集合。每個協(xié)議都會實(shí)現(xiàn)不同的方法集,對于 IPv4 Internet 協(xié)議族來說,每種協(xié)議都有對應(yīng)的處理方法,如下。對于 udp 來說,是通過 inet_dgram_ops 來定義的,其中注冊了 inet_recvmsg 方法。

//file: net/ipv4/af_inet.c
const struct proto_ops inet_stream_ops = {

.recvmsg       = inet_recvmsg,
.mmap          = sock_no_mmap,

}
const struct proto_ops inet_dgram_ops = {

.sendmsg       = inet_sendmsg,
.recvmsg       = inet_recvmsg,

}

socket 數(shù)據(jù)結(jié)構(gòu)中的另一個數(shù)據(jù)結(jié)構(gòu) struct sock *sk 是一個非常大,非常重要的子結(jié)構(gòu)體。其中的 sk_prot 又定義了二級處理函數(shù)。對于 UDP 協(xié)議來說,會被設(shè)置成 UDP 協(xié)議實(shí)現(xiàn)的方法集 udp_prot。

//file: net/ipv4/udp.c
struct proto udp_prot = {
.name          = "UDP",
.owner         = THIS_MODULE,
.close         = udp_lib_close,
.connect       = ip4_datagram_connect,

.sendmsg       = udp_sendmsg,
.recvmsg       = udp_recvmsg,
.sendpage      = udp_sendpage,

}

看完了 socket 變量之后,我們再來看 sys_revvfrom 的實(shí)現(xiàn)過程。

圖 12 recvfrom 函數(shù)內(nèi)部實(shí)現(xiàn)過程

在 inet_recvmsg 調(diào)用了 sk->sk_prot->recvmsg。

//file: net/ipv4/af_inet.c
int inet_recvmsg(struct kiocb *iocb, struct socket *sock, struct msghdr *msg,size_t size, int flags){  

err = sk-sk_prot-recvmsg(iocb, sk, msg, size, flags & MSG_DONTWAIT,
flags & ~MSG_DONTWAIT, &addr_len);
if (err = 0)
msg-msg_namelen = addr_len;
return err;
}

上面我們說過這個對于 udp 協(xié)議的 socket 來說,這個 sk_prot 就是 net / ipv4 / udp.c 下的 struct proto udp_prot。由此我們找到了 udp_recvmsg 方法。

//file:net/core/datagram.c:EXPORT_SYMBOL(__skb_recv_datagram);
struct sk_buff *__skb_recv_datagram(struct sock *sk, unsigned int flags,int *peeked, int *off, int *err){

do {
struct sk_buff_head *queue = &sk-sk_receive_queue;
skb_queue_walk(queue, skb) {

}
/* User doesn't want to wait */
error = -EAGAIN;
if (!timeo)
goto no_packet;
} while (!wait_for_more_packets(sk, err, &timeo, last));
}

終于我們找到了我們想要看的重點(diǎn),在上面我們看到了所謂的讀取過程,就是訪問 sk->sk_receive_queue。如果沒有數(shù)據(jù),且用戶也允許等待,則將調(diào)用 wait_for_more_packets () 執(zhí)行等待操作,它加入會讓用戶進(jìn)程進(jìn)入睡眠狀態(tài)。

五、總結(jié)

網(wǎng)絡(luò)模塊是 Linux 內(nèi)核中最復(fù)雜的模塊了,看起來一個簡簡單單的收包過程就涉及到許多內(nèi)核組件之間的交互,如網(wǎng)卡驅(qū)動、協(xié)議棧,內(nèi)核 ksoftirqd 線程等??雌饋砗軓?fù)雜,本文想通過圖示的方式,盡量以容易理解的方式來將內(nèi)核收包過程講清楚?,F(xiàn)在讓我們再串一串整個收包過程。

當(dāng)用戶執(zhí)行完 recvfrom 調(diào)用后,用戶進(jìn)程就通過系統(tǒng)調(diào)用進(jìn)行到內(nèi)核態(tài)工作了。如果接收隊列沒有數(shù)據(jù),進(jìn)程就進(jìn)入睡眠狀態(tài)被操作系統(tǒng)掛起。這塊相對比較簡單,剩下大部分的戲份都是由 Linux 內(nèi)核其它模塊來表演了。

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

1. 創(chuàng)建 ksoftirqd 線程,為它設(shè)置好它自己的線程函數(shù),后面指望著它來處理軟中斷呢

2. 協(xié)議棧注冊,linux 要實(shí)現(xiàn)許多協(xié)議,比如 arp,icmp,ip,udp,tcp,每一個協(xié)議都會將自己的處理函數(shù)注冊一下,方便包來了迅速找到對應(yīng)的處理函數(shù)

3. 網(wǎng)卡驅(qū)動初始化,每個驅(qū)動都有一個初始化函數(shù),內(nèi)核會讓驅(qū)動也初始化一下。在這個初始化過程中,把自己的 DMA 準(zhǔn)備好,把 NAPI 的 poll 函數(shù)地址告訴內(nèi)核

4. 啟動網(wǎng)卡,分配 RX,TX 隊列,注冊中斷對應(yīng)的處理函數(shù)

以上是內(nèi)核準(zhǔn)備收包之前的重要工作,當(dāng)上面都 ready 之后,就可以打開硬中斷,等待數(shù)據(jù)包的到來了。

當(dāng)數(shù)據(jù)到來了以后,第一個迎接它的是網(wǎng)卡(我去,這不是廢話么):

1. 網(wǎng)卡將數(shù)據(jù)幀 DMA 到內(nèi)存的 RingBuffer 中,然后向 CPU 發(fā)起中斷通知

2. CPU 響應(yīng)中斷請求,調(diào)用網(wǎng)卡啟動時注冊的中斷處理函數(shù)

3. 中斷處理函數(shù)幾乎沒干啥,就發(fā)起了軟中斷請求

4. 內(nèi)核線程 ksoftirqd 線程發(fā)現(xiàn)有軟中斷請求到來,先關(guān)閉硬中斷

5. ksoftirqd 線程開始調(diào)用驅(qū)動的 poll 函數(shù)收包

6. poll 函數(shù)將收到的包送到協(xié)議棧注冊的 ip_rcv 函數(shù)中

7. ip_rcv 函數(shù)再講包送到 udp_rcv 函數(shù)中(對于 tcp 包就送到 tcp_rcv)

現(xiàn)在我們可以回到開篇的問題了,我們在用戶層看到的簡單一行 recvfrom,Linux 內(nèi)核要替我們做如此之多的工作,才能讓我們順利收到數(shù)據(jù)。這還是簡簡單單的 UDP,如果是 TCP,內(nèi)核要做的工作更多,不由得感嘆內(nèi)核的開發(fā)者們真的是用心良苦。

理解了整個收包過程以后,我們就能明確知道 Linux 收一個包的 CPU 開銷了。首先第一塊是用戶進(jìn)程調(diào)用系統(tǒng)調(diào)用陷入內(nèi)核態(tài)的開銷。第二塊是 CPU 響應(yīng)包的硬中斷的 CPU 開銷。第三塊是 ksoftirqd 內(nèi)核線程的軟中斷上下文花費(fèi)的。后面我們再專門發(fā)一篇文章實(shí)際觀察一下這些開銷。

另外網(wǎng)絡(luò)收發(fā)中有很多末支細(xì)節(jié)咱們并沒有展開了說,比如說 no NAPI,GRO,RPS 等。因?yàn)槲矣X得說的太對了反而會影響大家對整個流程的把握,所以盡量只保留主框架了,少即是多!

廣告聲明:文內(nèi)含有的對外跳轉(zhuǎn)鏈接(包括不限于超鏈接、二維碼、口令等形式),用于傳遞更多信息,節(jié)省甄選時間,結(jié)果僅供參考,IT之家所有文章均包含本聲明。

相關(guān)文章

關(guān)鍵詞:Linux,linux

軟媒旗下網(wǎng)站: IT之家 最會買 - 返利返現(xiàn)優(yōu)惠券 iPhone之家 Win7之家 Win10之家 Win11之家

軟媒旗下軟件: 軟媒手機(jī)APP應(yīng)用 魔方 最會買 要知