本文将介绍在 Linux 系统中,数据包是如何一步一步从网卡传到进程手中的。本文只讨论以太网的物理网卡,不涉及虚拟设备,并且以一个 UDP 包的接收过程作为示例。

1
2
3
4
5
6
7
8
9
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 接收数据的逻辑。只要客户端有对应的数据发送过来,服务器端执行 recvfrom 后就能收到它,并把它打印出来。那么,当网络包到达网卡,直到 recvfrom 收到数据,这中间究竟都发生过什么

Linux 网络架构

在 Linux 内核实现中,链路层协议靠网卡驱动来实现,内核协议栈来实现网络层和传输层,内核对更上层的应用层提供 socket 接口来供用户进程访问。我们用 Linux 的视角看到的 TCP/IP 网络分层模型应该是下面这个样子的。

Linux 视角的网络协议栈

在 Linux 的源代码中,网络设备驱动对应的逻辑位于 driver/net/ethernet, 其中 intel 系列网卡的驱动在driver/net/ethernet/intel 目录下。协议栈模块代码位于 kernel 和 net 目录。

内核的软硬中断

内核和网络设备驱动是通过中断的方式来处理的。当网络设备上有数据到达的时候,会给 CPU 的相关引脚上触发一个电压变化,以通知 CPU 来处理数据。

对于网络模块来说,由于处理过程比较复杂和耗时,如果在硬中断函数中完成所有的处理,将会导致中断处理函数(其优先级过高)将过度占据 CPU,将导致 CPU 无法响应其它设备,例如鼠标和键盘的消息。因此,Linux 中断处理函数是分上半部和下半部的

上半部是只进行最简单的工作,快速处理然后释放 CPU,接着 CPU 就可以允许其它中断进来。剩下的绝大部分工作都被放到下半部中,可以慢慢地从容处理。

2.4 以后的内核版本采用的下半部实现方式是软中断,由 ksoftirqd 内核线程全权处理。和硬中断不同的是,硬中断是通过给 CPU 物理引脚施加电压变化,而软中断是通过给内存中的一个变量的二进制值以通知软中断处理程序。

网卡到内存

网卡需要有驱动才能工作,驱动是加载到内核中的一个模块——负责衔接网卡和内核的网络模块。驱动在加载的时候 将自己注册进 网络模块,当相应的网卡收到数据包时,网络模块 会调用 相应的驱动程序处理数据。

下图展示了数据包(packet)如何进入内存,并被内核的网络模块开始处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
                   +-----+
| | Memroy
+--------+ 1 | | 2 DMA +--------+--------+--------+--------+
| Packet |-------->| NIC |------------>| Packet | Packet | Packet | ...... |
+--------+ | | +--------+--------+--------+--------+
| |<--------+
+-----+ |
| +---------------+
| |
3 | Raise IRQ | Disable IRQ
| 5 |
| |
↓ |
+-----+ +------------+
| | Run IRQ handler | |
| CPU |------------------>| NIC Driver |
| | 4 | |
+-----+ +------------+
|
6 | Raise soft IRQ
|

1:数据包从外面的网络进入物理网卡(NIC)。如果目的 MAC 地址不是该网卡,且该网卡没有开启混杂模式,该包会被网卡丢弃。

2:网卡将数据包通过 DMA 的方式写入到指定的内存地址,该地址由网卡驱动分配并初始化。注:老的网卡可能不支持 DMA,不过新的网卡一般都支持。

3:网卡通过硬件中断(IRQ)通知 CPU,告诉它有数据来了。

4:CPU 根据中断表,调用已经注册的中断函数,这个中断函数会调到驱动程序(NIC Driver)中相应的函数。

5:驱动先禁用网卡的中断,表示驱动程序已经知道内存中有数据了,告诉网卡下次再收到数据包时,直接写内存就可以了,不要再通知 CPU 了。这样可以提高效率,避免 CPU 不停的被中断。

6:启动软中断。这步结束后,硬件中断处理函数就结束返回了。

由于硬中断处理程序执行的过程中不能被中断,所以如果它执行时间过长,会导致 CPU 没法响应其它硬件的中断,于是内核引入软中断,这样可以将硬中断处理函数中耗时的部分移到软中断处理函数里面来慢慢处理

内核的网络模块

软中断会触发内核网络模块中的软中断处理函数,后续流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
                                            +-----+
17 | |
+----------->| NIC |
| | |
|Enable IRQ +-----+
|
|
+------------+ Memroy
| | Read +--------+--------+--------+--------+
+--------------->| NIC Driver |<--------------------- | Packet | Packet | Packet | ...... |
| | | 9 +--------+--------+--------+--------+
| +------------+
| | | skb
Poll | 8 Raise softIRQ | 6 +-----------------+
| | 10 |
| ↓ ↓
+---------------+ Call +-----------+ +------------------+ +--------------------+ 12 +---------------------+
| net_rx_action |<-------| ksoftirqd | | napi_gro_receive |------->| enqueue_to_backlog |----->| CPU input_pkt_queue |
+---------------+ 7 +-----------+ +------------------+ 11 +--------------------+ +---------------------+
| | 13
14 | + - - - - - - - - - - - - - - - - - - - - - - +
↓ ↓
+--------------------------+ 15 +------------------------+
| __netif_receive_skb_core |----------->| packet taps(AF_PACKET) |
+--------------------------+ +------------------------+
|
| 16

+-----------------+
| protocol layers |
+-----------------+

