写在最前面

sk_buff 基础结构
qdisc 队列
socket 文件系统
SLAB 缓存使用的内存分配算法,有利于小内存分配

内核系统架构

内核系统架构
在内核系统架构中,我们最重要的几个组件是:系统调用接口、进程管理、内存管理、虚拟文件系统、网络堆栈、设备驱动程序和硬件控制几部分。

(1)系统调用接口:提供了某些机制执行从用户空间到内核的函数调用,这个接口是依赖于体系结构;

(2)进程管理:重点则是进程的执行,比如进程使用常见的一些操作,fork、exec、kill等,从而实现进程的调度、创建、停止以及进程间的通信等;

(3)内存管理:内存作为操作系统最重要的一个组成部分,而内存如果管理或者使用不规范,则可能让我们的内核效率受到影响,一般让硬件管理虚拟内存,则内存是按照页的方式进行管理的,一般为4K;

(4)虚拟文件系统:Linux中时允许不同的文件系统共存的,比如ext的几个版本,NTFS等等,它们都是通过同一套I/O操作的,而不需要去考虑具体的文件系统格式,除此之外,Linux认为“一切都是文件”,这就包括了普通文件、设备、socket等等,正是基于这两点,才产生了虚拟文件系统层,它是一个内核的中间抽象层,允许不同的文件和设备共存。

(5)网络堆栈:内核中的网络堆栈遵循我们通用的网络协议分层体系结构,比如我们的IP层、TCP层、socket层等等

(6)设备驱动程序:Linux内核中很多的代码都是在设备驱动程序中的,它们可以让不同的设备能够正常运行;

(7)依赖体系结构:虽然Linux是开源且独立的结构,但是有的组件却不得不考虑对应的体系结构,才能实现正常的一些操作或者让效率更好,而依赖体系结构就是由此诞生的

内核协议栈

Linux的内核协议栈,是基于BSD协议栈的,它的接口和协议栈本身的分层结构符合常用规范,它是基于分层的设计思想进行的,区别于理论的七层OSI架构,即我们的TCP/IP协议栈,主要分为:网络接口层、网络层、传输层以及我们的应用层

(1)网络接口层:它对应于OSI模型中的物理层和数据链路层,是TCP/IP和各种LAN或WAN的接口,它是数据包从一个设备的网络层传输到另外一个设备的网络层的方法,主要有ARP、NDP、L2TP、PPP、Ethernet等等协议类型;

(2)网络层:位于OSI模型的第三层,介于传输层和数据链路层间,它在网络接口层提供相邻两个端点之间的数据帧的传送功能上,进一步的管理网络的数据通信,通过不同的规则将数据从源传递到目的地,中间可能会经过很多个不同的节点,它的目的是实现源和目的端的数据透明传输,比如寻址、路由选择和连接的建立、保持、中断等,从而让传输层在不需要了解具体的网络结构下就可以进行数据传输和交换;主要有IPv4、IPv6、ICMP、ECN、IGMP等不同的协议,最主要的目的是为了传输层服务;

(3)传输层:在OSI中位于第四层,OSI中前三层主要是为了数据通信而存在的,而之后三层则是为了数据处理,是通信子网和资源子网的接口和桥梁,主要是为了提供网络层和会话层间的传输服务,或者说在两个主机进程间的通信提供服务,传输层提供逻辑连接的建立、传输层寻址、数据传输、传输连接释放、流量控制、拥塞控制、多路复用和解复用以及崩溃恢复等等,传输层在给定的链路上通过流量控制、分段和重组等方式来保证数据传输的可靠性,主要有TCP、UDP、SCTP等不同的协议,它的目的是向用户透明的传送报文,并且屏蔽了下层数据通信的细节;

