有些事,我能相通,也能接受,但是还是会难过好一阵子

image-20240828202324226

整体流程:

  • 系统初始化时,网卡驱动程序会向内核申请一块内存「ring buffer」,用于存储未来到达的网络数据包;
  • 网卡驱动程序将上一步申请的「ring buffer」地址告诉网卡;
  • 当数据包从网络上通过网线到达网卡后,网卡会通过DMA将数据拷贝到ring buffer中(这个过程不需要cpu参与);
  • 同时网卡会产生CPU硬中断,告诉CPU现在有数据来了,你必须最高优先级处理,否则数据待会存不下了;
  • CPU看到网卡产生的硬中断后,调用对应的网卡驱动硬中断处理程序;
  • 网卡驱动被调用后,首先禁用网卡的硬中断,然后启动对应的软中断函数;
  • 软中断函数开始从ring buffer中进行循环取包,并且封装为sk_buff,然后投递给网络协议栈进行处理;
  • 协议栈处理完成后数据就进入用户态的对应进程,进程就可以操作数据了。

中断

当硬件设备完成属于自己部分的操作后,需要CPU帮忙完成剩下的操作时,它需要有一个机制通知CPU。这个机制就叫中断。

中断本质上是一种特殊的电信号,由硬件设备发向CPU,CPU接收到中断后,会马上向操作系统反映此信号的到来,然后就由操作系统负责处理这些新到来的数据。

不同的硬件设备对应的中断不同,他们通过一个唯一的数字进行区分。因此,操作系统就可以区分中断是来自键盘还是硬盘,还是网卡。这样,操作系统才能给不同的中断提供对应的中断处理程序。

每种类型的中断都对应一个中断程序,当中断发生时,CPU就会找到对应的中断程序然后执行。中断在整个操作系统中拥有最高优先级,当一个中断到来后,CPU必须马上停止当前正在执行的程序,转而执行中断程序。

这里可以发现如果中断程序是一段耗时长的逻辑那么就会导致CPU无法释放,效率低下。为了解决这个问题,于是设计了软中断。那么硬件发出信号,CPU响应我们称为硬中断。

有了软中断后,CPU响应中断的逻辑变为了:硬件发出中断信号,CPU收到后调用对应的中断程序(中断程序必须逻辑简单,耗时短),然后中断程序对硬件进行复位或者禁用中断,然后调用软中断函数进行数据处理,而软中断对应的函数就可以让CPU按照自己的调度策略去执。

Linux设计为硬中断在哪个CPU上被响应,那么软中断也是在这个 CPU 上处理的。如果你发现你的 Linux 软中断 CPU 消耗都集中在一个核 上的话,做法是要把调整硬中断的CPU亲和性,来将硬中断打散到不同的 CPU 核上去。

Linux网卡注册中断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static int __igb_open(struct net_device *netdev, bool resuming) 
{
/* 分配多 TX 队列的内存空间 */
err = igb_setup_all_tx_resources(adapter);
/* 分配多 RX 队列的内存空间 */
err = igb_setup_all_rx_resources(adapter);
/* 给网卡配置 RX/TX 队列,给 RX 申请 DMA 空间 */
igb_configure(adapter);
/* 注册中断处理函数 */
err = igb_request_irq(adapter);
/* 打开 NAPI */
for (i = 0; i < adapter->num_q_vectors; i++)
napi_enable(&(adapter->q_vector[i]->napi));
/* 打开硬中断 */
igb_irq_enable(adapter);
/* 启动所有 TX 队列 */
netif_tx_start_all_queues(netdev);
}

int igb_open(struct net_device *netdev)
{
return __igb_open(netdev, false);
}

igb网卡软中断处理

1
2
3
4
5
6
7
8
9
10
11
# 软中断,处理数据包,放进 socket buffer,数据包处理完后,开启硬中断。
__do_softirq
|-- net_rx_action
|-- igb_poll # 遍历 softnet_data.poll_list
|-- igb_clean_rx_irq #调用 igb_clean_rx_irq 循环处理数据包,直到处理完
|-- napi_gro_receive #数据包合并
|--napi_skb_finish
|-- netif_receive_skb
|-- ip_rcv #ip层处理数据包
|-- tcp_v4_rcv #tcp处理数据包

DMA

DMA 全称是 Direct Memory Access,它可以在CPU不参与的情况下,完成外部硬件设备和存储器之间或者存储器和存储器之间的高速数据传输。

数据可以直接通过DMA进行快速拷贝,节省 CPU 的资源去做其他工作。

