Linux 内核网络关键数据结构

⚠ 转载请注明出处:作者:ZobinHuang,更新日期:Nov.16 2021


知识共享许可协议

    本作品ZobinHuang 采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,在进行使用或分享前请查看权限要求。若发现侵权行为,会采取法律手段维护作者正当合法权益,谢谢配合。


目录

有特定需要的内容直接跳转到相关章节查看即可。

    Section 1. sk_buff
        1.1 基本介绍
        1.2 Double Linked List 的组织方式
        1.3 和对应的 Data Buffer 的关联
        1.4 sk_buff 的分配和销毁方式
        1.5 对 data buffer 的相关操作
        1.6 描述 data buffer 自身的元数据
        1.7 sk_buff 的 Clone

    Section 2. net_device
        2.1 sk_buff 的 Clone
        2.2 标识字段
        2.3 net_device 的组织
        2.4 操作函数接口


    注明:

  • sk_buffnet_device 的内核源码研究是研究内核网络的基础;
  • 本文的分析基于内核版本 v5.14.18 展开;

1. sk_buff

1.1 基本介绍

    struct sk_buffinclude/linux/skbuff.h 中被定义,用于作为一个网络数据包的描述元数据,其各个成员被内核网络的各个层次用于管理和控制网络数据包,而实际的网络数据包数据则存放在一个与当前 sk_buff 对应的 data buffer 中。sk_buff 在一个网络数据包的收发中贯穿始终,通过高效率的指针操作,避免了昂贵的数据拷贝操作。

1.2 Double Linked List 的组织方式

    由于数据包有很多个,那么必然也存在多个 sk_buff 结构,那么 sk_buff 是如何组织的呢?

    内核将 sk_buff 维护成了双向链表,如上图所示。我们常常会听到内核利用的是循环队列来处理网络数据包,如 Socket 收发队列等,实际上指的就是由 sk_buff 组成的双向链表结构。将 sk_buff 组织为双向列表能够加速一个 从一个队列转移到另一个队列的过程。一个 sk_buff 双向链表由一个特殊的结构体:struct sk_buff_head 唯一确定,其在 include/linux/skbuff.h 中被定义,其具体定义如下所示。其中,qlen 成员用于描述双向链表中包含的 sk_buff 的个数; lock 成员是一个自旋锁变量,用于防止对这个双向链表的同时访问。

1
2
3
4
5
6
7
8
struct sk_buff_head {
/* These two members must be first. */
struct sk_buff *next;
struct sk_buff *prev;

__u32 qlen;
spinlock_t lock;
};

    同样地,在 sk_buff 中,也有类似的用于双向链表的成员变量:struct sk_buff *nextstruct sk_buff *prev,这里不再赘述。值得注意的是,sk_buff 还有一个指针成员 struct sk_buff *list,用于指向我们上面提到的链表头部结构 struct sk_buff_head,这是为了使得能够快速的从一个 sk_buff 结构出发找到整个双向链表。

    内核提供了一系列的函数,用于操作这些由 sk_buff 组成的双向链表,或者称之为 队列。常用的函数包括:

  • skb_queue_head_init: 初始化一个新的 sk_buff_head 结构,也即初始化出了一个新的空队列;
  • skb_queue_head, skb_queue_tail: 在队列的头部/尾部增添新的 sk_buff 结构;
  • skb_queue_dequeue, skb_dequeue_tail: 从队列的头部/尾部出队 sk_buff 结构;
  • skb_queue_purge: 清空一个队列;
  • skb_queue_walk: 本质是一个宏定义,用于在队列中循环;

    内核在实现上面这些函数的时候,都是保证其原子性的。具体的方法是:上面这些函数事实上都是一些包装函数 (wrapper),在它们的具体实现中,首先都会请求相应队列的自旋锁,然后再调用内部封装的函数来实现具体的功能。保证这些操作的原子性的原因是因为如果这些操作被异步事件中断,然后这些异步事件对相应的队列做了入队和出对的操作,那么这样将会引起冒险,导致数据结构被破坏。