(4)应用层:在OSI中位于第七层,是直接为应用程序提供服务的,作用是在实现多个系统应用进程间通信的同时,我八成一系列业务处理所需的服务,在TCP/IP协议栈中对应于OSI模型的会话层、表示层和应用层,所以需要负责会话管理和数据交换管理,以及协商和建立数据交换的格式,解决各应用程序间在数据格式表示上的差异,还有根本的完成用户希望在网络上完成的各种工作,负责完成网络应用程序和网络操作系统间的联系,应用层为用户提供常见的服务有文件服务、目录服务、文件传输服务等等,对应的协议有DHCP、DNS、FTP、HTTP、SSH等等不同的协议。

一次完整的数据交互经历的流程大概是这样的,首先用户的应用程序调用write或者read系统调用;然后确认文件描述符;之后会将数据通过文件描述符拷贝到socket buffer中,然后创建TCP片段并计算校验和,之后是添加IP头部,执行IP路由并计算校验和,之后则是添加Ethernet头部,执行ARP,然后是内核告诉我们的网卡我需要发送数据出去了,网卡则会从内存中获取数据并发送出去,发送完再通过中断告诉CPU

接收过程

网卡到内存

网卡需要有驱动才能工作,驱动是加载到内核中的模块,负责衔接网卡和内核的网络模块,驱动在加载的时候将自己注册进网络模块,当相应的网卡收到数据包时,网络模块会调用相应的驱动程序处理数据。
数据从网卡到内存过程

  • 1: 数据包进入物理网卡。如果目的地址不是该网卡,且该网卡没有开启混杂模式,该包会被网卡丢弃
  • 2: 网卡将数据包通过DMA方式写入到指定的内存RingBuffer中,地址由网卡驱动初始化。
  • 3: 网卡通过硬中断(IRQ)通知CPU,告诉它有数据来了
  • 4: CPU根据中断表,调用已经注册的中断函数,这个中断函数会调用驱动程序的相应函数
  • 5: 驱动先禁用网卡的中断,表示驱动程序已经知道内存中有数据了,告诉网卡下次再收到数据直接写内存就行,不要再通知CPU啦,避免CPU不停的被中断
  • 6: 启动软中断。这步结束后,硬件中断处理函数就结束返回了。由于硬中断处理程序执行的过程中不能被中断,所以如果它执行时间过长,会导致CPU没法响应其它硬件的中断,于是内核引入软中断,这样可以将硬中断处理函数中耗时的部分移到软中断处理函数里面来慢慢处理。

内核的网络处理

软中断会触发内核网络模块中的软中断处理函数,流程如下:
软中断处理过程

  • 7: 内核中的ksoftirqd进程专门负责软中断的处理,当它收到软中断后,就会调用相应软中断所对应的处理函数,对于上面第6步中是网卡驱动模块抛出的软中断,ksoftirqd会调用网络模块的net_rx_action函数
  • 8: net_rx_action调用网卡驱动里的poll函数来一个一个的处理数据包
  • 9: 在pool函数中,驱动会一个接一个的读取网卡写到内存中的数据包,内存中数据包的格式只有驱动知道
  • 10: 驱动程序将内存中的数据包转换成内核网络模块能识别的skb格式,然后调用napi_gro_receive函数
  • 11: napi_gro_receive会处理GRO相关的内容,也就是将可以合并的数据包进行合并,这样就只需要调用一次协议栈。然后判断是否开启了RPS,如果开启了,将会调用enqueue_to_backlog
  • 12: 在enqueue_to_backlog函数中,会将数据包放入CPU的softnet_data结构体的input_pkt_queue中,然后返回,如果input_pkt_queue满了的话,该数据包将会被丢弃,queue的大小可以通过net.core.netdev_max_backlog来配置
  • 13: CPU会接着在自己的软中断上下文中处理自己input_pkt_queue里的网络数据(调用_netifreceive_skb_core)
  • 14: 如果没开启RPS,napi_gro_receive会直接调用_netifreceive_skb_core
  • 15: 看是不是有AF_PACKET类型的socket(也就是我们常说的原始套接字),如果有的话,拷贝一份数据给它。tcpdump抓包就是抓的这里的包
  • 16: 调用协议栈相应的函数,将数据包交给协议栈处理
  • 17: 待内存中的所有数据包被处理完成后(即poll函数执行完成),启用网卡的硬中断,这样下次网卡再收到数据的时候就会通知CPU

