TCP/IP協議棧在Linux內核中的運行時序分析
0.要求
- 在深入理解Linux內核任務調度(中斷處理、softirg、tasklet、wq、內核線程等)機制的基礎上,分析梳理send和recv過程中TCP/IP協議棧相關的運行任務實體及相互協作的時序分析。
- 編譯、部署、運行、測評、原理、源代碼分析、跟蹤調試等
- 應該包括時序圖
1.TCP/IP協議棧總覽
在TCP/IP網絡分層模型裏,整個協議棧被分成了物理層、鏈路層、網絡層,傳輸層和應用層。物理層對應的是網卡和網線,應用層對應的是我們常見的FTP,HTTP等等各種應用。Linux實現的是鏈路層、網絡層和傳輸層這三層。
在Linux內核實現中,鏈路層協議靠網卡驅動來實現,內核協議棧來實現網絡層和傳輸層。內核對更上層的應用層提供socket接口來供用戶進程訪問。我們用Linux的視角來看到的TCP/IP網絡分層模型應該是下面這個樣子的。
在Linux的源代碼中,網絡設備驅動對應的邏輯位於driver/net/ethernet, 其中intel系列網卡的驅動在driver/net/ethernet/intel目錄下。協議棧模塊代碼位於kernel和net目錄。
內核和網絡設備驅動是通過中斷的方式來處理的。當設備上有數據到達的時候,會給CPU的相關引腳上觸發一個電壓變化,以通知CPU來處理數據。Linux中斷處理函數是分上半部和下半部的。上半部是隻進行最簡單的工作,快速處理然後釋放CPU,接着CPU就可以允許其它中斷進來。剩下將絕大部分的工作都放到下半部中,可以慢慢從容處理。2.4以後的內核版本採用的下半部實現方式是軟中斷,由ksoftirqd內核線程全權處理。
那麼內核收一個包應該是怎樣的呢?如下給出簡單示意圖。
2.linux內核啓動
Linux內核協議棧等模塊在具備接收網卡數據包之前,要做很多的準備工作纔行。比如要提前創建好ksoftirqd內核線程,要註冊好各個協議對應的處理函數,網絡設備子系統要提前初始化好,網卡要啓動好。只有這些都準備之後,我們才能真正開始接收數據包。那麼我們現在來看看這些準備工作都是怎麼做的。
2.1 創建ksoftirqd內核線程
Linux的軟中斷都是在專門的內核線程(ksoftirqd)中進行的,因此我們非常有必要看一下這些進程是怎麼初始化的,這樣我們才能在後面更準確地瞭解收包過程。該進程數量不是1個,而是N個,其中N等於你的機器的核數。
系統初始化的時候在kernel/smpboot.c中調用了smpboot_register_percpu_thread, 該函數進一步會執行到spawn_ksoftirqd(位於kernel/softirq.c)來創建出softirqd進程。
相關代碼如下:
1 //file: kernel/softirq.c 2 3 static struct smp_hotplug_thread softirq_threads = { 4 5 .store = &ksoftirqd, 6 .thread_should_run = ksoftirqd_should_run, 7 .thread_fn = run_ksoftirqd, 8 .thread_comm = "ksoftirqd/%u",}; 9 static __init int spawn_ksoftirqd(void){ 10 register_cpu_notifier(&cpu_nfb); 11 12 BUG_ON(smpboot_register_percpu_thread(&softirq_threads)); 13 return 0; 14 15 } 16 17 early_initcall(spawn_ksoftirqd);
當ksoftirqd被創建出來以後,它就會進入自己的線程循環函數ksoftirqd_should_run和run_ksoftirqd了。不停地判斷有沒有軟中斷需要被處理。
2.2網絡子系統初始化
linux內核通過調用subsys_initcall來初始化各個子系統,在源代碼目錄裏你可以grep出許多對這個函數的調用。這裏我們要說的是網絡子系統的初始化,會執行到net_dev_init函數。
//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);
在這個函數裏,會爲每個CPU都申請一個softnet_data數據結構,在這個數據結構裏的poll_list是等待驅動程序將其poll函數註冊進來,稍後網卡驅動初始化的時候我們可以看到這一過程。
2.3協議棧註冊
內核實現了網絡層的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數據結構中了。
2.4網卡驅動初始化與網卡啓動
這一步主要是初始化對應網卡的驅動程序,啓動網卡。驅動向內核註冊了 structure net_device_ops 變量,它包含着網卡啓用、發包、設置mac 地址等回調函數(函數指針)。當啓用一個網卡時(例如,通過 ifconfig eth0 up),net_device_ops 中的 igb_open方法會被調用,之後分配隊列內存,註冊中斷處理函數,然後打開硬中斷等包進來。
3.包的接收(收到socket接收隊列)
3.1硬中斷處理
首先當數據幀從網線到達網卡上的時候,第一站是網卡的接收隊列。網卡在分配給自己的RingBuffer中尋找可用的內存位置,找到後DMA引擎會把數據DMA到網卡之前關聯的內存裏,這個時候CPU都是無感的。當DMA操作完成以後,網卡會像CPU發起一個硬中斷,通知CPU有數據到達。
Linux在硬中斷裏只完成簡單必要的工作,剩下的大部分的處理都是轉交給軟中斷的。只是記錄了一個寄存器,修改了一下下CPU的poll_list,然後發出個軟中斷。
3.2 ksoftirqd內核線程處理軟中斷
簡單講就是系統會循環執行ksoftirqd內核線程,判斷softirq_pengding標誌根據軟中斷類型執行不同函數,之後調用驅動註冊的poll函數,取下數據包
然後送給協議棧。
3.3 網絡協議棧處理
3.3.1網絡層之前的處理
netif_receive_skb函數會根據包的協議,假如是udp包,會將包依次送到ip_rcv(),udp_rcv()協議處理函數中進行處理。(如果是tcp,則送到arp_rcv(),tcp_rcv())。
//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邏輯,這裏會將數據送入抓包點。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取出protocol,它會從數據包中取出協議信息,然後遍歷註冊在這個協議上的回調函數列表。ptype_base 是一個 hash table,ip_rcv 函數地址就是存在這個 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這一行就調用到了協議層註冊的處理函數了。對於ip包來講,就會進入到ip_rcv(如果是arp包的話,會進入到arp_rcv)。
3.3.2網絡層處理
我們再來大致看一下linux在ip協議層都做了什麼,包又是怎麼樣進一步被送到udp或tcp協議處理函數中的。
//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是一個鉤子函數,當執行完註冊的鉤子後就會執行到最後一個參數指向的函數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 後看到它又調用了 ip_route_input_mc。在ip_route_input_mc中,函數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調用的input方法就是路由子系統賦的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); } }
這裏將會根據包中的協議類型選擇進行分發,在這裏skb包將會進一步被派送到更上層的協議中,udp和tcp。
3.3.3傳輸層處理
以udp爲例,這裏的處理函數是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_skb(skb, uh->source, uh->dest, udptable); if (sk != NULL) { int ret = udp_queue_rcv_skb(sk, skb } icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0); }
__udp4_lib_lookup_skb是根據skb來尋找對應的socket,當找到以後將數據包放到socket的緩存隊列裏。如果沒有找到,則發送一個目標不可達的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上進行系統調用(socket被佔用),如果沒有,那就可以直接放到socket的接收隊列中。如果有,那就通過sk_add_backlog把數據包添加到backlog隊列。當用戶釋放的socket的時候,內核會檢查backlog隊列,如果有數據再移動到接收隊列中。
sk_rcvqueues_full接收隊列如果滿了的話,將直接把包丟棄。接收隊列大小受內核參數net.core.rmem_max和net.core.rmem_default影響。
4.recvfrom系統調用
上面我們說完了整個Linux內核對數據包的接收和處理過程,最後把數據包放到socket的接收隊列中了。那麼我們再回頭看用戶進程調用recvfrom後是發生了什麼。我們在代碼裏調用的recvfrom是一個glibc的庫函數,該函數在執行後會將用戶進行陷入到內核態,進入到Linux實現的系統調用sys_recvfrom。
以上一個包在linux內核下的接收讀取便成功了。
5.時序圖