12内核网络
网络功能的实现是内核最复杂、牵涉最广的一部分。除了经典的因特网协议(如TCP、UDP)和相关的IP传输机制之外,Linux还支持许多其他的互联方案,使得所有想得到的计算机/操作系统能够互操作。Linux也支持大量用于数据传输的硬件,如以太网卡和令牌环网适配器及ISDN卡和调制解调器
12.1 互联的计算机
12.2 ISO/OSI和TCP/IP参考模型
众所周知的ISO(International Organization for Standardization,国际标准化组织)设计了一种参考模型,定义了组成网络的各个层。该模型由7层组成,称为OSI(Open Systems Interconnection,开放系统互连)模型
但对某些问题来说,划分为7层过于详细了。因此,实际上通常使用另一种参考模型,其中将ISO/OSI模型的一些层合并为新层。该模型只有4层,因此其结构更为简单。这种模型称为TCP/IP参考模型,IP表示Internet Protocol(网际协议),而TCP表示Transmission Control Protocol(传输控制协议)。当今因特网上的大部分通信都是基于该模型的
每层都只能与紧邻(上方或下方)的层通信
各层执行的任务如下:
-
主机到网络层
负责将信息从一台计算机传输到远程计算机。它处理传输介质(同轴电缆,光纤)的物理性质,并将数据流划分为定长的帧(frame),以便在发生传输错误时重传数据块。如果几台计算机共享同一传输线路,网络接口卡必须有一个唯一的 ID号,称之为MAC地址(MAC address),通常烧进硬件中。各厂商之间的协议保证该ID是全球唯一的。MAC地址的一个例子是08:00:46:2B:FE:E8。从内核看来,该层是由网卡的设备驱动程序实现的 -
OSI模型的网络层在TCP/IP模型中称为
互联网络层
(Internet layer,也称IP层),二者在本质上是相同的,都是指在网络中的任何计算机之间交换数据的任务,所述计算机不一定是直接连接的,如下图
计算机A和B之间的直接传输链路是不存在的,因为二者在物理上是未连接的。因此,
网络层的任务是找到一条线路,使得计算机可以彼此通信
,例如,A-E-B或A-E-C-B
网络层也负责其他连接细节,如将传输的数据划分为特定长度的分组。这是必要的,因为对传输线路上的各个计算机而言,所能够处理的分组最大长度可能是不同的。在发送数据时,数据流划分为分组,这些分组在接收端重新组合。这样,高层协议可以透明地处理任意长度的数据,而无需费力考虑互联网络层或网络层的特定性质
网络层还分配网络中唯一的地址
,以便计算机可以彼此通信(这与前述的硬件地址是不同的,因为网络通常由物理子网组成)
在因特网中,网络层借助IP协议
(Internet Protocol)实现,IP协议有两个版本(IPv4和IPv6)。当前,大多数连接是根据IPv4处理的,但IPv6将在未来代替它。下文讨论IP连接时,总是指IPv4连接。
IP使用一定格式的地址来寻址计算机,格式如192.168.1.8或62.26.212.10。这些地址由正式注册的权威机构或提供者分配(有时候是动态的),或可以自由选择(在定义为私有的范围内)。
IP支持各种地址类别,允许在地址层次上将网络灵活地划分为子网(subnet),子网的大小取决于需求,子网甚至可以容纳数千万台计算机。但本书不会详细阐述该主题。读者可以参考网络和系统管理方面的大量文献,例如[Ste00]和[Fri02] -
在两种模型中,第4层都是传输层(transport layer)。其任务是
在两个建立了链路的计算机上,控制应用程序之间的数据传输
。在计算机之间建立通信链路还不够,还必须在客户和服务器应用程序之间建立连接,当然,这预先假定了计算机之间有一个现存的链路。在因特网中,TCP(Transmission Control Protocol,传输控制协议)或UDP(User Datagram Protocol,用户数据报协议)用于该目的
。每个对互联网络层数据感兴趣的应用程序都使用一个唯一的端口号,来唯一地标识目标系统上的服务器应用程序。通常,端口80用于Web服务器。浏览器客户端必须向服务器地址发送请求,以获得所需的数据。(自然,客户端也必须有一个唯一的端口号,使得Web服务器可以响应该请求,但客户端的端口号是动态生成的。)为完全定义一个端口地址,通常将端口号附加在IP地址后,用冒号分隔。例如,在地址为192.168.1.8的计算机上的Web服务器,可以通过地址192.168.1.8:80来唯一标识。
传输层的另一项任务是可以(但不是必须的)提供一种可靠的连接,使得通过该连接的数据按给定的顺序到达
。 -
TCP/IP参考模型中的应用层,对应OSI模型中的5~7层(会话层、表示层和应用层)。顾名思义,应用层表示从应用程序视角来看的网络连接。在两个应用程序之间建立通信连接之后,
应用层负责传输实际的内容
。毕竟,Web服务器与其客户端之间的通信,不同于邮件服务器。
为因特网定义了大量的标准协议。通常,它们是以RFC(Request for Comments)文档的形式定义的,打算使用或提供特定服务的应用程序必须实现相关的协议。大多数协议可以使用简单的telnet工具测试,因为它们是用简单的文本命令进行操作的。典型的例子是浏览器与Web服务器之间的通信流程,如下wolfgang@meitner> telnet 192.168.1.20 80 Trying 192.168.1.20... Connected to 192.168.1.20. Escape character is '^]'. GET /index.html HTTP/1.1 Host: www.sample.org Connection: close HTTP/1.1 200 OK Date: Wed, 09 Jan 2002 15:24:15 GMT Server: Apache/1.3.22 (Unix) Content-Location: index.html.en Vary: negotiate,accept-language,accept-charset TCN: choice Last-Modified: Fri, 04 May 2001 00:00:38 GMT ETag: "83617-5b0-3af1f126;3bf57446" Accept-Ranges: bytes Content-Length: 1456 Connection: close Content-Type: text/html Content-Language: en <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <head> ... </html>
telnet用来与计算机192.168.1.20的80端口建立一个TCP连接。所有的用户输入都通过该网络连接转发到与该地址(由IP地址和端口号唯一标识的)相关联的进程。在接收到请求之后,立即发送一个响应。所要的HTML页面的内容,连同一个包含了文档有关信息和其他资料的HTTP首部,会发送回来。Web浏览器使用同样的过程来访问数据,这对用户是透明的
由于网络的功能已经系统地划分为各个层,希望与其他计算机通信的应用程序,只需要关注少量细节。计算机之间的实际链路由较低的层实现,而应用程序只需要产生和读取文本串,无论两台计算机是在同一房间里并排安放,还是分别位于两个不同的地方
网络的层状结构在内核中反映为下述事实:不同的层次由分离的代码实现,不同层次的代码之间通过明确定义的接口来交换数据或转发命令
12.3 通过套接字通信
网卡不适用万物皆文件.一个原因是(所有层次)使用了许多不同的通信协议,为建立连接需要指定许多选项,且无法在打开设备文件时完成这些任务。因此,在/dev目录下没有与网卡对应的项
采用的解决方案是将一种称为套接字的特殊结构用作到网络实现的接口,这种方案现在已经成为工业标准。POSIX标准中也定义了套接字,因而Linux也实现了套接字
套接字现在用于定义和建立网络连接,以便可以用操作inode的普通方法(特别是读写操作)来访问网络
。从程序员的角度来看,创建套接字的最终结果是一个文件描述符
,它不仅提供所有的标准函数,还包括几个增强的函数。用于实际数据交换的接口对所有的协议和地址族都是同样的
在创建套接字时,不仅要区分地址和协议族
,还要区分基于流的通信和数据报的通信
。(对面向流的套接字来说)同样重要的一点是,套接字是为客户端程序建立的,还是为服务器程序建立的
12.3.1 创建套接字
套接字是使用socket库函数生成的,地址族,通信类型(流或数据报),第三个参数来选择协议.前两个参数已经唯一地定义了协议。将第三个参数指定为0,即通知函数使用适当的默认协议
bind函数用于给套接字分配本地地址,必须向该函数传递一个sockaddr_type结构作为参数。该结构定义了本地地址。因为不同地址族的地址类型也不同,所以该结构对每个地址族都有一个不同的版本,以便满足各种不同的要求。type指定了所需的地址类型
因特网地址由IP地址和端口号唯一定义
,这也是sockaddr_in定义为下列形式的原因:
struct sockaddr_in {
sa_family_t sin_family; /* Address family *///地址族
__be16 sin_port; /* Port number *///端口号
struct in_addr sin_addr; /* Internet address *///因特网地址
...
};
为明确地表示小端序和大端序类型,内核提供了几种数据类型。__be16、__be32和__be64分别表示位长为16、32、64位的大端序数据类型,而前缀为__le的变体则表示对应的小端序数据类型
。这些类型都定义在<types.h>
中。请注意,小端序和大端序类型最终都映射到同样的数据类型(即u32等),但显式指定字节序使得自动化的类型检查工具可以检查代码的正确性。
12.3.2 使用套接字
因特网超级守护进程(inetd、xinetd或其他类似程序)通常使用内建的echo服务器
bind
将套接字绑定到一个地址(本例中是192.186.1.20:7777)
listen
通知套接字被动地等待客户端连接请求的到来。该函数创建一个等待队列,将所有希望建立连接的(远程)进程放置在该队列上。队列的长度由listen的第二个参数指定。(SOMAXCONN是系统内部允许的等待队列的最大长度,用来防止任意指定等待队列的长度。)
accept
函数接受等待队列上第一个客户端的连接请求。在队列为空时,该函数将阻塞,直至有一个想要进行连接的客户端到来
在Linux(和所有其他UNIX变体)中,1~1024的所有端口称为保留端口(reserved port),只能由具备root权限的进程使用
echo服务器的功能很容易模拟,只需要在一个无限循环中读取所有客户端的输入并原样写回即可。在客户端关闭连接时,服务器的read将返回一个长度为0的数据流
,这样服务器也会终止。具体过程如下:
一个四元组(192.168.1.20:7777, 192.168.1.10:3506)用来唯一标识一个连接。前两个分量是服务器本地系统的地址和端口号,后两个分量是客户端的地址和端口号。
如果元组中某个分量仍然是未定的,则用星号(*)
表示。因而,在被动套接字上监听尚未有客户端连接的服务器进程,可以表示为192.168.1.20:7777, :
在服务器调用fork复制自身来处理某个连接之后,在内核中注册了两个套接字对,如下:
尽管两个服务器进程的套接字具有相同的IP地址/端口号组合,但二者对应的四元组是不同的。
因此,内核在分配输入和输出TCP/IP分组时,必须注意到四元组的所有4个分量,才能确保正确。该任务称为多路复用
(multiplexing)。
netstat
工具可以显示并检查系统上所有TCP/IP连接的状态。如果有两个客户端连接到服务器,将生成下列样例输出::
wolfgang@meitner> netstat -na
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 192.168.1.20:7777 0.0.0.0:* LISTEN
tcp 0 0 192.168.1.20:7777 192.168.1.10:3506 ESTABLISHED
tcp 0 0 192.168.1.20:7777 192.168.1.10:3505 ESTABLISHED
12.3.3 数据报套接字
UDP是建立在IP连接之上的第二种广泛使用的传输协议。UDP表示User Datagram Protocol(用户数据报协议),在如下几个基本方面与TCP有所不同
- UDP是面向分组的。在发送数据之前,无须建立显式的连接。
- 分组可以在传输期间丢失。不保证数据一定能到达其目的地
- 分组接收的次序不一定与发送的次序相同
UDP通常用于视频会议、音频流及类似的服务。在此类环境下,丢失几个分组并不要紧,用户只会注意到多媒体序列内容中出现短暂的漏失。但类似于IP,UDP保证分组到达目的地时,其内容不会发生改变。
分别使用TCP和UDP协议的进程,可以同时使用同样的IP地址和端口号。在多路复用时,内核会根据分组的传输协议类型,将其转发到适当的进程
12.4 网络实现的分层模型
相关的C语言代码划分为不同层次,各层次都有明确定义的任务,各个层次只能通过明确定义的接口与上下紧邻的层次通信。这种做法的好处在于,可以组合使用各种设备、传输机制和协议。例如,通常的以太网卡不仅可用于建立因特网(IP)连接,还可以在其上传输其他类型的协议,如Appletalk或IPX,而无须对网卡的设备驱动程序做任何类型的修改
分层模型不仅反映在网络子系统的设计上,而且也反映在数据传输的方式上(或更精确地说,对各层产生和传输的数据进行封装的方式)。通常,各层的数据都由首部和数据两部分组成:
首部部分包含了与数据部分有关的元数据(目标地址、长度、传输协议类型等),数据部分包含有用数据(或净荷)
传输的基本单位是(以太网)帧,网卡以帧为单位发送数据。帧首部部分的主数据项是目标系统的硬件地址,这是数据传输的目的地,通过电缆传输数据时也需要该数据项
高层协议的数据在封装到以太网帧时,将协议产生的首部和数据二元组封装到帧的数据部分。在因特网网络上,这是互联网络层数据
以太网帧的首部(和所有其他现代网络协议的首部部分)包含了一个标识符,唯一地标识了帧数据部分中的协议类型。这些标识符(用于以太网传输)由一个国际组织(IEEE)分配
协议栈中的所有协议都有这种划分。为此,传输的每个帧开始都是一系列协议首部,而后才是应用层的数据
上图说明了为容纳控制信息所牺牲的部分带宽
12.5 网络命名空间
//include/net/net_namespace.h
//网络命名空间结构体
struct net {
atomic_t count; /* To decided when the network
* namespace should be freed.
*///使用计数器,在使用特定的net实例前后,需要分别调用辅助函数get_net和put_net。在count降低到0时,将释放该命名空间,并将其从系统中删除
struct list_head list; /* list of network namespaces *///链表元素,表头为 net_namespace_list.copy_net_ns函数向该链表添加一个新的命名空间。在用create_new_namespace创建一组新的命名空间时,会自动调用该函数
struct proc_dir_entry *proc_net;//表示/proc/net
struct proc_dir_entry *proc_net_stat;//表示/proc/net/stats
struct proc_dir_entry *proc_net_root;//指向当前命名空间的procfs实例的根结点/proc
struct net_device *loopback_dev; /* The loopback *///每个命名空间都可以有一个不同的环回设备,而loopback_dev指向履行该职责的(虚拟)网络设备
struct list_head dev_base_head;//表头,网络设备由struct net_device表示。与特定命名空间关联的所有设备都保存在一个双链表上,表头为dev_base_head
struct hlist_head *dev_name_head;//表头,各个设备还通过另外两个双链表维护:一个将设备名用作散列键(dev_name_head),
struct hlist_head *dev_index_head;//表头,另一个将接口索引用作散列键(dev_index_head),接口可以是纯虚拟的实体,可能在真正的设备上实现。例如,一个网卡可以提供两个接口
};
//网络命名空间初始化和清除操作函数
struct pernet_operations {
struct list_head list;//链表元素,链表头为 pernet_list,register_pernet_subsys和unregister_pernet_subsys分别向该链表添加和删除数据元素。每当创建一个新的网络命名空间时,内核将遍历pernet_operations的链表,用表示新命名空间的net实例作为参数来调用初始化函数。在删除网络命名空间时,清理工作的处理是类似的
int (*init)(struct net *net);//网络命名空间初始化函数
void (*exit)(struct net *net);//网络命名空间清理函数
};
每当创建一个新的网络命名空间时,内核将遍历pernet_operations
的链表,用表示新命名空间的net实例作为参数来调用初始化函数。在删除网络命名空间时,清理工作的处理是类似的
大多数计算机通常都只需要一个网络命名空间。全局变量init_net
(在这里,该变量实际上是全局的,并未包含在另一个命名空间中)包含了该命名空间的net实例
网络子系统实现的所有全局函数,都需要一个网络命名空间作为参数,而网络子系统的所有全局属性,只能通过所述命名空间迂回访问
12.6 套接字缓冲区
在内核分析(收到的)网络分组时,底层协议的数据将传递到更高的层。发送数据时顺序相反,各种协议产生的数据(首部和净荷)依次向更低的层传递,直至最终发送。这些操作的速度对网络子系统的性能有决定性的影响,因此内核使用了一种特殊的结构,称为套接字缓冲区
(socket buffer),定义如下
//include/linux/skbuff.h
//套接字缓冲区结构体(socket buffer),套接字缓冲区用于在网络实现的各个层次之间交换数据,而无须来回复制分组数据
struct sk_buff {
/* These two members must be first. */
//与struct sk_buff_head中的指针组成循环双链表
struct sk_buff *next;
struct sk_buff *prev;
struct sock *sk;//指向用于处理该分组的套接字对应的socket实例
ktime_t tstamp;//分组到达的时间
struct net_device *dev;//指定了处理分组的网络设备。dev在处理分组的过程中可能会改变,例如,在未来某个时候,分组可能通过计算机的另一个设备发出
struct dst_entry *dst;//表示接下来该分组通过内核网络实现的路由。这里使用了一个特殊的格式
char cb[48];
unsigned int len,
data_len;
__u16 mac_len,
hdr_len;
union {
__wsum csum;
struct {
__u16 csum_start;
__u16 csum_offset;
};
};
__u32 priority;
__u8 local_df:1,
cloned:1,
ip_summed:2,
nohdr:1,
nfctinfo:3;
__u8 pkt_type:3,
fclone:2,
ipvs_property:1,
nf_trace:1;
__be16 protocol;
void (*destructor)(struct sk_buff *skb);
int iif;//输入设备的接口索引号
sk_buff_data_t transport_header;//传输层协议首部的起始(TCP,UDP)
sk_buff_data_t network_header;//网络层协议首部的起始(IP)
sk_buff_data_t mac_header;//MAC协议首部的起始
/* These elements must be at the end, see alloc_skb() for details. */
sk_buff_data_t tail;/*协议数据区域的结束位置*/
sk_buff_data_t end;/*数据在内存中的结束位置*/
unsigned char *head,/*数据在内存中的起始位置*/
*data;/*协议数据区域的起始位置*/
unsigned int truesize;
atomic_t users;
};
套接字缓冲区用于在网络实现的各个层次之间交换数据,而无须来回复制分组数据,对性能的提高很可观。套接字结构是网络子系统的基石之一,因为在产生和分析分组时,在各个协议层次上都需要处理该结构
12.6.1 使用套接字缓冲区管理数据
套接字缓冲区通过其中包含的各种指针与一个内存区域相关联,网络分组的数据就位于该区域中,下图中假定我们使用的是32位系统(在64位机器上,套接字缓冲区的组织稍有
不同)
套接字缓冲区的基本思想是,通过操作指针来增删协议首部
正确地解释数据需要做简单的类型转换,为此提供了几个辅助函数
//include/linux/tcp.h
//提取套接字缓冲区中tcp信息
static inline struct tcphdr *tcp_hdr(const struct sk_buff *skb)
//include/linux/udp.h
//提取套接字缓冲区中udp信息
static inline struct udphdr *udp_hdr(const struct sk_buff *skb)
其他传输层协议也提供了形如XXX_hdr
的辅助函数,这类函数需要一个指向struct sk_buff
的指针作为参数,并返回重新解释的传输首部数据
data和tail使得在不同协议层之间传递数据时,无须显式的复制操作,如下:
在一个新分组产生时,TCP层首先在用户空间中分配内存来容纳该分组数据
(首部和净荷)。分配的空间大于数据实际需要的长度,因此较低的协议层可以进一步增加首部。
分配一个套接字缓冲区,使得head和end分别指向上述内存区的起始和结束地址,而TCP数据位于data和tail之间
。
在套接字缓冲区传递到互联网络层时,必须增加一个新层。只需要向已经分配但尚未占用的那部分内存空间写入数据即可,除了data之外所有的指针都不变,data现在指向IP首部的起始处。下面的各层会重复同样的操作,直至分组完成,即将通过网络发送
对接收的分组进行分析的过程是类似的。分组数据复制到内核分配的一个内存区中,并在整个分析期间一直处于该内存区中。与该分组相关联的套接字缓冲区在各层之间顺序传递,各层依次将其中的各个指针设置为正确值
内核提供了一些用于操作套接字缓冲区的标准函数:
在64位 CPU上,可使用一点小技巧来节省一些空间。sk_buff_data_t的定义改为整型变量
//include/linux/skbuff.h
typedef unsigned int sk_buff_data_t;
由于在此类体系结构上,整型变量占用的内存只有指针变量的一半(前者是4字节,后者是8字节),该结构的长度缩减了20字节.但套接字缓冲区中包含的信息仍然是同样的。data和head仍然是常规的指针,而所有sk_buff_data_t类型的成员现在都解释为相对于前两者的偏移量
。指向传输层首部的指针现在计算如下:
//include/linux/skbuff.h
static inline unsigned char *skb_transport_header(const struct sk_buff *skb)
{
return skb->head + skb->transport_header;
}
skb_transport_header(const struct sk_buff *skb)//从给定的套接字缓冲区获取传输层首部的地址
skb_reset_transport_header(struct sk_buff *skb)//将传输层首部重置为数据部分的起始位置
skb_set_transport_header(struct sk_buff *skb, const int offset)//根据数据部分中给定的偏移量来设置传输层首部的起始地址
对MAC层和网络层首部来说,也有同样一组函数可用,只需将transport分别替换为mac或network即可
12.6.2 管理套接字缓冲区数据
//include/linux/skbuff.h
//表头来实现套接字缓冲区的等待队列
struct sk_buff_head {
/* These two members must be first. */
struct sk_buff *next;
struct sk_buff *prev;
__u32 qlen;//指定了等待队列的长度,即队列中成员的数目
spinlock_t lock;
};
分组通常放置在等待队列中,例如分组等待处理时,或需要重新组合已经分析过的分组时。
12.7 网络访问层(网络模型中的主机到网络层,物理层,链路层)
网络访问层。该层主要负责在计算机之间传输信息,与网卡的设备驱动程序直接协作
本节将详细介绍由各个网卡驱动程序提供、由网络实现代码使用的接口
,它们提供了硬件的抽象视图。
这里根据以太网帧来解释如何在“线上”(on the cable)表示数据,并描述接收到一个分组之后,将该分组传递到更高层之前,需要完成哪些步骤。这里还描述了与之相反的步骤,即分组产生之后,通过网络接口离开计算机之前,要执行的步骤
12.7.1 网络设备的表示
在内核中,每个网络设备
都表示为net_device
结构的一个实例。在分配并填充该结构的一个实例之后,必须用net/core/dev.c中的register_netdev
函数将其注册到内核。该函数完成一些初始化任务,并将该设备注册到通用设备机制内。这会创建一个sysfs项/sys/class/net/<device>
,关联到该设备对应的目录。如果系统包含一个PCI网卡和一个环回接口设备,则在/sys/class/net
中有两个对应项
root@meitner # ls -l /sys/class/net
total 0
lrwxrwxrwx 1 root root 0 2008-03-09 09:43 eth0 -> ../../devices/pci0000:00/0000:00:1c.5/
0000:02:00.0/net/eth0
lrwxrwxrwx 1 root root 0 2008-03-09 09:42 lo -> ../../devices/virtual/net/lo
-
数据结构
内核如何跟踪可用的网络设备,以及如何查找特定的网络设备。照例,这些设备不是全局的,而是按命名空间进行管理的。每个命名空间(net实例)中有如下3个机制可用- 所有的网络设备都保存在一个单链表中,表头为
init_net->dev_base_head
- 按设备名散列。辅助函数
dev_get_by_name(struct net * net, const char * name)
根据设备名在该散列表上查找网络设备 - 按接口索引散列。辅助函数
dev_get_by_index(struct net * net, int ifindex)
根据给定的接口索引查找net_device实例
net_device
结构包含了与特定设备相关的所有信息//include/linux/netdevice.h //每个网络设备都表示为net_device结构的一个实例 struct net_device { /*网络设备的名称,下面列出了常见的类型,字符串末尾的数字用于区分同一类型的多个适配器(如系统有两个以太网卡eth0,eth1) 名称 设备类型 ethX 以太网适配器,无论电缆类型和传输速度如何 pppX 通过调制解调器建立的PPP连接 isdnX ISDN卡 atmX 异步传输模式(asynchronous transfer mode),高速网卡的接口 lo 环回(loopback)设备,用于与本地计算机通信 */ char name[IFNAMSIZ]; /* device name hash chain */ struct hlist_node name_hlist; /* * I/O specific fields * FIXME: Merge these and struct ifmap into one */ unsigned long mem_end; /* shared mem end */ unsigned long mem_start; /* shared mem start */ unsigned long base_addr; /* device I/O address */ unsigned int irq; /* device IRQ number */ unsigned long state; struct list_head dev_list; /* The device initialization function. Called only once. */ int (*init)(struct net_device *dev); /* Interface index. Unique device identifier */ int ifindex;//网卡的唯一索引号,用于在net->dev_index_head链表头中找到网卡设备 struct net_device_stats* (*get_stats)(struct net_device *dev);//查询统计数据,并将数据封装到一个类型为net_device_stats的结构中返回。该结构的成员有20多个,都是一些数值,如发送、接收、出错、丢弃的分组的数目等。(统计学爱好者可用ifconfig和netstat -i查询这些数据。因为net_device结构没有提供存储net_device_stats对象的专用字段,各个设备驱动程序必须在私有数据区保存该对象。 /* Hardware header description */ const struct header_ops *header_ops;//指向结构的指针,该结构提供了更多的函数指针,用于操作硬件首部。其中最重要的是header_ops->create和header_ops->parse,前者创建一个新的硬件首部,后者分析一个给定的硬件首部 unsigned int flags; /* interface flags (a la BSD) */ unsigned mtu; /* interface MTU value *///(maximum transfer unit,最大传输单位)指定一个传输帧的最大长度。网络层的协议必须遵守该值的限制,可能需要将分组拆分为更小的单位 unsigned short type; /* interface hardware type *///保存设备的硬件类型,它使用的是<if_arp.h>中定义的常数。例如,ARPHRD_ETHER和ARPHDR_IEEE802分别表示10兆以太网和802.2以太网,ARPHRD_APPLETLK表示AppleTalk,而ARPHRD_LOOPBACK表示环回设备 unsigned short hard_header_len; /* hardware hdr length */ /* Interface address info. */ unsigned char perm_addr[MAX_ADDR_LEN]; /* permanent hw address */ unsigned char addr_len; /* hardware address length *///指定了dev_addr的长度。 int promiscuity; /* Protocol specific pointers */ //ip_ptr、ip6_ptr、atalk_ptr等指针指向特定于协议的数据,通用代码不会操作这些数据,这些指针中有一些可能包含非NULL值,因为一个网络设备可同时使用多个网络协议 void *atalk_ptr; /* AppleTalk link */ void *ip_ptr; /* IPv4 specific data */ void *dn_ptr; /* DECnet specific data */ void *ip6_ptr; /* IPv6 specific data */ void *ec_ptr; /* Econet specific data */ unsigned long last_rx; /* Time of last Rx */ /* Interface address info used in eth_type_trans() */ unsigned char dev_addr[MAX_ADDR_LEN]; /* hw address, (before bcast because most packets are unicast) *///存储设备的硬件地址(如以太网卡的MAC地址),而addr_len指定该地址的长度。 unsigned char broadcast[MAX_ADDR_LEN]; /* hw bcast add *///用于向附接的所有站点发送消息的广播地址 int (*hard_start_xmit) (struct sk_buff *skb, struct net_device *dev);//用于从等待队列(sk_buff_head)删除已经完成的分组(sk_buff)并将其发送出去 /* These may be needed for future network-power-down code. */ unsigned long trans_start; /* Time (in jiffies) of last Tx */ /* net_device结构的大多数成员都是函数指针,执行与网卡相关的典型任务。尽管不同适配器的 实现各有不同,但调用的语法(和执行的任务)总是相同的。因而这些成员表示了与下一个协议层次 的抽象接口。这些接口使得内核能够用同一组接口函数来访问所有的网卡,而网卡的驱动程序负责实 现细节 */ /* Called after device is detached from network. */ void (*uninit)(struct net_device *dev); /* Called after last user reference disappears. */ void (*destructor)(struct net_device *dev); /* Pointers to interface service routines. */ //open和stop分别初始化和终止网卡。这些操作通常在内核外部通过调用ifconfig命令触发。open负责初始化硬件寄存器并注册系统资源,如中断、DMA、IO端口等。close释放这些资源,并停止传输 int (*open)(struct net_device *dev);//初始化网卡 int (*stop)(struct net_device *dev);//终止网卡 #define HAVE_MULTICAST void (*set_multicast_list)(struct net_device *dev); #define HAVE_SET_MAC_ADDR int (*set_mac_address)(struct net_device *dev, void *addr); #define HAVE_PRIVATE_IOCTL int (*do_ioctl)(struct net_device *dev, struct ifreq *ifr, int cmd);//将特定于设备的命令发送到网卡 #define HAVE_SET_CONFIG int (*set_config)(struct net_device *dev, struct ifmap *map); #define HAVE_CHANGE_MTU int (*change_mtu)(struct net_device *dev, int new_mtu);//由eth_change_mtu实现的,负责修改最大传输单位。以太网的默认值是1.5KiB,其他传输技术各有不同的默认值。在某些情况下,增大/减小该值是有用的。但许多网卡不允许这样做,只支持默认的硬件设置 #define HAVE_TX_TIMEOUT void (*tx_timeout) (struct net_device *dev);//解决分组传输失败的问题 int (*neigh_setup)(struct net_device *dev, struct neigh_parms *); /* Network namespace this network device is inside */ struct net *nd_net;//指向设备所属的网络命名空间(由struct net的一个实例表示)。 /* class/net/name entry */ struct device dev; };
name
保存网络设备名称,常见设备名如下:
net_device
结构的大多数成员都是函数指针,执行与网卡相关的典型任务。尽管不同适配器的实现各有不同,但调用的语法(和执行的任务)总是相同的。因而这些成员表示了与下一个协议层次的抽象接口。这些接口使得内核能够用同一组接口函数来访问所有的网卡,而网卡的驱动程序负责实现细节。有些函数通常不是由特定于驱动程序的代码来实现的,它们对所有的以太网卡都是相同的。因而内核提供了默认实现(在
net/ethernet/net.c
中)可以将一个
ioctl
应用到套接字的文件描述符,从用户空间修改对应的网络设备的配置。必须指定<sockios.h>
中定义的某个符号常数,表明修改配置的哪一部分。例如,SIOCGIFHWADDR
负责设置网卡的硬件地址,内核最终将该任务委派给net_device 实例的set_mac_address
函数。设备相关的常数会传递给do_ioctl
函数处理。网络设备分两个方向工作,即发送和接收(这两个方向通常称为下向流和上向流)。内核源代码包含了两个驱动程序框架(
drivers/net
中的isa-skeleton.c和pci-skeleton.c
),可用作网络驱动程序的模板。在下文中,主要关注驱动程序与硬件的交互,但又不想局限于某种特定的专有网卡类型时,偶尔会引用这两个驱动程序 - 所有的网络设备都保存在一个单链表中,表头为
-
注册网络设备
注册流程:alloc_netdev
分配一个新的struct net_device
实例,一个特定于协议的函数用典型值填充该结构。对于以太网设备,该函数是ether_setup
。其他的协议会使用形如XXX_setup
的函数,其中XXX可以是fddi(fiber distributed data interface,光纤分布式数据接口)、tr(token ring,令牌环网)、ltalk(指Apple LocalTalk)、hippi(high-performance parallel interface,高性能并行接口)或fc(fiber channel,光纤通道)
内核中的一些伪设备在不绑定到硬件的情况下实现了特定的接口,它们也使用了net_device框架。例如,ppp_setup根据PPP协议初始化设备。内核源代码中还可以找到几个XXX_setup函数- 在struct net_device填充完毕后,需要用
register_netdev
或register_netdevice
注册。这两个函数的区别在于, register_netdev 可处理用作接口名称的格式串(有限)。 在net_device->dev中给出的名称可以包含格式说明符%d。在设备注册时,内核会选择一个唯一的数字来代替%d。例如,以太网设备可以指定eth%d,而内核随后会创建设备eth0、eth1……
便捷函数alloc_etherdev(sizeof_priv)
分配一个 struct net_device 实例,外加sizeof_priv字节私有数据区。回想前文可知,net_device->priv是一个指针,指向与设备相关联的特定于驱动程序的数据。此外,还调用了上面提到的ether_setup来设置特定于以太网的标准值。
register_netdevice
如下:
函数流程:
- 如果有初始化函数,调用初始化函数
- 分配特定命名空间的id
- 注册到sysfs中
- 加入命名空间的设备名链表和接口索引链表
12.7.2 接收分组
分组到达后驱动通过中断通知内核(或系统)网络驱动程序对特定于设备的中断设置了一个处理例程,因此每当该中断被引发时(即分组到达),内核都调用该处理程序,将数据从网卡传输到物理内存,或通知内核在一定时间后进行处理
几乎所有的网卡都支持DMA模式,能够自行将数据传输到物理内存。但这些数据仍然需要解释和处理
-
传统方法
当前,内核为分组的接收提供了两个框架
。其中一个很早以前就集成到内核中了,因而称为传统方法。但与超高速网络适配器协作时,该API会出现问题,因而网络子系统的开发者已经设计了一种新的API(通常称为NAPI)。使用旧API的适配器较多,而使用新API的较少。这没有问题,因为其物理传输速度没那么高,不需要新方法。
下图给出了在一个分组到达网络适配器之后,该分组穿过内核到达网络层函数的路径
因为分组是在中断上下文中接收到的,所以处理例程只能执行一些基本的任务,避免系统(或当前CPU)的其他任务延迟太长时间
在中断上下文中,数据由3个短函数处理(在驱动程序框架isa-skeleton.c中是这三个函数名,其他驱动中可能不同),执行了下列任务:net_interrupt
是由设备驱动程序设置的中断处理程序
。它将确定该中断是否真的是由接收到的分组引发的(也存在其他的可能性,例如,报告错误或确认某些适配器执行的传输任务)。如果确实如此,则控制将转移到net_rx
net_rx
函数也是特定于网卡的,首先创建一个新的套接字缓冲区。分组的内容接下来从网卡传输到缓冲区(也就是进入了物理内存)
,然后使用内核源代码中针对各种传输类型的库函数来分析首部数据
。这项分析将确定分组数据所使用的网络层协议,例如IP协议- 与上述两个方法不同,
netif_rx
函数不是特定于网络驱动程序的,该函数位于net/core/dev.c
。调用该函数,标志着控制由特定于网卡的代码转移到了网络层的通用接口部分.该函数的作用在于,将接收到的分组放置到一个特定于CPU的等待队列上,并退出中断上下文
,使得CPU可以执行其他任务.netif_rx在结束工作之前将软中断NET_RX_SOFTIRQ标记为即将执行
然后退出中断上下文
内核在
全局定义的 softnet_data 数组中管理进出分组的等待队列
,数组项类型为softnet_data
。为提高多处理器系统的性能,对每个CPU都会创建等待队列
,支持分组的并行处理。不使用显式的锁机制
来保护等待队列免受并发访问,因为每个CPU都只修改自身的队列
,不会干扰其他CPU的工作。下文将忽略多处理器相关内容,只考虑单“softnet_data等待队列”,避免过度复杂化。//include/linux/netdevice.h //管理进出网络分组的等待队列 struct softnet_data { ... struct sk_buff_head input_pkt_queue;//表头来实现套接字缓冲区的等待队列,对所有进入的分组建立一个链表 ... };
netif_rx
在结束工作之前将软中断NET_RX_SOFTIRQ标记为即将执行
然后退出中断上下文.net_rx_action
用作该软中断的处理程序。代码流程如下图。这里描述的是一个简化的版本。完整版包含了对高速网络适配器引入的新方法简化的代码流程如下:
- 遍历收到的分组等待队列,调用 n->poll(实际为process_backlog函数,在初始化时设置)
process_backlog代码流程如下:
- 从等待队列移除一个套接字缓冲区,该缓冲区管理着一个接收到的分组
- netif_receive_skb分析分组类型,以便根据分组类型将分组传递给网络层的接收函数(即传递到网络系统的更高一层)。为此,该函数遍历所有可能负责当前分组类型的所有网络层函数,一一调用deliver_skb
新的协议通过dev_add_pack增加
。各个数组项的类型为struct packet_type
如下://include/linux/netdevice.h //分组类型 struct packet_type { __be16 type; /* This is really htons(ether_type). *///协议的标识符,处理程序会使用该标识符 struct net_device *dev; /* NULL is wildcarded here *///将一个协议处理程序绑定到特定的网卡(NULL指针表示该处理程序对系统中所有网络设备都有效) int (*func) (struct sk_buff *, struct net_device *, struct packet_type *, struct net_device *);//指向网络层函数的指针,如果分组的类型适当,将其传递给该函数。其中一个处理程序就是ip_rcv,用于基于IPv4的协议 struct sk_buff *(*gso_segment)(struct sk_buff *skb, int features); int (*gso_send_check)(struct sk_buff *skb); void *af_packet_priv; struct list_head list; };
-
对高速接口的支持
如果设备不支持过高的传输率,那么此前讨论的旧式方法可以很好地将分组从网络设备传输到内核的更高层。每次一个以太网帧到达时,都使用一个IRQ来通知内核。这里暗含着“快”和“慢”的概念。 对低速设备来说,在下一个分组到达之前,IRQ的处理通常已经结束。由于下一个分组也通过IRQ通知,如果前一个分组的IRQ尚未处理完成,则会导致问题,高速设备通常就是这样。现代以太网卡的运作高达10 000 Mbit/s,如果使用旧式方法来驱动此类设备,将造成所谓的“中断风暴”。如果在分组等待处理时接收到新的IRQ,内核不会收到新的信息:在分组进入处理过程之前,内核是可以接收IRQ的,在分组的处理结束后,内核也可以接收IRQ。为解决该问题,NAPI使用了IRQ和轮询的组合
假定某个网络适配器此前没有分组到达,但从现在开始,分组将以高频率频繁到达。这就是NAPI设备的情况,如下所述- 第一个分组将导致网络适配器发出IRQ。为防止进一步的分组导致发出更多的IRQ,驱动程序会关闭该适配器的Rx IRQ。并将该适配器放置到一个轮询表上
- 只要适配器上还有分组需要处理,内核就一直对轮询表上的设备进行轮询
- 重新启用Rx中断
如果在新的分组到达时,旧的分组仍然处于处理过程中,工作不会因额外的中断而减速。虽然对设备驱动程序(和一般意义上的内核代码)来说轮询通常是一个很差的方法,但在这里该方法没有什么不利之处:在没有分组还需要处理时,将停止轮询,设备将回复到通常的IRQ驱动的运行方式。在没有中断支持的情况下,轮询空的接收队列将不必要地浪费时间,但NAPI并非如此
NAPI的另一个优点是可以高效地丢弃分组。如果内核确信因为有很多其他工作需要处理,而导致无法处理任何新的分组,那么网络适配器可以直接丢弃分组,无须复制到内核
只有设备满足如下两个条件时,才能实现NAPI方法:- 设备必须能够保留多个接收的分组,例如保存到DMA环形缓冲区中。下文将该缓冲区称为Rx缓冲区。
- 该设备必须能够禁用用于分组接收的IRQ。而且,发送分组或其他可能通过IRQ进行的操作,都仍然必须是启用的
如果系统中有多个设备,这是通过循环轮询各个设备来解决的,如下图:
如果一个分组到达一个空的Rx缓冲区,则将相应的设备置于轮询表中。由于链表本身的性质,轮询表可以包含多个设备
内核以循环方式处理链表上的所有设备:内核依次轮询各个设备,如果已经花费了一定的时间来处理某个设备,则选择下一个设备进行处理。此外,某个设备都带有一个相对权重,表示与轮询表中其他设备相比,该设备的相对重要性。较快的设备权重较大,较慢的设备权重较小。由于权重指定了在一个轮询的循环中处理多少分组,这确保了内核将更多地注意速度较快的设备与旧的API相比,关键性的变化在于,
支持NAPI的设备必须提供一个poll函数
。该方法是特定于设备的,在用netif_napi_add
注册网卡时指定。调用该函数注册,表明设备可以且必须用新方法处理//include/linux/netdevice.h //struct napi_struct经常嵌入到一个更大的结构中,后者包含了与网卡有关的、特定于驱动程序的数据。这样在内核使用poll函数轮询网卡时,可用container_of机制获得相关信息 struct napi_struct { struct list_head poll_list;//链表元素,链表头为 softnet_data->poll_list unsigned long state;//NAPI_STATE_SCHED或NAPI_STATE_DISABLE,前者表示设备将在内核的下一次循环时被轮询,后者表示轮询已经结束且没有更多的分组等待处理,但设备尚未从轮询表移除 int weight;//轮询表中的权重 int (*poll)(struct napi_struct *, int);//禁用网卡IRQ中断后调用的轮询函数 };
-
实现poll函数
poll函数需要两个参数:一个指向napi_struct实例的指针和一个指定了“预算”的整数,预算表示内核允许驱动程序处理的分组数目。我们并不打算处理真实网卡的可能的奇异之处,因此讨论一个伪函数
,该函数用于一个需要NAPI的超高速适配器static int hyper_card_poll(struct napi_struct *napi, int budget) { //从napi_struct的容器获得特定于设备的信息 struct nic *nic = container_of(napi, struct nic, napi); struct net_device *netdev = nic->netdev; int work_done; /*调用一个特定于硬件的方法(这里是hyper_do_poll)来执行所需要的底层操作从网络适配器获取分组,并使用像此前那样使用netif_receive_skb将分组传递到网络实现中更高的层,hyper_do_poll最多允许处理budget个分组。该函数返回实际上处理的分组的数目。必须区分以下两种情况 1.如果处理分组的数目小于预算,那么没有更多的分组,Rx缓冲区为空,否则,肯定还需要处理剩余的分组(亦即,返回值不可能小于预算)。因此,netif_rx_complete将该情况通知内核,内核将从轮询表移除该设备。接下来,驱动程序必须通过特定于硬件的适当方法来重新启用IRQ 2.已经完全用掉了预算,但仍然有更多的分组需要处理。设备仍然留在轮询表上,不启用中断 */ work_done = hyper_do_poll(nic, budget); if (work_done < budget) { //通知内核分组已经处理的差不多了,可以不用轮询了 netif_rx_complete(netdev, napi); //开中断 hcard_reenable_irq(nic); } return work_done; }
-
实现IRQ处理程序
NAPI也需要对网络设备的IRQ处理程序做一些改动。这里仍然不求助于任何具体的硬件,而介绍针对虚构设备的代码://drivers/net/e100.c static irqreturn_t e100_intr(int irq, void *dev_id)
函数流程如下:
- 从net_device->private获取,特定于接口的数据,这是大多数网卡驱动程序使用的方法
- 将设备放入轮询表上
- 禁用irq中断,引发软中断,在软中断中轮询
-
处理Rx软中断 net_rx_action
在讨论了为支持NAPI驱动程序需要做哪些改动之后,我们来考察一下内核需要承担的职责。net_rx_action
依旧是软中断NET_RX_SOFTIRQ
的处理程序。该函数的所有细节如下图:
函数流程:
- 轮询 softnet_data的poll_list, while (!list_empty(list))
- 如果软中断处理时间超过1个jiffie或处理的分组总数超过了netdev_budget指定的预算总值,总值设置为300,但可以通过/proc/sys/net/core/netdev_budget修改,则退出软中断轮询,重新触发软中断.否则继续往下执行 budget <= 0 || jiffies != start_time
- 调用网络设备注册的poll函数 n->poll
- 将调用过poll函数的网络设备放到轮询表尾部 list_move_tail
-
在NAPI之上实现旧式API
内核的常规行为,由一个与softnet_data
队列关联的伪网络设备控制,net/core/dev.c
中的process_backlog
标准函数用作poll方法。如果没有网络适配器将其自身添加到该队列的轮询表,其中只包含这个伪适配器,那么net_rx_action
的行为就是通过对process_backlog
的单一调用来处理队列中的分组,而不管分组的来源设备.在net_dev_init
函数中设置
12.7.3 发送分组
在网络层中特定于协议的函数通知网络访问层处理由套接字缓冲区定义的一个分组时,将发送完成的分组
当信息从计算机发送出去时,必须注意几个事项,除了特定协议需要完成的首部和校验和
,以及由高层协议实例生成的数据之外,分组的路由
是最重要的。(即使计算机只有一个网卡,内核仍然需要区分发送到外部目标的分组和针对环回接口的分组。)
因为该问题只能由更高层的协议实例决定(特别是,如果可以选择到预期目标的路由时),所以设备驱动程序假定高层协议已经做出了决策
在分组可以发送到下一个正确的计算机之前(通常不同于目标计算机,因为除非存在直接的硬件链路,否则IP分组通常通过网关发送
),必须确定接收方网卡的硬件地址。这是一个复杂的过程,将在12.8.5详细描述。此时,我们假定已经知道接收方的MAC地址。网络访问层的所需的另一个首部,通常由特定于协议的函数产生
net/core/dev.c
中的dev_queue_xmit
用于将分组放置到发出分组的队列上
。这里将忽略这个特定于设备的队列的实现,因为它并没有揭示什么网络层的运作机制。只要知道,在分组放置到等待队列上一定的时间之后,分组将发出即可。这是通过特定于适配器的函数hard_start_xmit完成的,在每个net_device结构中都以函数指针的形式出现,由硬件设备驱动程序实现
12.8 网络层
网络访问层仍然受到传输介质的性质以及相关适配器的设备驱动程序的很大影响。网络层(具体地说是IP协议)与网络适配器的硬件性质几乎是完全分离的
。该层不仅负责发送和接收数据
,还负责在彼此不直接连接的系统之间转发和路由分组。查找最佳路由并选择适当的网络设备来发送分组,也涉及对底层地址族的处理(如特定于硬件的MAC地址)
,这是该层至少要与网卡松散关联的原因。在网络层地址和网络访问层之间的指派是由这一层完成的,这也是互联网络层无法与硬件完全脱离的原因
为理解IP协议在内核中的实现,必须简要介绍其工作方式。很自然,这是个非常大的领域,只能略微谈谈相关的主题。详细描述可以参见许多专著,如[Ste00]和[Ste94]
12.8.1 IPv4
IP分组使用的协议首部如下图:
结构中各部分的语义如下:
- version(版本)指定了所用IP协议的版本。当前,该字段的有效值为4或6。在支持两种协议版本的主机上,所使用的版本由前一章讨论的传输协议标识符确定。对协议的两个版本来说,该标识符中保存的值是不同的
- IHL(IP首部长度)定义了首部的长度,由于选项数量可变,这个值并不总是相同的
- Codepoint(代码点)或Type of Service(服务类型)用于更复杂的协议选项,我们在这里无须关注
- Length(长度)指定了分组的总长度,即首部加数据的长度
- fragment ID(分片标识)标识了一个分片的IP分组的各个部分。分片方法将同一分片ID指定到同一原始分组的各个数据片,使之可标识为同一分组的成员。各个部分的相对位置由fragment offset(分片偏移量)字段定义。偏移量的单位是64 bit
- 有3个状态标志位用于启用或禁用特定的特性,目前只使用其中两个
- DF意为“don’t fragment”,即指定分组不可拆分为更小的单位。
- MF表示当前分组是一个更大分组的分片,后面还有其他分片有分片都会设置该标志位)
第三个标志位“保留供未来使用”,但考虑到IPv6的存在,这是不太可能的
- TTL意为“Time to Live”,指定了从发送者到接收者的传输路径上中间站点的最大数目(或跳数),在过去,这个值解释为分组生命周期的最大长度,按秒计算
- Protocol标识了IP分组承载的高层协议(传输层)。例如,TCP和UDP协议都有对应的唯一值
- Checksum包含了一个校验和,根据首部和数据的内容计算。如果指定的校验和与接收方计算的值不一致,那么可能发生了传输错误,应该丢弃该分组
- src和dest指定了源和目标的32位IP地址
- options用于扩展IP选项
- data保存了分组数据(净荷)
IP首部中所有的数值都以网络字节序存储(大端序)。
在内核源代码中,该首部由iphdr
数据结构实现:
//include/linux/ip.h
//ip分组使用的协议首部结构
struct iphdr {
#if defined(__LITTLE_ENDIAN_BITFIELD)
__u8 ihl:4,//IP首部长度,由于选项数量可变,这个值并不总是相同的
version:4;//版本,指定了所用IP协议的版本,4或6
#elif defined (__BIG_ENDIAN_BITFIELD)
__u8 version:4,
ihl:4;
#endif
__u8 tos;//Codepoint(代码点)或Type of Service(服务类型)用于更复杂的协议选项
__be16 tot_len;//指定了分组的总长度,即首部加数据的长度
__be16 id;//fragment ID(分片标识)标识了一个分片的IP分组的各个部分。分片方法将同一分片ID指定到同一原始分组的各个数据片,使之可标识为同一分组的成员。
__be16 frag_off;//标志(占4位)和分片偏移(占12位),各个部分的相对位置由fragment offset(分片偏移量)字段定义。偏移量的单位是64 bit。有3个状态标志位用于启用或禁用特定的特性,目前只使用其中两个,DF意为“don’t fragment”,即指定分组不可拆分为更小的单位。MF表示当前分组是一个更大分组的分片,后面还有其他分片(除了最后一个分片之外,所有分片都会设置该标志位)。第三个标志位“保留供未来使用”,但考虑到IPv6的存在,这是不太可能的。
__u8 ttl;//“Time to Live”,指定了从发送者到接收者的传输路径上中间站点的最大数目(或跳数),在过去,这个值解释为分组生命周期的最大长度,按秒计算
__u8 protocol;//标识了IP分组承载的高层协议(传输层)。例如,TCP和UDP协议都有对应的唯一值
__sum16 check;//包含了一个校验和,根据首部和数据的内容计算。如果指定的校验和与接收方计算的值不一致,那么可能发生了传输错误,应该丢弃该分组
__be32 saddr;//src addr指定了源32位IP地址
__be32 daddr;//dest addr指定了目标32位IP地址
/*The options start here. */
};
ip_rcv
函数是网络层的入口点。分组向上穿过内核的路线如图:
发送和接收操作的程序流程并不总是分离的,如果分组只通过当前计算机转发,那么发送和接收操作是交织的。这种分组不会传递到更高的协议层(或应用程序),而是立即离开计算机,发往新的目的地
12.8.2 接收分组
在分组(以及对应的套接字缓冲区,其中的指针已经设置了适当的值)转发到ip_rcv
之后,必须检查接收到的信息
,确保它是正确的。主要检查计算的校验和与首部中存储的校验和是否一致。其他的检查包括分组是否达到了IP首部的最小长度,分组的协议是否确实是IPv4(IPv6的接收例程是另一个)
。
在进行了这些检查之后,内核并不立即继续对分组的处理,而是调用一个netfilter
挂钩,使得用户空间可以对分组数据进行操作。netfilter
挂钩插入到内核源代码中定义好的各个位置,使得分组能够被外部动态操作。挂钩存在于网络子系统的各个位置,每种挂钩都有一个特别的标记,例如NF_IP_POST_ROUTING
(内核版本2.6.25将会把名称从NF_IP_*
改为NF_INET_*
。这项改动统一了IPv4和IPv6的名称)
在内核到达一个挂钩位置时,将在用户空间调用对该标记支持的例程。接下来,在另一个内核函数中继续内核端的处理(分组可能被修改过)。12.8.6介绍netfilter
机制的实现。
在下一步中,接收到的分组到达一个十字路口,此时需要判断该分组的目的地是本地系统还是远程计算机。根据对分组目的地的判断,需要将分组转发到更高层,或转到互联网络层的输出路径上(这里不打算讨论第三种选项,即通过多播将分组发送到一组计算机)
ip_route_input
负责选择路由。这个相对复杂的决策过程在12.8.5介绍。判断路由的结果是,选择一个函数,进行进一步的分组处理。可用的函数是ip_local_deliver
和ip_forward
。具体选择哪个函数,取决于分组是交付到本地计算机下一个更高协议层的例程,还是转发到网络中的另一台计算机。
12.8.3 交付到本地传输层
如果分组的目的地是本地计算机,ip_local_deliver
必须设法找到一个适当的传输层函数,将分组转送过去。IP分组通常对应的传输层协议是TCP或UDP。
-
分片合并 ip_defrag
由于IP分组可能是分片的,因此会带来一些困难。不见得一定有一个完整的分组可用。该函数的第一项任务,就是通过ip_defrag
重新组合分片分组的各个部分(内核可通过设置的分片标志位,或非0的分片偏移量值识别分片的分组。偏移量字段为0,表明这是分组的最后一个分片)。对应的代码流程图,如下图所示:
内核在一个独立的缓存中管理原本属于一个分组的各个分片,该缓存称为
分片缓存(fragment cache)
。在缓存中,属于同一分组的各个分片保存在一个独立的等待队列中,直至该分组的所有分片都到达
。函数流程:
ip_find
函数检查是否已经为对应的分组创建了等待队列。如果没有,则建立一个新的队列,并将当前处理的分组置于其上。 否则返回现存队列的地址ip_frag_queue
将分组置于队列上(分片缓存使用定时器机制来从缓存删除分片。在定时器到期时,如果属于某个分组的分片未能全部到达,则将其从缓存删除),在分组的所有分片都进入缓存(即第一个和最后一个分片都已经到达,且所有分片中数据的长度之和等于分组预期的总长度)后,调用ip_frag_reasm
将各个分片重新组合起来。接下来释放套接字缓冲区- 如果分组的分片尚未全部到达,则ip_defrag返回一个NULL指针,终止互联网络层的分组处理。在所有分片都到达后,将恢复处理
-
交付到传输层
下面返回到ip_local_deliver
。在分组的分片合并完成后,调用netfilter
挂钩NF_IP_LOCAL_IN
,恢复在ip_local_deliver_finish
函数中的处理。
在其中,根据分组的协议标识符确定一个传输层的函数,将分组传递给该函数。所有基于互联网络层的协议都有一个net_protocol
结构的实例,该结构定义如下://include/net/protocol.h //协议结构体,inet_add_protocol函数注册该结构 struct net_protocol { int (*handler)(struct sk_buff *skb);//协议例程,分组将(以套接字缓冲区的形式)被传递到该例程进行进一步处理,在套接字缓冲区中通过通常的指针操作“删除”IP首部后,剩下的工作就是调用传输层对应的接收例程,其函数指针存储在inet_protocol的handler字段中,例如,用于接收TCP分组的tcp_v4_rcv例程和用于接收UDP分组的udp_rcv void (*err_handler)(struct sk_buff *skb, u32 info);//在接收到ICMP错误信息并需要传递到更高层时,需要调用err_handler };
inet_add_protocol
标准函数用于将上述结构的实例(指针)存储到inet_protos
数组中,通过一种散列方法确定存储具体协议的索引位置在套接字缓冲区中通过通常的指针操作“删除”IP首部后,剩下的工作就是调用传输层对应的接收例程,其函数指针存储在
inet_protocol
的handler
字段中,例如,用于接收TCP
分组的tcp_v4_rcv
例程和用于接收UDP
分组的udp_rcv
。12.9介绍这些函数
12.8.4 分组转发
IP分组可能如上所述交付给本地计算机处理,它们也可能离开互联网络层,转发到另一台计算机,而不牵涉本地计算机的高层协议实例。分组的目标地址
可分为以下两类。
- 目标计算机在某个本地网络中,发送计算机与该网络有连接。
- 目标计算机在地理上属于远程计算机,不连接到本地网络,只能
通过网关访问
第二种场景要复杂得多。首先必须找到剩余路由中的第一个站点,将分组转发到该站点,这是向最终目标地址的第一步传输。因此,不仅需要计算机所属本地网络结构的相关信息,还需要相邻网络结构和相关的外出路径的信息
该信息由路由表(routing table)提供,路由表由内核通过多种数据结构实现并管理
,相关内容在12.8.5介绍。在接收分组时调用的ip_route_input
函数充当路由实现的接口,这一方面是因为该函数能够识别出分组是交付到本地还是转发出去,另一方面该函数能够找到通向目标地址的路由。目标地址存储在套接字缓冲区的dst字段中
这使得ip_forward
的工作非常容易,代码流程图如下:
函数流程:
- 检查ip分组TTL,如果TTL值小于或等于1,则丢弃分组,否则,将TTL计数器值减1,并重新计算校验和
- 调用
netfilter
挂钩NF_IP_FORWARD
后,内核在ip_forward_finish
中恢复处理ip_forward_finish
的处理:如果分组包含额外的选项(通常情况下没有),则在ip_forward_options
中处理dst_output
将分组传递到在路由期间选择、保存在skb->dst->output
中的发送函数。通常使用ip_output
,该函数将分组传递到与目标地址匹配的网络适配器。
12.8.5 发送分组
内核提供了几个通过互联网络层发送数据的函数,可由较高协议层使用。其中ip_queue_xmit
是最常使用的一个,代码流程图如下:
函数流程:
-
查找分组的路由,则查找路由,根据起源于同一套接字的所有分组的目标地址都是相同的,这样不必每次都重新确定路由 __sk_dst_check
-
为分组生成校验和(生成IP校验和对时间要求很高,可以在现代的处理器上进行高度优化。为此,各种体系结构在
ip_fast_csum
中用汇编语言提供了自身的快速实现) ip_send_check -
调用
netfilter
挂钩NF_IP_LOCAL_OUT
。接下来调用dst_output
函数。该函数基于确定路由期间找到的skb->dst->output
函数,后者位于套接字缓冲区中,与目标地址相关。通常,该函数指针指向ip_output
,本地产生和转发的分组将在该函数中汇集 -
转移到网络访问层 ip_output
函数流程:
- 调用
netfilter
挂钩NF_IP_POST_ROUTING
,接下来调用ip_finish_output
- 分组长度不大于传输介质MTU、无须分片,直接调用了
ip_finish_output2
- 否则分组分片调用
ip_fragment(skb, ip_finish_output2)
- 分组长度不大于传输介质MTU、无须分片,直接调用了
ip_finish_output2
函数流程:- 检查套接字缓冲区是否仍然有足够的空间容纳产生的硬件首部,空间不够则调用
skb_realloc_headroom
分片额外空间 - 调用由路由层设置的函数
dst->neighbour->output
,该函数指针通常指向dev_queue_xmit
,向网络访问层转移(链路层)
- 调用
-
分组分片 ip_fragment
ip_fragment将IP分组划分为更小的单位,如下图:
函数流程如下:
如果忽略RFC 791中记载的各种微妙情形,那么IP分片是非常简单的。- 在循环的每一轮中,都抽取出一个数据分片,其长度与对应的MTU兼容。
- 创建一个新套接字缓冲区来保存抽取的数据分片,旧的IP首部可以稍作修改后重用。所有的分片都会分配一个共同的分片ID,以便在目标系统上重新组装分组。分片的顺序基于分片偏移量建立,此时也需要适当地设置。MF(more fragments)标志位也需要设置。只有序列中的最后一个分片可以将该标志位置0。
- 每个分片都在使用ip_send_check产生校验和之后,用output发送(如 ip_output 函数等)
-
路由 ip_route_input
在任何IP实现中,路由都是一个重要的部分,不仅在转发外部分组时需要
,而且也用于发送本地计算机产生的分组。查找数据从计算机“外出”的正确路径的问题,不仅在处理非本地地址时会遇到,在本地计算机有几个网络接口时,也会有此类问题
。即使只有一个物理上的网络适配器,也可能有环回设备这样的虚拟接口,同样会导致该问题
每个接收到的分组都属于下列3个类别之一:- 其目标是本地主机。
- 其目标是当前主机直接连接的计算机。
- 其目标是远程计算机,只能经由中间系统到达
前面部分都是第一类分组。这些分组将传递到更高层的协议,进行进一步处理.
所有到达的分组都会传递到路由子系统.如果分组的目标系统与本地主机直接连接,路由通常特化为查找对应的网卡。否则,必须根据路由选择信息来查找网关系统(以及与网关相关联的网卡),分组需要通过网关来发送
随着内核版本的演变,路由的实现逐渐牵涉越来越广泛的内容,现在占网络子系统源代码的很大一部分。由于
许多路由工作都对时间要求很高
,因而使用了缓存和冗长的散列表来加速工作
。这反映到路由相关的大量数据结构上。为节省篇幅,这里不去关注诸如在内核数据结构中查找正确路由之类的机制,只考察内核用于传递结果的数据结构。路由的起始点是
ip_route_input
函数,函数流程:- 它首先试图在路由缓存中查找路由(这里不讨论该主题,也不涉及多播路由选择的问题)
ip_route_input_slow
用于根据内核的数据结构来建立一个新的路由。基本上,该例程依赖于fib_lookup
,后者的隐式返回值(通过一个用作参数的指针)是一个fib_result
结构的实例,包含了我们需要的信息。fib
代表转发信息库,是一个表,用于管理内核保存的路由选择信息。
路由结果关联到一个套接字缓冲区,套接字缓冲区的dst成员指向一个
dst_entry
结构的实例,该实例的内容是在路由查找期间填充的。该数据结构的定义如下://include/net/dst.h struct dst_entry { struct net_device *dev;//指定了用于处理该分组的网络设备 struct neighbour *neighbour;//存储了计算机在本地网络中的IP和硬件地址,这可以通过网络访问层直接到达.neighbour实例由内核中实现ARP(address resolution protocol,地址转换协议)的ARP层创建,ARP协议负责将IP地址转换为硬件地址 //对需要交付到本地的分组,input设置为ip_local_deliver,而output设置为ip_rt_bug(该函数只向内核日志输出一个错误信息,因为在内核代码中对本地分组调用output是一种错误,不应该发生)。对于需要转发的分组,input设置为ip_forward,而output设置为ip_output函数。 int (*input)(struct sk_buff*);//用于处理进入的分组,根据分组不同指向不同函数 int (*output)(struct sk_buff*);//用于处理外出的分组,根据分组不同指向不同函数 }; //include/net/neighbour.h //实例由内核中实现ARP(address resolution protocol,地址转换协议)的ARP层创建,ARP协议负责将IP地址转换为硬件地址。因为dst_entry结构有一个成员指针指向neighbour实例,网络访问层的代码在分组通过网络适配器离开当前系统时可调用output函数 struct neighbour { struct net_device *dev;//保存了网络设备的数据结构 unsigned char ha[ALIGN(MAX_ADDR_LEN, sizeof(unsigned long))];//设备的硬件地址 int (*output)(struct sk_buff *skb);//指向适当的内核函数的指针,在通过网络适配器传输分组时必须调用 };
neighbour实例由内核中实现ARP(address resolution protocol,地址转换协议)的ARP层创建,ARP协议负责将IP地址转换为硬件地址。因为dst_entry结构有一个成员指针指向neighbour实例,网络访问层的代码在分组通过网络适配器离开当前系统时可调用output函数。
12.8.6 netfilter
netfilter是一个Linux内核框架
,使得可以根据动态定义的条件来过滤和操作分组。这显著增加了可能的网络选项的数目,从简单的防火墙,到对网络通信数据的详细分析,到复杂的、依赖于状态的分组过滤器
。由于netfilter的精巧设计,网络子系统只需要少量代码就可以达到上述目的。
-
扩展网络功能
netfilter框架向内核添加了下列能力- 根据状态及其他条件,对不同数据流方向(进入、外出、转发)进行分组过滤(packet filtering)
- NAT(network address translation,网络地址转换),根据某些规则来转换源地址和目标地址。例如,NAT可用于实现因特网连接的共享,有几台不直接连接到因特网的计算机可以共享一个因特网访问入口(通常称为IP伪装或透明代理)
- 分组处理(packet manghing)和操作(manipulation),根据特定的规则拆分和修改分组
可以通过在运行时向内核载入模块来增强netfilter功能。一个定义好的规则集,告知内核在何时使用各个模块的代码。内核和netfilter之间的接口保持在很小(小到不能再小)的规模上,尽可能使两个领域彼此隔离,避免二者的相互干扰并改进网络代码的稳定性
netfilter挂钩位于内核中各个位置,以支持netfilter代码的执行。这些不仅用于IPv4,也用于IPv6和DECNET协议。这里只讨论了IPv4,但其概念同样适用于其他两种协议
netfilter实现划分为如下两个部分:
- 内核代码中的挂钩,位于网络实现的核心,用于调用netfilter代码
- netfilter模块,其代码挂钩内部调用,但其独立于其余的网络代码。一组标准模块提供了常用的函数,但可以在扩展模块中定义用户相关的函数
iptables
由管理员用来配置防火墙、分组过滤器和类似功能,这些只是建立在netfilter框架上的模块,它提供了一个功能全面、定义良好的库函数集合,以便分组的处理。这里不会详细描述如何从用户空间激活和管理这些规则,可以参见网终管理方面的大量文献 -
调用挂钩函数
在通过挂钩执行netfilter代码时,网络层的函数将会被中断
。挂钩的一个重要特性是,它们将一个函数划分为两部分,前一部分在netfilter代码调用前运行,而后一部分在其后执行
。为什么要使用两个独立的函数,而不是调用一个特定netfilter函数执行所有相关的netfilter模块,然后返回到调用函数呢?这种方法初看起来确实有点复杂,但可以解释如下。它使得优化(或管理员)可以决定不将netfilter功能编译到内核中
,在这种情况下,网络函数可以在不降低速度的情况下执行。它也导致需要在网络实现中加入大量的预处理器语句,根据特定的配置选项(启用或禁用netfilter),在编译时选择适当的代码netfilter挂钩通过
<netfilter.h>
中的NF_HOOK宏
调用。如果内核启用的netfilter支持,该宏定义如下://include/linux/netfilter.h static inline int nf_hook_thresh(int pf, unsigned int hook, struct sk_buff *skb, struct net_device *indev, struct net_device *outdev, int (*okfn)(struct sk_buff *), int thresh, int cond) { if (!cond) return 1; //遍历所有注册的netfilter挂钩并调用它们。如果分组被接受,则返回1,否则返回其他的值 return nf_hook_slow(pf, hook, skb, indev, outdev, okfn, thresh); } #define NF_HOOK_THRESH(pf, hook, skb, indev, outdev, okfn, thresh) \ ({int __ret; \ if ((__ret=nf_hook_thresh(pf, hook, (skb), indev, outdev, okfn, thresh, 1)) == 1)\ __ret = (okfn)(skb); \ __ret;}) #define NF_HOOK(pf, hook, skb, indev, outdev, okfn) \ NF_HOOK_THRESH(pf, hook, skb, indev, outdev, okfn, INT_MIN)
宏参数语义如下。
- pf是指调用的netfilter挂钩源自哪个协议族。IPv4层的所有调用都使用PF_INET。
- hook是挂钩编号,可能的值都定义在
<netfilter_ipv4.h>
中。在IPv4中,值的名称如NF_IP_FORWARD和NF_IP_LOCAL_OUT(用于IPv4)。 - skb是所处理的套接字缓冲区。
- indev和outdev是指向网络设备的net_device实例的指针,分组通过二者进入和离开内核。这些值可以赋值为NULL指针,因为并非所有挂钩的相关信息都是已知的(例如,在查找路由之前,内核并不知道分组将通过哪个设备离开内核)。
- okfn是一个函数指针,原型为int (*okfn)(struct sk_buff *)。它在netfilter挂钩结束时执行。
该宏在展开时,首先迂回到NF_HOOK_THRESH和nf_hook_thresh,然后才通过nf_hook_slow来处理netfilter挂钩,并调用用于结束netfilter处理的okfn函数。这种看起来比较复杂的方法是必要的,因为内核也提供了一种可能性,即只考虑优先级高于一定阈值的netfilter挂钩,而忽略所有其他挂钩。就NF_HOOK来说,阈值设置为最小的可能整数值,这样每个挂钩函数都会得到处理。但仍然可以直接使用NF_HOOK_THRESH设置一个特定的阈值。由于当前只有桥接实现和IPv6的连接跟踪机制利用了该特性,这里不会进一步讨论。
考虑NF_HOOK_THRESH的实现。首先调用了
nf_hook_thresh
。该函数首先检查cond中给定的条件是否为真。如果不是,则直接向调用者返回1。否则,调用nf_hook_slow。该函数遍历所有注册的netfilter挂钩并调用它们。如果分组被接受,则返回1,否则返回其他的值。如果nf_hook_thresh返回1,即netfilter判定接受该分组,那么控制传递到okfn中指定的结束处理函数
例子:
IP转发代码包含了一个典型的NF_HOOK宏调用//net/ipv4/ip_forward.c int ip_forward(struct sk_buff *skb) { ... return NF_HOOK(PF_INET, NF_IP_FORWARD, skb, skb->dev, rt->u.dst.dev, ip_forward_finish); }
其中指定的okfn是ip_forward_finish。如果上述测试确定没有为PF_INET和NF_IP_FORWARD注册netfilter挂钩,那么控制直接传递到该函数。否则,执行相关的netfilter代码,控制因此转入ip_forward_finish(假定分组没有丢弃或在内核控制下删除)。如果没有安装挂钩,那么上述代码流程的效果,等同于将ip_forward和ip_forward_finish实现为一个连续的过程。
内核版本2.6.24开始,几乎在所有情况下都可以删除
ip_forward_finish
的内联定义,是因为GNU C编译器已经能够进行一项额外的优化:过程尾部调用。该机制起源于函数式语言,例如,对Scheme语言的实现来说,这种机制是必须的。如果一个函数作为另一个函数的最后一条语句被调用,那么被调用者在结束工作后是不必返回调用者的,因为其中已经无事可做。这使得可以对调用机制进行一些简化,使执行速度能够与旧的内联机制一样,而又没有内联机制的代码复制问题
,因而不会增加内核可执行文件的大小。但gcc并未对所有挂钩函数进行这种优化,仍然有少量挂钩函数是内联的如果没有启用netfilter配置,扫描
nf_hooks
数组是没有意义,宏NF_HOOK的定义有些不同://include/linux/netfilter.h #define NF_HOOK(pf, hook, skb, indev, outdev, okfn) (okfn)(skb)
对挂钩函数的调用,直接替换为对okfn中指定函数的调用(关键字inline告知编译器通过复制代码来完成该调用)。原来的两个函数现在合并为一个,也不需要再插入一个函数调用
-
扫描挂钩表
如果至少注册了一个挂钩函数并需要调用,那么会调用nf_hook_slow
。所有挂钩都保存在二维数组nf_hooks中//net/netfilter/core.c //保存所有挂钩,该表的list_head元素作为双链表表头,双链表中可容纳 nf_hook_ops 实例 struct list_head nf_hooks[NPROTO][NF_MAX_HOOKS] __read_mostly; //include/linux/netfilter.h // nf_hooks 链表的元素 struct nf_hook_ops { struct list_head list;//链表元素 /* User fills in from here down. */ nf_hookfn *hook;//指向挂钩函数的指针,它需要的参数与NF_HOOK宏相同 struct module *owner;//如果该挂钩实现为模块,owner是一个指向所属模块的module数据结构的指针) int pf;//指定了协议族,这信息还可以从挂钩链表在nf_hooks中的位置推断出来 int hooknum;//指定了与挂钩相关的编号,这信息还可以从挂钩链表在nf_hooks中的位置推断出来 /* Hooks are ordered in ascending priority. */ int priority;//优先级,链表中的挂钩是按照优先级升序排列的,nf_ip_hook_priorities 中有一些默认值 }; typedef unsigned int nf_hookfn(unsigned int hooknum, struct sk_buff *skb, const struct net_device *in, const struct net_device *out, int (*okfn)(struct sk_buff *)); //include/linux/netfilter_ipv4.h //struct nf_hook_ops->priority的一些默认值 enum nf_ip_hook_priorities { NF_IP_PRI_FIRST = INT_MIN, NF_IP_PRI_CONNTRACK_DEFRAG = -400, NF_IP_PRI_RAW = -300, NF_IP_PRI_SELINUX_FIRST = -225, NF_IP_PRI_CONNTRACK = -200, NF_IP_PRI_MANGLE = -150, NF_IP_PRI_NAT_DST = -100, NF_IP_PRI_FILTER = 0, NF_IP_PRI_NAT_SRC = 100, NF_IP_PRI_SELINUX_LAST = 225, NF_IP_PRI_CONNTRACK_HELPER = INT_MAX - 2, NF_IP_PRI_NAT_SEQ_ADJUST = INT_MAX - 1, NF_IP_PRI_CONNTRACK_CONFIRM = INT_MAX, NF_IP_PRI_LAST = INT_MAX, };
NPROTO
指定系统支持的协议族的最大数目(当前为34)
。各个协议族的符号常数,诸如PF_INET和PF_DECnet,保存在include/linux/socket.h
中。每个协议可以定义NF_MAX_HOOKS个挂钩链表,默认值是8个
可以根据协议族和挂钩编号从
nf_hook
数组中选择适当的链表。接下来的工作委托给nf_iterate
,该函数保留所有链表元素,并调用hook函数 -
激活挂钩函数
每个hook函数都返回下列值之一:NF_ACCEPT
表示接受分组。这意味着所述例程没有修改数据。内核将继续使用未修改的分组,使之穿过网络实现中剩余的协议层(或通过后续的挂钩)NF_STOLEN
表示挂钩函数“窃取”了一个分组并处理该分组。此时,该分组已与内核无关,不必再调用其他挂钩。还必须取消其他协议层的处理NF_DROP
通知内核丢弃该分组。如同NF_STOLEN,其他挂钩或网络层的处理都不再需要了。套接字缓冲区(和分组)占用的内存空间可以释放,因为其中包含的数据可以被丢弃,例如,挂钩可能认定分组是损坏的NF_QUEUE
将分组置于一个等待队列上,以便其数据可以由用户空间代码处理。不会执行其他挂钩函数NF_REPEAT
表示再次调用该挂钩
最终,除非所有挂钩函数都返回
NF_ACCEPT
(NF_REPEAT不是最终结果),否则分组不会在网络子系统进一步处理。所有其他的分组,不是被丢弃,就是由netfilter子系统处理内核提供了一个挂钩函数的集合,使得不必为每个场合都单独定义挂钩函数。这些称为
iptables
,用于分组的高层处理。它们使用用户空间工具iptables
配置,这里不讨论该工具
12.8.7 IPv6
-
概述和创新
1998年,定义了一项名为IPv6(该标准不能称之为IPv5,因为该名称已经用于STP协议,它也定义在一个RFC中,但公众对此了解很少。)的新标准。现在Linux内核对该标准的支持已经达到了产品级水平。该协议的完整实现在net/ipv6
目录下。网络层的模块化开放结构意味着IPv6可以利用现存的成熟的基础设施IPv6的一项关键改变是
采用了全新的分组格式
,其中使用了128位IP地址,因而可以更容易、更快速地处理。IPv6分组的结构如下图所示:该结构比IPv4简单得多。其首部只包含8个字段,不像IPv4那样是14个。 需要特别注意的是,
其中没有与分片相关的字段
。尽管IPv6也支持将分组数据划分为更小的单元,但相关信息保存在一个扩展首部中,next header字段即指向该首部。由于IPv6支持可变数量扩展首部,所以它能很容易引入新特性由于地址长度由32位增长到128位,IP地址的记号也发生了改变。维持此前的记号(字节数组,点分十进制记法)将导致超长的地址字符串。因而对IPv6地址优先使用十六进制记法,例如 EDC:BA98:7654:3210:FEDC:BA98:7654:3210 和 1080:0:0:0:8:800:200C:417A。混用IPv4和IPv6格式,将出现诸如0:0:0:0:0:FFFF:129.144.52.38这样的地址,也是允许的
-
实现
在IPv6分组穿过网络各个协议层时,会采用何种路径呢?在较低的协议层上,与IPv4相比不会有什么改变,因为其中使用的机制与高层协议无关。但在数据传递到互联网络层之后,改变很明显
。如下图给出了IPv6实现的(粗粒度的)代码流程图IPv4到IPv6的结构性变更并不多。尽管函数名称不同,但代码穿过内核所沿的路径大体上是相同的
12.9 传输层
两个基于IP的主要传输协议分别是UDP和TCP,前者用于发送数据报,后者可建立安全的、面向连接的服务。尽管UDP是一个简单的、易于实现的协议,但TCP有几个隐藏良好(不为人所知)的陷阱和障碍,这使得其实现比较复杂
12.9.1 UDP
ip_local_deliver
负责分发IP分组传输的数据内容。net/ipv4/udp.c
中的udp_rcv
用于进一步处理UDP数据报。相关的代码流程图如下:
udp_rcv
只是__udp4_lib_rcv
的一个包装器,因为它与RFC 3828
定义的UDP-lite
协议共享代码。
函数流程:
- 在确认分组未经篡改之后,必须用
__udp4_lib_lookup
查找与之匹配的监听套接字。连接参数可以从UDP首部获取,其结构如上图.“源端口”和“目的端口”分别指定了分组的源系统和目标系统的端口号,可接受的值为0~65535,因为二者使用的都是16位值。“长度”是分组(首部和数据)的总长度,按字节计算,“校验和”保存的是一个可选的校验和__udp4_lib_lookup
在全局udp_hash表中查找与分组目标端口匹配的sock结构实例
- 没找到则向源系统发送目标不可到达的消息,并丢弃分组内容
- 找到了调用 udp_queue_rcv_skb
- 调用 sock_queue_rcv_skb
- 将包含分组数据的套接字缓冲区插入到
sk_receive_queue
链表末端,其表头保存在特定于套接字的sock
结构中 - 调用
sk_data_ready
指向的函数(如果用标准函数sock_init_data来初始化sock实例,通常是sock_def_readable),通知套接字有新数据到达。这会唤醒在sk_sleep队列上睡眠、等待数据到达的所有进程
- 将包含分组数据的套接字缓冲区插入到
- 调用 sock_queue_rcv_skb
//include/linux/udp.h
//udp分组的首部
struct udphdr {
__be16 source;//源端口
__be16 dest;//目的端口
__be16 len;//长度(分组首部和数据)
__sum16 check;//校验和
};
//include/net/sock.h
//网络访问层(链路层)的接口,socket是到用户空间的接口
//简化版
struct sock {
wait_queue_head_t *sk_sleep;//等待通过套接字交付数据的进程,在sk_sleep等待队列上睡眠
struct sk_buff_head sk_receive_queue;//表头,接收数据时,需要将数据放置在包含套接字缓冲区的等待队列上,调用skb_queue_tail将包含分组数据的套接字缓冲区插入到sk_receive_queue链表末端,其表头保存在特定于套接字的sock结构中
void (*sk_data_ready)(struct sock *sk, int bytes);//调用sk_data_ready指向的函数(如果用标准函数sock_init_data来初始化sock实例,通常是sock_def_readable),通知套接字有新数据到达。这会唤醒在sk_sleep队列上睡眠、等待数据到达的所有进程。
}
12.9.2 TCP
TCP用于支持数据流安全传输的面向连接的通信模型,在内核中不仅需要更多的管理开销,还需要进一步的操作,诸如通过计算机之间的协商来显式建立连接。内核中TCP的实现,有很大一部分用于特定场景的处理(或防止)以及用于提高传输性能的优化,所有这些微妙奇异之处都不在这里讨论。
下面介绍TCP协议的3个主要部分(连接建立、连接终止和数据流的按序传输
),在考察实现之前,首先来描述标准本身对过程的一些要求
。
TCP连接总处于某个明确定义的状态
。这些状态包括listen
和established
状态。还有其他状态,以及明确定义的规则,用于各个状态之间的迁移,如下图:
它包含的信息几乎完全描述了TCP实现的行为。本质上,内核可以区分各个状态并实现状态间的迁移(使用称为有限状态机的工具)。但这既不特别高效,也不快速,因此内核采用了一种不同的方法。但在描述各个TCP操作时,将不断引用该图,并将其作为进行考察的基础
-
TCP首部
TCP分组的首部包含了状态数据和其他连接信息:
- source和dest指定了所用的端口号。类似于UDP,二者都是2字节
- seq是一个序列号。它指定了TCP分组在数据流中的位置,在数据丢失需要重新传输时很重要。
- ack_seq包含了一个序列号,在确认收到TCP分组时使用。
- doff表示数据偏移量(data offset)并指定了TCP首部结构的长度,由于一些选项是可变的,其值并不总是相同的
- reserved不可用(因而总是应该设置为0)
- urg(紧急)、ack(确认)、psh(推)、rst(重置)、syn(同步)和fin都是控制标志,用于检查、建立和结束连接
- window告诉连接的另一方,在接收方的缓冲区满之前,可以发送多少字节。这用于在快速的发送方与低速接收方通信时防止数据的积压
- checksum是分组的校验和。
- options是可变长度列表,包含了额外的连接选项
- 实际数据(或净荷)在首部之后。options字段可能需要补齐,因为数据必须起始于32位边界位置(为了简化处理)
首部由
tcphdr
数据结构实现。必须注意系统的字节序,因为其中使用了位域字段//include/linux/tcp.h //tcp首部数据结构 struct tcphdr { __be16 source;//源端口 __be16 dest;//目的端口 __be32 seq;//序列号。它指定了TCP分组在数据流中的位置,在数据丢失需要重新传输时很重要 __be32 ack_seq;//确认序列号,在确认收到TCP分组时使用 #if defined(__LITTLE_ENDIAN_BITFIELD) __u16 res1:4, doff:4, fin:1, syn:1, rst:1, psh:1, ack:1, urg:1, ece:1, cwr:1; #elif defined(__BIG_ENDIAN_BITFIELD) __u16 doff:4,//数据偏移量(data offset)并指定了TCP首部结构的长度,由于一些选项是可变的,其值并不总是相同的 res1:4,//不可用(因而总是应该设置为0) cwr:1, ece:1, urg:1,//紧急,控制标志,用于检查、建立和结束连接 ack:1,//确认,控制标志,用于检查、建立和结束连接 psh:1,//推,控制标志,用于检查、建立和结束连接 rst:1,//重置,控制标志,用于检查、建立和结束连接 syn:1,//同步,控制标志,用于检查、建立和结束连接 fin:1;//,控制标志,用于检查、建立和结束连接 #else #error "Adjust your <asm/byteorder.h> defines" #endif __be16 window;//告诉连接的另一方,在接收方的缓冲区满之前,可以发送多少字节。这用于在快速的发送方与低速接收方通信时防止数据的积压 __sum16 check;//分组的校验和 __be16 urg_ptr;//紧急指针 };
-
接收TCP数据 tcp_v4_rcv(IP层进入TCP层的入口)
所有TCP操作(连接建立和关闭,数据传输)都是通过发送带有各种属性和标志的分组来进行的
。在讨论状态迁移之前,必须确定TCP数据是如何传递到传输层的,且首部中的信息在何处进行分析系统中的每个TCP套接字都归入3个散列表之一
,分别接受下列状态的套接字- 完全连接的套接字
- 等待连接(监听状态)的套接字
- 处于建立连接过程中(使用下文讨论的三次握手)的套接字
在互联网络层处理过分组之后,
tcp_v4_rcv
是TCP层的入口。代码流程图如下:
函数流程:
- 对分组数据进行各种检查并将首部中的信息复制到套接字缓冲区的控制块
- 查找等待该分组的套接字 __inet_lookup
- __inet_lookup_established 检查已连接的套接字,如果失败向下执行
- inet_lookup_listener 检查所有的监听套接字
- 调用 tcp_v4_do_rcv 根据套接字不同状态执行不同程序
-
三次握手
在可以使用TCP链路之前,必须在客户端和主机之间显式建立连接。在主动(active) 和被动(passive)连接的建立方式是有区别的内核(即连接所涉及的两台机器的内核)在连接建立之前,会看到下述情形:
客户端进程的套接字状态为CLOSED,而服务器套接字的状态是LISTEN
建立TCP连接的过程需要交换3个TCP分组
,因而称为三次握手
(three-way handshake)。根据上图中的状态图,会发生下列操作。- 客户端通过向服务器发送
SYN
来发出连接请求(这是一个SYN
标志位置位的空分组的名称)。客户端的套接字状态由CLOSED
变为SYN_SENT
- 服务器在一个监听套接字上接收到连接请求,并返回
SYN
和ACK
(这一步骤可划分为两部分,发送的第一个分组ACK
置位,第二个分组SYN
置位,但实际上没有这样做)。服务器套接字的状态由LISTEN
变为SYN_RECV
- 客户端套接字接收到
SYN/ACK
分组后,切换到ESTABLISHED
状态,表明连接已经建立。一个ACK
分组被发送到服务器 - 服务器接收到
ACK
分组,也切换到ESTABLISHED
状态。这就完成了两端的连接建立工作,可以开始数据交换。
原则上,可以仅使用一个或两个分组建立连接。但这可能带来一种风险,由于与同一地址(IP地址和端口号)之间的旧连接的延期分组的存在,可能导致建立有缺陷的连接。三次握手的目的就是要防止这种情况
在连接建立后
,TCP链路的特点就很清楚了。每个分组发送时都指定一个序列号,而接收方的TCP协议实例在接收到每个分组之后,都必须确认
。我们考察一下向Web服务器发出的连接请求的记录(网络连接数据可以用诸如tcpdump和wireshark之类的工具捕获。)1 192.168.0.143 192.168.1.10 TCP 1025 > http [SYN] Seq=2895263889 Ack=0 2 192.168.1.10 192.168.0.143 TCP http > 1025 [SYN, ACK] Seq=2882478813 Ack=2895263890 3 192.168.0.143 192.168.1.10 TCP 1025 > http [ACK] Seq=2895263890 Ack=2882478814
客户端对第一个分组生成随机的序列号2895263889,
保存在TCP首部的SEQ字段
。服务器对该分组的到达,响应一个组合的SYN/ACK分组,序列号是新的(在本例中是2882478813)。我们在这里感兴趣的是SEQ/ACK字段的内容(数值字段,不是标志位)。服务器填充该字段时,将接收到的字节数目加1,再加到接收的序列号上(底层的原理,在下文讨论)分组还需要设置ACK标志,这用于向客户端表示已经接收到第一个分组
。无须产生额外的分组来确认收到第一个分组。确认可以在任何分组中给出,只要该分组设置了ACK标志并填充ack字段即可。为建立连接而发送的分组不包含数据
,只有TCP首部是有意义的。首部中len字段存储的长度总是0
。这里描述的机制不是特定于Linux内核的,对所有希望通过TCP通信的操作系统来说,都是必须实现的。下面几节将更多阐述上述操作特定于Linux内核的实现
- 客户端通过向服务器发送
-
被动连接建立(服务器在LISTEN状态等待客户端连接)
服务器收到客户端的握手第一阶段后的处理
:
被动连接建立并不源于内核本身,而是在接收到一个连接请求的SYN分组后触发的。因而其起点是tcp_v4_rcv
函数,如上文所述,该函数查找一个监听套接字,并将控制权转移到tcp_v4_do_rcv
,其代码流程图如下:
tcp_v4_rcv 特定于被动连接的流程如下:
- 将tcp首部中的信息复制到套接字缓冲区的控制块
- 从已连接和监听的套接字hash表中查找等待当前分组的套接字 __inet_lookup
- 多路分解器,基于套接字状态将代码控制流划分为不同的分支。 tcp_v4_do_rcv
- sk->sk_state == TCP_LISTEN => 执行网络层中建立新连接所需的各种初始化任务 tcp_v4_hnd_req
- 状态迁移,区分各种可能的套接字状态来调用适当的传输函数 tcp_rcv_state_process
- TCP_LISTEN 状态调用 icsk->icsk_af_ops->conn_request(sk, skb),为 tcp_v4_conn_request 函数,该函数结束前发送的确认分组,其中不仅包含了设置的ACK标志和接收到的分组的序列号,还包括新生成的序列号和SYN标志,这是三次握手过程的要求。这样就完成了连接建立的第一阶段,将状态设置为
SYN_RECV
- TCP_LISTEN 状态调用 icsk->icsk_af_ops->conn_request(sk, skb),为 tcp_v4_conn_request 函数,该函数结束前发送的确认分组,其中不仅包含了设置的ACK标志和接收到的分组的序列号,还包括新生成的序列号和SYN标志,这是三次握手过程的要求。这样就完成了连接建立的第一阶段,将状态设置为
接着等待客户端应答:
- tcp_rcv_state_process=>case TCP_SYN_RECV,收到应答后设为连接状态 tcp_set_state(sk, TCP_ESTABLISHED);
//include/net/tcp_states.h //tcp套接字状态枚举 enum { TCP_ESTABLISHED = 1, TCP_SYN_SENT, TCP_SYN_RECV, TCP_FIN_WAIT1, TCP_FIN_WAIT2, TCP_TIME_WAIT, TCP_CLOSE, TCP_CLOSE_WAIT, TCP_LAST_ACK, TCP_LISTEN, TCP_CLOSING, /* Now a valid state */ TCP_MAX_STATES /* Leave at the end! */ };
-
主动连接建立(客户端连接服务器)
主动连接建立发起时,是通过用户空间应用程序调用open
库函数,发出socketcall
系统调用到达内核函数tcp_v4_connect
,其代码流程图如下图的上半部所示:
函数流程如下:
- 该函数开始于查找到目标主机的IP路由 ip_route_connect
- 在产生TCP首部并将相关的值设置到套接字缓冲区中之后,套接字状态从
CLOSED
改变为SYN_SENT
。 tcp_set_state(sk, TCP_SYN_SENT); - 接下来
tcp_connect
将一个SYN
分组发送到互联网络层,接下来到服务器端。此外,在内核中创建一个定时器,确保如果在一定的时间内没有接收到确认,将重新发送分组 tcp_connect
现在客户端必须等待服务器对SYN分组的确认以及确认连接请求的一个SYN分组,这是通过普通的TCP机制接收的(上图的下半部)。
客户端的tcp_rcv_state_process
流程:
4. case TCP_SYN_SENT => tcp_rcv_synsent_state_process
1. 将套接字状态设置为ESTABLISHED
2. 并向服务器应答ACK分组,完成连接建立 tcp_send_ack -
分组传输(框架介绍)
在按照上述方式建立一个连接之后,数据即可在计算机之间传输。该过程在某些情况下相当棘手,因为TCP的特性
,如下所述:- 按可保证的次序传输字节流。
- 通过自动化机制重传丢失的分组
- 每个方向上的数据流都独立控制,并与对应主机的速度匹配。
在讲述如何通过已建立的连接来实现数据传输之前,必须讨论一些底层的原理。我们对
数据丢失时发挥作用的机制
特别感兴趣基于序列号来确认分组的概念,也用于普通的分组。但与上文提到的内容相比,序列号揭示了有关数据传输的更多东西。序列号根据何种方案分配?
在建立连接时,生成一个随机数
(由内核使用drivers/char/random.c
中的secure_tcp_sequence_number
生成)。接下来使用一种系统化的方法来支持对所有进入分组的严格确认在最初发送的序列号基础上,会为TCP传输的每个字节都分配一个唯一的序列号。例如,假定TCP系统的初始随机数是100。因而,发送的前16个分组的序列号为100、101……115
TCP使用一种累积式确认(cumulative acknowlegment)方案。这意味着一次确认将涵盖一个连续的字节范围。通过ack字段发送的数字将确认数据流在上一个ACK数目和当前ACK数目之间的所有字节。(如果尚未发送确认,没有上一个ACK数目,则将初始序列号作为起点。)
ACK数目确认了此前所有的字节,其中最后一个字节的索引号比ACK数目小1
,因而ACK数目也表示了下一个字节的索引号
。例如,ACK数目166确认了字节索引165之前(含)的所有字节,预期下一个分组中从字节166开始该机制也用于跟踪丢失的分组
。请注意,TCP没有提供显式的重传请求机制
。换句话说,接收方不能请求发送方重传丢失的分组。如果在一定的超时时间内发送方没有收到确认,则发送丢失的部分数据是发送方的责任我们假定连接已经按上述的方式建立,因此有两个套接字(在不同的系统上)都设置为
ESTABLISHED
状态 -
接收分组(TCP连接后接收数据)
从tcp_v4_rcv
函数开始的接收分组的代码调用流程如下:
- tcp_v4_rcv
- tcp_v4_do_rcv 多路分解器,基于套接字状态将代码控制流划分为不同的分支,进入 TCP_ESTABLISHED 分支
- tcp_rcv_established 处理TCP连接后接收的数据
- tcp_v4_do_rcv 多路分解器,基于套接字状态将代码控制流划分为不同的分支,进入 TCP_ESTABLISHED 分支
tcp_rcv_established
函数处理的分组需要符合下列条件之一,才能归类为快速路径(fast path),否则进入低速路径(slow path)- 分组必须只包含对上一次发送数据的确认
- 分组必须只包含预期将接收的数据
- 此外,下列标志都不能设置:SYN、URG、RST或FIN
几乎所有分组都属于这些类别
tcp_rcv_established 函数快速路径流程:
- 检查分组,找到更为复杂的分组,并将其返回到低速路径
- 分析分组长度,确认分组的内容是数据还是确认,ACK分组不包含数据,与TCP首部长度是相同的,ACK分组数据用 tcp_ack 函数处理
- tcp_ack 函数 应答ack包,在这里,过时的分组以及由于接收方的TCP实现缺陷或传输错误和超时等造成的发送过早的分组,都被过滤出去。该函数最重要的任务不仅包括分析有关连接的新信息(例如,接收窗口信息)和其他TCP协议的微妙之处,还需要从重传队列中删除确认数据。该队列包含了所有发送的分组,如果在一定的时间限制内没有收到ACK确认,则需要重传。
- 调用 sk->sk_data_ready(sk, 0) 通知用户进程有新数据可用
tcp_rcv_established 函数低速路径要处理许多TCP选项,低速路径中的代码要牵涉到更广泛的内容
在低速路径中,数据不能直接转发到套接字,因为必须对分组选项进行复杂的检查,而后可以是TCP子系统的响应。不按序到达的数据放置到一个专门的等待队列上,直至形成一个连续的数据段,才会被处理
。只有到那时,才能将完整的数据传递到套接字 - tcp_v4_rcv
-
发送分组 tcp_sendmsg
从TCP层来看,TCP分组的发送,由更高层网络协议实例对tcp_sendmsg
函数的调用开始。流程图如下:
函数流程:
- 如果套接字状态不是已连接状态,调用
sk_stream_wait_connect
一直等待到连接 - 将数据从用户空间进程的地址空间复制到内核空间,并建立TCP分组
- 发送数据 tcp_push_one
- 检查目前是否可以发送数据。接收方过载导致的分组积压,可能使得现在无法发送数据。 tcp_snd_test
- 使用地址族相关的af_specific->queue_xmit函数(IPv4使用的是ip_queue_xmit),将数据转发到互联网络层. tcp_transmit_skb
- 处理对统计量的更新。更重要的是,它会初始化所发送TCP信息段(TCPsegment)的重传定时器。不必对每个TCP分组都这样做,该机制只用于已经确认的数据区之后的第一个分组. update_send_head
发送TCP分组的工作,并不仅仅限于构建一个首部并转入互联网络层。还必须遵守下列需求(这绝不是完备的列表)
4. 接收方等待队列上必须有足够的空间可用于该数据
5. 必须实现防止连接拥塞的ECN机制
6. 必须检测某一方出现失效的情况,以免通信出现停顿
7. TCP慢启动(slow-start)机制要求在通信开始时,逐渐增大分组长度
8. 发送但未得到确认的分组,必须在一定的超时时间间隔之后反复重传,直至接收方最终确认重置TCP重传定时器函数
为inet_csk_reset_xmit_timer
,该定时器是未确认分组重发的基础,是TCP传输的一种保证。如果接收方在一定的时间内没有确认收到数据,则重传数据。与特定套接字关联的sock实例中包含了一个重传计时器的链表
,用于发送的每个分组。内核使用的超时函数
是tcp_write_timer
,如果没有收到ACK
,该函数会调用tcp_retransmit_timer
函数。在重传数据时,必须注意下列问题- 连接在此期间可能已经关闭。在这种情况下,保存的分组和定时器将从内核内存中删除
- 如果重传尝试的次数超过了
sysctl_tcp_retries2
变量指定的限制,则放弃重传(该变量的默认值是15,但可以使用/proc/sys/net/ipv4/tcp_retries2
修改)
在收到ACK之后,删除相应分组的重传定时器
- 如果套接字状态不是已连接状态,调用
-
连接终止
类似于连接建立,TCP连接的关闭也是通过一系列分组交换完成的.连接可以采用下列两种方法关闭- 在参与传输的某一方(偶尔也会两个系统同时发出请求的情况)显式请求关闭连接时,连接会以优雅关闭(graceful close)的方式终止
- 高层协议有可能导致连接终止或异常中止(例如,可能因为程序崩溃)
只讨论第一种情况
为了优雅地关闭连接,TCP连接的参与方必须交换4个分组
。各个步骤的顺序描述如下:- 计算机A调用标准库函数close,发出一个TCP分组,首部中的FIN标志置位。A的套接字切换到FIN_WAIT_1状态
- B收到FIN分组并返回一个ACK分组。其套接字状态从ESTABLISHED改变为CLOSE_WAIT。收到FIN后,以“文件结束”的方式通知套接字
- 在收到ACK分组之后,计算机A的套接字状态从FIN_WAIT_1变为FIN_WAIT_2
- 计算机B上与对应套接字相关的应用程序也执行close,从B向A发送FIN分组。计算机B的套接字状态变为LAST_ACK
- 计算机A用一个ACK分组确认B发送的FIN,然后首先进入TIME_WAIT状态,接下来在一定时间后自动切换到CLOSED状态
- 计算机B收到ACK分组,其套接字也切换到CLOSED状态
状态迁移在中枢的分配器函数(
tcp_rcv_state_process
)中进行,可能的代码路径包括处理现存连接的tcp_rcv_established
,以及尚未讨论的tcp_close
函数主动调用close
:- 用户层close
- tcp_close
- 如果套接字的状态为LISTEN(即没有到另一台计算机的连接),则将套接字的状态改为CLOSED
- 否则在通过
tcp_close_state
并tcp_set_state
调用链将套接字状态设置为TCP_FIN_WAIT1
之后,tcp_send_fin
向另一方发送一个FIN分组(这种方法与TCP标准是不完全兼容的,因为在FIN发送之前,套接字实际上不允许改变其状态。但Linux提出的替换方案更简单,易于实现,在实用中也没有引起任何问题。这也是内核开发者沿着这条路走下来的原因,在tcp_close中的相关注释中提到了这一点。)
- tcp_close
从
TCP_FIN_WAIT1
到TCP_FIN_WAIT2
状态的迁移通过中枢的分配器函数tcp_rcv_state_process
进行,因为不再需要采取快速路径处理现存连接。
我们熟悉的一种情况是,收到的带有ACK
标志的分组触发到TCP_FIN_WAIT2
状态的迁移,具体的状态迁移通过tcp_set_state
进行。现在只需要从另一方发送过来的一个FIN
分组,即可将TCP连接置为TCP_TIME_WAIT
状态(然后会自动切换到TCP_CLOSE
状态)TCP另一方调用close
:
在收到第一个FIN分组因而需要被动关闭连接的另一方,状态迁移的过程是类似的。因为收到第一个FIN分组是套接字状态为ESTABLISHED
,处理由tcp_rcv_established
的低速路径进行,涉及向另一方发送一个ACK分组,并将套接字状态改为TCP_CLOSING
下一个状态转移(到TCP_LAST_ACK
)是通过调用close库函数(进而调用了内核的tcp_close_state
函数)进行的。此时,只需要另一方再发送一个ACK分组,即可终止连接。该分组也是通过tcp_rcv_state_process
函数处理,该函数将套接字状态改为CLOSED(通过tcp_done),释放套接字占用的内存空间,并最终终止连接上文只描述了从FIN_WAIT_1状态可能发生的状态迁移。根据图中TCP有限状态自动机所示,其他两种备选方案由内核实现,但与上文描述的路径相比,备选方案的使用要少得多,因此我们有充足的理由不在这里阐述相关内容。
12.10 应用层
内核与用户空间套接字之间的接口实现在C标准库中,使用了socketcall
系统调用
socketcall充当一个多路分解器,将各种任务分配由不同的过程执行,例如打开一个套接字、绑定或发送数据
Linux采用了内核套接字
的概念,使得与用户空间中的套接字的通信尽可能简单。对程序使用的每个套接字来说,都对应于一个socket结构和sock结构的实例。二者分别充当向下(到内核)和向上的(到用户空间)接口
。前几节已经提到了这两个结构,但没有详细定义,现在将给出其定义。
12.10.1 socket数据结构
//include/linux/net.h
struct socket {
socket_state state;//表示套接字的连接状态 SS_FREE 等
unsigned long flags;
const struct proto_ops *ops;//处理套接字的特定于协议的函数
struct file *file;//指向一个伪文件的file实例,用于与套接字通信
struct sock *sk;
short type;//指定所用协议类型的数字标识符
};
typedef enum {
SS_FREE = 0, /* not allocated *///未分配
SS_UNCONNECTED, /* unconnected to any socket *///未连接到任何套接字
SS_CONNECTING, /* in process of connecting *///处于连接过程中
SS_CONNECTED, /* connected to socket *///已经连接到另一个套接字
SS_DISCONNECTING /* in process of disconnecting *///处于断开连接过程中
} socket_state;
struct proto_ops {
int family;
struct module *owner;
int (*release) (struct socket *sock);
int (*bind) (struct socket *sock,
struct sockaddr *myaddr,
int sockaddr_len);
int (*connect) (struct socket *sock,
struct sockaddr *vaddr,
int sockaddr_len, int flags);
int (*socketpair)(struct socket *sock1,
struct socket *sock2);
int (*accept) (struct socket *sock,
struct socket *newsock, int flags);
int (*getname) (struct socket *sock,
struct sockaddr *addr,
int *sockaddr_len, int peer);
unsigned int (*poll) (struct file *file, struct socket *sock,
struct poll_table_struct *wait);
int (*ioctl) (struct socket *sock, unsigned int cmd,
unsigned long arg);
int (*compat_ioctl) (struct socket *sock, unsigned int cmd,
unsigned long arg);
int (*listen) (struct socket *sock, int len);
int (*shutdown) (struct socket *sock, int flags);
int (*setsockopt)(struct socket *sock, int level,
int optname, char __user *optval, int optlen);
int (*getsockopt)(struct socket *sock, int level,
int optname, char __user *optval, int __user *optlen);
int (*compat_setsockopt)(struct socket *sock, int level,
int optname, char __user *optval, int optlen);
int (*compat_getsockopt)(struct socket *sock, int level,
int optname, char __user *optval, int __user *optlen);
int (*sendmsg) (struct kiocb *iocb, struct socket *sock,
struct msghdr *m, size_t total_len);
int (*recvmsg) (struct kiocb *iocb, struct socket *sock,
struct msghdr *m, size_t total_len,
int flags);
int (*mmap) (struct file *file, struct socket *sock,
struct vm_area_struct * vma);
ssize_t (*sendpage) (struct socket *sock, struct page *page,
int offset, size_t size, int flags);
};
socket_state
列出的枚举值,与传输层协议在建立和关闭连接时使用的状态值毫不相关。它们表示与外界(即用户程序)相关的一般性状态
proto_ops
中许多函数指针与C标准库中的对应函数同名,C库函数会通过socketcall系统调用执行上述的函数指针
结构中包含的sock指针,包含了对内核有意义的附加的套字管理数据。内核自身将最重要的一些成员放置到sock_common
结构中,并将该结构的一个实例嵌入到struct sock
开始处
//include/net/sock.h
struct sock_common {
unsigned short skc_family;
volatile unsigned char skc_state;
struct hlist_node skc_node;//散列表的表元
atomic_t skc_refcnt;
unsigned int skc_hash;//散列值
struct proto *skc_prot;
};
//网络访问层(链路层)的接口,socket是到用户空间的接口
struct sock {
struct sock_common __sk_common;
struct sk_buff_head sk_receive_queue;//表头,接收数据时,需要将数据放置在包含套接字缓冲区的等待队列上,调用skb_queue_tail将包含分组数据的套接字缓冲区插入到sk_receive_queue链表末端,其表头保存在特定于套接字的sock结构中
struct sk_buff_head sk_write_queue;//发送数据时,需要将数据放置在包含套接字缓冲区的等待队列上,
struct timer_list sk_timer;//TCP重传计时器链表
void (*sk_data_ready)(struct sock *sk, int bytes);//调用sk_data_ready指向的函数(如果用标准函数sock_init_data来初始化sock实例,通常是sock_def_readable),通知套接字有新数据到达。这会唤醒在sk_sleep队列上睡眠、等待数据到达的所有进程。
};
系统的各个sock结构实例被组织到一个协议相关的散列表中
。skc_node
用作散列表的表元,而skc_hash
表示散列值。
在发送和接收数据时,需要将数据放置在包含套接字缓冲区的等待队列上(sk_receive_queue
和sk_write_queue
)。
此外,每个sock结构都关联了一组回调函数函数,由内核用来引起用户程序对特定事件的关注或进行状态改变。在简化版本中,只有一个函数指针sk_data_ready
,因为它是最重要的。在数据到达后,需要用户进程处理时,将调用该指针指向的函数。通常,指针的值是sock_def_readable
socket结构的ops成员类型为struct proto_ops,而sock的prot成员类型为struct proto,二者很容易混淆。后者定义如下:
//include/net/sock.h
struct proto {
void (*close)(struct sock *sk,
long timeout);
int (*connect)(struct sock *sk,
struct sockaddr *uaddr,
int addr_len);
int (*disconnect)(struct sock *sk, int flags);
struct sock * (*accept) (struct sock *sk, int flags, int *err);
int (*ioctl)(struct sock *sk, int cmd,
unsigned long arg);
int (*init)(struct sock *sk);
int (*destroy)(struct sock *sk);
void (*shutdown)(struct sock *sk, int how);
int (*setsockopt)(struct sock *sk, int level,
int optname, char __user *optval,
int optlen);
int (*getsockopt)(struct sock *sk, int level,
int optname, char __user *optval,
int __user *option);
int (*sendmsg)(struct kiocb *iocb, struct sock *sk,
struct msghdr *msg, size_t len);
int (*recvmsg)(struct kiocb *iocb, struct sock *sk,
struct msghdr *msg,
size_t len, int noblock, int flags,
int *addr_len);
int (*sendpage)(struct sock *sk, struct page *page,
int offset, size_t size, int flags);
int (*bind)(struct sock *sk,
struct sockaddr *uaddr, int addr_len);
};
这两个结构中有些成员的名称相似(经常是相同的),但它们表示不同的功能。proto的操作用于(内核端)套接字层和传输层之间的通信
,而socket结构的ops成员所包含的各个函数指针则用于与系统调用通信
。换句话说,它们构成了用户端和内核端套接字之间的关联。
12.10.2 套接字和文件
在连接建立后,用户空间进程使用普通的文件操作来访问套接字。每个套接字都分配了一个该类型的inode,inode又关联到另一个与普通文件相关的结构。用于操作文件的函数保存在一个单独的指针表中
//include/linux/fs.h
struct inode {
const struct file_operations *i_fop;//对文件操作的函数集合的指针(如打开,读取,写入等),由虚拟文件系统使用来处理块设备 /* former ->i_op->default_file_ops */
};
因而,对套接字文件描述符的文件操作,可以透明地重定向到网络子系统的代码。套接字使用的file_operations如下:
//net/socket.c
static const struct file_operations socket_file_ops = {
.owner = THIS_MODULE,
.llseek = no_llseek,
.aio_read = sock_aio_read,
.aio_write = sock_aio_write,
.poll = sock_poll,
.unlocked_ioctl = sock_ioctl,
#ifdef CONFIG_COMPAT
.compat_ioctl = compat_sock_ioctl,
#endif
.mmap = sock_mmap,
.open = sock_no_open, /* special open code to disallow open via /proc */
.release = sock_close,
.fasync = sock_fasync,
.sendpage = sock_sendpage,
.splice_write = generic_splice_sendpage,
};
前缀为sock_的函数都是简单的包装器例程,它们会调用sock_operations中的例程,如下例中的sock_mmap所示:
//net/socket.c
static int sock_mmap(struct file *file, struct vm_area_struct *vma)
{
struct socket *sock = file->private_data;
return sock->ops->mmap(file, sock, vma);
}
inode和套接字的关联,是通过下列辅助结构,将对应的两个结构实例分配到内存中的连续位置
//include/net/sock.h
struct socket_alloc {
struct socket socket;
struct inode vfs_inode;
};
内核提供了两个宏来进行必要的指针运算,根据inode找到相关的套接字实例(SOCKET_I
),或反过来(SOCK_INODE
)。为简化处理,每当将一个套接字附加到文件时,sock_attach_fd
将struct file
的private_data
成员设置为指向socket
实例。上面给出的sock_mmap
例子就利用了这一点。
12.10.3 socketcall系统调用
文件功能中的读写操作可以通过虚拟文件系统相关系统调用进入内核,然后重定向到socket_file_ops
结构的函数指针,除此之外,还需要对套接字执行其他任务,这些不能融入到文件方案中。举例来说,这些操作包括创建套接字、bind、listen等
为此,Linux提供了socketcall
系统调用,它实现在sys_socketcall
中
17个套接字操作只对应到一个系统调用,这比较引人注目。由于所要处理的任务不同,参数列表可能差别很大。该系统调用的第一个参数是一个数值常数,选择所要的系统调用。例如,可能的值包括SYS_SOCKET、SYS_BIND、SYS_ACCEPT和SYS_RECV。标准库的例程名称与这些常数基本上是一一对应的,但在内部都重定向为使用socketcall和对应的常数。只有一个系统调用,是由历史原因造成的
sys_socketcall的任务并不特别困难,它只充当一个分派器,将系统调用转到其他函数并传递参数,后者中的每个函数都实现了一个“小”的系统调用
//net/socket.c
asmlinkage long sys_socketcall(int call, unsigned long __user *args)
尽管目标函数所遵循的命名规则与对应的库函数相同,但它们只能经由socketcall调用,而无法通过任何其他系统调用访问
socketcall的各个“子调用”如下:
12.10.4 创建套接字 sys_socket(socket)
sys_socket
是创建新套接字的起点。相关的代码流程图如下:
函数流程:
- sys_socket
- sock_create 创建一个新的套接字数据结构
- __sock_create
- sock_alloc 分配所需内存,socket和inode的
- net_families[family]->create 为套接字分配内存后,刚好调用函数create。inet_create用于因特网连接(TCP和UDP都使用该函数)。它创建一个内核内部的sock实例,尽可能初始化它,并将其插入到内核的数据结构
- __sock_create
- sock_map_fd 为套接字创建一个伪文件(文件操作通过socket_ops指定)。还要分配一个文件描述符,将其作为系统调用的结果返回
- sock_create 创建一个新的套接字数据结构
//net/socket.c
//内核的所有传输协议,sock_register用于向该数据库增加新数据项
static const struct net_proto_family *net_families[NPROTO] __read_mostly;
//include/linux/net.h
struct net_proto_family {
int family;
int (*create)(struct net *net, struct socket *sock, int protocol);
struct module *owner;
};
12.10.5 接收数据 sys_recvfrom
使用recvfrom
和recv
以及与文件相关的readv
和read
函数来接收数据。因为这些函数的代码非常类似,在处理过程的早期就合并起来,因此我们只讨论sys_recvfrom
,代码流程图如下:
- sys_recvfrom
- fget_light 根据task_struct的描述符表,查找对应的file实例
- sock_from_file 确定与之关联的inode,并通过使用
SOCKET_I
最终找到相关的套接字 - sock_recvmsg 调用特定于协议的接收例程sock->ops->recvmsg.例如,TCP使用 tcp_recvmsg 来完成该工作。UDP使用的例程是 udp_recvmsg
- udp_recvmsg
- skb_recv_datagram
- skb = skb_dequeue(&sk->sk_receive_queue); 如果接收队列(通过sock结构的 sk_receive_queue 成员实现)上至少有一个分组,则移除并返回该分组
- while (!wait_for_packet(sk, err, &timeo)); 如果接收队列是空的,显然没有数据可以传递 到用户进程。在这种情况下,进程使用wait_for_packet使自身睡眠,直至数据到达
- skb_recv_datagram
- udp_recvmsg
- move_addr_to_user 将数据从内核空间复制到用户空间,使用了copy_to_user函数
TCP的实现遵循了类似的模式,但其中涉及许多细节和协议的奇异之处,因而要稍微复杂一些
12.10.6 发送数据 sys_sendto
用户空间程序在发送数据时,还有几种可供选择的方法。它们可以使用两个与网络有关的库函数
(sendto和send)或文件层的write和writev函数。同样,这些函数的控制流在内核中的特定位置会
合并为一,因此,考察上述第一个函数的实现(在内核源代码的sys_sendto过程中)即足以。相关
的代码流程图如下:
- sys_sendto
- fget_light 根据task_struct的描述符表,查找对应的file实例
- sock_from_file 确定与之关联的inode,并通过使用SOCKET_I最终找到相关的套接字
- move_addr_to_kernel 从用户空间复制到内核空间
- sock_sendmsg 调用特定于协议的发送例程sock->ops->sendmsg。该例程产生一个所需协议格式的分组,并转发到更低的协议层
12.11 内核内部的网络通信
与其他主机通信,不只是用户层应用程序的需求。内核同样需要与其他计算机通信,即使没有用户层的显式请求。这不仅仅对一些古怪的特性(如某些发行版包含在内核内部的Web服务器)有用。网络文件系统如CIFS或NCPFS都依赖于内核内部提供的网络通信支持
但这尚未满足内核在通信方面的所有需求,还遗漏了最后一部分:各个内核组件之间的通信,以及用户层和内核之间的通信。netlink机制提供了所需的框架
12.11.1 通信函数
内核内部的网络API
。其定义基本上与用户层相同,如下:
//include/linux/net.h
extern int kernel_sendmsg(struct socket *sock, struct msghdr *msg,
struct kvec *vec, size_t num, size_t len);
extern int kernel_recvmsg(struct socket *sock, struct msghdr *msg,
struct kvec *vec, size_t num,
size_t len, int flags);
extern int kernel_bind(struct socket *sock, struct sockaddr *addr,
int addrlen);
extern int kernel_listen(struct socket *sock, int backlog);
extern int kernel_accept(struct socket *sock, struct socket **newsock,
int flags);
extern int kernel_connect(struct socket *sock, struct sockaddr *addr,
int addrlen, int flags);
extern int kernel_getsockname(struct socket *sock, struct sockaddr *addr,
int *addrlen);
extern int kernel_getpeername(struct socket *sock, struct sockaddr *addr,
int *addrlen);
extern int kernel_getsockopt(struct socket *sock, int level, int optname,
char *optval, int *optlen);
extern int kernel_setsockopt(struct socket *sock, int level, int optname,
char *optval, int optlen);
extern int kernel_sendpage(struct socket *sock, struct page *page, int offset,
size_t size, int flags);
extern int kernel_sock_ioctl(struct socket *sock, int cmd, unsigned long arg);
extern int kernel_sock_shutdown(struct socket *sock,
enum sock_shutdown_cmd how);
除了kernel_sendmsg
和kernel_recvmsg
之外,其他接口的参数大体上都与用户层API相同,只
是不再通过文件描述符来指定套接字,而直接使用了指向struct socket实例的指针。这些接口的实
现都比较简单,实际上都是一些包装器例程,真正的工作由保存在struct socket的ops成员中的函
数指针(这些是协议操作结构proto_ops的成员)完成
//net/socket.c
int kernel_connect(struct socket *sock, struct sockaddr *addr, int addrlen,
int flags)
{
return sock->ops->connect(sock, addr, addrlen, flags);
}
在指定用于保存接收/发送数据的缓冲区空间时,需要稍微谨慎一些。 kernel_sendmsg
和 kernel_recvmsg
并不像用户层那样直接通过struct msghdr
访问数据区,而是利用了struct kvec
。但内核自动地提供了两种表示之间的转换,如kernel_sendmsg
所示
//net/socket.c
int kernel_sendmsg(struct socket *sock, struct msghdr *msg,
struct kvec *vec, size_t num, size_t size)
{
...
int result;
...
msg->msg_iov = (struct iovec *)vec;
msg->msg_iovlen = num;
result = sock_sendmsg(sock, msg, size);
...
return result;
}
12.11.2 netlink机制(内核与用户层通信机制)
netlink是一种基于网络的机制,允许在内核内部以及内核与用户层之间进行通信
。其正式的定义可在RFC 3549中找到。它的思想是,基于BSD的网络套接字使用网络框架在内核和用户层之间进行通信
。但netlink套接字大大扩展了可能的用途。该机制不仅仅用于网络通信。现在,该机制最重要的用户是通用对象模型,它使用netlink套接字将各种关于内核内部事务的状态信息传递到用户层。其中包括新设备的注册和移除、硬件层次上发生的特别的事件,等等
。在此前的内核版本中,netlink曾经可以编译为模块,但现在只要内核支持网络,该机制就自动集成到内核中。这强调了该机制的重要性。
内核中还有其他一些可选的方法能够实现类似的功能,比如procfs或sysfs中的文件。但与这些方法相比,netlink机制有一些很明显的优势
- 任何一方都不需要轮询。如果通过文件传递状态信息,那么用户层需要不断检查是否有新消息到达
- 系统调用和ioctl也能够从用户层向内核传递信息,但比简单的netlink连接更难于实现。另外,使用netlink不会与模块有任何冲突,但模块和系统调用显然配合得不是很好
- 内核可以直接向用户层发送信息,而无须用户层事先请求。使用文件也可以做到,但系统调用和ioctl是不可能的
- 除了标准的套接字,用户空间应用程序不需要使用其他东西来与内核交互
netlink只支持数据报信息,但提供了双向通信。另外,netlink不仅支持单播消息,也可以进行多播。类似于任何其他基于套接字的机制,netlink的工作方式是异步的
有两个手册页提供了netlink机制的文档:netlink(3)
描述了内核中用于操作、访问、创建netlink数据报的宏。手册页netlink(7)
包含了有关netlink套接字的一般性信息,并给出了这里使用的数据结构的文档。另外请注意,/proc/net/netlink
包含了关于当前活动的netlink连接的一些信息
在用户空间,有两个库,简化了创建支持netlink套接字的应用程序的工作
-
libnetlink与iproute2软件包捆绑在一起。在编写这个库时,就特别考虑到了路由套接字(routing socket)。此外,它不是独立的代码,如果要独立使用,必须从该软件包中提取出来
-
libnl是一个独立的库,并没有对特定的使用情况进行优化。相反,它对各种类型的netlink连接都提供了支持,包括路由套接字
-
数据结构
-
指定地址
类似于每个网络协议,每个netlink套接字都需要分配一个地址。下列struct sockaddr
的变体表示netlink地址//include/linux/netlink.h //netlink的struct sockaddr的变体表示netlink地址 struct sockaddr_nl { sa_family_t nl_family; /* AF_NETLINK *///指定不同的族 NETLINK_ROUTE 等,为区分内核的不同部分使用的各个不同的netlink通道 unsigned short nl_pad; /* zero */ __u32 nl_pid; /* port ID *///为此类套接字提供了唯一标识符。对内核自身来说,该字段总是0,而用户空间应用程序通常使用其线程组ID。请注意,这里并没有明确指定nl_pid表示进程ID,它可以是任何唯一值,使用线程组ID不过是方便而已。nl_pid是单播地址。每个地址族还可以指定不同的多播组 __u32 nl_groups; /* multicast groups mask *///是一个位图,表示该套接字所属的多播地址。如果不允许使用多播,该字段为0 }; //sockaddr_nl->nl_family #define NETLINK_ROUTE 0 /* Routing/device hook *///是netlink套接字最初的目的,即修改路由选择信息 #define NETLINK_UNUSED 1 /* Unused number */ #define NETLINK_USERSOCK 2 /* Reserved for user mode socket protocols */ #define NETLINK_FIREWALL 3 /* Firewalling hook */ #define NETLINK_INET_DIAG 4 /* INET socket monitoring *///用来监控IP套接字,更多细节请参见net/ipv4/inet_diag.c #define NETLINK_NFLOG 5 /* netfilter/iptables ULOG */ #define NETLINK_XFRM 6 /* ipsec *///用于发送和接收有关IPSec(更一般地说,也可能是有关任何XFRM变换)的信息 #define NETLINK_SELINUX 7 /* SELinux event notifications */ #define NETLINK_ISCSI 8 /* Open-iSCSI */ #define NETLINK_AUDIT 9 /* auditing */ #define NETLINK_FIB_LOOKUP 10 #define NETLINK_CONNECTOR 11 #define NETLINK_NETFILTER 12 /* netfilter subsystem */ #define NETLINK_IP6_FW 13 #define NETLINK_DNRTMSG 14 /* DECnet routing messages */ #define NETLINK_KOBJECT_UEVENT 15 /* Kernel messages to userspace *///是内核通用对象模型向用户层发送信息所采用的协议(反过来,从用户层到内核是不能采用此类消息的)。该通道构成了7.4.2节中讨论的热插拔机制的基础 #define NETLINK_GENERIC 16 /* leave room for NETLINK_DM (DM Events) */ #define NETLINK_SCSITRANSPORT 18 /* SCSI Transports */ #define NETLINK_ECRYPTFS 19
-
netlink协议族
每个协议族都需要在内核中注册一个net_proto_family
实例。该结构包含一个函数指针,在创建属于该协议族的新套接字时调用。netlink
将netlink_create
用于该目的。该函数分配一个struct sock的实例,通过socket->sk关联到套接字。但不仅为struct sock分配了空间,还为分配了一个更大的结构,定义如下://net/netlink/af_netlink.c struct netlink_sock { /* struct sock has to be the first member of netlink_sock */ struct sock sk;//一个netlink套接字的struct sock实例,与之相关联、特定于netlink的netlink_socket实例,可以使用辅助函数nlk_sk获得 u32 pid;//连接两端的端口ID分别保存在pid和dst_pid中 u32 dst_pid;//连接两端的端口ID分别保存在pid和dst_pid中 ... void (*netlink_rcv)(struct sk_buff *skb);//指向一个函数,在接收数据时调用 ... };
sock实例直接嵌入到netlink_sock中。给出一个netlink套接字的struct sock实例,与之相关联、特定于netlink的netlink_socket实例,可以
使用辅助函数nlk_sk获得
。连接两端的端口ID分别保存在pid和dst_pid中。netlink_rcv指向一个函数,在接收数据时调用 -
消息格式
netlink消息需要遵守一定的格式,如下图:
每个消息由两部分组成:首部和净荷
。首部表示为struct nlmsghdr
,而净荷可以任意的(如果netlink用于传输属性,内核为此提供了标准数据结构struct nlattr
。这种做法没有详细讨论,但请注意,所有定义的属性,以及一系列辅助函数,都可以在include/net/netlink.h
找到)。首部所需的内容由下列数据结构定义://include/linux/netlink.h //netlink消息首部 struct nlmsghdr { __u32 nlmsg_len; /* Length of message including header *///整个消息的长度,包括首部和任何所需的填充字节,保存在nlmsg_len中 __u16 nlmsg_type; /* Message content *///消息类型由nlmsg_type表示。该值是协议族私有的,通用的netlink代码不会检查或修改 __u16 nlmsg_flags; /* Additional flags *///各种标志可以保存在nlmsg_flags中。所有可能的值都定义在<netlink.h>中。对我们来说,主要关注两个标志:如果消息包含一个请求,要求执行某个特定的操作(而不是传输一些状态信息),那么NLM_F_REQUEST将置位,而NLM_F_ACK要求在接收到上述消息并成功处理请求之后发送一个确认消息。 __u32 nlmsg_seq; /* Sequence number *///nlmsg_seq包含一个序列号,表示一系列消息之间在时间上的前后关系 __u32 nlmsg_pid; /* Sending process port ID *///标识发送者的唯一的端口ID保存在nlmsg_pid中 };
netlink消息的各个部分,总是如图,
对齐到NLMSG_ALIGNTO(通常是4)字节边界
。由于struct nlmsghdr的长度当前是NLMSG_ALIGNTO的倍数,首部部分自然是满足对齐条件的。但净荷后面可能需要填充一些字节。为确保满足对齐的要求,内核在<netlink.h>
中引入了几个宏,可用于正确计算边界。手册页netlink(3)对这些宏的描述很完善,这里将不重复了。一个消息的长度不应该超过一页,这样对内存分配的压力较小。但如果使用的页大于8 KiB,那么消息长度不应该超过8 KiB,因为不应该强制用户层分配过大的缓冲区来接收netlink消息。
内核定义了常数NLMSG_GOODSIZE,这是消息总长度的推荐值
。NLMSG_DEFAULT_SIZE指定了净荷部分可用空间的长度
。在分配用于netlink消息的套接字缓冲区时,NLMSG_GOODSIZE是缓冲区长度的一个很好的选择 -
跟踪netlink连接
内核使用几个散列表,跟踪了由sock实例表示的所有netlink连接。这些散列表是围绕全局数组nl_table
实现的,该数组包含了指向struct netlink_table实例的指针。这里不详细讲述该结构的实际定义,因为所用的散列方法非常简单。- nl_table的每个数组元素都为每个协议族成员提供了一个独立的散列表。回想前文,每个协议族成员都由
NETLINK_XXX
定义的一个常数来标识,例如,XXX可以是ROUTE或KOBJECT_UEVENT等 - 散列链编号使用
nl_pid_hashfn
确定,是基于端口ID和一个与该散列链相关的(唯一的)随机数计算得出(实际上的情况更复杂一些,因为在散列表项更多时,内核会再散列所有表项,这里忽略了这种额外的复杂性)
netlink_insert
用于向散列表插入新的表项,而netlink_lookup
用来查找sock实例//net/netlink/af_netlink.c static int netlink_insert(struct sock *sk, struct net *net, u32 pid) static __inline__ struct sock *netlink_lookup(struct net *net, int protocol, u32 pid)
请注意,
散列数据结构并未设计为按命名空间进行操作
,因为整个系统只有一个全局结构实例。但该代码是可以感知到网络命名空间的:在查找sock实例时,代码确保了结果在适当的命名空间中。来自不同命名空间的相同端口ID可以同时处于同一散列链上,而没有问题 - nl_table的每个数组元素都为每个协议族成员提供了一个独立的散列表。回想前文,每个协议族成员都由
-
特定于协议的操作
由于用户层应用程序使用标准的套接字接口来处理netlink连接,内核必须提供一组特定的协议操作。这些操作定义如下://net/netlink/af_netlink.c static const struct proto_ops netlink_ops = { .family = PF_NETLINK, .owner = THIS_MODULE, .release = netlink_release, .bind = netlink_bind, .connect = netlink_connect, .socketpair = sock_no_socketpair, .accept = sock_no_accept, .getname = netlink_getname, .poll = datagram_poll, .ioctl = sock_no_ioctl, .listen = sock_no_listen, .shutdown = sock_no_shutdown, .setsockopt = netlink_setsockopt, .getsockopt = netlink_getsockopt, .sendmsg = netlink_sendmsg, .recvmsg = netlink_recvmsg, .mmap = sock_no_mmap, .sendpage = sock_no_sendpage, };
-
-
编程接口
通用的套接字实现提供了netlink所需的大部分基本功能。netlink套接字既可以从内核打开,也可以从用户层打开。前一种情况下,使用了netlink_kernel_create
,而在后一种情况下,将通过标准的网络编程接口触发netlink_ops的bind方法
。为节省篇幅,这里不会详细讲述用户层协议处理程序的实现,而只考虑如何从内核初始化连接。该函数需要多个不同的参数//net/netlink/af_netlink.c /* 参数: net:表示网络命名空间, unit:指定所属协议族成员, input:回调函数,在数据到达该套接字时将调用input。如果对input指定了NULL指针,那么套接字将只能从内核向用户层传输数据,反过来就不行了 groups:给出了多播组的数目, cb_mutex:互斥量用来保护netlink的回调函数,通常可以对互斥量参数指定NULL指针,内核将回退到默认的锁方案, */ struct sock * netlink_kernel_create(struct net *net, int unit, unsigned int groups, void (*input)(struct sk_buff *skb), struct mutex *cb_mutex, struct module *module)
代码流程图如下:
函数流程:
- 分配socket结构体 sock_create_lite
- 分配netlink_sock结构体 __netlink_create
- input函数保存到 netlink_sock->netlink_rcv中
- 将新的sock实例插入到netlink的散列表中 netlink_insert
以uevent机制的netlink套接字为例
://lib/kobject_uevent.c static int __init kobject_uevent_init(void) { //创建netlink机制的socket,由于uevent消息不需要用户层的输入,因而不必指定input函数 uevent_sock = netlink_kernel_create(&init_net, NETLINK_KOBJECT_UEVENT, 1, NULL, NULL, THIS_MODULE); ... return 0; }
在创建套接字之后,内核可以构建sk_buff实例,并用netlink_unicast或netlink_broadcast将其发送出去
在允许双向通信时,会涉及更多的东西。例如,考虑审计子系统,它不仅向用户空间发送消息,还从用户空间接收一些消息。首先,需要在调用netlink_kernel_create时指定一个input函数
- audit_init 审计子系统初始化(kernel/audit.c)
- audit_sock = netlink_kernel_create(&init_net, NETLINK_AUDIT, 0,audit_receive, NULL, THIS_MODULE); 创建netlink套接字与用户层通信,audit_receive 函数负责接收处理(保存在套接字缓冲区中)
- audit_receive => audit_receive_skb
- 遍历套接字缓冲区中的netlink消息
- 处理netlink消息 audit_receive_msg
- 解析出错或消息需要应答则调用 netlink_ack 应答
- 移除处理过的数据,该函数没有删除数据,只是相应地设置了套接字缓冲区的数据指针。但效果是相同的 skb_pull
- audit_receive => audit_receive_skb
- audit_sock = netlink_kernel_create(&init_net, NETLINK_AUDIT, 0,audit_receive, NULL, THIS_MODULE); 创建netlink套接字与用户层通信,audit_receive 函数负责接收处理(保存在套接字缓冲区中)
总结
各协议层的数据划分为首部和数据两部分
各层处理收到的网络分组使用同一个 套接字缓冲区,每层处理完后只需修改指针位置,这避免了数据复制
分组通常放置在等待队列中,等待处理或需要重新组合已经分析过的分组
内核提供了两个框架接收分组,一个用于老设备,一个用于高速的新设备
- 旧机制:中断收到网络分组后初步解析然后创建套接字加入等待队列中,并设置软中断NET_RX_SOFTIRQ
- 新机制:使用了IRQ和轮询的组合,第一个分组使网络适配器发出IRQ,为防止发出更多IRQ,驱动关闭适配器的Rx IRQ,并将该适配器放置到一个轮询表上.只要适配器上还有分组需要处理,内核就一直对轮询表上的设备进行轮询.等到不再有网络数据发送过来后重新启用Rx中断(由于网络数据多时接收将产生大量中断,中断的切换也会有开销,轮询开销更小,所以高速设备接收的第一包用中断,之后用轮询,直到接收完毕)
netfilter机制用于动态定义条件来过滤和操作分组,用于防火墙,分组过滤等.netfilter回调函数在内核各个位置
netlink机制使用netlink套接字使内核与用户层通信
=========================================
涉及的命令和配置:
netstat -i
ifconfig
网络命名空间全局表头net_namespace_list
大多数计算机通常都只需要一个网络命名空间。全局变量init_net
(在这里,该变量实际上是全局的,并未包含在另一个命名空间中)包含了该命名空间的net实例
全局数组softnet_data
,管理进出分组的等待队列
全局散列表数组ptype_base
所有用于从底层的网络访问层接收数据的网络层函数都注册在一个散列表中
全局散列表ptype_all
包含了对所有分组类型调用的分组处理程序
软中断处理分组的预算总值为全局变量netdev_budget
,可以通过/proc/sys/net/core/netdev_budget
修改
全局数组inet_protos
,存着注册的net_protocol
结构协议
全局二维数组nf_hooks
,存着所有netfilter钩子函数
内核提供了一个挂钩函数的集合,使得不必为每个场合都单独定义挂钩函数。这些称为iptables
,用于分组的高层处理。它们使用用户空间工具iptables
配置,这里不讨论该工具
网络连接数据可以用诸如tcpdump
和wireshark
之类的工具捕获。
tcp重传次数全局变量sysctl_tcp_retries2
,可以使用/proc/sys/net/ipv4/tcp_retries2
全局指针数组net_families
,存着内核的所有传输协议
/proc/net/netlink
包含了关于当前活动的netlink连接的一些信息
全局数组nl_table
,包含了指向struct netlink_table实例的指针