7:内核中的 ksoftirqd 进程专门负责软中断的处理,当它收到软中断后,就会调用相应软中断所对应的处理函数,对于上面第 6 步中是网卡驱动模块抛出的软中断,ksoftirqd 会调用网络模块的 net_rx_action 函数。

8:net_rx_action 函数调用网卡驱动里的 poll 函数来一个一个的处理数据包,如 Intel 的 IGB 网卡的 igb_poll 函数。

9:在 pool 函数中,驱动会一个接一个的读取网卡写到内存中的数据包,内存中数据包的格式只有驱动知道。

10:驱动程序将内存中的数据包转换成内核网络模块能识别的 skb 格式,然后调用 napi_gro_receive 函数,将数据包交给内核。

11:napi_gro_receive 会处理 GRO 相关的内容,也就是将可以合并的数据包进行合并,这样就只需要调用一次协议栈。然后判断是否开启了 RPS,如果开启了,将会调用 enqueue_to_backlog。

12:在 enqueue_to_backlog 函数中,会将数据包放入 CPU 的 softnet_data 结构体的 input_pkt_queue 中,然后返回。如果 input_pkt_queue 满了的话,该数据包将会被丢弃,queue 的大小可以通过 net.core.netdev_max_backlog 来配置。

13:CPU 会接着在自己的软中断上下文中处理自己 input_pkt_queue 里的网络数据(调用__netif_receive_skb_core)。

14:如果没开启 RPS,napi_gro_receive 会直接调用__netif_receive_skb_core。

15:看是不是有 AF_PACKET 类型的 socket(也就是我们常说的原始套接字),如果有的话,拷贝一份数据给它。tcpdump 抓包就是抓的这里的包。

16:调用协议栈相应的函数,将数据包交给协议栈处理。

17:待内存中的所有数据包被处理完成后(即 poll 函数执行完成),启用网卡的硬中断,这样下次网卡再收到数据的时候就会通知 CPU。

enqueue_to_backlog 函数也会被 netif_rx 函数调用,而 netif_rx 正是 lo 设备发送数据包时调用的函数。

协议栈

IP 层

由于是 UDP 包,所以第一步会进入 IP 层,然后一级一级的函数往下调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
          |
| 16
↓ promiscuous mode &&
+--------+ PACKET_OTHERHOST (set by driver) +-----------------+
| ip_rcv |-------------------------------------->| drop this packet|
+--------+ +-----------------+
|
|

+---------------------+
| NF_INET_PRE_ROUTING |
+---------------------+
|
|

+---------+
| | enabled ip forword +------------+ +----------------+
| routing |-------------------->| ip_forward |------->| NF_INET_FORWARD|
| | DIP is not local +------------+ +----------------+
+---------+ |
| |
| destination IP is local ↓
↓ +---------------+
+------------------+ | dst_output_sk |
| ip_local_deliver | +---------------+
+------------------+
|
|

+------------------+
| NF_INET_LOCAL_IN |
+------------------+
|
|

+-----------+
| UDP layer |
+-----------+
  • ip_rcv:该函数是 IP 模块的入口函数,在该函数里面,第一件事就是将垃圾数据包(目的 MAC 地址不是当前网卡,但由于网卡设置了混杂模式而被接收进来)直接丢掉,然后调用注册在 NF_INET_PRE_ROUTING 上的函数。
  • NF_INET_PRE_ROUTING:netfilter 放在协议栈中的钩子,可以通过 iptables 来注入一些数据包处理函数,用来修改或者丢弃数据包,如果数据包没被丢弃,将继续往下走。
  • routing:进行路由,如果是目的 IP 不是本地 IP,且没有开启 ip forward 功能,那么数据包将被丢弃,如果开启了 ip forward 功能,那将进入 ip_forward 函数。
  • ip_forward:ip_forward 会先调用 netfilter 注册的 NF_INET_FORWARD 相关函数,如果数据包没有被丢弃,那么将继续往后调用 dst_output_sk 函数。
  • dst_output_sk:该函数会调用 IP 层的相应函数将该数据包发送出去,同下一篇要介绍的数据包发送流程的后半部分一样。
  • ip_local_deliver:如果上面 routing 的时候发现目的 IP 是本地 IP,那么将会调用该函数。在该函数中,会先调用 NF_INET_LOCAL_IN 相关的钩子程序,如果通过,数据包将会向下发送到 UDP 层。

UDP 层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
         |
|

+---------+ +-----------------------+
| udp_rcv |----------->| __udp4_lib_lookup_skb |
+---------+ +-----------------------+
|
|

+--------------------+ +-----------+
| sock_queue_rcv_skb |----->| sk_filter |
+--------------------+ +-----------+
|
|

+------------------+
| __skb_queue_tail |
+------------------+
|
|

+---------------+
| sk_data_ready |
+---------------+
  • udp_rcv:该函数是 UDP 模块的入口函数,它里面会调用其它的函数,主要是做一些必要的检查,其中一个重要的调用是__udp4_lib_lookup_skb,该函数会根据目的 IP 和端口找对应的 socket,如果没有找到相应的 socket,那么该数据包将会被丢弃,否则继续。
  • sock_queue_rcv_skb:主要干了两件事,一是检查这个 socket 的 receive buffer 是不是满了,如果满了的话,丢弃该数据包,然后就是调用 sk_filter 看这个包是否是满足条件的包,如果当前 socket 上设置了 filter,且该包不满足条件的话,这个数据包也将被丢弃(在 Linux 里面,每个 socket 上都可以像 tcpdump 里面一样定义 filter,不满足条件的数据包将会被丢弃)。
  • __skb_queue_tail:将数据包放入 socket 接收队列的末尾。
  • sk_data_ready:通知 socket 数据包已经准备好。