协议栈

ip 层 假设是UDP包

ip 层

  • ip_rcv: ip_rcv函数是IP模块的入口函数,在该函数里面,第一件事就是将垃圾数据包(目的mac地址不是当前网卡,但由于网卡设置了混杂模式而被接收进来)直接丢掉,然后调用注册在NF_INET_PRE_ROUTING上的函数
  • NF_INET_PRE_ROUTING: netfilter放在协议栈中的钩子,可以通过iptables来注入一些数据包处理函数,用来修改或者丢弃数据包,如果数据包没被丢弃,将继续往下走
  • routing: 进行路由,如果是目的IP不是本地IP,且没有开启ip forward功能,那么数据包将被丢弃,如果开启了ip forward功能,那将进入ip_forward函数
  • ip_forward: ip_forward会先调用netfilter注册的NF_INET_FORWARD相关函数,如果数据包没有被丢弃,那么将继续往后调用dst_output_sk函数
  • dst_output_sk: 该函数会调用IP层的相应函数将该数据包发送出去
  • ip_local_deliver: 如果上面routing的时候发现目的IP是本地IP,那么将会调用该函数,在该函数中,会先调用NF_INET_LOCAL_IN相关的钩子程序,如果通过,数据包将会向下发送到UDP层

UDP层

UDP

  • udp_rcv: udp_rcv函数是UDP模块的入口函数,它里面会调用其它的函数,主要是做一些必要的检查,其中一个重要的调用是_udp4lib_lookup_skb,该函数会根据目的IP和端口找对应的socket,如果没有找到相应的socket,那么该数据包将会被丢弃,否则继续
  • sock_queue_rcv_skb: 主要干了两件事,一是检查这个socket的receive buffer是不是满了,如果满了的话,丢弃该数据包,然后就是调用sk_filter看这个包是否是满足条件的包,如果当前socket上设置了filter,且该包不满足条件的话,这个数据包也将被丢弃(在Linux里面,每个socket上都可以像tcpdump里面一样定义filter,不满足条件的数据包将会被丢弃)
  • _skbqueue_tail: 将数据包放入socket接收队列的末尾
  • sk_data_ready: 通知socket数据包已经准备好

发送过程

socket层

socket 发送

  • socket(…): 创建一个socket结构体,并初始化相应的操作函数,由于我们定义的是UDP的socket,所以里面存放的都是跟UDP相关的函数
  • sendto(sock, …): 应用层的程序(Application)调用该函数开始发送数据包,该函数数会调用后面的inet_sendmsg
  • inet_sendmsg: 该函数主要是检查当前socket有没有绑定源端口,如果没有的话,调用inet_autobind分配一个,然后调用UDP层的函数
  • inet_autobind: 该函数会调用socket上绑定的get_port函数获取一个可用的端口,由于该socket是UDP的socket,所以get_port函数会调到UDP代码里面的相应函数

UDP层

udp层

  • udp_sendmsg: udp模块发送数据包的入口,该函数较长,在该函数中会先调用ip_route_output_flow获取路由信息(主要包括源IP和网卡),然后调用ip_make_skb构造skb结构体,最后将网卡的信息和该skb关联
  • ip_route_output_flow: 该函数会根据路由表和目的IP,找到这个数据包应该从哪个设备发送出去,如果该socket没有绑定源IP,该函数还会根据路由表找到一个最合适的源IP给它。 如果该socket已经绑定了源IP,但根据路由表,从这个源IP对应的网卡没法到达目的地址,则该包会被丢弃,于是数据发送失败,sendto函数将返回错误。该函数最后会将找到的设备和源IP塞进flowi4结构体并返回给udp_sendmsg
  • ip_make_skb: 该函数的功能是构造skb包,构造好的skb包里面已经分配了IP包头,并且初始化了部分信息(IP包头的源IP就在这里被设置进去),同时该函数会调用ip_append_dat,如果需要分片的话,会在ip_append_data函数中进行分片,同时还会在该函数中检查socket的send buffer是否已经用光,如果被用光的话,返回ENOBUFS
  • udp_send_skb(skb, fl4) 主要是往skb里面填充UDP的包头,同时处理checksum,然后调用IP层的相应函数

