前言
網路程式設計中我們接觸得比較多的是socket api和epoll模型,對於系統核心和網路卡驅動接觸得比較少,一方面可能我們的系統沒有需要深度調優的需求,另一方面網路程式設計涉及到硬體,驅動,核心,虛擬化等複雜的知識,使人望而卻步。網路上網路卡收包相關的資料也比較多,但是比較分散,在此梳理了網路卡收包的流程,分享給大家,希望對大家有幫助,文中引用了一些同事的圖表和摘選了網上資料,在文章最後給出了原始的連結,感謝這些作者的分享。
1.整體流程
網路卡收包從整體上是網線中的高低電平轉換到網路卡FIFO儲存再拷貝到系統主記憶體(DDR3)的過程,其中涉及到網路卡控制器,CPU,DMA,驅動程式,在OSI模型中屬於物理層和鏈路層,如下圖所示。
2.關鍵資料結構
在核心中網路資料流涉及到的程式碼比較複雜,見圖1(原圖在附件中),其中有3個資料結構在網路卡收包的流程中是最主要的角色,它們是:sk_buff,softnet_data,net_device。
圖1核心網路資料流
sk_buff
sk_buff結構是Linux網路模組中最重要的資料結構之一。sk_buff可以在不同的網路協議層之間傳遞,為了適配不同的協議,裡面的大多數成員都是指標,還有一些union,其中data指標和len會在不同的協議層中發生改變,在收包流程中,即資料向上層傳遞時,下層的首部就不再需要了。圖2即演示了資料包傳送時指標和len的變化情況。(linux原始碼不同的版本有些差別,下面的截圖來自linux 2.6.20)。
圖2 sk_buff在不同協議層傳遞時,data指標的變化示例
softnet_data
softnet_data 結構內的欄位就是 NIC 和網路層之間處理佇列,這個結構是全域性的,每個cpu一個,它從 NIC中斷和 POLL 方法之間傳遞資料資訊。圖3說明了softnet_data中的變數的作用。
net_device
net_device中poll方法即在NAPI回撥的收包函式。
net_device代表的是一種網路裝置,既可以是物理網路卡,也可以是虛擬網路卡。在sk_buff中有一個net_device * dev變數,這個變數會隨著sk_buff的流向而改變。在網路裝置驅動初始化時,會分配接收sk_buff快取佇列,這個dev指標會指向收到資料包的網路裝置。當原始網路裝置接收到報文後,會根據某種演算法選擇某個合適的虛擬網路裝置,並將dev指標修改為指向這個虛擬裝置的net_device結構。
3.網路收包原理
本節主要引用網路上的文章,在關鍵的地方加了一些備註,騰訊公司內部主要使用Intel 82576網路卡和Intel igb驅動,和下面的網路卡和驅動不一樣,實際上原理是一樣的,只是一些函式命名和處理的細節不一樣,並不影響理解。
網路驅動收包大致有3種情況:
no NAPI:
mac每收到一個乙太網包,都會產生一個接收中斷給cpu,即完全靠中斷方式來收包
缺點是當網路流量很大時,cpu大部分時間都耗在了處理mac的中斷。
netpoll:
在網路和I/O子系統尚不能完整可用時,模擬了來自指定裝置的中斷,即輪詢收包。
缺點是實時性差
NAPI:
採用中斷 + 輪詢的方式:mac收到一個包來後會產生接收中斷,但是馬上關閉。
直到收夠了netdev_max_backlog個包(預設300),或者收完mac上所有包後,才再開啟接收中斷
通過sysctl來修改 net.core.netdev_max_backlog
或者通過proc修改 /proc/sys/net/core/netdev_max_backlog
圖3 softnet_data與介面層和網路層之間的關係
下面只寫核心配置成使用NAPI的情況,只寫TSEC驅動。核心版本 linux 2.6.24。
NAPI相關資料結構
每個網路裝置(MAC層)都有自己的net_device資料結構,這個結構上有napi_struct。每當收到資料包時,網路裝置驅動會把自己的napi_struct掛到CPU私有變數上。這樣在軟中斷時,net_rx_action會遍歷cpu私有變數的poll_list,執行上面所掛的napi_struct結構的poll鉤子函式,將資料包從驅動傳到網路協議棧。
核心啟動時的準備工作
3.1 初始化網路相關的全域性資料結構,並掛載處理網路相關軟中斷的鉤子函式
start_kernel()
--> rest_init()
--> do_basic_setup()
--> do_initcall
-->net_dev_init
__init net_dev_init(){
//每個CPU都有一個CPU私有變數 _get_cpu_var(softnet_data)
//_get_cpu_var(softnet_data).poll_list很重要,軟中斷中需要遍歷它的
for_each_possible_cpu(i) {
struct softnet_data *queue;
queue = &per_cpu(softnet_data, i);
skb_queue_head_init(&queue->input_pkt_queue);
queue->completion_queue = NULL;
INIT_LIST_HEAD(&queue->poll_list);
queue->backlog.poll = process_backlog;
queue->backlog.weight = weight_p;
}
//在軟中斷上掛網路傳送handler
open_softirq(NET_TX_SOFTIRQ, net_tx_action, NULL);
//在軟中斷上掛網路接收handler
open_softirq(NET_RX_SOFTIRQ, net_rx_action, NULL);
}複製程式碼
softirq
中斷處理“下半部”機制
中斷服務程式一般都是在中斷請求關閉的條件下執行的,以避免巢狀而使中斷控制複雜化。但是,中斷是一個隨機事件,它隨時會到來,如果關中斷的時間太長,CPU就不能及時響應其他的中斷請求,從而造成中斷的丟失。
因此,Linux核心的目標就是儘可能快的處理完中斷請求,盡其所能把更多的處理向後推遲。例如,假設一個資料塊已經達到了網線,當中斷控制器接受到這個中斷請求訊號時,Linux核心只是簡單地標誌資料到來了,然後讓處理器恢復到它以前執行的狀態,其餘的處理稍後再進行(如把資料移入一個緩衝區,接受資料的程式就可以在緩衝區找到資料)。
因此,核心把中斷處理分為兩部分:上半部(top-half)和下半部(bottom-half),上半部(就是中斷服務程式)核心立即執行,而下半部(就是一些核心函式)留著稍後處理。
2.6核心中的“下半部”處理機制:
1) 軟中斷請求(softirq)機制(注意不要和程式間通訊的signal混淆)
2) 小任務(tasklet)機制
3) 工作佇列機制
我們可以通過top命令檢視softirq佔用cpu的情況:
softirq實際上也是一種註冊回撥的機制,ps –elf 可以看到註冊的函式由一個守護程式(ksoftirgd)專門來處理,而且是每個cpu一個守護程式。
3.2 載入網路裝置的驅動
NOTE:這裡的網路裝置是指MAC層的網路裝置,即TSEC和PCI網路卡(bcm5461是phy)在網路裝置驅動中建立net_device資料結構,並初始化其鉤子函式 open(),close() 等掛載TSEC的驅動的入口函式是 gfar_probe
// 平臺裝置 TSEC 的資料結構
static struct platform_driver gfar_driver = {
.probe = gfar_probe,
.remove = gfar_remove,
.driver = {
.name = "fsl-gianfar",
},
};
int gfar_probe(struct platform_device *pdev)
{
dev = alloc_etherdev(sizeof (*priv)); // 建立net_device資料結構
dev->open = gfar_enet_open;
dev->hard_start_xmit = gfar_start_xmit;
dev->tx_timeout = gfar_timeout;
dev->watchdog_timeo = TX_TIMEOUT;
#ifdef CONFIG_GFAR_NAPI
netif_napi_add(dev, &priv->napi,gfar_poll,GFAR_DEV_WEIGHT); //軟中斷裡會呼叫poll鉤子函式
#endif
#ifdef CONFIG_NET_POLL_CONTROLLER
dev->poll_controller = gfar_netpoll;
#endif
dev->stop = gfar_close;
dev->change_mtu = gfar_change_mtu;
dev->mtu = 1500;
dev->set_multicast_list = gfar_set_multi;
dev->set_mac_address = gfar_set_mac_address;
dev->ethtool_ops = &gfar_ethtool_ops;
}複製程式碼
3.3啟用網路裝置
3.3.1 使用者呼叫ifconfig等程式,然後通過ioctl系統呼叫進入核心
socket的ioctl()系統呼叫
--> sock_ioctl()
--> dev_ioctl() //判斷SIOCSIFFLAGS
--> __dev_get_by_name(net, ifr->ifr_name) //根據名字選net_device
--> dev_change_flags() //判斷IFF_UP
--> dev_open(net_device) //呼叫open鉤子函式複製程式碼
對於TSEC來說,掛的鉤子函式是 gfar_enet_open(net_device)
3.3.2 在網路裝置的open鉤子函式裡,分配接收bd,掛中斷ISR(包括rx、tx、err),對於TSEC來說
gfar_enet_open
-->給Rx Tx Bd 分配一致性DMA記憶體
-->把Rx Bd的“EA地址”賦給資料結構,實體地址賦給TSEC暫存器
-->把Tx Bd的“EA地址”賦給資料結構,實體地址賦給TSEC暫存器
-->給 tx_skbuff 指標陣列分配記憶體,並初始化為NULL
-->給 rx_skbuff 指標陣列分配記憶體,並初始化為NULL
-->初始化Tx Bd
-->初始化Rx Bd,提前分配儲存乙太網包的skb,這裡使用的是一次性dma對映
(注意:`#define DEFAULT_RX_BUFFER_SIZE 1536`保證了skb能存一個乙太網包)複製程式碼
rxbdp = priv->rx_bd_base;
for (i = 0; i < priv->rx_ring_size; i++) {
struct sk_buff *skb = NULL;
rxbdp->status = 0;
//這裡真正分配skb,並且初始化rxbpd->bufPtr, rxbdpd->length
skb = gfar_new_skb(dev, rxbdp);
priv->rx_skbuff[i] = skb;
rxbdp++;
}
rxbdp--;
rxbdp->status |= RXBD_WRAP; // 給最後一個bd設定標記WRAP標記複製程式碼
-->註冊TSEC相關的中斷handler:錯誤,接收,傳送複製程式碼
request_irq(priv->interruptError, gfar_error, 0, "enet_error", dev)
request_irq(priv->interruptTransmit, gfar_transmit, 0, "enet_tx", dev)//包傳送完
request_irq(priv->interruptReceive, gfar_receive, 0, "enet_rx", dev) //包接收完
-->gfar_start(net_device)
// 使能Rx、Tx
// 開啟TSEC的 DMA 暫存器
// Mask 掉我們不關心的中斷event複製程式碼
最終,TSEC相關的Bd等資料結構應該是下面這個樣子的
3.4中斷裡接收乙太網包
TSEC的RX已經使能了,網路資料包進入記憶體的流程為:
網線 --> Rj45網口 --> MDI 差分線
--> bcm5461(PHY晶片進行數模轉換) --> MII匯流排
--> TSEC的DMA Engine 會自動檢查下一個可用的Rx bd
-->把網路資料包 DMA 到 Rx bd 所指向的記憶體,即skb->data
接收到一個完整的乙太網資料包後,TSEC會根據event mask觸發一個 Rx 外部中斷。
cpu儲存現場,根據中斷向量,開始執行外部中斷處理函式do_IRQ()
do_IRQ 虛擬碼
上半部處理硬中斷
檢視中斷源暫存器,得知是網路外設產生了外部中斷
執行網路裝置的rx中斷handler(裝置不同,函式不同,但流程類似,TSEC是gfar_receive)
- mask 掉 rx event,再來資料包就不會產生rx中斷
- 給napi_struct.state加上 NAPI_STATE_SCHED 狀態
- 掛網路裝置自己的napi_struct結構到cpu私有變數_get_cpu_var(softnet_data).poll_list
- 觸發網路接收軟中斷( __raise_softirq_irqoff(NET_RX_SOFTIRQ); ——> wakeup_softirqd() )
下半部處理軟中斷
依次執行所有軟中斷handler,包括timer,tasklet等等
執行網路接收的軟中斷handler net_rx_action - 遍歷cpu私有變數_get_cpu_var(softnet_data).poll_list
- 取出poll_list上面掛的napi_struct 結構,執行鉤子函式napi_struct.poll()
(裝置不同,鉤子函式不同,流程類似,TSEC是gfar_poll - 若poll鉤子函式處理完所有包,則開啟rx event mask,再來資料包的話會產生rx中斷
- 呼叫napi_complete(napi_struct *n)
- 把napi_struct 結構從_get_cpu_var(softnet_data).poll_list 上移走,同時去掉 napi_struct.state 的 NAPI_STATE_SCHED 狀態
3.4.1 TSEC的接收中斷處理函式
3.4.2 網路接收軟中斷net_rx_actiongfar_receive{ #ifdef CONFIG_GFAR_NAPI // test_and_set當前net_device的napi_struct.state 為 NAPI_STATE_SCHED // 在軟中斷裡呼叫 net_rx_action 會檢查狀態 napi_struct.state if (netif_rx_schedule_prep(dev, &priv->napi)) { tempval = gfar_read(&priv->regs->imask); tempval &= IMASK_RX_DISABLED; //mask掉rx,不再產生rx中斷 gfar_write(&priv->regs->imask, tempval); // 將當前net_device的 napi_struct.poll_list 掛到 // CPU私有變數__get_cpu_var(softnet_data).poll_list 上,並觸發軟中斷 // 所以,在軟中斷中呼叫 net_rx_action 的時候,就會執行當前net_device的 // napi_struct.poll()鉤子函式,即 gfar_poll() __netif_rx_schedule(dev, &priv->napi); } #else gfar_clean_rx_ring(dev, priv->rx_ring_size); #endif }複製程式碼
net_rx_action(){
struct list_head *list = &__get_cpu_var(softnet_data).poll_list;
//通過 napi_struct.poll_list,將N多個 napi_struct 連結到一條鏈上
//通過 CPU私有變數,我們找到了鏈頭,然後開始遍歷這個鏈
int budget = netdev_budget; //這個值就是 net.core.netdev_max_backlog,通過sysctl來修改
while (!list_empty(list)) {
struct napi_struct *n;
int work, weight;
local_irq_enable();
//從鏈上取一個 napi_struct 結構(接收中斷處理函式里加到連結串列上的,如gfar_receive)
n = list_entry(list->next, struct napi_struct, poll_list);
weight = n->weight;
work = 0;
if (test_bit(NAPI_STATE_SCHED, &n->state)) //檢查狀態標記,此標記在接收中斷里加上的
//使用NAPI的話,使用的是網路裝置自己的napi_struct.poll
//對於TSEC是,是gfar_poll
work = n->poll(n, weight);
WARN_ON_ONCE(work > weight);
budget -= work;
local_irq_disable();
if (unlikely(work == weight)) {
if (unlikely(napi_disable_pending(n)))
//操作napi_struct,把去掉NAPI_STATE_SCHED狀態,從連結串列中刪去
__napi_complete(n);
else
list_move_tail(&n->poll_list, list);
}
netpoll_poll_unlock(have);
}
out:
local_irq_enable();
}
static int gfar_poll(struct napi_struct *napi, int budget){
struct gfar_private *priv = container_of(napi, struct gfar_private, napi);
struct net_device *dev = priv->dev; //TSEC對應的網路裝置
int howmany;
//根據dev的rx bd,獲取skb並送入協議棧,返回處理的skb的個數,即乙太網包的個數
howmany = gfar_clean_rx_ring(dev, budget);
// 下面這個判斷比較有講究的
// 收到的包的個數小於budget,代表我們在一個軟中斷裡就全處理完了,所以開啟 rx中斷
// 要是收到的包的個數大於budget,表示一個軟中斷裡處理不完所有包,那就不開啟rx 中斷,
// 待到下一個軟中斷裡再接著處理,直到把所有包處理完(即howmany<budget),再開啟rx 中斷
if (howmany < budget) {
netif_rx_complete(dev, napi);
gfar_write(&priv->regs->rstat, RSTAT_CLEAR_RHALT);
//開啟 rx 中斷,rx 中斷是在gfar_receive()中被關閉的
gfar_write(&priv->regs->imask, IMASK_DEFAULT);
}
return howmany;
}複製程式碼
gfar_clean_rx_ring(dev, budget){
bdp = priv->cur_rx;
while (!((bdp->status & RXBD_EMPTY) || (--rx_work_limit < 0))) {
rmb();
skb = priv->rx_skbuff[priv->skb_currx]; //從rx_skbugg[]中獲取skb
howmany++;
dev->stats.rx_packets++;
pkt_len = bdp->length - 4; //從length中去掉乙太網包的FCS長度
gfar_process_frame(dev, skb, pkt_len);
dev->stats.rx_bytes += pkt_len;
dev->last_rx = jiffies;
bdp->status &= ~RXBD_STATS; //清rx bd的狀態
skb = gfar_new_skb(dev, bdp); // Add another skb for the future
priv->rx_skbuff[priv->skb_currx] = skb;
if (bdp->status & RXBD_WRAP) //更新指向bd的指標
bdp = priv->rx_bd_base; //bd有WARP標記,說明是最後一個bd了,需要“繞回來”
else
bdp++;
priv->skb_currx = (priv->skb_currx + 1) & RX_RING_MOD_MASK(priv->rx_ring_size);
}
priv->cur_rx = bdp; /* Update the current rxbd pointer to be the next one */
return howmany;
}
gfar_process_frame()
-->RECEIVE(skb) //呼叫netif_receive_skb(skb)進入協議棧
#ifdef CONFIG_GFAR_NAPI
#define RECEIVE(x) netif_receive_skb(x)
#else
#define RECEIVE(x) netif_rx(x)
#endif複製程式碼
在軟中斷中使用NAPI
上面net_rx_action的主要流程如圖4所示,執行一次網路軟中斷過程中,網路卡本身的Rx中斷已經關閉了,即不會產生新的接收中斷了。local_irq_enable和local_irq_disable設定的是cpu是否接收中斷。進入網路軟中斷net_rx_action的時候,會初始一個budget(預算),即最多處理的網路包個數,如果有多個網路卡(放在poll_list裡),是共享該budget,同時每個網路卡也一個權重weight或者說是配額quota,一個網路卡處理完輸入佇列裡包後有兩種情況,一次收到的包很多,quota用完了,則把收包的poll虛擬函式又掛到poll_list隊尾,重新設定一下quota值,等待while輪詢;另外一種情況是,收到的包不多,quota沒有用完,表示網路卡比較空閒,則把自己從poll_list摘除,退出輪詢。整個net_rx_action退出的情況有兩種:budget全部用完了或者是時間超時了。
圖4net_rx_action主要執行流程
3.5 DMA 8237A
在網路卡收包中涉及到DMA的操作,DMA的主要作用是讓外設間(如網路卡和主記憶體)傳輸資料而不需要CPU的參與(即不需要CPU使用專門的IO指令來拷貝資料),下面簡單介紹一下DMA的原理,如圖5所示。
圖5 DMA系統組成
網路卡採用DMA方式(DMA控制器一般在系統板上,有的網路卡也內建DMA控制器),ISR通過CPU對DMA控制器程式設計(由DMA的驅動完成,此時DMA相當於一個普通的外設,程式設計主要指設定DMA控制器的暫存器),DMA控制器收到ISR請求後,向主CPU發出匯流排HOLD請求,獲取CPU應答後便向LAN發出DMA應答並接管匯流排,同時開始網路卡緩衝區與記憶體之間的資料傳輸,這個時候CPU可以繼續執行其他的指令,當DMA操作完成後,DMA則釋放對匯流排的控制權。
4.網路卡多佇列
網路卡多佇列是硬體的一種特性,同時也需要核心支援,騰訊公司使用的Intel 82576是支援網路卡多佇列的,而且核心版本要大於2.6.20。對於單佇列的網路卡,只能產生一箇中斷訊號,並且只能由一個cpu來處理,這樣會導致多核系統中一個核(預設是cpu0)負載很高。網路卡多佇列在網路卡的內部維持多個收發佇列,併產生多箇中斷訊號使不同的cpu都能處理網路卡收到的包,從而提升了效能,如圖6所示。
圖6多佇列網路卡工作收包流程示意圖
MSI-X :一個裝置可以產生多箇中斷,如下圖中的54-61號中斷eth1-TxRx-[0-7],實際是eth1網路卡佔用的中斷號。
CPU 親和性:每個中斷號配置只有一個cpu進行處理,其中的值:01,02,04等為16進位制,相應bit為1的值程式碼cpu的編號。
5.I/O虛擬化SR-IOV
伺服器虛擬化技術在分散式系統中很常見,它能提高裝置利用率和運營效率。伺服器虛擬化包括處理器虛擬化,記憶體虛擬化和I/0裝置的虛擬化,與網路有關的虛擬化屬於I/0虛擬化,I/0裝置虛擬化的作用是單個I/O裝置可以被多個虛擬機器共享使用。對於客戶機作業系統中的應用程式來說,它發起 I/O 操作的流程和在真實硬體平臺上的作業系統是一樣的,整個 I/O 流程有所不同的在於裝置驅動訪問硬體這個部分。I/0虛擬化經過多年的發展,主要模型如表1所示,早期的裝置模擬如圖7所示,可以看到網路資料包從物理網路卡到虛擬機器中的程式需要經過很多額外的處理,效率很低。SR-IOV則直接從硬體上支援虛擬化,如圖8所示,裝置劃分為一個物理功能單元PF(Physical Functions)和多個虛擬功能單元 VF(Virtual Function),每個虛擬功能單元都可以作為一個輕量級的 I/O 裝置供虛擬機器使用,這樣一個裝置就可以同時被分配給多個虛擬機器,解決了因裝置數量限制給虛擬化系統帶來的可擴充套件性差的問題。每個 VF 都有一個唯一的 RID(Requester Identifier,請求標識號)和收發資料包的關鍵資源,如傳送佇列、接收佇列、DMA 通道等,因此每個 VF 都具有獨立收發資料包的功能。所有的 VF 共享主要的裝置資源,如資料鏈路層的處理和報文分類。
表1幾種I/0虛擬化計算對比
圖7裝置模擬
圖8支援SR-IOV的裝置結構
SR-IOV需要網路卡支援:
需要有專門的驅動來支援:
VF的驅動實際上和普通的網路卡差不多,最後都會執行到netif_receive_skb中,然後將接收到的包發給它的vlan虛擬子裝置進行處理。
docker中使用SR-IOV
啟用VF
#echo "options igb max_vfs=7" >>/etc/modprobe.d/igb.conf
#reboot
設定VF的VLAN#ip link set eth1 vf 0 vlan 12
將VF移到container network namespace#ip link set eth4 netns $pid
#ip netns exec $pid ip link set dev eth4 name eth1
#ip netns exec $pid ip link set dev eth1 up
In container:
設定IP#ip addr add 10.217.121.107/21 dev eth1
閘道器#ip route add default via 10.217.120.1
6.參考資料
- Linux核心原始碼剖析——TCP/IP實現,上冊
- Understanding Linux Network Internals
- 基於SR-IOV技術的網路卡虛擬化研究和實現
- 82576 sr-iov driver companion guide
- 網路卡工作原理及高併發下的調優
- 多佇列網路卡簡介
- Linux核心NAPI機制分析
- 網路資料包收發流程(一):從驅動到協議棧
- 中斷處理“下半部”機制
- Linux 上的基礎網路裝置詳解
- DMA operating system