调用完 sk_data_ready 之后,一个数据包处理完成,等待应用层程序来读取,上面所有函数的执行过程都在软中断的上下文中。

socket

应用层一般有两种方式接收数据,一种是 recvfrom 函数阻塞在那里等着数据来,这种情况下当 socket 收到通知后,recvfrom 就会被唤醒,然后读取接收队列的数据;另一种是通过 epoll 或者 select 监听相应的 socket,当收到通知后,再调用 recvfrom 函数去读取接收队列的数据。两种情况都能正常的接收到相应的数据包。

了解数据包的接收流程有助于帮助我们搞清楚我们可以在哪些地方监控和修改数据包,哪些情况下数据包可能被丢弃,为我们处理网络问题提供了一些参考。同时了解 netfilter 中相应钩子的位置,对于了解 iptables 的用法有一定的帮助,同时也会帮助我们后续更好的理解 Linux 下的网络虚拟设备。

Linux 启动

内核协议栈等模块在具备接收网卡数据包之前,要做很多的准备工作才行。比如,要提前创建好 ksoftirqd 内核线程,要注册好各个协议对应的处理函数,网络设备子系统要提前初始化好,网卡要启动好。只有这些都 Ready 之后,我们才能真正开始接收数据包。那么,我们现在来看看这些准备工作都是怎么做的。

下面主要结合 Linux 3.10 内核代码,对上面的流程概述进行更详细的分析。

ksoftirqd 线程创建

Linux 的软中断都是在专门的内核线程(ksoftirqd)中进行的。因此,我们非常有必要看一下这些线程是怎么初始化的,这样我们才能在后面更准确地了解收包过程。该线程数量不是 1 个,而是 N 个,其中 N 等于你的机器的核数。

定义用于管理 SoftIRQ 处理的线程化机制的全局变量:

1
2
3
4
5
6
7
8
9
// file: kernel/softirq.c
static struct smp_hotplug_thread softirq_threads = {
.store = &ksoftirqd, // 指向每个 CPU 的 ksoftirqd 线程的地址
.thread_should_run = ksoftirqd_should_run, // 线程是否需要被唤醒执行的回调函数
.thread_fn = run_ksoftirqd, // 实际执行软中断处理的回调函数
.thread_comm = "ksoftirqd/%u", // 线程的名称,%u 用于表示对应的 CPU 编号
};

DEFINE_PER_CPU(struct task_struct *, ksoftirqd); // 定义每个 CPU 都有的线程任务

定义一个内核初始化函数,用于启动每个 CPU 上的 ksoftirqd 线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// file: kernel/softirq.c
static __init int spawn_ksoftirqd(void) {
// 注册 SoftIRQ 线程。如果注册失败,触发 BUG 并停止系统
BUG_ON(smpboot_register_percpu_thread(&softirq_threads));
return 0;
}

// 系统启动的早期阶段调用这个函数,确保 SoftIRQ 线程在系统初始化时被注册
early_initcall(spawn_ksoftirqd);


// file: kernel/smpboot.c
/**
* smpboot_register_percpu_thread - Register a per_cpu thread related to hotplug
* @plug_thread: Hotplug thread descriptor
*
* Creates and starts the threads on all online cpus.
*/
int smpboot_register_percpu_thread(struct smp_hotplug_thread* plug_thread) {
unsigned int cpu;
int ret = 0;

mutex_lock(&smpboot_threads_lock);
for_each_online_cpu(cpu) {
ret = __smpboot_create_thread(plug_thread, cpu); // 为当前 CPU 创建线程
if (ret) {
smpboot_destroy_threads(plug_thread); // 销毁所有已创建的线程
goto out;
}
// 唤醒新创建的线程,使其开始处理 SoftIRQ
smpboot_unpark_thread(plug_thread, cpu);
}
// 将当前线程 fd 添加到热插拔线程的全局链表中,以便后续管理
list_add(&plug_thread->list, &hotplug_threads);
out:
mutex_unlock(&smpboot_threads_lock);
return ret;
}
EXPORT_SYMBOL_GPL(smpboot_register_percpu_thread);

当 ksoftirqd 被创建出来以后,它就会进入自己的线程循环函数 ksoftirqd_should_run 和 run_ksoftirqd 了。不停地判断有没有软中断需要被处理。这里需要注意的一点是,软中断不仅仅只有网络软中断,还有其它类型。

网络子系统初始化