目前,大部分的计算机都配备了 DMA 控制器。借助于 DMA 机制,计算机的 I/O 过程就能更加高效。

介绍

DMA(直接内存访问)是一种计算机系统特性,允许外部设备直接访问内存而无需经过CPU。这种机制可以提高数据传输效率,减轻CPU负担。以下是关于DMA的详细介绍:

工作原理

  1. 初始化:CPU配置DMA控制器,设定数据源地址、目的地址和传输数据的大小。
  2. 请求:外设请求DMA传输。
  3. 传输:DMA控制器接管总线,直接读写内存数据。
  4. 完成:传输完成后,DMA控制器向CPU发送中断信号,通知传输结束。

Ring Buffer

Ring Buffer是一个环形缓冲区,但是他的底层是个 FIFO 的队列。他提供了一种免加锁的方式去解决数据竞争问题。同时也可以避免频繁的申请/释放内存,避免内存碎片的产生。

本文提到的Ring Buffer,位于网卡和协议栈之间,用于两者之间进行数据传递。

前面我们提到系统初始化时,网卡驱动程序会向内核申请ring buffer,其实除了ring buffer外还需要额外申请一块内存用于存储数据包,这片内存由skb_buffer链表组成。

需要注意的是:Ring Buffer 中存储的是 sk_buff 的 Descriptor,而不是 sk_buff 本身,本质是一个指针,也称为 Packet Descriptor。

Packet Descriptor 有 Ready 和 Used 这 2 种状态。初始时 Descriptor 指向一个预先分配好且是空的 sk_buff 空间,处在 Ready 状态。当有 Frame 到达时,DMA Controller 从 Rx Ring Buffer 中按顺序找到下一个 Ready 的 Descriptor,将 Frame 的数据 Copy 到该 Descriptor 指向的 sk_buff 空间中,最后标记为 Used 状态。

以下为e1000_rx_ring的结构:

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
43
struct e1000_rx_ring {
/* pointer to the descriptor ring memory */
void *desc; /* 内存描述符(e1000_rx_desc)数组。 */
/* physical address of the descriptor ring */
dma_addr_t dma;
/* length of descriptor ring in bytes */
unsigned int size;
/* number of descriptors in the ring */
unsigned int count;
/* next descriptor to associate a buffer with */
unsigned int next_to_use;
/* next descriptor to check for DD status bit */
unsigned int next_to_clean;
/* array of buffer information structs */
struct e1000_rx_buffer *buffer_info;
struct sk_buff *rx_skb_top;

/* cpu for rx queue */
int cpu;

u16 rdh;
u16 rdt;
};

/* 描述符指向的内存块。*/
struct e1000_rx_buffer {
union {
struct page *page; /* jumbo: alloc_page */
u8 *data; /* else, netdev_alloc_frag */
} rxbuf;
dma_addr_t dma;
};

/* Receive Descriptor - 内存描述符。*/
struct e1000_rx_desc {
__le64 buffer_addr; /* Address of the descriptor's data buffer */
__le16 length; /* Length of data DMAed into data buffer */
__le16 csum; /* Packet checksum */
u8 status; /* Descriptor status */
u8 errors; /* Descriptor Errors */
__le16 special;
};

sk_buff

sk_buff 是最重要的数据结构,用来表示已接收或将要传输的数据。

sk_buff双向链表

​ sk_buff是由双向链表组成的,和传统的双向链表类似,sk_buff 链表的每个节点也通过 next 和 prev 分别指向后继和前驱节点。同时为了可以快速找到 整个链表的头节点,于是额外定义了一个数据结构(sk_buff_head)作为链表的头部节点。然后要求每个sk_buff节点都预留一个字段指向sk_buff_head,这样就可以保证无论当前访问的是哪个节点都可以快速找到链表头。

image-20240828202606579

sk_buff数据结构

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
43
44
45
struct sk_buff_head {
/* These two members must be first. */
struct sk_buff *next;
struct sk_buff *prev;

__u32 qlen; //表示链表中的节点数
spinlock_t lock; //用作多线程同步
};
struct sk_buff {
union {
struct {
/* These two members must be first to match sk_buff_head. */
struct sk_buff *next; //后续节点
struct sk_buff *prev; //前序节点

union {
struct net_device *dev; //记录接受或发送报文的网络设备
/* Some protocols might use this space to store information,
* while device pointer would be NULL.
* UDP receive path is one user.
*/
unsigned long dev_scratch;
};
};

struct list_head list; //指向头节点
};

union {
struct sock *sk; //报文所属的套接字
int ip_defrag_offset;
};

union {
ktime_t tstamp; //报文时间戳
u64 skb_mstamp_ns; /* earliest departure time */
};

__u16 transport_header; //指向传输层协议首部的起始。
__u16 network_header; //指向网络层协议首部的开始。
__u16 mac_header; //指向 MAC 协议首部的开始。

sk_buff_data_t tail;
sk_buff_data_t end;
unsigned char *head,*data;

sk_buff分段

image-20240828202634290

sk_buff 通过head,data,tail,end 将缓存空间分成不同的部分。

