Linux 网络内核协议栈深度剖析(一):总览架构与 sk_buff 生命周期

本系列基于 Linux 6.4-rc1 源码(commit ac9a78681b92),所有代码片段均从真实源文件读取,路径为 include/linux/skbuff.hnet/core/skbuff.c


一、为什么要读懂 Linux 网络协议栈

网络性能调优、eBPF 程序开发、内核模块编写,乃至排查一个”神秘的丢包”——这些工作的底层都有一个共同的核心:sk_buff(socket buffer)。它是 Linux 内核网络子系统中最关键的数据结构,每一个在内核中流动的网络报文都以 sk_buff 的形式存在。

本系列文章将从零到深,系统拆解 Linux 网络协议栈。第一篇先建立全局视图,再深入 sk_buff 的内存模型与操作原语。


二、网络协议栈完整架构

2.1 接收路径(RX Path)

从网卡收到一帧,到用户态 read() 返回数据,内核经历的完整调用链如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
硬件中断
└─ napi_schedule() // 将 NAPI poll 加入软中断队列
└─ net_rx_action() // softirq: NET_RX_SOFTIRQ
└─ napi->poll() // 驱动的 poll 函数,如 igb_poll()
└─ napi_gro_receive()
└─ netif_receive_skb()
└─ __netif_receive_skb_core()
├─ deliver_skb() → packet socket tap
└─ ip_rcv() // IPv4 入口
└─ ip_rcv_finish()
└─ ip_local_deliver()
└─ ip_local_deliver_finish()
└─ tcp_v4_rcv() // TCP 入口
└─ tcp_rcv_established()
└─ tcp_queue_rcv()
└─ sk->sk_data_ready()
└─ 唤醒用户态 read()

关键节点说明:

NAPI(New API) 是现代驱动的标准轮询机制。硬中断只负责触发 napi_schedule(),将 poll 工作推给软中断 NET_RX_SOFTIRQ,从而避免高速网卡的中断风暴。驱动在 poll 函数中用 napi_build_skb()__napi_alloc_skb() 创建 skb,并调用 napi_gro_receive() 进入 GRO(Generic Receive Offload)聚合层。

netif_receive_skb() 是协议层的分发入口。它通过 ptype_base 哈希表查找与 skb->protocol 对应的协议处理函数(如 ip_rcvipv6_rcv)。

ip_rcv() 完成 IP 首部校验、路由查找(ip_route_input_noref()),然后决定报文去向:本地投递还是转发。本地投递进入 ip_local_deliver(),这里完成 IP 分片重组,再调用上层协议(TCP/UDP/ICMP)的接收函数。

tcp_v4_rcv() 根据四元组找到 socket,将报文挂入 sk->sk_receive_queue,并通过 sk->sk_data_ready 回调唤醒阻塞在 read() 的用户进程。

2.2 发送路径(TX Path)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户态 write() / sendmsg()
└─ sock_sendmsg()
└─ tcp_sendmsg()
└─ tcp_push_one() / __tcp_push_pending_frames()
└─ tcp_write_xmit()
└─ tcp_transmit_skb() // 封装 TCP 首部
└─ ip_queue_xmit() // 封装 IP 首部 + 路由
└─ ip_output()
└─ ip_finish_output()
└─ ip_finish_output2()
└─ neigh_output() // ARP/邻居子系统
└─ dev_queue_xmit() // 入队
└─ qdisc_run() // 流量控制
└─ dev_hard_start_xmit()
└─ ndo_start_xmit() // 驱动发送

发送路径的核心在于 TCP 在 sk_write_queue 中维护待发送的 skb。TCP 实现了可靠传输,skb 只有在收到 ACK 后才会通过 tcp_clean_rtx_queue() 释放。发送时,TCP 先调用 skb_clone() 产生一个 fclone(fast-clone),将克隆体交给 IP 层,自己保留原始 skb 用于重传。


三、sk_buff 结构体深度分析

3.1 什么是 sk_buff

sk_buff 是 Linux 内核网络子系统中报文的载体。它本身不直接存储报文数据,而是一个元数据描述符,通过指针指向实际的数据缓冲区。其设计目标是:在协议栈各层之间传递报文时,尽量避免数据拷贝,只修改指针和元数据。

3.2 核心内存布局:四个指针