Linux 内核通过调用 subsys_initcall 来初始化各个子系统,其中网络子系统的初始化会执行到 net_dev_init 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// file: net/core/dev.c
static int __init net_dev_init(void){
// ......
for_each_possible_cpu(i) {
// 获取每个 CPU 的 softnet_data 结构
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 (struct list_head) 是 等待驱动程序将其 poll 函数注册进来的,稍后网卡驱动初始化的时候我们可以看到这一过程。

另外,open_softirq 函数为每一种软中断都注册一个处理函数。NET_TX_SOFTIRQ 的处理函数为 net_tx_action,NET_RX_SOFTIRQ 的处理函数为 net_rx_action。

1
2
3
4
5
6
7
8
9
10
11
12
// file: kernel/softirq.c
void open_softirq(int nr, void (*action)(struct softirq_action*)) {
softirq_vec[nr].action = action;
}

// 管理 SoftIRQ 的数组,它存储了每种软中断类型对应的处理函数
static struct softirq_action softirq_vec[NR_SOFTIRQS];

// file: include/linux/interrupt.h
struct softirq_action {
void (*action)(struct softirq_action*);
};

open_softirq 会把不同的 action 记录在 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 全局变量中。

协议栈注册

相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// file: net/ipv4/af_inet.c
static struct packet_type ip_packet_type __read_mostly = {
.type = cpu_to_be16(ETH_P_IP), // 指定包类型为 IPv4 包,Big-Endian 16-bits
.func = ip_rcv, // 接收到 IP 包时的处理函数
};

static const struct net_protocol udp_protocol = {
.handler = udp_rcv, // 接收到 UDP 包时的处理函数
.err_handler = udp_err, // 发生错误时的处理函数
.no_policy = 1, // 不使用安全策略
.netns_ok = 1, // 支持网络命名空间(Network Namespaces)
};

static const struct net_protocol tcp_protocol = {
.early_demux = tcp_v4_early_demux, // 在早期阶段做 TCP 的多路分解
.handler = tcp_v4_rcv,
.err_handler = tcp_v4_err,
.no_policy = 1,
.netns_ok = 1,
};

// 初始化 IPv4 协议栈的函数,__init 宏表示该函数在内核启动时调用
static int __init inet_init(void) {
// ......
// 将 ICMP(1), UDP(17), TCP(6) 协议处理程序注册到 IPv4 协议栈
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__);
// ......

// 注册 IP 数据包处理函数
dev_add_pack(&ip_packet_type);
}

上面的代码中我们可以看到,全局变量 udp_protocol 中的.handler 是 udp_rcv,tcp_protocol 中的.handler 是 tcp_v4_rcv,都通过 inet_add_protocol 被初始化了进来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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;
}

// 使用 cmpxchg(compare-and-swap) 原子操作尝试将 inet_protos[protocol] 赋值为 prot,
// 如果 inet_protos[protocol] 之前是 NULL,则设置为 prot 并返回 0
// 如果已经有协议注册在该位置,则返回 -1,表示注册失败
return !cmpxchg((const struct net_protocol **)&inet_protos[protocol], NULL, prot) ? 0 : -1;
}

const struct net_protocol *inet_protos[MAX_INET_PROTOS];

inet_add_protocol 函数将 tcp 和 udp 对应的处理函数都注册到了 inet_protos 全局数组 中了。

再看 dev_add_pack(&ip_packet_type)这一行,全局变量 ip_packet_type 中的.type 是协议类型,.func 是 ip_rcv 函数,在 dev_add_pack 中会被注册到 ptype_base 全局哈希表 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void dev_add_pack(struct packet_type *pt) {
struct list_head *head = ptype_head(pt);

spin_lock(&ptype_lock);
list_add_rcu(&pt->list, head);
spin_unlock(&ptype_lock);
}

static inline struct list_head *ptype_head(const struct packet_type *pt) {
if (pt->type == htons(ETH_P_ALL)) {
return &ptype_all;
} else {
// PTYPE_HASH_MASK 用来计算哈希值(哈希桶)
return &ptype_base[ntohs(pt->type) & PTYPE_HASH_MASK];
}
}


// 基于特定数据包的协议类型(如 IPv4、IPv6、ARP 等)
struct list_head ptype_base[PTYPE_HASH_SIZE] __read_mostly;
// 不论协议类型是什么,用于处理所有接收到的数据包
struct list_head ptype_all __read_mostly;

这里我们需要记住 inet_protos 数组记录着 udp,tcp 的处理函数地址,ptype_base 数组存储着 ip_rcv 函数的处理地址。后面我们会看到软中断中会通过 ptype_base 找到 ip_rcv 函数地址,进而将 ip 包正确地送到 ip_rcv() 中执行。在 ip_rcv 中将会通过 inet_protos 数组找到 tcp 或者 udp 的处理函数,再而把包转发给 udp_rcv() 或 tcp_v4_rcv() 函数。

扩展一下,如果看一下 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// file: drivers/net/ethernet/intel/igb/igb_main.c
static struct pci_driver igb_driver = {
.name = igb_driver_name, // "igb"
.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;
}

module_init(igb_init_module);

驱动的 pci_register_driver 调用完成后,Linux 内核就知道了该驱动的相关信息,比如 igb 网卡驱动的 igb_driver_name 和 igb_probe 函数地址等等。当网卡设备被识别以后,内核会调用其驱动的.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 等函数,该函数在网卡被启动的时候会被调用。

1
2
3
4
5
6
7
8
9
10
11
12
// 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 初始化过程中,还调用到了 igb_alloc_q_vector。它注册了一个 NAPI 机制所必须的 poll 函数,对于 igb 网卡驱动来说,这个函数就是 igb_poll,如下代码所示。

1
2
3
4
5
6
7
8
9
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);
// ......
}

NAPI(New API)机制:Linux 内核中的一种网络中断处理机制——在网卡收到数据包后,触发一次中断,然后通过 poll 机制处理接收和发送队列中的数据。处理完成后会根据需要重新启用中断。目的:通过将高频中断转换为轮询,可以在网络负载较高时提高系统的处理效率,减少由于中断频率过高导致的 CPU 开销。

启动网卡

当上面的初始化都完成以后,就可以启动网卡了。回忆前面网卡驱动初始化时,我们提到了驱动向内核注册了 igb_netdev_ops 变量,它包含着网卡启用、发包、设置 MAC 地址等回调函数。当启用一个网卡时(例如,通过 ifconfig eth0 up),net_device_ops 中的 igb_open 方法会被调用。它通常会做以下事情:

启动网卡
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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);

