skaiuijing

前言

程序与数据结构之间到底是什么关系?为什么说程序 = 数据结构 + 算法?

或者说,人与社会之间是什么?为什么说人是社会关系的总和?

在辩证法看来,对与不对互相显现,对必然要用不对参考,不对也必然要有对参考。

同样的,每一个社会上的人都无法完全独立,我们期望展现自己的主体性,但主体性的自己却要得到来自他人的承认,期望的是他人的自己,渴望的是他人的认可的自己。

主人凌驾于奴隶之上,通过对奴隶发号施令完成自己想做的事,但主人的权威借由奴隶完成,没有奴隶的能动性,也就没有主人的主体性,在这一刻,主人却成为了奴隶的奴隶。

同样的,每个人都渴望自己比别人更优秀,但在螺旋式下降的另一端看来,却是每个人都希望别人比自己更差。

对象、数据结构与接口

对象、数据结构的接口与方法,正是其自身程序关系的体现。每一个数据结构或者对象,并不是孤立的,而是除了自身外,一切程序关系的总和。

所以,当我们在进行程序设计时,应当考虑的是对象或者数据结构的交互,在这个交互的过程中,程序的运行便从中体现。

教程

本篇是本系列第二篇,这一篇主要介绍网络数据包的运输者:BUF。

本篇将会介绍各种网络数据包的基本内容,buf与内存息息相关,我们都知道各种协议都有头部,那么数据包除了有效的内容外,其余的字节究竟占用了多少内存呢?这个问题在本篇将会进行解析。

不管是笔者写的玩具协议栈,还是Linux网络,数据包内容基本都是一样的,都要遵守网络协议规范,所以,本篇将会以Linux内核的数据包为例,讲解数据包的构成,当然,具体的代码实现则取自笔者写的协议栈。

而在下一篇,笔者将会使用eBPF技术拆解Linux网络数据包,实现网络流量的捕获与监控,这也正是本篇先讲解数据包结构的原因。

因此,全系列文章分为两个方向:TCP/IP协议栈实现与Linux网络分析。

在文章标题笔者会使用递增的序列号以表示顺序,但会使用不同的标题以此进行区分,例如如果读者只对TCP/IP协议栈实现感兴趣,可以尝试跳过Linux网络与eBPF部分,反之亦可。

Linux网络数据包sk_buff

sk_buff是内核中运输数据包的结构体,想象一下,每一个数据包都是一个包裹,那么sk_buff就是最外层的包装。

它的结构体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct sk_buff {
union {
struct {
struct sk_buff *next;
struct sk_buff *prev;
union {
struct net_device *dev;
long unsigned int dev_scratch;
};
};
struct rb_node rbnode;
struct list_head list;
struct llist_node ll_node;
};
struct sock *sk;
省略一些字段
sk_buff_data_t tail;
sk_buff_data_t end;
unsigned char *head;
unsigned char *data;
};

其中,最主要的字段是data字段,它直接指向网络数据包内容。

1
2
3
4
5
6
7
graph LR
subgraph "sk_buff[]"
direction LR
A0["data"] -.-> D[DataBuf]
end


既然DataBuf是数据包内容,那么网络数据包又是什么结构呢?