1.3 和对应的 Data Buffer 的关联

    为了把一个 sk_buff 结构和对应的 Data Buffer 关联起来,sk_buff 中维护了四个指针成员。如上图所示,head 成员和 end 成员分别指向了分配给当前数据包的整个 Data Buffer 的起始和结尾地址;而 data 成员和 tail 则指向了 Data Buffer 中实际数据的起始和结尾地址。

    在理解了 sk_buff 和对应 Data Buffer 的关系后,我们来看与长度相关的一些字段:unsigned int len 用于描述 Data Buffer 的总长度,这个总长度既包括了由 headend 包含的 Data Buffer 的长度,也包括了 Fragments Buffer 的长度,我们在后面会对后者进行介绍;unsigned int data_len 只用于描述 Fragments Buffer 的长度;unsigned int mac_len 用于描述 MAC Header 的长度;unsigned int truesize 可以被理解为 len + sizeof(sk_buff),即包括 sk_buff 和 Data Buffer 在内的总长度。

    sk_buff 在网络包收发的过程中是逐层传递的,以接收方向为例,即在 Driver`\rightarrow`L3`\rightarrow`L4 的过程中,Network Subsystem 会在 sk_buff 所指向的 Data Buffer 中逐层去除头部,而去除的过程是通过修改我们上面提到过的 data 指针来实现的,并没有真的把 Data Buffer 的相应区间给释放掉 (i.e. 这是很浪费 CPU Cycle 的)。这样一来,一个直觉就是 sk_buff 中需要有相应的字段,用于指向各层头部在 Data Buffer 中的位置,以方便在 data 指针被修改后有机会继续追踪被舍弃的头部。因此,在 sk_buff 中,__u16 transport_header (在内核的旧版本中名为 union {...} h) 用于在 Data Buffer 中指向三层以上的头部 (e.g. TCP, UDP 或 ICMP) 的起始地址;__u16 network_header (在内核的旧版本中名为 union {...} nh) 用于在 Data Buffer 中指向三层头部 (e.g. IPv4, IPv6 或 ARP);__u16 mac_header (在内核的旧版本中名为 union {...} mac) 用于在 Data Buffer 中指向二层的头部。skb_transport_header(skb)skb_network_header(skb)skb_mac_header(skb) 分别用于返回一个 sk_buff 结构的上述成员。

1.4 sk_buff 的分配和销毁方式

sk_buff 的分配

    内核通过调用 struct sk_buff* alloc_skb 函数来分配 sk_buff 和对应的 data buffer 的,而它实际上是对 struct sk_buff* __alloc_skb 函数 的包装 (wrapper),后者是在 /net/core/skbuff.c 中定义的,其简化版的定义如下所示。简单来看,其就是先为 sk_buff 分配了内存空间,然后为 data buffer 分配了内存空间,最后将 sk_buff 和 data buffer 使用我们上述的四个指针结构对应起来。

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
struct sk_buff *__alloc_skb(unsigned int size, gfp_t gfp_mask, int flags, int node){
/* ... */

/* 分配 sk_buff */
if ((flags & (SKB_ALLOC_FCLONE | SKB_ALLOC_NAPI)) == SKB_ALLOC_NAPI &&
likely(node == NUMA_NO_NODE || node == numa_mem_id()))
skb = napi_skb_cache_get();
else
skb = kmem_cache_alloc_node(cache, gfp_mask & ~GFP_DMA, node);
if (unlikely(!skb))
return NULL;
prefetchw(skb);

/* ... */

/* 分配 data buffer */
size = SKB_DATA_ALIGN(size);
size += SKB_DATA_ALIGN(sizeof(struct skb_shared_info));
data = kmalloc_reserve(size, gfp_mask, node, &pfmemalloc);
if (unlikely(!data))
goto nodata;

/* ... */

/* 绑定 sk_buff 和对应的 data buffer */
__build_skb_around(skb, data, 0);

/* ... */

return skb;

nodata:
kmem_cache_free(cache, skb);
return NULL;
}

    值得注意的是,在分配 data buffer 之前,函数使用了 SKB_DATA_ALIGN 宏强制将分配的内存区域空间进行了对齐。其效果如下所示:

    为了观察对 __alloc_skb 函数的使用的实际例子,我们把眼光放向网卡设备驱动。当驱动程序收上来一个数据包时,为了将数据包准备好送给内核网络的子系统,就必须要将网络数据包用 sk_buff 管理起来。驱动程序是通过调用 struct sk_buff* dev_alloc_skb 函数来获得 sk_buff 的,而它实际上是对 struct sk_buff* __netdev_alloc_skb 函数 的包装 (wrapper),后者是在 /net/core/skbuff.c 中定义的,其简化版的定义如下所示。

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
/* include/linux/skbuff.h */
static inline struct sk_buff *dev_alloc_skb(unsigned int length)
{
return netdev_alloc_skb(NULL, length);
}