/* 启用 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;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static int igb_request_irq(struct igb_adapter* adapter) {
if (adapter->msix_entries) {
err = igb_request_msix(adapter);
// ......
}
// ......
}
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, q_vector);
}
}

在上面的代码中跟踪函数调用,__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 的绑定行为)。

当做好以上准备工作以后,就可以开门迎客(数据包)了!

中断与协议栈处理

硬中断处理

首先,当数据帧从网线到达网卡时,第一站是网卡的接收队列。网卡在分配给自己的 RingBuffer 中寻找可用的内存位置,找到后 DMA 会把数据拷贝到网卡之前关联的内存里,这个时候 CPU 都是无感的。当 DMA 操作完成以后,网卡会向 CPU 发起一个硬中断,通知 CPU 有数据到达。

网卡数据硬中断处理过程

注意:当 RingBuffer 满的时候,新来的数据包将给丢弃。ifconfig 查看网卡的时候,可以里面有个 overruns,表示因为环形队列满被丢弃的包。如果发现有丢包,可能需要通过 ethtool 命令来加大环形队列的长度。

在《启动网卡》一节,我们说到了网卡的硬中断注册的处理函数是 igb_msix_ring。

1
2
3
4
5
6
7
8
9
10
// 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 只是记录一下硬件中断频率(据说目的是在减少对 CPU 的中断频率时用到)。顺着 napi_schedule 调用一路跟踪下去,__napi_schedule => ____napi_schedule

1
2
3
4
5
/* 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,将驱动 napi_struct 传过来的 poll_list 添加了进来 。其中 softnet_data 中的 poll_list 是一个双向列表,其中的设备都带有输入帧等着被处理。紧接着__raise_softirq_irqoff 触发了一个软中断 NET_RX_SOFTIRQ, 这个所谓的触发过程只是对一个变量进行了一次或运算而已

1
2
3
4
5
6
7
8
9
10
11
// 在禁用中断的情况下触发软中断(softirq)
void __raise_softirq_irqoff(unsigned int nr) {
trace_softirq_raise(nr); // 记录软中断触发事件
or_softirq_pending(1UL << nr); // 设置相应的软中断挂起标志,表明该软中断需要稍后处理
}

// file: include/linux/irq_cpustat.h
#define or_softirq_pending(x) (local_softirq_pending() |= (x)) // 添加新的中断类型:与当前 CPU 上的软中断挂起标志按位“或”

#define local_softirq_pending() \ __IRQ_STAT(smp_processor_id(), __softirq_pending)
#define __IRQ_STAT(cpu, member) (irq_stat[cpu].member)

我们说过,Linux 在硬中断里只完成简单必要的工作,剩下的大部分的处理都是转交给软中断的。通过上面代码可以看到,硬中断处理过程真的是非常短。只是记录了一个寄存器,修改了一下 CPU 的 poll_list,然后发出个软中断。就这么简单,硬中断工作就算是完成了。

ksoftirqd 线程处理

ksoftirqd 内核线程处理软中断

内核线程初始化的时候,我们介绍了 ksoftirqd 线程中的两个函数 ksoftirqd_should_run 和 run_ksoftirqd。其中 ksoftirqd_should_run 代码如下:

1
2
3
static int ksoftirqd_should_run(unsigned int cpu) {
return local_softirq_pending();
}

这里看到和硬中断中调用了同一个函数 local_softirq_pending。使用方式不同的是硬中断位置是为了写入标记,这里仅仅只是读取。如果硬中断中设置了 NET_RX_SOFTIRQ,这里自然能读取的到。接下来会真正进入线程函数 run_ksoftirqd 中处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
static void run_ksoftirqd(unsigned int cpu) {
local_irq_disable(); // 禁用本地中断,防止处理中断时被其他中断打断

// 检查当前 CPU 上是否有挂起的软中断
if (local_softirq_pending()) {
__do_softirq(); // 遍历处理挂起的所有软中断,调用相应的处理函数来处理事件
// ......
local_irq_enable(); // 重新启用本地中断
cond_resched(); // 判断是否需要主动让出 CPU 给其他任务,防止占用过多时间
return;
}
local_irq_enable(); // 如果没有软中断挂起,直接启用本地中断
}

在__do_softirq 函数中,遍历当前 CPU 挂起的软中断类型,调用其注册的 action 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
asmlinkage void __do_softirq(void) {
pending = local_softirq_pending(); // 获取当前 CPU 挂起的软中断 bitmap
h = softirq_vec; // 指向 struct softirq_action softirq_vec[NR_SOFTIRQS]数组首地址

do {
if (pending & 1) { // 检查最低位是否有挂起的软中断
unsigned int vec_nr = h - softirq_vec; // 计算当前处理的软中断编号(指针运算)
// ......
trace_softirq_entry(vec_nr); // 跟踪进入软中断处理(调试使用)
h->action(h); // 执行软中断处理函数(核心)
trace_softirq_exit(vec_nr); // 跟踪退出软中断处理(调试使用)
}
h++; // 移动到下一个软中断处理程序
pending >>= 1; // 将挂起的软中断 bitmap 右移,检查下一个软中断
} while (pending); // 当有挂起的软中断时,继续处理
}

在《网络子系统初始化》小节, 我们看到我们为 NET_RX_SOFTIRQ 注册了处理函数 net_rx_action。所以 net_rx_action 函数就会被执行到了。

这里需要注意一个细节,硬中断中设置软中断标记,和 ksoftirq 的判断是否有软中断到达,都是基于 smp_processor_id() 的。这意味着只要硬中断在哪个 CPU 上被响应,那么软中断也是在这个 CPU 上被处理。所以说,如果你发现你的 Linux 软中断 CPU 消耗都集中在一个核上的话,做法是要把调整硬中断的 CPU 亲和性,来将硬中断打散到不同的 CPU 核上去。

我们再来把精力集中到这个核心函数 net_rx_action 上来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/* 
* 用于处理网络接收(NET_RX_SOFTIRQ)的软中断处理函数
* 通过遍历每个网络设备的 NAPI 对象,调用对应的 poll 函数来处理网卡接收的网络数据包
* 它有一定的时间和工作量限制(通过 time_limit 和 budget),以防止一次软中断
* 处理时间过长或处理过多网络数据包,确保系统的响应性
*/
static void net_rx_action(struct softirq_action* h) {
struct softnet_data* sd = &__get_cpu_var(softnet_data); // 获取当前 CPU 的网络相关软中断数据
unsigned long time_limit = jiffies + 2; // 设置时间限制,最多处理到两个 jiffies 后
int budget = netdev_budget; // 获取当前可处理的网络包数量的“预算”

local_irq_disable(); // 禁用本地中断,确保处理过程中不会被打断

// 当 poll_list 中还有网络设备的 NAPI(napi_struct) 对象时,继续处理
while (!list_empty(&sd->poll_list)) {
struct napi_struct* n;
// ......

// 获取 poll_list 中的第一个 NAPI 对象
n = list_first_entry(&sd->poll_list, struct napi_struct, poll_list);
weight = n->weight; // 一次最多处理多少个包

work = 0; // 记录在本次循环中处理的网络数据包数量

// 检查 NAPI 对象是否处于调度状态(是否被安排处理网络数据包)
if (test_bit(NAPI_STATE_SCHED, &n->state)) {
work = n->poll(n, weight); // 调用 NAPI poll 函数处理网络包(核心)
trace_napi_poll(n);
}

budget -= work; // 减少剩余预算
}
}