我们生活中的大部分应用都是基于TCP协议的,例如网页的http流量,以http数据包为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
graph LR
subgraph "DataBuf (TCP Packet)"
direction LR
EH["Ethernet Header
src: MAC_A
dst: MAC_B
type: IPv4"]
IH["IP Header
src: 192.168.0.1
dst: 192.168.0.2
protocol: TCP"]
TH["TCP Header
src port: 12345
dst port: 80
flags: SYN"]
PL["Payload
HTTP GET /index.html"]
end

EH --> IH --> TH --> PL

真实的数据也是层层封装,网卡头部、IP头部、TCP头部,最后才是http数据包,其中包含了各种字段,它们显示在网页上,构成了我们浏览的信息。

网络是分层的,当一个数据包被发送时,每一层都会构造对应层的头部,而当数据包接收时,每一层也会解析对应层的头部。

网络数据结构

buf结构

既然我们最外层还需要一个buf结构,那么怎么实现呢?

我们可以使用链表挂载,当接收数据时,我们将会使用buf结构体挂载数据包。

像Linux内核的buf结构中,有一个红黑树字段,这是因为Linux网络需要优化性能,例如根据数据包本身的性质,在某些网络子系统中需要排序,比如大包优先或者小包优先,但是,我们只是实现一个简陋的玩具协议栈,使用链表足以,至于需要遍历的地方,直接O(n)迭代即可。

这样做是因为,我们造的轮子只是一个玩具,而不是工具。世界上没有几个人有勇气在实际生产中使用自己造的轮子,玩具适合学习,而工具才适合生产。

1
2
3
4
5
6
7
8
9
10
11
struct buf {
struct list_node node;
uint16_t tol_len; //pure message size
uint16_t data_len;
uint8_t *data;
uint8_t type;
uint8_t flags;
struct _sockaddr *sin;
uint8_t data_buf[MLEN];
};

我们的链表设计也根本不需要多复杂,我们的目标是网络协议栈,而不是高深的数据结构算法,所以笔者也没必要大费周章讲解红黑树的实现与原理。

简单直接才是真理,因此,链表的初始化、增加、移除,都力求简单高效:

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 list_node {
struct list_node *next;
struct list_node *prev;
};

void list_node_init(struct list_node *node);
void list_add_prev(struct list_node *next, struct list_node *prev);
void list_add_next(struct list_node *prev, struct list_node *next);
void list_remove(struct list_node *node);


void list_node_init(struct list_node *node)
{
*node = (struct list_node) {
.next = node,
.prev = node
};
}

void list_add_prev(struct list_node *next, struct list_node *prev)
{
prev->prev = next->prev;
prev->next = next;
next->prev->next = prev;
next->prev = prev;
}

void list_add_next(struct list_node *prev, struct list_node *next)
{
next->prev = prev;
next->next = prev->next;
prev->next->prev = next;
prev->next = next;

}


void list_remove(struct list_node *node)
{
node->prev->next = node->next;
node->next->prev = node->prev;
}

现在,我们可以尝试写一下每一层对应的数据结构体,

attribute((packed))是为了防止编译器优化。为了数据包的统一解析,字段之间是不能进行字节对齐的。

网卡接口层:

1
2
3
4
5
6
7

struct eth_hdr {
unsigned char ether_dhost[6];
unsigned char ether_shost[6];
unsigned short ether_type;
}__attribute__((packed));

IP网际层:

有一点需要注意,网络字节的存储是大端序,但是我们日常的机器又往往是小端序,因此前两个字段需要按照端序进行调整。如果你的机器是大端序,请互换前两个字段的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

struct ip_struct {
/*LITTLE_ENDIAN!!!*/
unsigned char ip_hl:4;
unsigned char ip_v:4;
unsigned char ip_tos;
short ip_len;
unsigned short ip_id;
short ip_off;
unsigned char ip_ttl;
unsigned char ip_p;
unsigned short ip_sum;
struct _in_addr ip_src;
struct _in_addr ip_dst;

}__attribute__((packed));

传输层:

UDP协议头部:

1
2
3
4
5
6
7

struct udphdr {
unsigned short uh_sport;
unsigned short uh_dport;
unsigned short uh_ulen;
unsigned short uh_sum;
}__attribute__((packed));

TCP将会在后面进行重点讲解,将会占据实现篇大半篇幅:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

struct tcphdr {
unsigned short th_sport;
unsigned short th_dport;
unsigned int th_seq;
unsigned int th_ack;
//little ENDIAN!!!
unsigned char th_x2:4;
unsigned char th_off:4;
unsigned char th_flags;
unsigned short th_win;
unsigned short th_sum;
unsigned short th_urp;
}__attribute__((packed));

对于Linux内核来说,如果加上驱动层,也就四层协议栈,从俯瞰的视角来看,每一层协议栈的头部却是如此的简洁,而通过这些看似简单的字段,却可以将流量运输到世界各地。

结语

现在我们已经知道了数据包的基本组成,那么下一节,笔者将会介绍使用eBPF技术对数据包进行解析。网络优化的第一步,就是识别并解析流量。

网络中,每一层上下层都紧密相连,但却不知道其他层,每一层都遵守规范,从相邻的层中接收数据包。

对于其他层而言,相邻层都是提供数据包的接口,它与相邻层的关系也仅限于此,除此之外并没有任何关系。

所以,在上一篇,笔者说不管是使用物理网卡还是虚拟网卡,本质上对于协议栈的实现都是无所谓的,网卡只是一个黑箱,一个接口接收数据,一个接口发送数据,不管是任何网卡,它与其他层的关系也仅限于此。

就像是如果你被一个人调包了,但替换你的这个人,他的所作所为与你一模一样,那么其他人也不会感觉有任何异样。

网络层与层之间,依靠这种分层的方式工作得非常好。就像人与人之间,许多人看待人或事物,本质上也是如此,我们关心对他人的输出,也关心他人对我们的输入,我们通过输出作用于世界,也被来自外界的输入作用。