skbuff.h 中的官方注释给出了最清晰的内存模型(源码第 706-728 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* DOC: Basic sk_buff geometry
*
* ---------------
* | sk_buff |
* ---------------
* ,--------------------------- + head
* / ,----------------- + data
* / / ,----------- + tail
* | | | , + end
* | | | |
* v v v v
* -----------------------------------------------
* | headroom | data | tailroom | skb_shared_info |
* -----------------------------------------------
* + [page frag]
* + [page frag]
* + frag_list --> | sk_buff |
*/

四个核心指针在结构体末尾(源码第 1053-1057 行):

1
2
3
4
5
6
7
/* 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;
refcount_t users;

各指针含义:

指针 含义
head 分配的缓冲区起始地址,固定不变
data 当前有效数据的起始位置(随 skb_push/pull 移动)
tail 当前有效数据的结束位置(随 skb_put 移动)
end 缓冲区末尾(紧接 skb_shared_info),固定不变

在 64 位系统(NET_SKBUFF_DATA_USES_OFFSET 宏定义时),tailend 存储的是相对于 head偏移量,而非指针,节省内存并方便原子操作。

Headroom(头部预留空间)data - head 的字节数,发送时用于各层协议依次 skb_push() 写入自己的首部,无需移动数据。Tailroomend - tail 的字节数(不含 skb_shared_info),接收时用于 skb_put() 追加数据。

3.3 len、data_len 与 truesize 的区别

这三个字段是初学者最容易混淆的:

1
2
unsigned int		len,         // 源码第 896 行
data_len;
  • skb->len:报文的逻辑总长度,等于线性区域长度加上所有分片的长度。skb_headlen(skb) + skb->data_len
  • skb->data_len:仅计算非线性部分(page frags + frag_list)的字节数。线性 skb 的 data_len 为 0。
  • **truesize**:内核为这个 skb 实际占用的内存大小,包含 sk_buff 结构体本身、数据缓冲区以及 skb_shared_info。用于 socket 发送/接收缓冲区的内存计费(sk_wmem_alloc / sk_rmem_alloc)。
1
2
3
4
/* return minimum truesize of one skb containing X bytes of data */
#define SKB_TRUESIZE(X) ((X) + \
SKB_DATA_ALIGN(sizeof(struct sk_buff)) + \
SKB_DATA_ALIGN(sizeof(struct skb_shared_info)))

truesize 的精确计算在 __finalize_skb_around() 中(net/core/skbuff.c 第 352-375 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static inline void __finalize_skb_around(struct sk_buff *skb, void *data,
unsigned int size)
{
struct skb_shared_info *shinfo;

size -= SKB_DATA_ALIGN(sizeof(struct skb_shared_info));

/* Assumes caller memset cleared SKB */
skb->truesize = SKB_TRUESIZE(size);
refcount_set(&skb->users, 1);
skb->head = data;
skb->data = data;
skb_reset_tail_pointer(skb);
skb_set_end_offset(skb, size);
// ...
}

3.4 cb[48]:各层私有控制块

1
char    cb[48] __aligned(8);   // 源码第 880 行

这 48 字节是各协议层的私有暂存区,对上下层完全透明。内核使用宏来访问它:

IP 层(include/net/ip.h):

1
2
3
4
5
6
7
8
struct inet_skb_parm {
int iif;
struct ip_options opt; /* Compiled IP options */
u16 flags;
// IPSKB_FORWARDED, IPSKB_XFRM_TUNNEL_SIZE, ...
};

#define IPCB(skb) ((struct inet_skb_parm*)((skb)->cb))

TCP 层(include/net/tcp.h):

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
struct tcp_skb_cb {
__u32 seq; /* Starting sequence number */
__u32 end_seq; /* SEQ + FIN + SYN + datalen */
union {
__u32 tcp_tw_isn;
struct {
u16 tcp_gso_segs;
u16 tcp_gso_size;
};
};
// tx 路径:发送时间戳、delivered 计数等
// rx 路径:ip header 信息(h4/h6)
union {
struct {
u32 is_app_limited:1;
// ...
} tx;
union {
struct inet_skb_parm h4;
struct inet6_skb_parm h6;
} header;
};
};

#define TCP_SKB_CB(__skb) ((struct tcp_skb_cb *)&((__skb)->cb[0]))

TCP 在发包时用 TCP_SKB_CB(skb)->seq 记录序列号,在收包时用 TCP_SKB_CB(skb)->header.h4 读取 IP 层留下的信息。由于 sizeof(struct tcp_skb_cb) <= 48,两者都合法地复用同一块内存。

3.5 sk 与 dev 字段

1
2
3
4
union {
struct sock *sk; // 所属 socket
int ip_defrag_offset; // IP 分片重组时复用
};

skb->sk 指向拥有这个报文的 socket。在发送路径中,skb 一直持有对 socket 的引用(用于内存计费);在接收路径进入 TCP/UDP 处理后也会被赋值。ip_defrag_offset 是 IP 分片重组阶段对 sk 字段的临时复用,无需额外内存。

1
2
3
4
5
6
7
8
9
10
11
union {
struct {
struct sk_buff *next;
struct sk_buff *prev;
union {
struct net_device *dev;
unsigned long dev_scratch;
};
};
// ...
};

skb->dev 指向 skb 当前关联的网络设备struct net_device)。在接收路径,它指向收包的网卡;在发送路径,它指向出口网卡。某些协议(如 UDP 接收路径)会将 dev 置 NULL 并用 dev_scratch 存储其他临时信息。

