TCP实现之:sk_buff结构浅析
一、前言
sk_buff
是内核中用于存储报文缓存信息的结构体,可以算得上是内核协议栈中最重要的一个数据结构了。一方面,为了保持高效的网络报文处理效率,这要求sk_buff
的结构也必须是高效的;另一方面,sk_buff
被内核协议栈中的各个协议所共同使用,并在各个协议之间传递,这要求其能够兼容所有的网络协议。种种因素导致了这个结构体异常的复杂,广定义就花了三页的代码,这里我们简单对其关键属性进行分析。
二、报文存储
首先我们要理解网络报文存储的几个概念:线性缓存区、IP
分片区和分散聚合I/O区。这几个概念还挺抽象,咱们一个个分析哈。
2.1 线性区
skb
的初始化
首先来分析一下线性缓存区。线性缓存区就是sk_buff
常规的存储报文数据(包括报文头和报文数据)的地方。sk_buff
的创建是通过__alloc_skb
函数来进行的,该函数的定义如下:
struct sk_buff *__alloc_skb(unsigned int size, gfp_t gfp_mask,
int flags, int node)
在进行skb
的内存分配时,要分配两部分的内存:skb
结构体本身所占据的内存和存放报文数据的线性缓冲区buff
内存。__alloc_skb
第一个参数size
代表的是要分配的线性缓存区buff
的大小。在分配buff
的时候,内核会在size
的基础上额外分配一段内存用于存储skb_shared_info
,该结构体用于存储一些额外的信息,如IP分片的frag_list
等。
sk_buff
采用四个字段来管理和维护buff
缓冲区:head
、data
、tail
和end
,其与线性缓存区的对应关系如下图所示:
head
:指向缓存区头部的地址。end
:指向缓存区尾部的地址。data
:指向报文数据的地址。注意,这里的报文数据是一个相对的过程。对于IP协议,它不关注甚至不知晓链路层协议,因此它关注的报文数据是从IP
头部开始的,这里的data就会指向IP
报头的地址;而对于TCP
协议,data
就会指向TCP报头地址。skb
在各层协议之间传递时,data
会被修改。data
与head
之间的这段空间用于报头的填充,被称为headroom
。tail
:指向报文数据的尾部。end
与tail
之间的这段空间被称为tailroom
,有些协议可能会在报文的尾部附加信息,这段空间为这种情况做准备的。
我们注意到,tail
和end
的类型是sk_buff_data_t
,翻开源码可以看到该数据类型根据内核配置的不同,意义也不同:采用地址还是采用相对于head
的偏移量。
#ifdef NET_SKBUFF_DATA_USES_OFFSET
typedef unsigned int sk_buff_data_t;
#else
typedef unsigned char *sk_buff_data_t;
#endif
在sk_buff
刚创建的时候,head
、data
和tail
均指向缓存区开始的地方,end
指向缓冲区的尾部,如下图所示:
![image-20200512202001728](/home/xm/document/article/网络学习/TCP实现之:sk_buff结构浅析/image-20200512202001728.png)
协议相关变量
除了上面基本的四个变量外,sk_buff
还定义了一些成员变量用于对标准协议的支持。首先我们来看一下,如何从skb
中提取二层、三层和四层报文数据。skb
使用以下三个成员变量来记录各个协议层的报文在线性缓冲区中的位置,这三个变量是相对于head
的偏移量。
__u16 transport_header; //传输层报文(tcp、udp等)头部偏移量
__u16 network_header; //网络层报文(ip等)头部偏移量
__u16 mac_header; //以太网报文头部偏移量
内核为每个协议都封装好了函数,用于从skb
中获取报文数据地址:
static inline unsigned char *skb_transport_header(const struct sk_buff *skb)
{
return skb->head + skb->transport_header;
}
static inline unsigned char *skb_network_header(const struct sk_buff *skb)
{
return skb->head + skb->network_header;
}
static inline unsigned char *skb_mac_header(const struct sk_buff *skb)
{
return skb->head + skb->mac_header;
}
各个协议的报文获取函数基本上都是上面三个函数的直接调用,例如iphdr()
直接调用的skb_network_header()
。
除此之外,sk_buff
中还定义了与之对应的inner
类型的成员变量,用于在网卡处进行报文分段:
__u16 inner_transport_header;
__u16 inner_network_header;
__u16 inner_mac_header;
统计相关变量
unsigned int len,
data_len;
__u16 mac_len,
hdr_len;
unsigned int truesize;
refcount_t users;
mac_len
:二层协议头部的长度,在以太网中为以太网报头的长度。hdr_len
:对于克隆的skb
,可读写的报文头部的长度。len
:当前报文数据的长度,包括当前skb
线性缓冲区和分片区中报文数据长度的总和。data_len
:IP
分片区数据的长度。truesize
:整个套接字缓冲区的长度,包括skb
、线性缓冲区以及分散聚合I/O区的数据长度。users
:当前skb
的引用计数,引用计数归0的时候skb
才能被释放。
2.2 IP
分片区
IP
分片区,顾名思义,就是存储用于IP
分片时的数据。想要理解这个概念,首先我们看一下skb
线性缓存区尾部的那个skb_shared_info
结构体:
struct skb_shared_info {
struct sk_buff *frag_list; -> IP分片数据区
skb_frag_t frags[MAX_SKB_FRAGS]; -> 分散聚合I/O数据区
......
};
该结构体中的frag_list
是指向struct sk_buff
结构体的指针,准确的说,指向sk_buff
链表的首地址,该链表中的sk_buff
通过struct sk_buff
的next
字段形成一个链表,具体的构造过程可参考我的《TCP实现之: IP 分片内核实现
》中的__ip_make_skb
函数。
需要说明的是,frag_list
链表中的skb
都没有分配IP
报头,且当前skb
的len
字段的值是自身以及所有frag_list
中的skb
的和,truesize
也是这样的。由于IP报文长度的上限是0xffff
,所以len
的长度不会超过这个值。
2.3 分散聚合I/O区
什么是分散聚合?简单来说,就是分散—聚合!把分散的数据聚合起来就叫分散聚合。什么意思呢?举个例子,我有两块缓冲数据:buf1
和buf2
,现在想将其整合成一个报文发送出去。常规的流程可能是,先申请一块大的内存buf3
,然后将buf1
、buf2
拷贝到buf3
中,最后将buf3
交给网卡来进行数据的发送。这个过程中有个问题,就是产生了两次内存拷贝,浪费了资源。分散聚合I/O就是不分配buf3
,而是分配一个数组用来存储buf1
、buf2
的地址,并将其传递给网卡。网卡拿到数组后,依次取出里面的指针,并将指针指向的数据发送出去,从而避免了无用的内存拷贝。
struct skb_shared_info {
skb_frag_t frags[MAX_SKB_FRAGS]; -> 分散聚合I/O数据区
......
};
MAX_SKB_FRAGS
为16,意味着分散数据区不能超过16个。skb_frag_t
是用来存储分散缓存区信息的,其定义如下:
struct skb_frag_struct {
struct {
struct page *p;
} page;
#if (BITS_PER_LONG > 32) || (PAGE_SIZE >= 65536)
__u32 page_offset;
__u32 size;
#else
__u16 page_offset;
__u16 size;
#endif
};
从上面可以看出,其存储了缓存区所在的页和页偏移,以及缓存区的大小。