图解 Linux 内核网络栈

图解 Linux 内核网络栈

基于linux版本v6.12.31

网络模型

提到网络,我们可能会想到TCP/IP网络模型和OSI网络模型:前者是目前事实的协议模型,后者是试图成为互联网世界的标准框架的概念模型;

两者都是分层的模型,区别主要在应用层和物理层,OSI网络模型进行了细分,分别对应应用层/表示层/会话层和数据链路层/物理层,中间的传输层和网络层是一致的;

网络数据都是自上而下,进行层层封装的,每一层都由上一层数据加本层协议的头数据组成,如下图所示:

数据格式

本文我们主要是对Linux内核网络栈进行了解,其主要处理数据链路层、网络层、传输层这三层的网络协议数据,所以我们主要分析下这三层网络协议的数据格式。

后续文中的L2层对应数据链路层,L3层对应网络层,L4层对应传输层。

接下来,我们自上而下逐层进行分析:

L4层(传输层)

在传输层,我们听过较多的就是TCP/UDP协议了,TCP的数据单元称作数据段segment,UDP的数据单元称作数据报datagram。

由于UDP协议比较简单,本文我们关注UDP协议:

UDP数据报文分为两部分:头数据、来自模型上层的数据;

头数据分为四部分:

  1. 源端口号
  2. 目标端口号
  3. 总长度:头部长度+数据长度
  4. 检验和:检验数据准确性

L3层(网络层)

在网络层,被广泛使用的就是IPv4协议了,其数据单元称作数据包packet:

IP数据包文分为两部分:头数据、来自模型上层的数据,这里就是UDP协议的数据了;

头数据组成比较多,我们关注如下部分:

  1. 源IP地址
  2. 目标IP地址
  3. 版本:有v4和v6,本文只讨论v4
  4. 总长度:头部长度+数据长度
  5. 协议:上层协议是什么

L2层(数据链路层)

在数据链路层,本文我们关注介质访问控制协议(MAC),其数据单元称作数据帧frame:

数据帧由如下部分组成:

  1. 目的mac地址
  2. 源mac地址
  3. 类型
  4. Preamble 用于标记帧开始
  5. FCS 用于检查数据是否正确

目前我们对数据格式有了大概了解,接下来用一个UDP发送/接收的例子,来分析数据在Linux内核网络栈中的流转:

UDP 例子:发送/接收

接收端 server.c

c 复制代码
// server.c
// gcc server.c -o server
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/types.h>

int main() {
  char *ip = "127.0.0.1";
  int port = 1234;
  struct sockaddr_in server_addr;
  bzero(&server_addr, sizeof(server_addr));
  server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
  server_addr.sin_family = AF_INET;
  server_addr.sin_port = htons(port);
  server_addr.sin_addr.s_addr = inet_addr(ip);

  int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

  bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));

  char buffer[1024];
  bzero(buffer, 1024);
  struct sockaddr_in client_addr;
  int addr_size = sizeof(client_addr);
  recvfrom(sockfd, buffer, 1024, 0, (struct sockaddr *)&client_addr,
           &addr_size);
  printf("[+]Data recv: %s\n", buffer);

  return 0;
}

发送端 client.c

c 复制代码
// client.c
// gcc client.c -o client
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>

int main(void) {
  char *ip = "127.0.0.1";
  int port = 1234;
  struct sockaddr_in server_addr;
  bzero(&server_addr, sizeof(server_addr));
  server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
  server_addr.sin_family = AF_INET;
  server_addr.sin_port = htons(port);
  server_addr.sin_addr.s_addr = inet_addr(ip);

  int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

  char buffer[1024];
  bzero(buffer, 1024);
  strcpy(buffer, "Hello, World!");
  sendto(sockfd, buffer, 1024, 0, (struct sockaddr *)&server_addr, sizeof(server_addr));
  printf("[+]Data send: %s\n", buffer);

  return 0;
}

编译运行

编译并运行两个程序,查看两个程序的输出:接收端收到了发送端发送的Hello, World!。

bash 复制代码
gcc server.c -o server
gcc client.c -o client

# 两个窗口分别运行,查看输出:
./server
./client
# 输出如下:
# client 窗口:[+]Data send: Hello, World!
# server 窗口:[+]Data recv: Hello, World!

本文不对socket做详细介绍,可理解为Linux对网络操作的抽象,不管用户使用什么协议,只需要通过socket操作
我们重点关注sendto,recvfrom调用

Linux 网络栈分析

0. 重要结构体 sk_buff