  • head,end指向已经分配的缓存空间的头和尾。
  • data,tail指向实际数据的头和尾。

其中每层(数据链路、网络、传输。。。)data,tail 之间设置自己对应的协议头,然后可以在对应的

1
2
3
__u16   transport_header; //指向传输层协议首部的起始。
__u16 network_header; //指向网络层协议首部的开始。
__u16 mac_header; //指向 MAC 协议首部的开始。

字段中设置对应起始位置。

sk_buff初始化时

linux使用 alloc_skb初始化sk_buff,函数定义在 net/core/skbuff.c 中。

image-20240828202721247

head、data、tail初始化的时候都是重合的,指向缓存区开头。

发送数据时sk_buff变化

image-20240828202740246

  1. 当要求 TCP 传输某些数据时,它会按照某些条件(TCP Max Segment Size(mss),对分散收集 I/O 支持等)分配一个缓冲区。
  2. TCP 在缓冲区的头部保留(通过调用 skb_reserve)足够的空间,以容纳所有层(TCP,IP,Link 层)的所有协议头。参数 MAX_TCP_HEADER 是所有级别的所有协议头的总和。
  3. TCP 的 payload (应用层传输的数据)被复制到缓冲区中。
  4. TCP 层添加它的协议头。
  5. TCP 层将缓冲区移交给 IP 层,IP层也添加协议头。
  6. IP 层将缓冲区移交给下一层,下一层也添加它的协议头。

再添加报文协议头时,也会同时对

1
2
3
__u16   transport_header; //指向传输层协议首部的起始。
__u16 network_header; //指向网络层协议首部的开始。
__u16 mac_header; //指向 MAC 协议首部的开始。

赋予对应的值

接收数据时sk_buff变化

由于直接移动指针比复制数据更加高效,所以当数据报文从下往上传递时,只需要移动对应指针就可以丢弃上一层的协议头。例如:报文从L2(数据链路层)->L3(IP层)时,只需要移动data指针就可以丢弃数据链路层的协议头,更加高效。

image-20240828202759006

  • 深入理解Linux网络技术内幕(文中的图大部分来自该书)
  • Linux 内核源码剖析:TCP/IP实现

本文转载自:Linux是怎么从网络上接收数据包的

补充:io_uring

[译] Linux 异步 I/O 框架 io_uring:基本原理、程序示例与性能压测(2020)

https://arthurchiao.art/blog/intro-to-io-uring-zh/

image-20240828205345500

  • 应用程序提交的IO 请求会直接进入submission queue 队列的尾部,内核进程会不断的从SQ 队列的头部消费请求
  • 内核处理完的SQ后会更新CQ tail 部分 ,应用程序读取到CQ 的head时,会更新CQ的head
  • SQ 中的任务称之为 SQE(entry), CQ中的任务称之为CQE

三种工作模式

内核轮询模式

  1. 内核线程:当使用 io_uring 时,系统会创建一个内核线程来处理提交的 I/O 请求(SQE),并轮询新的请求。
  2. 轮询工作:应用程序无需切换到内核态,就能触发 I/O 操作。应用通过 mmap 映射共享内存区,更新提交队列(SQ)以提交请求。
  3. 提交和收割:应用程序通过更新 SQE 来提交 I/O,请求完成后,内核会将结果放入完成队列(CQ)。应用程序可以通过轮询 CQ 来收割 I/O 结果,而无需进行系统调用。【io_uring 的提交和收割过程确实是通过 mmap 实现的】
  4. 空闲状态:如果内核线程在处理 I/O 请求时空闲时间超过用户定义的阈值,它会通知应用程序,然后进入空闲状态。这时,应用程序需要调用 io_uring_enter() 来唤醒内核线程。
  5. 忙碌状态:如果 I/O 请求一直较为繁忙,内核线程将持续运行,并不会进入休眠状态。

优势:

  1. Mmap 机制减少了 内存复制
  2. 内核轮询模式下,没有用户态和内核态的切换降低了损耗
  3. 基于SQ和CQ 机制下的数据竞争消除,即没有并发竞争损