3.6 _skb_refdst:路由缓存

1
2
3
4
5
6
7
union {
struct {
unsigned long _skb_refdst;
void (*destructor)(struct sk_buff *skb);
};
// ...
};

_skb_refdst 存储指向 struct dst_entry 的指针(路由缓存条目)。最低位被用作标志位 SKB_DST_NOREF,表示是否持有引用计数:

1
2
3
4
5
6
7
8
9
#define SKB_DST_NOREF   1UL
#define SKB_DST_PTRMASK ~(SKB_DST_NOREF)

static inline struct dst_entry *skb_dst(const struct sk_buff *skb)
{
WARN_ON((skb->_skb_refdst & SKB_DST_NOREF) &&
!rcu_read_lock_held() && !rcu_read_lock_bh_held());
return (struct dst_entry *)(skb->_skb_refdst & SKB_DST_PTRMASK);
}

在快速转发路径(如 ip_forward())中,内核使用 skb_dst_set_noref() 以 RCU 方式持有路由缓存,避免昂贵的引用计数操作。

3.7 GSO/GRO 相关字段

GSO(Generic Segmentation Offload)和 GRO(Generic Receive Offload)是 Linux 将分段/聚合工作推迟到驱动层或硬件的机制,核心信息存储在 skb_shared_info(见下节)中:

1
2
3
4
5
6
7
struct skb_shared_info {
// ...
unsigned short gso_size; // 单个 MSS 大小
unsigned short gso_segs; // 待分段数量
unsigned int gso_type; // SKB_GSO_TCPV4, SKB_GSO_TCPV6, ...
// ...
};

发送路径中,TCP 构造一个大 skb(可包含多个 MSS 的数据),设置 gso_size = tp->mss_cachegso_segs = 数量,并设置 ip_summed = CHECKSUM_PARTIAL,由驱动或网卡完成实际切割和 checksum。

接收路径中,GRO 将多个小包聚合成一个大 skb,减少协议栈处理次数。skb->slow_gro 标志指示该 skb 在 GRO 合并时携带了额外状态(连接跟踪、路由等),需要在释放时特殊处理。

3.8 Checksum Offload 字段

1
2
3
4
5
6
7
8
__u8    ip_summed:2;    // CHECKSUM_NONE/UNNECESSARY/COMPLETE/PARTIAL
union {
__wsum csum;
struct {
__u16 csum_start;
__u16 csum_offset;
};
};

四种状态的含义(源码 include/linux/skbuff.h 第 99-250 行有完整文档):