函数开头的 time_limit 和 budget 是用来控制 net_rx_action 函数主动退出的,目的是保证网络包的接收不霸占 CPU 不放。等下次网卡再有硬中断过来的时候再处理剩下的接收数据包。其中 budget 可以通过内核参数调整。这个函数中剩下的核心逻辑是获取到当前 CPU 变量 softnet_data,对其 poll_list 进行遍历, 然后执行到网卡驱动注册到的 poll 函数。对于 igb 网卡来说,就是 igb 驱动的 igb_poll 函数了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static int igb_poll(struct napi_struct* napi, int budget) {
// 从 napi 结构体中获取 igb_q_vector 结构体指针,包含了网络数据发送和接收的队列
struct napi_struct* q_vector = container_of(napi, struct igb_q_vector, napi);
bool clean_complete = true;
// ......
if (q_vector->tx.ring)
clean_complete = igb_clean_tx_irq(q_vector);

if (q_vector->rx.ring)
// 清理接收队列中的中断、网络数据包(给定 budget 下)
clean_complete &= igb_clean_rx_irq(q_vector, budget);
if (!clean_complete)
return budget
// ......
return 0;
}

在读取操作中,igb_poll 的重点工作是对 igb_clean_rx_irq 的调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*
* 在网卡接收到网络数据包后,负责从接收队列中提取这些数据包,
* 处理包头信息,进行校验,并将其交给上层网络栈。
*/
static bool igb_clean_rx_irq(struct igb_q_vector* q_vector, const int budget) {
struct igb_ring* rx_ring = q_vector->rx.ring; // 获取接收队列(接收环形缓冲区)
struct sk_buff* skb = rx_ring->skb; // 获取当前用于存储数据包的缓冲区
unsigned int total_packets = 0; // 统计本次处理的数据包数量
// ......
do {
/* retrieve a skb buffer from the ring */
skb = igb_fetch_rx_buffer(rx_ring, rx_desc, skb);
if (!skb)
break;

/* fetch next skb buffer in frame if non-eop (end of packet) */
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; // 如果包头信息有问题,释放 skb,跳过处理
continue;
}

/* populate checksum, timestamp, VLAN, and protocol */
igb_process_skb_fields(rx_ring, rx_desc, skb);

// 将处理好的 skb 交给上层协议栈进行进一步处理
napi_gro_receive(&q_vector->napi, skb);

skb = NULL;
total_packets++;
} while (likely(total_packets < budget));
// ......
}

igb_fetch_rx_buffer 和 igb_is_non_eop 的作用就是把数据帧从 RingBuffer 上取下来。为什么需要两个函数呢?因为有可能一个帧要占多个 RingBuffer,所以是在一个循环中获取的,直到帧尾部。获取下来的一个数据帧用一个 sk_buff 来表示。获取完数据以后,对其进行一些校验,然后开始设置 skb 变量的 timestamp, VLAN id, protocol 等字段。接下来进入到 napi_gro_receive 中:

1
2
3
4
5
6
7
// file: net/core/dev.c
gro_result_t napi_gro_receive(struct napi_struct* napi, struct sk_buff* skb) {
skb_gro_reset_offset(skb); // 重置数据包的 GRO 偏移信息,准备进行 GRO 处理

// 调用 dev_gro_receive 尝试合并数据包,并根据其结果决定是否将数据包传递到网络协议栈进行处理
return napi_skb_finish(dev_gro_receive(napi, skb), skb);
}