/* include/linux/skbuff.h */
static inline struct sk_buff *netdev_alloc_skb(struct net_device *dev,
unsigned int length)
{
return __netdev_alloc_skb(dev, length, GFP_ATOMIC);
}

/* net/code/skbuff.c */
struct sk_buff *__netdev_alloc_skb(struct net_device *dev, unsigned int len,
gfp_t gfp_mask)
{
/* ... */

skb = __alloc_skb(len, gfp_mask, SKB_ALLOC_RX, NUMA_NO_NODE);
if (!skb)
goto skb_fail;
goto skb_success;

/* ... */

skb_success:
skb_reserve(skb, NET_SKB_PAD);
skb->dev = dev;

skb_fail:
return skb;
}

    简单来看,在处于中间的包装函数 netdev_alloc_skb 我们可以看到,其为 __netdev_alloc_skb 的第三个参数传入了 GFP_ATOMIC,这是在请求原子性操作的含义,因为通常来说分配 sk_buff 这个动作通常是在 Interrupt Routinue 中完成的,所以需要保证操作的原子性。而在 __netdev_alloc_skb 函数中,我们可以看见起始它就是简单地调用了 __alloc_skb 函数来获得一个 sk_buff 和对应大小的 data buffer。在获取成功后,又调用了我们在后面将会介绍到的 skb_reserve 函数来在 data buffer 的前面预留长度为 NET_SKB_PAD 的空间 (p.s. #define NET_SKB_PAD max(32, L1_CACHE_BYTES))。


sk_buff 的销毁

    有创建就有销毁,内核通过调用 kfree_skb 函数来释放一个 sk_buff,其是在 net/core/skbuff.c 中被定义的。kfree_skb 的具体定义如下所示。这里穿插一下,sk_buff 使用 user 成员来记录当前的 sk_buff 正在被多少个实体所使用,需要进行记录的原因是为了避免在一个 sk_buff 正在被使用的情况下被错误地释放,并且值得注意的是这个 user 成员只是记录 sk_buff 的被引用情况,并不包括 data buffer 的被引用情况,data buffer 的被引用情况是由描述 data buffer 元数据的结构体 skb_shared_info 的成员 dataref 描述的,我们在后面将会进行介绍。回到我们的主题,kfree_skb 只有在一个 sk_buffuser 为 1 的时候,也即只有一个实体在使用当前 sk_buff 并且请求进行释放的时候,才会真正的将 sk_buff 给释放掉,否则只是简单地将 user 成员自减 1。这一切都是在 skb_unref 函数中完成的。

1
2
3
4
5
6
7
8
void kfree_skb(struct sk_buff *skb)
{
if (!skb_unref(skb))
return;

trace_kfree_skb(skb, __builtin_return_address(0));
__kfree_skb(skb);
}

    总的来看,内核释放一个 sk_buff 的过程如下所示。当发现 sk_buff 的被引用记录已经到底时 (i.e. skb->user 成员 为 1), __kfree_skb 函数将被调用,在其中分别调用了 skb_release_all(skb)kfree_skbmem(skb) 函数,它们分别在 skb_release_allkfree_skbmem 中被定义。

    在 skb_release_all(skb) 中,其首先会先释放掉由当前 sk_buff_skb_refdst 成员指向的 dst_entry 结构,后者用于 Routing Subsystem,我们在后面相应部分的文章中将会进行介绍;接着其会调用由 sk_buffvoid (*destructor)(struct sk_buff *skb) 成员指向的 destructor 函数;然后它会继续调用 skb_release_data(skb) 函数,来释放掉 data buffer,这个释放过程有些许曲折:我们在后面将会看到,一个 sk_buff 结构是可以被 Clone 的,也就是说允许复制出指向同一个 data buffer 的 sk_buff,以供不同的处理需求使用,因此在释放 data buffer 之前,还需要判断:(1) 这个 sk_buff 是否有其它 Clone 实体 以及 (2) 其它 Clone 实体是否以及被释放 (如果有的话)。对于前者的判断,可以基于 sk_buffcloned 成员来实现,对于后者的判断,可以基于用于描述 data buffer 元数据的结构体 struct skb_shared_infodataref 成员来实现,dataref 成员用于记录一个 data buffer 一共被多少的 sk_buff 所指向,我们在后面将会继续介绍 struct skb_shared_info 结构体。当 (1) 当前 sk_buff 没有 Clone 实体,或者 (2) 当前对应的 data buffer 的被引用次数仅为 1 时,当前 data buffer 便会被释放。值得注意的是被释放的 data buffer 包括了主 data buffer (i.e. 由 sk_buff->headsk_buff->end 包围的缓冲区) 和 fragment data buffer。

    上述的 skb_release_all(skb) 函数实际上的主要功能是释放了 data buffer,而对于 sk_buff 本体则仍然没有被释放。sk_buff 的释放实际上指的是把它占用的空间收归回 buffer pool (cache) 中,以供以后创建 sk_buff 使用,这样的设计可以加速 sk_buff 的创建。sk_buff 的回收工作是由 kfree_skbmem(skb) 来完成的。

    另外,同样地,对于驱动程序来说,同样也有一个 wrapper 函数:驱动程序将调用 dev_kfree_skb 函数来释放相应的 sk_buff 结构,这个函数是在 include/linux/skbuff.h 中定义的,由于它实际上就是简单地调用了 __kfree_skb 函数,所以此处不再赘述。

1.5 对 data buffer 的相关操作

    内核同样提供了一些函数用于 data buffer 的操作,下面我们对它们的功能进行介绍。

skb_reserve

    skb_reserve 用于在 data buffer 的前部预留一定的空间,以在发送数据的时候给还没有添加的头部预留空间,或者强制使得数据进行对齐 (align)。如上图所示,skb_reserve 是通过调整 sk_buffdatatail 指针来实现的预留空间。值得注意的是,skb_reserve 通常是在 data buffer 刚被分配之后就被马上调用,也就是说调用 skb_reserve 的时候,datatail 指针通常指向的还是同一个地址。

    上图所展示的例子源自以太网网卡驱动程序的接收函数:由于以太网帧头是 14 字节,因此接收程序通常会在 data buffer 的最前面预留 2 个字节,这样一来三层的头部就能实现 16-Byte alignment 了。

    上图是使用 skb_reserve 的另一个例子 —— 一个 TCP 数据包从 socket layer 向下逐层发送的 data buffer 的变化过程,具体阐述如下所示:

  1. 当用户态程序调用系统调用接口请求发送 TCP 数据时,内核首先会根据 TCP Maximum Segment Size 来分配一个 data buffer,对应上图的 (a);
  2. 在 data buffer 分配完成后,内核会调用 skb_reserve 来给 L4、L3 和 L2 的头部预留一定的空间,对应上图的 (b)。这个预留的空间的大小考虑了最坏 (i.e. 最长) 的情况,即 MAX_TCP_HEADER 宏的大小;
  3. 接着,内核将用户程序要发送的数据从用户空间的 buffer 拷贝到 data buffer 中,对应上图的 (c);
  4. 交给 TCP 层添加 TCP 头部,对应了上图的 (d);
  5. 交给 IP 层添加 IP 头部,对应了上图的 (e);
  6. 交给二层 (Neighboring Layer) 添加二层头部,对应了上图的 (f)。

skb_put

    skb_put 用于在 data buffer 的尾部添加数据。但是值得注意的是,skb_reserve 不会真的向 data buffer 中添加数据,而是只会移动 sk_bufftail 的成员,向这个新数据区域中拷贝数据的操作是需要显式调用其他的函数来实现的。

skb_push

    skb_push 用于在 data buffer 的前部添加数据。同样地值得注意的是,skb_push 不会真的向 data buffer 中添加数据,而是只会移动 sk_buffdata 的成员,向这个新数据区域中拷贝数据的操作是需要显式调用其他的函数来实现的。

skb_pull

    skb_pull 用于从 data buffer 的前部中删除一块数据区域。同样地值得注意的是,skb_pull 不会真的释放 data buffer 中的相应区域,而是只会移动 sk_buffdata 的成员来实现高效率的删除操作。

1.6 描述 data buffer 自身的元数据

    事实上,紧跟在 data buffer 后面的有一个叫做 skb_shared_info 的结构体,如上图所示。这个结构体用于存储描述 data buffer 的一些元数据,它是在 include/linux/skbuff.h 中被定义的,具体定义如下所示:

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
struct skb_shared_info {
__u8 flags;
__u8 meta_len;
__u8 nr_frags;
__u8 tx_flags;
unsigned short gso_size;
/* Warning: this field is not always filled in (UFO)! */
unsigned short gso_segs;
struct sk_buff *frag_list;
struct skb_shared_hwtstamps hwtstamps;
unsigned int gso_type;
u32 tskey;

/*
* Warning : all fields before dataref are cleared in __alloc_skb()
*/
atomic_t dataref;

/* Intermediate layers must ensure that destructor_arg
* remains valid until skb destructor */
void * destructor_arg;

/* must be last field, see pskb_expand_head() */
skb_frag_t frags[MAX_SKB_FRAGS];
};

    我们下面对一些比较重要的字段进行解释。

    dataref 字段我们在上面稍微有所提及,用于描述当前 data buffer 被多少个 sk_buff 所使用,我们在后面将会看到,sk_buff 是可以被 Clone 的,因此存在多个 sk_buff 引用同一个 data buffer 的情况,尽管各个 sk_buff 中的数据可能有所不同。

    nr_flagsfrag_listfrags 适用于处理 IP 分片 (Fragment) 的,我们在上面曾经提及过,关于 IP Fragment 的具体内容在后面的文章中会进行详细阐述。

    值得注意的是,我们要想访问一个 data buffer 对应的 skb_shared_info 结构,我们需要从一个 sk_buff 出发,使用宏 skb_shinfo 对其进行访问,其具体定义如下所示

1
#define skb_shinfo(SKB)	((struct skb_shared_info *)(skb_end_pointer(SKB)))

1.7 sk_buff 的 Clone

    对于一份网络数据 (i.e. 一个 data buffer),有可能同时会有多个处理进程对其进行处理。不同的处理进程可能对 sk_buff 中的不同元数据有不同的记录。针对这种情况,为了实现上的高效率,内核使能了针对 sk_buff 的 Clone 操作,我们可以通过调用 skb_clone 函数来实现这种操作,它是在 net/core/skbuff.c 中被定义的。原生的和被 Clone 的 sk_buff 指向同一个 data buffer,内核通过记录 data buffer 的被引用数来防止一个 data buffer 被过早地释放,我们在上面分析 skb_shared_info 结构体的 dataref 成员的时候就已经分析过了。

    如上图所示,是 Clone sk_buff 的示意图。被 Clone 的 sk_buff 不会加入任何双向链表,也不会属于任何 socket。原生的和 Clone 出来的 sk_buffcloned 成员都被设置为 1,以代表它们存在 Clone 实体。

    当一个 sk_buff 被 Clone 时,它们所指向的 data buffer 中的内容就不应该再被修改,也就是说使用这个 data buffer 的不同进程可以在不上锁的情况下就直接访问该 data buffer。如果实在有需要修改 data buffer 的情况,那么那个进程就不仅需要 Clone sk_buff,也需要对 data buffer 进行 Clone。为了克隆 data buffer,有以下两种选择:

    在 include/linux/skbuff.h 中定义的函数 pskb_copy 可以用于 Clone 由 sk_buff->headsk_buff->end 括起来的 data buffer,并且返回一个对应的 sk_buff 结构体。其过程如上第一张图片所示。

    在 source/net/core/skbuff.c 中定义的函数 skb_copy 可以用于 Clone 包括 sk_buff->headsk_buff->end 括起来的 data buffer 和 fragment data buffer 在内的数据缓冲区,并且返回一个对应的 sk_buff 结构体。其过程如上第二张图片所示。

2. net_device

2.1 基本介绍

    结构体 net_deviceinclude/linux/netdevice.h 中被定义,用于存储与一个 network device (p.s. 以下简称 device) 相关的所有信息。net_device 可用于描述真实 device (e.g. Ethernet NIC) 和虚拟 device (e.g. bonding 和 VLAN 设备)。net_device 是一个通用的定义,具体的设备驱动会会根据自己的需要填充 net_device 中的各个成员,并且对流出的函数接口予以具体实现。

2.2 标识字段

    ifindex 成员是一个唯一的标识符:当一个 net_device 被注册进内核时会通过调用 dev_new_index 来获取这个唯一的标识符。

    iflink 成员通常被 tunnel device 所使用,用于指明这个 tunnel device 真正以来的物理 device。

2.3 net_device 的组织

    内核将所有的 net_device 组织成为一个单向链表结构,成员 dev_list 用于指向这个全局链表结构。

    另外,内核还将 net_device 塞进了一个哈希函数的哈希桶中,成员 index_hlist 用于指向相应的哈希桶。

2.4 操作函数接口

    net_device 定义了很多函数指针成员,各个厂商的设备驱动会对这些函数选择性的予以实现。这些函数指针成员可以被理解为面向对象编程中接口的概念。常用的接口如下所示:

成员名
作用
struct ethtool_ops *ethtool_ops
这个结构中包含了一系列用于 获取/设置 各种不同的 device 参数的函数指针
struct net_device_ops *netdev_ops
这个结构中包含了一系列 device 的基础操作函数指针,常见的接口包括:
  1. ndo_init: 当一个 net_device 被注册时被调用;
  2. ndo_uninit: 当一个 net_device 被解注册或者注册失败时被调用;
  3. ndo_open: 当一个 device 被开启 (up) 时被调用;
  4. ndo_stop: 当一个 device 被关闭 (down) 时被调用;
  5. ndo_start_xmit: 当有数据包要被传输时被调用;
  6. ndo_set_mac_address: 当要设置 device 二层地址的时候被调用 (p.s. 如果 device 允许的话);
  7. ndo_do_ioctl: ioctl 是一个用于向 device 发送命令的系统调用,这个函数在处理 ioctl 的时候被调用;
  8. ndo_change_mtu: 当要设置二层最大传输单元 (MTU) 时被调用;
  9. ndo_tx_timeout: 当一个发送 Watchdog 期满的时候被调用 (i.e. 说明某次发送用时过长);
  10. ...

2.5 其他字段

    其它字段在有需要的时候再进行整理。

附录:参考源

  1. The Linux Foundation, sk_buff
  2. https://www.linux.it/~rubini/docs/vinter/vinter.html