场景 含义
CHECKSUM_NONE RX 硬件未校验,软件需要验证
CHECKSUM_UNNECESSARY RX 硬件已验证,无需软件重算
CHECKSUM_COMPLETE RX 硬件提供了整包 checksum(存于 skb->csum
CHECKSUM_PARTIAL TX 软件填写了伪首部,请求硬件完成最终 checksum

TX 路径中,csum_start 是从 skb->head 开始计算 checksum 的起始偏移,csum_offset 是相对于 csum_start 的 checksum 写入位置。

3.9 skb_shinfo() 与 skb_shared_info

skb_shared_info 紧跟在数据缓冲区的末尾(end 指针处):

1
#define skb_shinfo(SKB) ((struct skb_shared_info *)(skb_end_pointer(SKB)))

完整结构(源码第 574-599 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct skb_shared_info {
__u8 flags; // SKBFL_ZEROCOPY_ENABLE 等零拷贝标志
__u8 meta_len;
__u8 nr_frags; // page frags 数量(最多 MAX_SKB_FRAGS=17)
__u8 tx_flags; // SKBTX_HW_TSTAMP 等时间戳标志
unsigned short gso_size;
unsigned short gso_segs;
struct sk_buff *frag_list; // 指向下一个 skb(用于 IP 分片链、超大帧)
struct skb_shared_hwtstamps hwtstamps;
unsigned int gso_type;
u32 tskey;
/*
* Warning : all fields before dataref are cleared in __alloc_skb()
*/
atomic_t dataref; // 数据区引用计数(clone 计数)
unsigned int xdp_frags_size;
void *destructor_arg;
/* must be last field, see pskb_expand_head() */
skb_frag_t frags[MAX_SKB_FRAGS];
};

frags[] 是 Scatter-Gather IO 的核心。每个 skb_frag_t(实为 struct bio_vec)描述一个页片段:

1
2
3
4
5
6
7
8
typedef struct bio_vec skb_frag_t;

// bio_vec 定义:
struct bio_vec {
struct page *bv_page;
unsigned int bv_len;
unsigned int bv_offset;
};

网卡驱动通过 DMA 将数据直接写入这些页,frags[] 只存储(页, 偏移, 长度)三元组,完全避免数据拷贝。

frag_list 是一个 skb 链表,用于两种场景:

  1. IP 分片重组:各个分片 skb 通过 frag_list 链接在一起,skb->len 是全部分片的总长度。
  2. GSO fraglistSKB_GSO_FRAGLIST 模式下,将多个小 skb 链成链表交给驱动。

datarefskb_shared_info 中最重要的计数器,分为两个 16 位字段:低 16 位是总引用数,高 16 位是 payload-only 引用数(nohdr clone)。skb_clone() 共享数据区时递增此计数,skb_release_data() 递减,归零后才真正释放数据缓冲区。


四、sk_buff 操作函数详解

4.1 alloc_skb / __alloc_skb:slab 缓存分配

内核为 sk_buff 头部维护了专用的 slab 缓存(net/core/skbuff.c 第 89-90 行):

1
2
struct kmem_cache *skbuff_cache __ro_after_init;
static struct kmem_cache *skbuff_fclone_cache __ro_after_init;

核心分配函数 __alloc_skb()(源码第 625-687 行):

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 sk_buff *__alloc_skb(unsigned int size, gfp_t gfp_mask,
int flags, int node)
{
struct kmem_cache *cache;
struct sk_buff *skb;
bool pfmemalloc;
u8 *data;

cache = (flags & SKB_ALLOC_FCLONE)
? skbuff_fclone_cache : skbuff_cache;

if (sk_memalloc_socks() && (flags & SKB_ALLOC_RX))
gfp_mask |= __GFP_MEMALLOC;

/* Get the HEAD */
if ((flags & (SKB_ALLOC_FCLONE | SKB_ALLOC_NAPI)) == SKB_ALLOC_NAPI &&
likely(node == NUMA_NO_NODE || node == numa_mem_id()))
skb = napi_skb_cache_get(); // NAPI 路径:per-CPU 缓存
else
skb = kmem_cache_alloc_node(cache, gfp_mask & ~GFP_DMA, node);
if (unlikely(!skb))
return NULL;
prefetchw(skb);

data = kmalloc_reserve(&size, gfp_mask, node, &pfmemalloc);
if (unlikely(!data))
goto nodata;

memset(skb, 0, offsetof(struct sk_buff, tail));
__build_skb_around(skb, data, size);
skb->pfmemalloc = pfmemalloc;

if (flags & SKB_ALLOC_FCLONE) {
struct sk_buff_fclones *fclones;
fclones = container_of(skb, struct sk_buff_fclones, skb1);
skb->fclone = SKB_FCLONE_ORIG;
refcount_set(&fclones->fclone_ref, 1);
}
return skb;
nodata:
kmem_cache_free(cache, skb);
return NULL;
}

分配分两步:

  1. 从 slab 缓存分配 sk_buff 结构体(仅元数据,约 232 字节)
  2. 调用 kmalloc_reserve() 分配数据缓冲区

kmalloc_reserve() 内部会优先尝试 skb_small_head_cache(专为 TCP 头部优化的小块缓存,大小为 SKB_HEAD_ALIGN(MAX_TCP_HEADER)),对于不超过 1024 字节的小报文走快速路径。

NAPI 路径还有额外优化:napi_skb_cache_get() 维护一个 per-CPU 的 64 个 skb 的批量缓存,使用 kmem_cache_alloc_bulk() 批量分配,大幅减少 slab 调用开销。

4.2 skb_reserve:为各层头部预留空间

1
2
3
4
5
static inline void skb_reserve(struct sk_buff *skb, int len)
{
skb->data += len;
skb->tail += len;
}

在 skb 刚分配、尚未写入数据时调用,同时移动 datatail,使 headdata 之间形成 headroom。后续各层通过 skb_push() 向前扩展,写入自己的首部,无需移动已有数据。

驱动分配接收 skb 时通常调用:

1
2
skb_reserve(skb, NET_SKB_PAD);          // 驱动预留(NET_SKB_PAD = 32 字节)
skb_reserve(skb, NET_SKB_PAD + NET_IP_ALIGN); // NAPI 路径额外对齐

发送路径中,sock_alloc_send_skb() 预留的空间要包含所有协议层首部的总长度:

1
NET_SKB_PAD + MAX_HEADER(ETH + IP + TCP 首部之和)

4.3 skb_push / skb_pull / skb_put:指针操作

这三个函数是协议栈各层操作数据的核心原语(源码第 2383-2428 行):

skb_put() — 向 tail 方向扩展,用于接收路径追加数据:

1
2
3
4
5
6
7
8
9
10
void *skb_put(struct sk_buff *skb, unsigned int len)
{
void *tmp = skb_tail_pointer(skb);
SKB_LINEAR_ASSERT(skb);
skb->tail += len;
skb->len += len;
if (unlikely(skb->tail > skb->end))
skb_over_panic(skb, len, __builtin_return_address(0));
return tmp; // 返回新区域的起始地址
}

skb_push() — 向 head 方向扩展,用于发送路径在 headroom 中写入首部:

1
2
3
4
5
6
7
8
void *skb_push(struct sk_buff *skb, unsigned int len)
{
skb->data -= len;
skb->len += len;
if (unlikely(skb->data < skb->head))
skb_under_panic(skb, len, __builtin_return_address(0));
return skb->data;
}

skb_pull() — 从 data 头部移除数据,用于接收路径跳过已处理的首部:

1
2
3
4
5
6
7
8
9
10
void *skb_pull(struct sk_buff *skb, unsigned int len)
{
return skb_pull_inline(skb, len);
}

static inline void *__skb_pull(struct sk_buff *skb, unsigned int len)
{
skb->len -= len;
return skb->data += len;
}

三者关系图示(发送路径,从上层到下层):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
初始状态(alloc + reserve 后):
[headroom...................| tailroom | skb_shared_info]
^ ^ ^
head data=tail end

tcp_push() → skb_push(TCP_hdr_len):
[headroom......|TCP hdr|data| tailroom | skb_shared_info]
^ ^
data tail

ip_output() → skb_push(IP_hdr_len):
[headroom..|IP hdr|TCP hdr|data| tailroom | skb_shared_info]
^ ^
data tail

dev_hard_start_xmit() → 发出后 ip_finish_output2() → skb_push(ETH_hdr_len):
[ETH hdr|IP hdr|TCP hdr|data| tailroom | skb_shared_info]
^ ^
data (= head) tail

4.4 skb_clone vs skb_copy:引用计数 vs 深拷贝

**skb_clone()**(源码第 1853-1881 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct sk_buff *skb_clone(struct sk_buff *skb, gfp_t gfp_mask)
{
struct sk_buff_fclones *fclones = container_of(skb,
struct sk_buff_fclones, skb1);
struct sk_buff *n;

if (skb->fclone == SKB_FCLONE_ORIG &&
refcount_read(&fclones->fclone_ref) == 1) {
n = &fclones->skb2; // 快路径:使用预分配的 fclone
refcount_set(&fclones->fclone_ref, 2);
n->fclone = SKB_FCLONE_CLONE;
} else {
n = kmem_cache_alloc(skbuff_cache, gfp_mask);
if (!n) return NULL;
n->fclone = SKB_FCLONE_UNAVAILABLE;
}
return __skb_clone(n, skb);
}

skb_clone() 只复制 sk_buff 元数据结构,共享数据缓冲区(递增 shinfo->dataref)。克隆体不能修改数据内容,但可以修改自己的首部指针(data 等)。适用于:

  • TCP 发送时保留原始 skb 用于重传
  • Netfilter 分支处理

fclone 优化:TCP 通过 SKB_ALLOC_FCLONE 标志分配 sk_buff_fclones(两个 skb 紧挨着分配),第一次 clone 时直接使用伴生的 skb2,无需额外 slab 分配。

**skb_copy()**(源码第 1933-1953 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct sk_buff *skb_copy(const struct sk_buff *skb, gfp_t gfp_mask)
{
int headerlen = skb_headroom(skb);
unsigned int size = skb_end_offset(skb) + skb->data_len;
struct sk_buff *n = __alloc_skb(size, gfp_mask,
skb_alloc_rx_flag(skb), NUMA_NO_NODE);
if (!n) return NULL;

/* Set the data pointer */
skb_reserve(n, headerlen);
/* Set the tail pointer and length */
skb_put(n, skb->len);

BUG_ON(skb_copy_bits(skb, -headerlen, n->head, headerlen + skb->len));
skb_copy_header(n, skb);
return n;
}

skb_copy() 完全复制数据,包括 headroom、线性数据以及所有 page frags,返回一个完全独立的 skb。代价是需要实际的数据拷贝。适用于需要修改数据内容的场景。

操作 复制元数据 复制数据 共享 shinfo 可修改数据
skb_clone()
skb_copy()
pskb_copy() 仅线性部分 是(frags) 仅线性部分

4.5 skb_linearize:处理分散/聚集 IO

1
2
3
4
5
6
7
8
9
static inline int skb_linearize(struct sk_buff *skb)
{
return skb_is_nonlinear(skb) ? __skb_linearize(skb) : 0;
}

static inline int __skb_linearize(struct sk_buff *skb)
{
return __pskb_pull_tail(skb, skb->data_len) ? 0 : -ENOMEM;
}

当代码需要顺序访问报文所有字节,但 skb 是非线性的(data_len > 0,存在 page frags 或 frag_list)时,调用 skb_linearize() 将所有 page frags 的数据拷贝到线性区域,整合为线性 skb。

这是一个较重的操作,会触发内存分配和拷贝。协议栈中有大量 pskb_may_pull() 调用——它只确保 skb 的首部位于线性区域(仅 pull 必要的字节),比完整的 skb_linearize() 代价小得多。

4.6 kfree_skb vs consume_skb:释放路径的语义区别

两者都会递减 skb->users 引用计数,归零后调用 __kfree_skb() 释放内存,但语义不同,这对 tracing 工具(如 drop_monitorskb:kfree_skb tracepoint)至关重要:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// kfree_skb:报文被丢弃(drop),通常意味着异常
void kfree_skb_reason(struct sk_buff *skb, enum skb_drop_reason reason)
{
if (__kfree_skb_reason(skb, reason))
__kfree_skb(skb);
}

// consume_skb:报文被正常消费,不是 drop
void consume_skb(struct sk_buff *skb)
{
if (!skb_unref(skb))
return;
trace_consume_skb(skb, __builtin_return_address(0)); // 触发 consume tracepoint
__kfree_skb(skb);
}

释放链路(__kfree_skb() 路径):

1
2
3
4
5
6
7
8
9
10
11
12
13
__kfree_skb(skb)
└─ skb_release_all(skb, reason, false)
├─ skb_release_head_state(skb)
│ ├─ skb_dst_drop(skb) // 释放路由缓存引用
│ ├─ skb->destructor(skb) // 如 sock_wfree() 归还 socket 内存配额
│ ├─ nf_conntrack_put() // 释放连接跟踪引用
│ └─ skb_ext_put(skb) // 释放扩展数据
└─ skb_release_data(skb, reason, false)
├─ 递减 shinfo->dataref
├─ napi_frag_unref() // 释放 page frags(可能触发 page_pool 回收)
├─ kfree_skb_list(frag_list)
└─ skb_free_head() // 释放数据缓冲区
└─ kfree_skbmem(skb) // 归还 sk_buff 到 slab 缓存

五、零拷贝相关机制

5.1 skb_frag_t 页片段机制

skb_frag_t 等价于 struct bio_vec,每个 frag 持有一个 struct page * 引用:

1
2
3
4
5
6
7
typedef struct bio_vec skb_frag_t;

// 访问 frag 的工具函数:
static inline unsigned int skb_frag_size(const skb_frag_t *frag)
{
return frag->bv_len;
}

驱动在接收时通常这样操作:

  1. 预先分配 page(通常通过 page_pool 管理),通过 DMA 映射给网卡
  2. 网卡将数据写入 page,触发中断/NAPI
  3. 驱动调用 skb_add_rx_frag() 将 page 引用填入 frags[]
1
2
3
4
5
6
7
8
void skb_add_rx_frag(struct sk_buff *skb, int i, struct page *page,
int off, int size, unsigned int truesize)
{
skb_fill_page_desc(skb, i, page, off, size);
skb->len += size;
skb->data_len += size;
skb->truesize += truesize;
}

page_pool 是 6.x 内核引入的高性能页缓存机制,通过 skb->pp_recycle 标志和 skb_pp_recycle() 函数,在 skb 释放时直接将 page 归还给 page_pool 而非系统内存池,避免了 page allocator 的开销,实现”原地回收”。

5.2 sendfile/splice 的 skb 复用

sendfile()splice() 系统调用走的是 SKBFL_SHARED_FRAG 路径:文件页缓存中的 page 直接被加入 skb 的 frags[],内核标记该 frag 为可能被外部修改:

1
2
3
4
5
6
/* This indicates at least one fragment might be overwritten
* (as in vmsplice(), sendfile() ...)
* If we need to compute a TX checksum, we'll need to copy
* all frags to avoid possible bad checksum
*/
SKBFL_SHARED_FRAG = BIT(1),

此时 skb 的数据直接来自 page cache,全程无需拷贝到内核网络缓冲区。但如果需要计算 TX checksum(ip_summed == CHECKSUM_PARTIAL 但硬件不支持),内核必须通过 skb_copy_ubufs() 先复制 frags 内容,再计算 checksum,这是 zero-copy sendfile 的一个隐含成本。

用户态零拷贝(MSG_ZEROCOPYSKBFL_ZEROCOPY_ENABLE)则通过 ubuf_info 回调机制,在 DMA 传输完成后通知用户态释放缓冲区,实现用户态缓冲区到网卡的真正零拷贝。


六、关键结构体尺寸速查(x86-64,Linux 6.4)

结构体 大小
struct sk_buff ~232 字节
struct skb_shared_info ~320 字节(含 17 个 frags)
skb->cb[48] 48 字节
struct tcp_skb_cb 44 字节(含 IPv6 时)
struct inet_skb_parm ~20 字节
skb_frag_t (bio_vec) 12 字节

七、系列文章导航

本系列共计划 10 篇,系统覆盖 Linux 网络内核的核心子系统:

篇次 标题 核心内容
Part 1 总览架构与 sk_buff 生命周期(本文) 协议栈全路径、sk_buff 内存模型、操作函数、零拷贝
Part 2 网卡驱动与 NAPI 机制 ndo_open/ndo_start_xmit、中断合并、NAPI poll、page_pool、XDP
Part 3 IP 层:路由、分片与转发 fib_lookup、路由缓存、ip_fragment、conntrack hook 点
Part 4 TCP 实现(一):连接管理与状态机 三次握手、TIME_WAIT、tcp_rcv_state_process、SYN cookies
Part 5 TCP 实现(二):拥塞控制与发送缓冲区 CUBIC/BBR、tcp_write_xmit、TSQ、Nagle 算法
Part 6 TCP 实现(三):接收与流量控制 tcp_rcv_established、OOO 队列、接收窗口、tcp_recvmsg
Part 7 UDP 与多播 udp_sendmsgudp_rcv、GRO for UDP、多播路由
Part 8 Socket 层与 VFS 接口 sock_allocinet_stream_opspoll/epoll 实现、零拷贝 recv
Part 9 Netfilter 与 iptables/nftables hook 框架、连接跟踪(nf_conntrack)、NAT 实现原理
Part 10 性能调优与 eBPF 网络编程 RSS/RPS/RFS、SO_REUSEPORT、XDP、TC eBPF、sockmap

参考资料

  • Linux 6.4-rc1 源码:include/linux/skbuff.hnet/core/skbuff.c
  • Linux 内核文档:Documentation/networking/skbuff.rst(内核源码树内)
  • Jonathan Corbet 等:Linux Device Drivers, 3rd Edition,Chapter 17
  • Jesper Dangaard Brouer:Network stack receive path
  • Thomas Graf:Kernel Networking Walkthrough(netdev 历届会议资料)