dev_gro_receive 这个函数代表的是网卡 GRO 特性,可以简单理解成能够将多个小的 TCP 包合并为一个大的包进行处理,目的是减少传送给网络栈的包数,这有助于减少 CPU 的使用量。我们暂且忽略,直接看 napi_skb_finish, 这个函数主要就是调用了 netif_receive_skb。

1
2
3
4
5
6
7
8
9
10
11
// 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; // 如果数据包处理失败,标记为 GRO_DROP
break;
// ......
}
return ret;
}

netif_receive_skb 是 Linux 内核中接收数据包的通用接口,它会将数据包传递到相应的网络协议栈进行处理,例如 TCP/IP 协议栈。

声明,后面的小节也都属于软中断的处理过程,只不过由于篇幅太长,单独拿出来成小节。

网络协议栈处理

netif_receive_skb 函数会根据包的协议,假如是 udp 包,会将包依次送到 ip_rcv(), udp_rcv()协议处理函数中进行处理。

网络协议栈处理流程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/*
* Linux 内核网络栈中接收到数据包后,通过 netif_receive_skb 函数将数据包传递给协议处理层
* 它展示了接收的数据包在网络设备驱动层和上层网络协议栈之间的传递流程
*/

int netif_receive_skb(struct sk_buff* skb) {
// ......
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) {
struct net_device* orig_dev;

orig_dev = skb->dev;
// ......
// 遍历 ptype_all 列表中的所有协议类型
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; // 更新 pt_prev 为当前协议类型
}
}
// ......
type = skb->protocol;
// 在 ptype_base 哈希表中查找与数据包协议类型匹配的处理器,处理与 skb 类型匹配的协议
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; // 更新 pt_prev 为当前协议类型
}
}
}

在__netif_receive_skb_core 中,我看着原来经常使用的 tcpdump 的抓包点,很是激动,看来读一遍源代码时间真的没白浪费。接着__netif_receive_skb_core 取出 protocol,它会从数据包中取出协议信息,然后遍历注册在这个协议上的回调函数列表。ptype_base 是一个 hash table,在《协议注册》小节我们提到过。ip_rcv 函数地址就是存在这个 hash table 中的。

1
2
3
4
5
6
7
// 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)。

IP 协议层处理

我们再来大致看一下 Linux 在 ip 协议层都做了什么,包又是怎么样进一步被送到 udp 或 tcp 协议处理函数中的。

1
2
3
4
5
6
7
// 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);
}

#define NF_HOOK(pf, hook, skb, indev, outdev, okfn) (okfn)(skb) /* #ifndef CONFIG_NETFILTER */

这里 NF_HOOK 是一个钩子函数,当执行完注册的钩子后就会执行到最后一个参数指向的函数 ip_rcv_finish(skb)。

1
2
3
4
5
6
7
8
9
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, 如下:

1
2
3
4
5
6
7
8
9
// 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)。

1
2
3
4
/* 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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// file: net/ipv4/ip_input.c
int ip_local_deliver(struct sk_buff* skb) {
/* 检查 IP 分片重组,如果是,调用 ip_defrag 进行分片重组 */
if (ip_is_fragment(ip_hdr(skb))) {
if (ip_defrag(skb, IP_DEFRAG_LOCAL_DELIVER))
return 0; // 重组失败或数据包需要等待其他分片到达
}

// 在本地 LOCAL_IN 输入链中处理 IPv4 数据包
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; // 从 IP 头部获取协议号
const struct net_protocol* ipprot;

// 使用 RCU 机制安全地获取 inet_protos 表中的协议处理器
ipprot = rcu_dereference(inet_protos[protocol]);
if (ipprot != NULL) {
ret = ipprot->handler(skb);
}
}

如《协议栈注册》小节看到 inet_protos 中保存着 tcp_rcv 和 udp_rcv 的函数地址。这里将会根据包中的协议类型选择进行分发,在这里 skb 包将会进一步被派送到更上层的协议中——udp 和 tcp。

UDP 协议层处理

在《协议栈注册》小节的时候我们说过,udp 协议的处理函数是 udp_rcv。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 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) {
struct sock* sk;
// ......
// 根据数据包的源端口和目标端口在 UDP 协议表中查找相应的 socket
sk = __udp4_lib_lookup_skb(skb, uh->source, uh->dest, udptable);
if (sk != NULL) {
// 找到了,数据包 skb 排队到套接字的接收队列中,等待应用程序进一步处理
int ret = udp_queue_rcv_skb(sk, skb);
return 0;
}
// ......
// 没找到,发送 ICMP 目标不可达消息,告知发送方无法到达目标端口
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0);
// ......
return 0;
}

__udp4_lib_lookup_skb 是根据 skb 来寻找对应的 socket,当找到以后将数据包放到 socket 的缓存队列里。如果没有找到,则发送一个目标不可达的 icmp 包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 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; // 如果接收队列已满,则丢弃数据包

ipv4_pktinfo_prepare(skb); // 准备 IPv4 数据包信息
bh_lock_sock(sk);
// 检查套接字是否未被用户空间持有
if (!sock_owned_by_user(sk)) {
rc = __udp_queue_rcv_skb(sk, skb); // 将数据包排队到套接字的接收队列中
} else {
// 如果套接字被用户空间持有,将数据包添加到套接字的后备(backlog)队列中
if (sk_add_backlog(sk, skb, sk->sk_rcvbuf)) {
bh_unlock_sock(sk);
goto drop;
}
}
bh_unlock_sock(sk);
return rc;

drop:
kfree_skb(skb);
return rc;
}