在开始分析Linux网络栈之前,我们先了解一下一个重要的结构体sk_buff,其保存着接收或发送数据的元数据,贯穿Linux网络栈。

c 复制代码
// include/linux/skbuff.h
struct sk_buff {
	// ...

	// 双链表
	struct sk_buff		*next;
	struct sk_buff		*prev;
	struct net_device	*dev;             // 设备

	unsigned int		len,              // 总长度
				data_len;         // 数据部分长度
	sk_buff_data_t		tail;
	sk_buff_data_t		end;
	unsigned char		*head,
				*data;            // 数据

	__u16			transport_header; // L4 头指针,相对 head 的偏移量
	__u16			network_header;   // L3 头指针,相对 head 的偏移量
	__u16			mac_header;       // L2 头指针,相对 head 的偏移量

	// ...
};

sk_buff结构体很大,我们省略一些字段,关注目前我们需要的字段:

  1. sk_buff通过双链表组织数据,通过next和prev字段关联前后链表节点;
  2. sk_buff.head指向存储数据的区域,这部分区域又分为两部分:sk_buff.data指向的实际数据(协议头、载荷)、共享信息,具体看下一节的操作图解;
  3. 网络栈逐层传递sk_buff时,只会添加对应层的协议头部,不会复制数据,具体也看下一节图解;
操作skb_buff.head指向的数据区域

通过skb_reserve, skb_put, sk_push等函数操作skb_buff.head指向的数据区域

c 复制代码
// 1. 通过 alloc_skb 分配 sk_buff

// include/linux/skbuff.h
// alloc_skb -> __alloc_skb

// net/core/skbuff.c
// __alloc_skb -> __build_skb_around -> __finalize_skb_around
static inline void __finalize_skb_around(struct sk_buff *skb, void *data,
					 unsigned int size)
{
    // ...
  	skb->head = data;
	skb->data = data;
    // ...
}

// 2. 通过 skb_reserve 分配头部空间
// include/linux/skbuff.h
static inline void skb_reserve(struct sk_buff *skb, int len)
{
	skb->data += len;
	skb->tail += len;
}

// 3. 通过 skb_put 分配数据空间
// net/core/skbuff.c
void *skb_put(struct sk_buff *skb, unsigned int len)
{
  // ...
	skb->tail += len;
	skb->len  += len;
  // ...
}

// 4. 通过 skb_push 往数据空间前追加数据(使用头部空间)
// 通常用于追加协议头部数据
// net/core/skbuff.c
void *skb_push(struct sk_buff *skb, unsigned int len)
{
	skb->data -= len;  // NOTE: 指针上移,使用头部空间
	skb->len  += len;
  // ...
}


// 5. skb_pull 和 skb_push 效果相反
// 通常用于将 data 指针指向某个协议的数据(不包含协议头)
// include/linux/skbuff.h
// skb_pull -> __skb_pull
static inline void *__skb_pull(struct sk_buff *skb, unsigned int len)
{
	skb->len -= len;
	return skb->data += len;
}
添加头部

这里用L2层的以太网协议头做个例子,L3层和L4层的操作类似,都是对这几个字段进行操作:

c 复制代码
// net/core/skbuff.c
int skb_eth_push(struct sk_buff *skb, const unsigned char *dst,
		 const unsigned char *src)
{
	struct ethhdr *eth;
  // ...
	skb_push(skb, sizeof(*eth));         // NOTE: 追加头大小的数据空间
	skb_reset_mac_header(skb);           // 设置头,相对head的偏移量
	skb_reset_mac_len(skb);              // 设置头长度

	eth = eth_hdr(skb);                  // 获取协议头
	ether_addr_copy(eth->h_dest, dst);   // 设置目的mac地址,函数的第2个参数
	ether_addr_copy(eth->h_source, src); // 设置源mac地址,函数的第3个参数
	eth->h_proto = skb->protocol;        // 设置协议
  // ..
}
static inline void skb_reset_mac_header(struct sk_buff *skb)
{
	// NOTE: 保存着相对head的偏移量
	skb->mac_header = skb->data - skb->head;
}

1. 发送

发送数据按如下步骤执行:

  1. 用户态执行系统调用sendto
  2. 内核态执行相关系统调用,内核网络栈处理数据,放至网卡处Ringbuffer
  3. 网卡将数据发送出去,发送成功后,请求中断
  4. CPU执行中断处理逻辑,清除Ringbuffer

函数调用大概如下,稍微过一下即可:

