Linux 网络 —— 数据包的接收
背景
最初学习计算机网络的时候,通常都是从 TCP/IP 网络分层模型入手,学习各种协议,如 TCP、IP等,以及相关的原理,并未过多关注整个协议栈具体是如何实现的。在开发的过程中,通过高级语言往往只需要几行就能实现一个简单的网络程序,并从网络接收数据。然而数据具体是如何从网卡达到我们的进程中的呢?在这个过程中网卡和内核到底又做了些什么呢?数据在这个过程中是如何流转、复制的呢?带着这些问题,笔者最近学习了下 Linux 网络数据包的接收,并总结如下。
通过阅读本文应该能够了解:
Linux 网络栈是如何接收数据的,数据从网卡到内核各个部分是如何进行流转的;
网络栈在正式工作之前需要经过哪些初始化,这些初始化工作的目的是什么;
数据包从网卡到内核需要经过几次复制;
网络相关的硬中断、软中断分别是如何实现的,二者是如何配合的;
ethtool
、tcpdump
、iptables
等分别工作在何处,原理是什么。
本文基于内核 3.10 版本,网卡举例使用的是 igb 网卡。
系统初始化
Linux 系统在接收数据包之前需要做很多的准备工作,比如创建内核 ksoftirqd 线程,便于后续处理软中断;网络子系统初始化,注册各个协议的处理函数,便于后面的协议栈处理;网卡设备子系统初始化,便于后面从网卡接收数据包。这里首先对这些初始化的过程进行记录,其中重点是网卡设备的初始化。
内核线程 ksoftirqd 初始化
Linux 在接收网络数据的时候需要用到中断机制,包括硬中断和软中断。 其中 Linux 的软中断都是在内核线程 ksoftirq 中进行的,该线程在系统中有 N 个 (N=机器核数),线程被 CPU 调度。
系统初始化的时候创建了这一线程,之后它就进入到这自己的线程循环函数 ksoftirqd_should_run
和 run_ksoftirqd
,判断有无中断需要处理。这里的中断不仅仅包括网络相关的软中断,还有其他的中断类型, 具体可以查看 Linux 的定义。
关于Linux中断相关的原理与实现,推荐阅读下面这篇文章:
网络子系统初始化
Linux 内核通过 subsys_initcall
调用来初始化各个子系统,这里我们来看一看网络子系统的初始化 net_dev_init。
网络子系统初始化的核心是为每个 CPU 都申请了一个 softnet_data
数据结构,并且为网络中断注册了对应的处理函数,其中 NET_TX_SOFTIRQ
的处理函数是 net_tx_action
, NET_RX_SOFTIRQ
的处理函数是net_rx_action
。中断和其对应的处理函数之间是如何对应的可以跟踪阅读 open_softirq
。
最后,来看一看这个 softnet_data
数据结构:
协议栈注册
网络传输的过程中涉及到各种协议,每种协议也都有其对应的处理函数,如 IP 协议对应的是 ip_rcv()
, TCP,UDP 协议对应的是 tcp_v4_rcv
和udp_rcv
。这些协议的处理函数也是通过内核注册的,内核在初始化的时候通过 fs_initcall
调用 inet_init
进行网络协议栈的注册。
具体来说, inet_init
中调用 inet_add_protocol
将 udp 和 tcp 的处理函数注册到了 inet_protos
数组中,将 ip 处理函数注册到了 ptype_base
哈希表中。后面软中断中以及对应的调用中就是通过这些数据结构找到对应的处理函数的。
网卡设备初始化
每个驱动程序会通过 module_init
向内核注册一个初始化函数。驱动被加载的时候,这个初始化函数会被调用,调用完成内核就知道了驱动的相关信息,比如驱动的 name, 驱动注册的 probe 函数。网卡被识别之后,内核就会调用 probe
函数。 probe
做的事情因厂商和设备而异,其目的是为了让设备 ready,典型的过程包括:
PCI 设备启动
设置 DMA 掩码
注册设备驱动支持的 ethtool
例如, intel 系列的 igb 网卡igb_probe
过程主要分为这样几个步骤:
启用 PCI 设备并设置 DMA 掩码;
请求 PCI 设备的内存资源;
设置网络设备操作函数
netdev->netdev_ops = &igb_netdev_ops;
,(netdev);igb_set_ethtool_ops
;注册 ethtool 处理函数;
复制 MAC 地址等信息.
其中第5步,就是 ethtool 工具的注册,使用该工具的时候,内核就会找到对应的回调函数,对于 igb 网卡设备来说,函数的实现在 drivers/net/ethernet/intel/igb/igb_ethtool.c
下。第3步,网络设备操作函数注册中包含了 igb_open
等函数,会在网卡启动的时候调用。
在 igb_probe
调用的过程中还会通过 igb_alloc_q_vector
注册一个 NAPI 机制必须的 poll 函数,对于 igb 网卡设备来说,这个函数就是 igb_poll
。
网卡启动
完成了上面的初始化之后,就可以启动网卡了。在上面网卡设备的初始化过程中,驱动向内核注册了 netdev_ops
变量,启动网卡的时候,其中 .ndo_open
上指向的igb_open
方法就会被调用。
在这个函数中会分配传输/接收描述符数组,注册中断处理函数,并启用 NAPI。
下面是描述符数组的具体创建过程:
最后看下中断函数的注册igb_request_irq(adapter)
, 顺着调用链,可以看到,多队列网卡会为每个队列注册中断,对应的中断处理函数是 igb_mix_ring
.
数据接收
网卡收包
当上面的一切初始化完成之后就网卡就可以收包了,数据从网线进入网卡,通过 DMA 直接将数据写到 Ring Buffer,这里涉及到第一次数据复制: 从网卡通过DMA将数据复制到 Ring Buffer 。当DMA完成之后,网卡会向 CPU 发一个硬中断,通知 CPU 有数据到达。
硬中断处理
CPU 收到网卡发起硬中断之后,就会调用驱动注册的硬中断处理函数。对于 igb 网卡,这个处理函数就是在网卡启动这一节最后提到的 igb_mix_ring
。
这里顺着调用链看,最后是在 ____napi_schedule
。
____napi_schedule
中主要完成了两件事:
将从驱动中传来的
napi_struct
添加到一个 poll list,这个poll list 是 attach 在 CPU 变量 softness_data 上的;触发一个
NET_RX_SOFTIRQ
类型的软中断。
这里可以看到驱动的硬中断处理函数做的事情很少,因为硬中断的一个目标就是要快,但软中断将会在和硬中断相同的 CPU 上执行。这就 是为什么给每个 CPU 一个特定的硬中断非常重要:这个 CPU 不仅处理这个硬中断,而且通 过 NAPI 处理接下来的软中断来收包。
软中断处理
内核的软中断都是在 ksoftirqd
内核线程中处理的,介绍 ksoftirqd
线程初始化的时候提到过它的循环函数 ksoftirqd_should_run
和 run_ksoftirqd
。
这里如果硬中断写入了标记,那么在 local_softirq_pending
就能够读取到。在 __do_softirq
中就会根据当前 CPU 的软中断类型,调用注册的 action 方法。对于接收数据包来说就是 net_rx_action
。
软中断处理函数 net_rx_action
软中断处理函数 net_rx_action
的核心逻辑是从当前 CPU 获取 softnet_data
变量,对其 poll_list
进行遍历,执行网卡驱动注册的 poll 函数,例如对于 igb 网卡就是 igb_poll
。
驱动 poll 函数处理 —— RingBuffer 取下数据帧
网卡 poll
函数的核心就是将数据帧从 RingBuffer 上取下来,然后初始化一个 struct sk_buff *skb
结构体变量,也就是我们最常打交道的内核协议栈中的数据包。这里也涉及到第二次数据复制: 从 Ring buffer 复制到 skb 结构体。
收取完数据后,会对其进行一些校验, 然后开始设置 sbk 变量的 time_stamp, VLAN id,protocol 等字段。接下来会进入 napi_gro_receive()
,这个函数代表的是网卡 GRO 特性,GRO
之后的调用链是 napi_gro_receive()
-> napi_skb_finish
-> netif_receive_skb
,在 netif_receive_skb
中数据被送往协议栈。
顺着调用链可以看到关键的处理逻辑如下,这里记录两个点:
一是我们常用的 tcpdump
抓包点就在这个函数中,它将抓包函数以协议的方式挂在 ptype_all
上,设备层遍历所有的协议就能进行抓包了。
二是在此处会从 skb
中取出协议,根据协议类型送往具体的处理函数中。这里这个函数是如何找到的呢?还记得在之前介绍协议初始化的时候提到过,协议的处理的函数是注册在数据结构中的,具体来说这里的 ptype_base
是一个哈希表, ip_rcv
函数地址就在这个函数中。这里在对应的位置顺着调用栈 __netif_receive_skb_core() --> deliver_skb()
可以看到最后有调用 pt_prev->func(skb, skb->dev, pt_prev, orig_dev);
就是调用的协议栈的处理函数。对于 IP 包就会被送完 ip_rcv
中处理。
ip_rcv
的核心实际上就是最后的 NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, skb, dev, NULL,ip_rcv_finish);
它在最后会以 netfilter hook 的方式调用 ip_rcv_finish()
方法。 这样任何 iptables 规则都能在 packet 刚进入 IP 层协议的时候被应用。
协议栈处理
L3 层协议处理 (IPv4)
根据前面的介绍根据 ptype_base
能够找到协议对应的处理函数,对于 IP 协议包就会被送往 ip_rcv
函数中处理。
ip_rcv
中会计算统计信息,进行校验,在最后会以 netfilter hook 的方式调用 ip_rcv_finish() 方法, iptables 规则就都能在 packet 刚进入 IP 层协议的时候被应用。在 netfilter 中完成对数据的处理之后, 如果数据没有被丢掉,就会调用 ip_rcv_finish
。
在 ip_rcv_finish
为了能将包送到合适的目的地,需要一个路由 子系统的 dst_entry 变量。路由子系统完成工作后,会更新计数器,然后调用 dst_input(skb)
,后者会进一步调用 dst_entry 变量中的 input 方法,这个方法是一个函数指针,由路由子系统初始化。例如 ,如果 packet 的最终目的地是本机,路由子系统会将 ip_local_deliver
赋 给 input。
ip_local_deliver
的处理逻辑和之前的类似,如果包没有被丢弃,那么会调用 ip_local_deliver_finish
函数。
在 ip_local_deliver_finish
中会获取更上一层的处理协议,在上面介绍协议栈的注册的时候提到过,tcp 和 udp 的协议处理函数就注册在 inet_protos
中。这样通过调用相应的函数就能够将 skb 包进一步派送到更上层的协议中。
L4 层协议处理 (TCP/UDP)
L3 层处理之后会将 skb 送往更上一层的 L4 层进行处理,此处将以为 udp
为例进行介绍, TCP
相关的内容更加负责。
在介绍协议层初始化的时候提到过,UDP 注册的协议处理函数是 udp_rcv
, 这个函数非常简单,只有一行,调用 __udp4_lib_rcv()
接收 UDP 报文, 其中指定了协议类型是 IPPROTO_UDP;这是因为 __udp4_lib_rcv()
封装了两种 UDP 协议的处理。
__udp4_lib_rcv
函数用于处理接收到的UDP数据包。它验证数据包的有效性,检查校验和,并将数据包传递给相应的套接字。如果没有找到匹配的套接字,则丢弃数据包或发送ICMP错误消息。
这里具体看一下如何送入 socket 队列。在 __udp4_lib_lookup_skb
函数中会根据源端口和目的端口找到对应的 socket,如果找到了就会调用 udp_queue_rcv_skb
函数将 skb 送入 socket 队列。
udp_queue_rcv_skb
函数用于将接收到的UDP数据包加入到相应的套接字接收队列中。它会进行一系列检查和处理,包括策略检查、封装处理、UDP-Lite特定检查、校验和验证、队列容量检查等。
顺着调用链 udp_queue_rcv_skb
-> __udp_queue_rcv_skb
-> sock_queue_rcv_skb
, 可以看到最后的 sock_queue_rcv_skb
函数将数据包加入到套接字的接收队列中。最后,所有在这个 socket 上等待数据的进程都收到一个通知通过 sk_data_ready
通知处理函数。
到此,一个数据包就完成了从到达网卡,依次穿过协议栈,到达 socket 的过程。
小结
本文分析了 Linux 网络接收数据的大致流程。当用户执行网 recvfrom
系统调用之后,Linux 就陷入内核态工作了。如果此时接收队列没有数据,进程就会进入睡眠态,被挂起。
Linux 网络在接收数据之前需要做很多的准备工作,包括:
创建
ksoftirqd
线程,设置自己的线程函数,后面需要其处理软中断;注册协议栈,Linux 要实现很多的协议,如 IP,ARP,ICMP,TCP,UDP 等,每种协议都需要注册自己的协议处理函数;
网卡驱动初始化,每个驱动都有自己的初始化函数,内核调用驱动初始化,在整个过程中会准备好 DMA,把 NAPI 的
poll
函数地址告诉内核。启动网卡,分配 TX,RX 队列,注册中断对应的处理函数。
当数据到来时,处理步骤总结如下:
网卡将数据 DMA 到内存的 RingBuffer 中,然后向 CPU 发出中断通知;
CPU 相应中断,调用网卡驱动时注册的中断处理函数;
中断函数中发出对应的网络软中断;
内核发现软中断请求到来,先关闭硬中断;
ksoftirqd
线程调用驱动的poll
函数接收数据包;poll
函数将数据包送到协议栈注册的ip_rcv
函数中;ip_rcv
将数据包送到上层协议注册的udp_rcv
中(如果是 tcp 协议送往tcp_v4_rcv
)。