sock_owned_by_user 判断的是用户是不是正在这个 socket 上进行系统调用(socket 被占用),如果没有,那就可以直接放到 socket 的接收队列中。如果有,那就通过 sk_add_backlog 把数据包添加到 backlog 队列。当用户释放 socket 的时候,内核会检查 backlog 队列,如果有数据再移动到接收队列中。

sk_rcvqueues_full 接收队列如果满了的话,将直接把包丢弃。接收队列大小受内核参数 net.core.rmem_max 和 net.core.rmem_default 影响。

recvfrom 系统调用

花开两朵,各表一枝。上面我们说完了整个 Linux 内核对数据包的接收和处理过程,最后把数据包放到 socket 的接收队列中了。那么我们再回头看用户进程调用 recvfrom 后是发生了什么。我们在代码里调用的 recvfrom 是一个 glibc 的库函数,该函数在执行后会将用户进程陷入到内核态,进入到 Linux 实现的系统调用 sys_recvfrom。在理解 Linux 对 sys_recvfrom 之前,我们先来简单看一下 socket 这个核心数据结构。这个数据结构太大了,我们只把对和我们今天主题相关的内容画出来,如下:

socket 内核数据结构

socket 数据结构中的 struct proto_ops 对应的是协议的方法集合。每个协议都会实现不同的方法集,对于 IPv4 协议族来说,每种协议都有对应的处理方法,如下。对于 udp 来说,是通过 inet_dgram_ops 来定义的,其中注册了 inet_recvmsg 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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 数据结构中的另一个数据结构 struct sock *sk 是一个非常大,非常重要的子结构体。其中的 sk_prot 又定义了二级处理函数。对于 UDP 协议来说,会被设置成 UDP 协议实现的方法集 udp_prot。

1
2
3
4
5
6
7
8
9
10
11
12
// 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_recvfrom 的实现过程。

recvfrom 函数内部实现过程

在 inet_recvmsg 调用了 sk->sk_prot->recvmsg。

1
2
3
4
5
6
7
8
9
10
// 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 协议的 socket 来说,这个 sk_prot 就是 net/ipv4/udp.c 下的 struct proto udp_prot。由此我们找到了 udp_recvmsg 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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));
}

终于我们找到了我们想要看的重点,在上面我们看到了所谓的读取过程,就是访问 sk->sk_receive_queue。如果没有数据,且用户也允许等待,则将调用 wait_for_more_packets()执行等待操作,它加入会让用户进程进入睡眠状态。

总结

网络模块是 Linux 内核中最复杂的模块了,看起来一个简简单单的收包过程就涉及到许多内核组件之间的交互,如网卡驱动、协议栈,内核 ksoftirqd 线程等。看起来很复杂,本文想通过图示的方式,尽量以容易理解的方式来将内核收包过程讲清楚。现在让我们再串一串整个收包过程。

当用户执行完 recvfrom 调用后,用户进程就通过系统调用进行到内核态工作了。如果接收队列没有数据,进程就进入睡眠状态被操作系统挂起。这块相对比较简单,剩下大部分的戏份都是由 Linux 内核其它模块来表演了。

首先在开始收包之前,Linux 要做许多的准备工作:

  1. 创建 ksoftirqd 线程,为它设置好它自己的线程函数,后面指望着它来处理软中断呢。
  2. 协议栈注册,Linux 要实现许多协议,比如 arp,icmp,ip,udp,tcp,每一个协议都会将自己的处理函数注册一下,方便包来了迅速找到对应的处理函数。
  3. 网卡驱动初始化,每个驱动都有一个初始化函数,内核会让驱动也初始化一下。在这个初始化过程中,把自己的 DMA 准备好,把 NAPI 的 poll 函数地址告诉内核。
  4. 启动网卡,分配 RX,TX 队列,注册中断对应的处理函数。

以上是内核准备收包之前的重要工作,当上面都 ready 之后,就可以打开硬中断,等待数据包的到来了。

当数据到来了以后,第一个迎接它的是网卡(我去,这不是废话么):

  1. 网卡将数据帧 DMA 到内存的 RingBuffer 中,然后向 CPU 发起中断通知。
  2. CPU 响应中断请求,调用网卡启动时注册的中断处理函数。
  3. 中断处理函数几乎没干啥,就发起了软中断请求。
  4. 内核线程 ksoftirqd 线程发现有软中断请求到来,先关闭硬中断。
  5. ksoftirqd 线程开始调用驱动的 poll 函数收包。
  6. poll 函数将收到的包送到协议栈注册的 ip_rcv 函数中。
  7. ip_rcv 函数再讲包送到 udp_rcv 函数中(对于 tcp 包就送到 tcp_rcv)。

现在我们可以回到开篇的问题了,我们在用户层看到的简单一行 recvfrom,Linux 内核要替我们做如此之多的工作,才能让我们顺利收到数据。这还是简简单单的 UDP,如果是 TCP,内核要做的工作更多,不由得感叹内核的开发者们真的是用心良苦。

理解了整个收包过程以后,我们就能明确知道 Linux 收一个包的 CPU 开销了。首先第一块是用户进程调用系统调用陷入内核态的开销。第二块是 CPU 响应包的硬中断的 CPU 开销。第三块是 ksoftirqd 内核线程的软中断上下文花费的。

另外网络收发中有很多末支细节咱们并没有展开了说,比如说 no NAPI, GRO,RPS 等。因为我觉得说的太对了反而会影响大家对整个流程的把握,所以尽量只保留主框架了,少即是多!

参考资料:

  1. https://segmentfault.com/a/1190000008836467
  2. https://cloud.tencent.com/developer/article/1966873
  3. https://cloud.tencent.com/developer/article/2378177