c 复制代码
// udp sendto
// 1. 系统调用层
// net/socket.c
sendto -> __sys_sendto -> __sock_sendmsg -> sock_sendmsg_nosec -> inet_sendmsg
// net/ipv4/af_inet.c
inet_sendmsg -> udp_sendmsg

// 2. 传输层
// net/ipv4/udp.c
udp_sendmsg -> udp_send_skb -> ip_send_skb

// 3. 网络层
// net/ipv4/ip_output.c
ip_send_skb -> ip_local_out -> __ip_local_out -> dst_output(路由)
// 3.1 路由(网络层数据包转发)
// include/net/dst.h 
dst_output -> output -> ip_output
// net/ipv4/ip_output.c
ip_output -> ip_finish_output -> __ip_finish_output -> ip_finish_output2 -> neigh_output

// 4. 链路层(邻居协议,比如ARP协议,找到mac地址)
// 邻居定义:同一以局域网且在同一子网下,或L3层一跳可达
// include/net/neighbour.h
neigh_output -> neigh_hh_output -> dev_queue_xmit(进入网络设备子系统)

// 5. 网络设备层:管理网络设备
// include/linux/netdevice.h
dev_queue_xmit -> __dev_queue_xmit
// net/core/dev.c
__dev_queue_xmit -> __dev_xmit_skb -> sch_direct_xmit ->  dev_hard_start_xmit -> xmit_one -> netdev_start_xmit -> __netdev_start_xmit
// include/linux/netdevice.h
__netdev_start_xmit -> ndo_start_xmit

// 5. 驱动层:以igb为例
// drivers/net/ethernet/intel/igb/igb_main.c
igb_xmit_frame(注册到ndo_start_xmit) -> igb_xmit_frame_ring -> igb_tx_map(到设备)
// DMA
igb_tx_map -> dma_map_single

// NOTE: 这样子就把数据放到网卡了,网卡会通过**中断**告知数据发送成功

// 1. 硬中断
// drivers/net/ethernet/intel/igb/igb_main.c
igb_msix_ring -> napi_schedule
// include/linux/netdevice.h
napi_schedule -> __napi_schedule
// net/core/dev.c
__napi_schedule -> ____napi_schedule -> raise_softirq_irqoff

// 2. 软中断:清理资源
// net/core/dev.c
__napi_poll(n->poll,注册的igb_poll)
// drivers/net/ethernet/intel/igb/igb_main.c
igb_poll -> igb_clean_tx_irq

我们主要分析 是如何处理sk_buff里各层协议的协议头:

  1. 传输层 入口:udp_sendmsg
c 复制代码
// net/ipv4/udp.c
int udp_sendmsg(struct sock *sk, struct msghdr *msg, size_t len)
{
	// ...
	struct sk_buff *skb;
	// ...
	// NOTE: 注意这个 getfrag 函数,是从用户层复制数据的
	getfrag = is_udplite ? udplite_getfrag : ip_generic_getfrag;  
	// ...
	// NOTE: 这里会在sk_buff内分配好 IP/UDP 协议的头部,后续直接拿就可以赋值
	// sizeof(struct udphdr) 表示 udp 协议头的长度,后续的transhdrlen字段就是这个值
	skb = ip_make_skb(sk, fl4, getfrag, msg, ulen,
			  sizeof(struct udphdr), &ipc, &rt,
			  &cork, msg->msg_flags); 
	// ...
	// 发送 skb
	err = udp_send_skb(skb, fl4, &cork);
	// ...
}

udp_sendmsg这个函数会

  1. 调用ip_make_skb函数分配好sk_buff(包含L4, L3层的协议头)且复制用户的数据到sk_buff
  2. 调用udp_send_skb给udp协议头赋值且通过ip_send_skb把skb发往下一层
c 复制代码
// 1.分配 sk_buff,复制用户数据
// net/ipv4/ip_output.c
struct sk_buff *ip_make_skb(struct sock *sk,
			    struct flowi4 *fl4,
			    int getfrag(void *from, char *to, int offset,
					int len, int odd, struct sk_buff *skb),
			    void *from, int length, int transhdrlen,
			    struct ipcm_cookie *ipc, struct rtable **rtp,
			    struct inet_cork *cork, unsigned int flags)
{
	// 管理 sk_buff 双链表
	struct sk_buff_head queue;
	__skb_queue_head_init(&queue);
	// ...
	// NOTE: 具体分配sk_buffer的地方
	err = __ip_append_data(sk, fl4, &queue, cork,
			       &current->task_frag, getfrag,
			       from, length, transhdrlen, flags);
	// 给IP协议头赋值
	return __ip_make_skb(sk, fl4, &queue, cork);
}

