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技术对数据包进行解析。网络优化的第一步,就是识别并解析流量。
网络中,每一层上下层都紧密相连,但却不知道其他层,每一层都遵守规范,从相邻的层中接收数据包。
对于其他层而言,相邻层都是提供数据包的接口,它与相邻层的关系也仅限于此,除此之外并没有任何关系。
所以,在上一篇,笔者说不管是使用物理网卡还是虚拟网卡,本质上对于协议栈的实现都是无所谓的,网卡只是一个黑箱,一个接口接收数据,一个接口发送数据,不管是任何网卡,它与其他层的关系也仅限于此。
就像是如果你被一个人调包了,但替换你的这个人,他的所作所为与你一模一样,那么其他人也不会感觉有任何异样。
网络层与层之间,依靠这种分层的方式工作得非常好。就像人与人之间,许多人看待人或事物,本质上也是如此,我们关心对他人的输出,也关心他人对我们的输入,我们通过输出作用于世界,也被来自外界的输入作用。