IP层

IP层

  • ip_send_skb: IP模块发送数据包的入口,该函数只是简单的调用一下后面的函数
  • _iplocal_out_sk: 设置IP报文头的长度和checksum,然后调用下面netfilter的钩子
  • NF_INET_LOCAL_OUT: netfilter的钩子,可以通过iptables来配置怎么处理该数据包,如果该数据包没被丢弃,则继续往下走
  • dst_output_sk: 该函数根据skb里面的信息,调用相应的output函数,在我们UDP IPv4这种情况下,会调用ip_output
  • ip_output: 将上面udp_sendmsg得到的网卡信息写入skb,然后调用NF_INET_POST_ROUTING的钩子
  • NF_INET_POST_ROUTING: 在这里,用户有可能配置了SNAT,从而导致该skb的路由信息发生变化
  • ip_finish_output: 这里会判断经过了上一步后,路由信息是否发生变化,如果发生变化的话,需要重新调用dst_output_sk(重新调用这个函数时,可能就不会再走到ip_output,而是走到被netfilter指定的output函数里,这里有可能是xfrm4_transport_output),否则往下走
  • ip_finish_output2: 根据目的IP到路由表里面找到下一跳(nexthop)的地址,然后调用ipv4_neigh_lookup_noref去arp表里面找下一跳的neigh信息,没找到的话会调用neigh_create构造一个空的neigh结构体
  • dst_neigh_output: 在该函数中,如果上一步ip_finish_output2没得到neigh信息,那么将会走到函数neigh_resolve_output中,否则直接调用neigh_hh_output,在该函数中,会将neigh信息里面的mac地址填到skb中,然后调用dev_queue_xmit发送数据包
  • neigh_resolve_output: 该函数里面会发送arp请求,得到下一跳的mac地址,然后将mac地址填到skb中并调用dev_queue_xmit

netdevice 子系统

netdevice

  • dev_queue_xmit: netdevice子系统的入口函数,在该函数中,会先获取设备对应的qdisc,如果没有的话(如loopback或者IP tunnels),就直接调用dev_hard_start_xmit,否则数据包将经过Traffic Control模块进行处理
  • Traffic Control: 这里主要是进行一些过滤和优先级处理,在这里,如果队列满了的话,数据包会被丢掉,这步完成后也会走到dev_hard_start_xmit
  • dev_hard_start_xmit: 该函数中,首先是拷贝一份skb给“packet taps”,tcpdump就是从这里得到数据的,然后调用ndo_start_xmit。如果dev_hard_start_xmit返回错误的话(大部分情况可能是NETDEV_TX_BUSY),调用它的函数会把skb放到一个地方,然后抛出软中断NET_TX_SOFTIRQ,交给软中断处理程序net_tx_action稍后重试(如果是loopback或者IP tunnels的话,失败后不会有重试的逻辑)
  • ndo_start_xmit: 这是一个函数指针,会指向具体驱动发送数据的函数

Device Driver

ndo_start_xmit 会绑定到网卡驱动的相应函数,大概流程如下:

  • 将skb放入网卡自己的发送队列
  • 通知网卡发送数据包
  • 网卡发送完成后发送中断给CPU
  • 收到中断后进行skb的清理工作
文档更新时间: 2021-01-22 14:57   作者:周国强