// 具体分配sk_buffer
static int __ip_append_data(struct sock *sk,
			    struct flowi4 *fl4,
			    struct sk_buff_head *queue,
			    struct inet_cork *cork,
			    struct page_frag *pfrag,
			    int getfrag(void *from, char *to, int offset,
					int len, int odd, struct sk_buff *skb),
			    void *from, int length, int transhdrlen,
			    unsigned int flags)
{
		// 拿到队列尾部的skb
		skb = skb_peek_tail(queue);
		// 分配 sk_buff
		if (!skb)
			goto alloc_new_skb;
		//...
alloc_new_skb:
		//...
		// 分配新 sk_buff
		skb = sock_alloc_send_skb(sk, alloclen,
				(flags & MSG_DONTWAIT), &err);
		//...
		// ip 协议头长度
		fragheaderlen = sizeof(struct iphdr) + (opt ? opt->optlen : 0);
		//...
		// sk_buff 放入数据的地方,设置了网络层头、传输层头
		// L3层链路层的协议头,在后续处理
		data = skb_put(skb, fraglen + exthdrlen - pagedlen);
		skb_set_network_header(skb, exthdrlen);
		skb->transport_header = (skb->network_header +
					 fragheaderlen);
		// ...
		// 从 from 复制copy数量的数据到 data+transhdrlen
		// transhdrlen 是在调用ip_make_skb是计算的udp协议头大小
		// 也就是说把用户数据复制到udp数据报的数据部分
		if (copy > 0 && getfrag(from, data + transhdrlen, offset, copy, fraggap, skb) < 0) {
		// ...
		// NOTE: 将 sk_buff 放入队列
		__skb_queue_tail(queue, skb);
		// ...
}

// 设置ip协议头
struct sk_buff *__ip_make_skb(struct sock *sk,
			      struct flowi4 *fl4,
			      struct sk_buff_head *queue,
			      struct inet_cork *cork)
{
	// ... 
	skb = __skb_dequeue(queue); // NOTE: 从队列中获取一个skb

	//...
	// NOTE: 设置 IP协议头:版本、源IP地址、目标IP地址
	iph = ip_hdr(skb);
	iph->version = 4;
	//...
	ip_copy_addrs(iph, fl4); // NOTE: 设置ip地址
	//...

}

// 2. 给协议头复制,发往下一层
// net/ipv4/udp.c
// NOTE: 这里获取了skb 的 UDP 协议头,进行赋值
static int udp_send_skb(struct sk_buff *skb, struct flowi4 *fl4,
			struct inet_cork *cork)
{

	struct udphdr *uh;
	uh = udp_hdr(skb); 
	// NOTE 设置udp协议头:源端口、目标端口
	uh->source = inet->inet_sport;
	uh->dest = fl4->fl4_dport;

	// 往下一层发送 skb
	err = ip_send_skb(sock_net(sk), skb);
}
  1. 网络层入口:ip_send_skb

网络层对sk_buff基本没有什么操作,都在__ip_make_skb函数里做完了。

  1. 链路层入口:neigh_output
c 复制代码
// include/net/neighbour.h
static inline int neigh_output(struct neighbour *n, struct sk_buff *skb,
			       bool skip_cache)
{
	// ...
	return neigh_hh_output(hh, skb); // NOTE: 使用缓存的硬件地址发送,也就是mac地址,hh里包含协议头
	// ...
}
static inline int neigh_hh_output(const struct hh_cache *hh, struct sk_buff *skb)
{
	// ...
		hh_len = READ_ONCE(hh->hh_len); // NOTE: 获取头长度
		memcpy(skb->data - HH_DATA_MOD, hh->hh_data,
		       HH_DATA_MOD);            // NOTE: skb->data 指针上移,放置 hh->hh_data 协议头
	// ...
	return dev_queue_xmit(skb);             // NOTE: 发往网络设备子系统处理
}

2. 接收

接收数据按如下步骤执行:

  1. 网卡收到数据,请求中断
  2. CPU执行中断处理逻辑,从Ringbuffer中拿出数据
  3. 内核网络栈处理数据
  4. 用户态的recvfrom调用获得数据

函数调用大概如下,稍微过一下即可:

c 复制代码
1. 软中断
// net/core/dev.c
__napi_poll(n->poll,网卡初始化时注册的igb_poll)
// drivers/net/ethernet/intel/igb/igb_main.c
igb_poll -> igb_clean_rx_irq -> napi_gro_receive(skb 结构体已包含可用的 L2 数据)

2. gro: Generic Receive Offloading
// net/core/gro.c
napi_gro_receive -> napi_skb_finish -> gro_normal_one
// include/net/gro.h
gro_normal_one -> gro_normal_list -> netif_receive_skb_list_internal

// net/core/dev.c
netif_receive_skb_list_internal -> __netif_receive_skb_list -> __netif_receive_skb_list_core -> __netif_receive_skb_core
-> deliver_ptype_list_skb -> deliver_skb(pt_prev->func指向ip_rcv,通过dev_add_pack注册ip_packet_type)

// NOTE: 再进行Linux网络栈处理

// 1. IP 网络层
// net/ipv4/ip_input.c
ip_rcv -> ip_rcv_finish -> dst_input
// 1.1 路由
// include/net/dst.h
dst_input -> ip_local_deliver
// net/ipv4/ip_input.c
ip_local_deliver -> ip_local_deliver_finish -> ip_protocol_deliver_rcu -> udp_rcv

// 2. 传输层
// net/ipv4/udp.c
udp_rcv -> __udp4_lib_rcv -> udp_unicast_rcv_skb -> udp_queue_rcv_skb -> udp_queue_rcv_one_skb -> __udp_queue_rcv_skb 
-> __udp_enqueue_schedule_skb -k __skb_queue_tail -> sk_data_ready/sock_def_readable

// 3. 用户/系统调用层
// net/core/sock.c
sock_def_readable -> sk_wake_async_rcu
// include/net/sock.h
sk_wake_async_rcu -> sock_wake_async

从sk_buff中拿出各层协议头,可以通过对应协议_hdr方法获取

c 复制代码
// include/linux/ip.h
static inline struct iphdr *ip_hdr(const struct sk_buff *skb)
{
	return (struct iphdr *)skb_network_header(skb);
}

// include/linux/udp.h
static inline struct udphdr *udp_hdr(const struct sk_buff *skb)
{
	return (struct udphdr *)skb_transport_header(skb);
}

// include/linux/skbuff.h
static inline unsigned char *skb_mac_header(const struct sk_buff *skb)
{
	DEBUG_NET_WARN_ON_ONCE(!skb_mac_header_was_set(skb));
	return skb->head + skb->mac_header;
}
c 复制代码
// net/ipv4/udp.c
int __udp_enqueue_schedule_skb(struct sock *sk, struct sk_buff *skb)
{
	// socket的接收队列
	struct sk_buff_head *list = &sk->sk_receive_queue;
	// ...
	// 放入队尾
	__skb_queue_tail(list, skb);
	// ...
	// 通知数据准备好了
	if (!sock_flag(sk, SOCK_DEAD))
		INDIRECT_CALL_1(sk->sk_data_ready, sock_def_readable, sk);
	// ...
}

Linux 关于网络的下一些工具

bash 复制代码
# apt install net-tools iproute2

# L4
## 检查端口是不是open,UDP 加上 -u
nc -zv IP PORT

# L3
## 获取当前设备网络接口地址
ip -br addr
## 发送 ICMP 协议包,看地址是否可达
ping IP
# 路由表:决定包转发到哪里
route
ip -br route

# L2
# 当前网络接口配置、状态等
ip -br link
## arp表:ip 和 mac 地址之间的映射
arp -a

# 查看网络设备驱动
lspci -v | grep -A8 Ethernet
相关推荐
且从容.12 小时前
Ubuntu安装flume
linux·ubuntu·flume
承渊政道12 小时前
Linux网络学习【Reactor反应堆模式】
linux·网络·学习·ubuntu·reactor
且从容.15 小时前
ubuntu安装flink
linux·ubuntu·flink
爱和冰阔落16 小时前
【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork
linux·运维·服务器·网络
程序员沉梦听雨16 小时前
WSL2的安装与使用
linux·wsl
微小的鱼17 小时前
Linux的渲染与显示系统链路
android·java·linux
lisanmengmeng17 小时前
alertmanger 标签 rules 打上分组标签
linux·运维·服务器
Android系统攻城狮18 小时前
Linux FFmpeg深度解析之av_write_frame调用流程与实战(三十三)
linux·运维·服务器·ffmpeg·音视频进阶·ffmpeg音视频
Linux-lucky1 天前
45-Linux学习之旅之防火墙与WEB加固
linux·前端·网络·学习·ubuntu
禁默1 天前
启元 Q1 二次开发实践:Ubuntu 部署与仿真验证,从 PrimeConsole 到 ROS 2 Humble + RViz
linux·运维·ubuntu