土法炼钢兴趣小组的算法知识备份

【Linux 网络子系统深度拆解】sk_buff 全解:内核网络包的终极容器

文章导航

分类入口
linuxnetworking
标签入口
#sk_buff#linux-kernel#network-stack#skb_shared_info#page-pool#bpftrace#zero-copy#NAPI

目录

你在用 bpftrace 追踪一个收包延迟问题,发现 __alloc_skb 在高 PPS(Packets Per Second)场景下吃掉了 15% 的 CPU。你想优化,但连 sk_buff 的内存布局都没搞清楚——headdata 指针到底指向哪里?skb_pull() 移动的是哪个指针?skb_clone()skb_copy() 的代价差在哪里?skb_shared_info 里的 fragment 数组是怎么回事?

这些问题不搞清楚,后面讲收包路径、发包路径、GRO/GSO、XDP 都是空中楼阁。

本文就干一件事:sk_buff 这个数据结构彻底拆开。从内存布局到指针操作,从 clone 机制到 fragment 机制,从分配路径到回收路径。每一个关键字段都追到内核源码,每一个关键操作都给出函数签名和行为描述。

本文基于 Linux 6.6 LTS 内核源码(include/linux/skbuff.h)。6.8 中的差异会单独标注。


一、sk_buff 是什么:内核网络栈的通用货币

Linux 内核网络栈处理的每一个网络包——不管是从网卡收到的以太网帧,还是用户态调用 send() 发出的 TCP 段——在内核中都被封装在一个 struct sk_buff 里。

sk_buff 不直接包含包数据。它是一个元数据结构(metadata structure),持有指向实际数据缓冲区的指针,以及大量协议栈各层需要的状态字段。这个设计的核心思想是:

  1. 一个包从网卡到用户态,经过驱动、L2、L3、L4、netfilter、socket 等多个层次,每个层次都需要读写不同的元数据。sk_buff 把这些元数据集中在一个结构体里,避免每层都分配自己的上下文。
  2. 不同层次需要访问包的不同部分(以太网头、IP 头、TCP 头、payload),但不需要每次都复制数据。sk_buff 通过移动指针来”剥洋葱”,每层只需要调整 data 指针的位置。
  3. 同一个包可能被多个路径同时引用(例如 tcpdump 抓包和正常协议栈处理),需要高效的共享机制。sk_buff 的 clone 机制允许只复制元数据、共享底层数据。

整个 struct sk_buffinclude/linux/skbuff.h 中定义,约 220 行。下面逐块拆解。


二、内存布局:四大指针与数据区

sk_buff 内存布局与指针关系

head、data、tail、end

sk_buff 最核心的设计是四个指针,它们划分了数据缓冲区的不同区域:

     head        data       tail       end
      |           |          |         |
      v           v          v         v
      +----------+----------+---------+------------------+
      | headroom |   data   | tailroom| skb_shared_info  |
      +----------+----------+---------+------------------+
// include/linux/skbuff.h, struct sk_buff(末尾字段)
sk_buff_data_t      tail;
sk_buff_data_t      end;
unsigned char       *head,
                    *data;
unsigned int        truesize;
refcount_t          users;

四个指针的含义:

指针 含义 谁设置
head 分配的数据缓冲区起始地址,一旦分配不再移动 __alloc_skb()
data 当前协议层有效数据的起始位置,随层次移动 skb_push()/skb_pull()
tail 当前有效数据的结尾位置 skb_put()
end 数据缓冲区的结尾,skb_shared_info 紧随其后 __alloc_skb()

关键细节:在 64 位系统上,tailend 不是指针,而是相对于 head 的偏移量(unsigned int 类型)。这是为了节省 sk_buff 结构体的大小:

// include/linux/skbuff.h
#if BITS_PER_LONG > 32
#define NET_SKBUFF_DATA_USES_OFFSET 1
#endif

#ifdef NET_SKBUFF_DATA_USES_OFFSET
typedef unsigned int sk_buff_data_t;
#else
typedef unsigned char *sk_buff_data_t;
#endif

在 64 位系统上,用 4 字节偏移代替 8 字节指针,每个 sk_buff 省 8 字节。在每秒处理百万包的场景下,这个节省不是小数。

headroom 和 tailroom

headdata 之间的区域叫 headroomtailend 之间叫 tailroom

为什么需要 headroom?考虑发包路径:应用层调用 send() 写入 payload,然后 TCP 层要在 payload 前面加 TCP 头,IP 层要在 TCP 头前面加 IP 头,以太网层要在 IP 头前面加 MAC 头。如果没有 headroom,每加一层头部都得把整个 payload 往后移——这是 O(n)memmove

有了 headroom,加头部变成了移动 data 指针:

// skb_push: 向前移动 data 指针,扩大有效数据区
static inline void *__skb_push(struct sk_buff *skb, unsigned int len)
{
    skb->data -= len;
    skb->len  += len;
    return skb->data;
}

这是 O(1) 操作。同样的思路,收包时用 skb_pull() 向后移动 data 指针来”剥掉”已处理的协议头:

// skb_pull: 向后移动 data 指针,缩小有效数据区
static inline void *__skb_pull(struct sk_buff *skb, unsigned int len)
{
    skb->len -= len;
    // ... 省略边界检查
    skb->data += len;
    return skb->data;
}

驱动层在分配 sk_buff 时会预留足够的 headroom,通常调用 skb_reserve() 来设定:

// skb_reserve: 在空 skb 上设置 headroom
static inline void skb_reserve(struct sk_buff *skb, int len)
{
    skb->data += len;
    skb->tail += len;
}

skb_reserve() 只能在 skb 还没有数据时调用。典型的驱动收包流程是:

skb = __alloc_skb(size, GFP_ATOMIC, 0, NUMA_NO_NODE);
skb_reserve(skb, NET_IP_ALIGN + NET_SKB_PAD);
// 然后 DMA 把网卡数据写到 data 指针位置
skb_put(skb, pkt_len);  // 标记有效数据长度

指针操作全景

分配后,skb_reserve() 之前:
head = data = tail                      end
  |                                      |
  v                                      v
  +--------------------------------------+------------------+
  |          全部是 tailroom             | skb_shared_info  |
  +--------------------------------------+------------------+

skb_reserve(skb, headroom) 之后:
head            data = tail              end
  |               |                       |
  v               v                       v
  +--------------+------------------------+------------------+
  |   headroom   |       tailroom         | skb_shared_info  |
  +--------------+------------------------+------------------+

skb_put(skb, pkt_len) 之后(DMA 已写入数据):
head            data           tail      end
  |               |              |        |
  v               v              v        v
  +--------------+--------------+---------+------------------+
  |   headroom   | packet data  |tailroom | skb_shared_info  |
  +--------------+--------------+---------+------------------+

收包时 skb_pull(skb, eth_hdr_len) 剥掉以太网头:
head                   data     tail     end
  |                      |        |       |
  v                      v        v       v
  +--------------+------+--------+--------+------------------+
  |   headroom   |eth hd| IP+pay |tailrm  | skb_shared_info  |
  +--------------+------+--------+--------+------------------+
                  ^
                  mac_header 记录这里的偏移

发包时 skb_push(skb, ip_hdr_len) 加上 IP 头:
head       data                  tail    end
  |          |                     |      |
  v          v                     v      v
  +----------+--------------------+------+------------------+
  | headroom |IP hdr + TCP + pay  |tailrm| skb_shared_info  |
  +----------+--------------------+------+------------------+

这四个操作——skb_reserve()skb_put()skb_push()skb_pull()——是整个网络栈数据操作的基础。理解它们就理解了”为什么网络包处理不需要大量 memcpy“。


三、关键字段分类拆解

struct sk_buff 有几十个字段。按功能分成几组来看。

链表与队列管理

union {
    struct {
        struct sk_buff      *next;
        struct sk_buff      *prev;
        union {
            struct net_device   *dev;
            unsigned long       dev_scratch;
        };
    };
    struct rb_node      rbnode;     // netem、IP 分片重组、TCP 重传队列用
    struct list_head    list;
    struct llist_node    ll_node;
};

sk_buff 用链表串成队列(sk_buff_head)。next/prev 构成双向链表。同一个 union 里还有 rbnode(红黑树节点),TCP 协议栈用它来维护有序的重传队列和乱序队列。ll_node 是 lockless list 节点,用于 socket 的 defer_list 等无锁场景。

dev 字段指向收到这个包的网卡(struct net_device),或者即将发送这个包的网卡。在某些协议路径中 dev 会被暂时设为 NULL,这时 dev_scratch 可以复用这个空间存其他数据(UDP 收包路径就这么干的)。

所属 socket 与时间戳

struct sock     *sk;        // 关联的 socket,收包时可能为 NULL

union {
    ktime_t     tstamp;             // 到达/离开时间
    u64         skb_mstamp_ns;      // EDT: 最早出发时间
};

sk 指向拥有这个包的 socket。收包早期阶段(驱动到协议栈)sk 为 NULL;协议栈完成四元组匹配后才会设置。发包时 sk 从一开始就指向发送方的 socket。

tstamp 有双重含义:收包时记录到达时间,发包时可以是 EDT(Earliest Departure Time)——TC qdisc 用这个字段实现 pacing。

控制缓冲区 cb

char    cb[48] __aligned(8);

48 字节的”万能口袋”。每个协议层可以把自己的私有数据塞进这 48 字节,例如 TCP 的 tcp_skb_cb

// include/net/tcp.h
struct tcp_skb_cb {
    __u32       seq;        // 起始序号
    __u32       end_seq;    // 结束序号
    // ... 其他 TCP 元数据
};

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

cb 的规则是:谁持有 skb,谁就可以用 cb 如果你想让 cb 里的数据跨层传递,必须先 skb_clone(),否则下一层会直接覆盖。

长度字段

unsigned int    len,            // 有效数据总长度(线性区 + fragment)
                data_len;       // fragment 中的数据长度
__u16           mac_len,        // MAC 头长度
                hdr_len;        // clone skb 的可写头部长度

len 是整个包的有效数据长度,包含线性区(head buffer 中的部分)和 fragment(skb_shared_info 中引用的 page)。

data_len 是 fragment 部分的长度。所以 线性区数据长度 = len - data_len。如果 data_len == 0,说明所有数据都在线性区(没有使用 fragment)。

协议头偏移

__be16      protocol;           // L3 协议类型(ETH_P_IP, ETH_P_IPV6 等)
__u16       transport_header;   // L4 头相对于 head 的偏移
__u16       network_header;     // L3 头相对于 head 的偏移
__u16       mac_header;         // L2 头相对于 head 的偏移

这三个偏移量记录了各层协议头在数据缓冲区中的位置。注意它们是相对于 head 的偏移,不是指针。这样在 data 指针被 skb_push/skb_pull 移动之后,协议头的位置仍然可以准确找到。

收包时,各层协议处理函数会设置对应的偏移:

// 以太网层设置 mac_header
skb_reset_mac_header(skb);  // mac_header = skb->data - skb->head

// IP 层设置 network_header
skb_reset_network_header(skb);

// TCP 层设置 transport_header
skb_reset_transport_header(skb);

然后用 skb_mac_header()skb_network_header()skb_transport_header() 取回对应位置的指针。

封装协议支持

__be16      inner_protocol;
__u16       inner_transport_header;
__u16       inner_network_header;
__u16       inner_mac_header;
__u8        encapsulation:1;

用于隧道和封装场景(VXLAN、GRE、GENEVE 等)。外层头用普通的 protocol/network_header 等字段,内层头用 inner_* 系列字段。encapsulation 标志位表示内层头有效。

校验和卸载

__u8        ip_summed:2;    // CHECKSUM_NONE / UNNECESSARY / COMPLETE / PARTIAL
__u8        csum_valid:1;
__u8        csum_complete_sw:1;
__u8        csum_level:2;

union {
    __wsum      csum;           // 硬件/软件计算的校验和
    struct {
        __u16   csum_start;     // 校验和计算的起始偏移
        __u16   csum_offset;    // 校验和字段在包中的偏移
    };
};

校验和卸载(Checksum Offload)是网卡和协议栈之间的重要优化接口:

ip_summed 含义 场景
CHECKSUM_NONE 校验和未计算 需要软件计算
CHECKSUM_UNNECESSARY 校验和已验证正确 网卡硬件已验证
CHECKSUM_COMPLETE 网卡提供了原始校验和 存在 csum 字段中,协议栈做最终验证
CHECKSUM_PARTIAL 协议栈只算了伪头部 发包时由网卡完成最终计算

位标志字段

sk_buff 使用大量单比特位标志来节省空间:

__u8    cloned:1,       // 是否被 clone 过
        nohdr:1,        // 头部数据已释放给 clone
        fclone:2,       // fast clone 状态
        peeked:1,       // 已被 MSG_PEEK 读取过
        head_frag:1,    // head buffer 来自 page fragment
        pfmemalloc:1,   // 使用 PFMEMALLOC 保留内存分配
        pp_recycle:1;   // page_pool 回收标记
__u8    pkt_type:3;     // 包类型:PACKET_HOST / BROADCAST / MULTICAST / OTHERHOST
__u8    ignore_df:1;    // 忽略 Don't Fragment 标志

其中 pkt_type 在收包阶段由 eth_type_trans() 根据目的 MAC 地址设置:

流量控制与路由

__u32       priority;       // QoS 优先级
__u32       hash;           // 流 hash(RSS/RPS 用)
__u16       queue_mapping;  // 网卡硬件队列编号
__u16       tc_index;       // Traffic Control 索引
__u32       mark;           // netfilter 标记(iptables -j MARK)
int         skb_iif;        // 收包接口索引
unsigned long   _skb_refdst;    // 路由缓存条目

hash 字段在收包时由硬件 RSS 或软件 RPS 计算,用于将同一个流的包分发到同一个 CPU。queue_mapping 标识硬件多队列网卡的具体队列编号。mark 是 netfilter 标记,iptables -j MARK --set-mark 设置的值就存在这里,策略路由 ip rule fwmark 也读这个字段。

Netfilter 连接追踪

#if defined(CONFIG_NF_CONNTRACK) || defined(CONFIG_NF_CONNTRACK_MODULE)
unsigned long    _nfct;
#endif

_nfct 存储 conntrack 条目指针(低 3 位存 conntrack 状态)。这是 netfilter 连接追踪的核心字段,NAT、有状态防火墙都依赖它。

引用计数与销毁

refcount_t      users;              // skb 元数据的引用计数
void            (*destructor)(struct sk_buff *skb);  // 销毁回调

users 控制 sk_buff 结构体本身的生命周期。当 users 降到 0 时,kfree_skb()consume_skb() 释放这个 sk_buff。注意这和 skb_shared_info.dataref 不同——后者控制数据缓冲区的生命周期。

destructor 回调在 skb 被释放时调用,用于通知 socket 层释放发送缓冲区配额。


四、skb_shared_info:数据缓冲区的另一半

在数据缓冲区的末尾(end 指针之后)紧跟着一个 struct skb_shared_info。它不是 sk_buff 的字段,而是通过 skb_shinfo(skb) 宏访问的独立结构体:

#define skb_shinfo(SKB) ((struct skb_shared_info *)(skb_end_pointer(SKB)))
// include/linux/skbuff.h
struct skb_shared_info {
    __u8        flags;
    __u8        meta_len;
    __u8        nr_frags;           // fragment 数量
    __u8        tx_flags;           // 发送时间戳标志
    unsigned short  gso_size;       // GSO 分段大小
    unsigned short  gso_segs;       // GSO 分段数量
    struct sk_buff  *frag_list;     // 附加的 skb 链表
    union {
        struct skb_shared_hwtstamps hwtstamps;  // 硬件时间戳
        struct xsk_tx_metadata_compl xsk_meta;  // AF_XDP 元数据
    };
    unsigned int    gso_type;       // GSO 类型标志
    u32         tskey;              // 时间戳 key

    atomic_t    dataref;            // 数据缓冲区引用计数

    unsigned int    xdp_frags_size; // XDP multi-buffer fragment 大小

    void *      destructor_arg;     // zero-copy 回调上下文

    skb_frag_t  frags[MAX_SKB_FRAGS];   // page fragment 数组
};

fragment 机制

frags[] 数组是 sk_buff 支持非线性数据的关键。一个包的数据可以分布在多个不连续的内存页中:

sk_buff
  |
  +-- head buffer (线性区): [headroom][eth+ip+tcp header + 部分 payload]
  |
  +-- skb_shared_info
        +-- frags[0] -> page A, offset, len  (更多 payload)
        +-- frags[1] -> page B, offset, len  (更多 payload)
        +-- nr_frags = 2

为什么需要 fragment?

  1. 大包拆分:GRO(Generic Receive Offload)把多个小包合并成一个大包时,把后续包的数据作为 fragment 挂到第一个包上,避免 memcpy
  2. sendfile/splice:零拷贝发送时,payload 直接引用 page cache 中的页面,不拷贝到线性区。
  3. 分片重组:IP 分片重组时,各分片的数据可以直接作为 fragment,不需要拷贝到一个大的连续缓冲区。

MAX_SKB_FRAGS 在大多数配置下是 17,意味着一个 sk_buff 最多可以引用 17 个不连续的内存页。

frag_list

除了 frags[] 数组,skb_shared_info 还有一个 frag_list 字段,指向一个 sk_buff 链表。这是另一种非线性数据组织方式:

sk_buff (主包)
  +-- skb_shared_info
        +-- frag_list -> sk_buff -> sk_buff -> NULL

frag_list 主要用于 IP 分片重组——把各个分片的完整 sk_buff 串成链表。与 frags[] 的区别是:frags[] 引用的是裸内存页,而 frag_list 引用的是完整的 sk_buff,每个都有自己的元数据。

dataref:数据缓冲区的引用计数

atomic_t    dataref;

dataref 控制数据缓冲区(从 headend + skb_shared_info)的生命周期。这和 sk_buff.users(控制 sk_buff 元数据的生命周期)是独立的两层引用计数。

dataref 的高 16 位和低 16 位有不同含义:

#define SKB_DATAREF_SHIFT 16
#define SKB_DATAREF_MASK ((1 << SKB_DATAREF_SHIFT) - 1)

这个设计允许 TCP 在发送克隆时,让 clone 可以独立修改头部空间,而原始 skb 保持 payload 不变。

GSO/TSO 字段

unsigned short  gso_size;
unsigned short  gso_segs;
unsigned int    gso_type;

GSO(Generic Segmentation Offload)让协议栈可以把大于 MTU 的包一直传到网卡驱动层附近才做分段。gso_size 是每个分段的大小(通常等于 MSS),gso_segs 是分段数。如果网卡支持 TSO(TCP Segmentation Offload),分段完全由硬件完成;否则在 dev_queue_xmit() 路径中由软件 GSO 完成。


五、分配与释放:热路径上的性能关键

分配路径

sk_buff 的分配涉及两次内存分配:

  1. sk_buff 结构体本身——从 slab cache skbuff_head_cache 分配
  2. 数据缓冲区——从 kmalloc 或 page allocator 分配
// net/core/skbuff.c(简化)
struct sk_buff *__alloc_skb(unsigned int size, gfp_t gfp_mask,
                            int flags, int node)
{
    struct sk_buff *skb;

    // 第一次分配:sk_buff 结构体
    if (flags & SKB_ALLOC_FCLONE)
        skb = kmem_cache_alloc_node(skbuff_fclone_cache, gfp_mask, node);
    else
        skb = kmem_cache_alloc_node(skbuff_head_cache, gfp_mask, node);

    // 第二次分配:数据缓冲区(包含 skb_shared_info 的空间)
    size = SKB_DATA_ALIGN(size);
    size += SKB_DATA_ALIGN(sizeof(struct skb_shared_info));
    data = kmalloc_reserve(&size, gfp_mask, node, &pfmemalloc);

    // 初始化指针
    skb->head = data;
    skb->data = data;
    skb_reset_tail_pointer(skb);
    skb->end = skb->tail + size - SKB_DATA_ALIGN(sizeof(struct skb_shared_info));

    // 初始化 skb_shared_info
    memset(skb_shinfo(skb), 0, offsetof(struct skb_shared_info, dataref));
    atomic_set(&skb_shinfo(skb)->dataref, 1);

    return skb;
}

注意 SKB_DATA_ALIGN 确保数据缓冲区对齐到 cache line,避免 false sharing。

Fast Clone 机制

TCP 发包时经常需要 clone sk_buff(原始的留在重传队列,clone 的交给网卡发送)。普通流程是两次 slab 分配(原始 + clone)。Fast clone 优化是一次分配两个 sk_buff

// include/linux/skbuff.h
struct sk_buff_fclones {
    struct sk_buff  skb1;       // 原始 skb
    struct sk_buff  skb2;       // 预分配的 clone 位置
    refcount_t      fclone_ref; // 共享引用计数
};

当调用 alloc_skb_fclone() 时,分配一个 sk_buff_fclones 结构体,后续 skb_clone() 直接使用预分配的 skb2,省掉一次 kmem_cache_alloc

NAPI 批量分配

在高 PPS 场景下,每收一个包就调用一次 __alloc_skb() 的开销很大。NAPI 驱动可以使用 napi_alloc_skb() 从 per-CPU 的 page frag cache 中分配,减少锁竞争和 slab 分配开销。

// 标志位
#define SKB_ALLOC_NAPI  0x04

// 驱动收包热路径
skb = napi_alloc_skb(napi, len);

释放路径

释放 sk_buff 有两个入口:

// 正常消费完毕(协议栈处理完成)
void consume_skb(struct sk_buff *skb);

// 异常丢弃(队列满、校验和错误等)
void kfree_skb(struct sk_buff *skb);
void kfree_skb_reason(struct sk_buff *skb, enum skb_drop_reason reason);

kfree_skb 会触发 trace_kfree_skb tracepoint,这是追踪网络丢包的关键手段。consume_skb 触发 trace_consume_skb,不算丢包。6.x 内核的 kfree_skb_reason 还会携带丢包原因枚举(enum skb_drop_reason),drop_monitor 等工具用这个信息定位具体的丢包原因。

释放流程:

  1. 减少 users 引用计数
  2. 如果降到 0,调用 destructor 回调
  3. 减少 dataref
  4. 如果 dataref 也降到 0,释放数据缓冲区
  5. 如果有 fragment,释放各 page 引用
  6. 如果 pp_recycle 标志位设置,通过 page_pool 回收而非直接释放

page_pool 回收

传统的 skb 释放路径需要把 page 还给伙伴系统(buddy allocator),这在高 PPS 场景下是瓶颈。page_pool 机制在驱动和网络栈之间建立了一个”回收池”:

// pp_recycle 标志位
__u8    pp_recycle:1;   // 标记这个 skb 的 page 来自 page_pool

pp_recycle 为 1 时,skb 释放时会把 page 还给驱动的 page_pool 而非伙伴系统,大幅减少分配/释放开销。这是 Linux 5.x 以后网络性能优化的关键特性之一。


六、clone 与 copy:共享 vs 深拷贝

skb_clone:只复制元数据

struct sk_buff *skb_clone(struct sk_buff *skb, gfp_t gfp_mask);

skb_clone() 创建一个新的 sk_buff 结构体,但共享底层数据缓冲区

原始 skb:  [sk_buff A] ---> [head buffer + skb_shared_info]
                                     ^
clone skb: [sk_buff B] ----/         |
                                     dataref = 2

clone 只需要一次 slab 分配(新的 sk_buff 结构体),不需要复制任何包数据。代价是:clone 和原始 skb 不能独立修改数据缓冲区(因为共享同一块内存)。

典型使用场景:

skb_copy:完整深拷贝

struct sk_buff *skb_copy(const struct sk_buff *skb, gfp_t gfp_mask);

skb_copy() 创建一个完全独立的副本——新的 sk_buff 结构体 + 新的数据缓冲区 + memcpy 所有数据。代价是:需要分配数据缓冲区 + 复制所有包数据。

pskb_copy:部分拷贝

struct sk_buff *__pskb_copy(struct sk_buff *skb, int headroom, gfp_t gfp_mask);

pskb_copy() 是折中方案:复制线性区数据(头部),但共享 fragment 页面。适用于需要修改协议头但不需要修改 payload 的场景。

三者对比

操作 复制元数据 复制线性区 复制 fragment 分配次数 典型用途
skb_clone 否(共享) 否(共享) 1(slab) 抓包、多路径分发
pskb_copy 否(共享) 2(slab + 数据) 修改头部但不改 payload
skb_copy 是(深拷贝) 2+(slab + 数据 + pages) 需要完全独立修改

在高 PPS 场景下,skb_cloneskb_copy 的性能差距是数量级的。能用 skb_clone 的地方绝不用 skb_copy,这是内核网络栈的基本原则。


七、truesize:内存计费

unsigned int    truesize;

truesize 记录这个 sk_buff 实际消耗的总内存,包括 sk_buff 结构体本身 + 数据缓冲区 + fragment 页面。它的用途是内存计费——TCP/UDP 协议栈用 truesize 来计算 socket 接收/发送缓冲区的使用量,决定是否触发内存压力(sk_rmem_alloc / sk_wmem_alloc)。

truesize 的初始值通常是:

skb->truesize = SKB_TRUESIZE(size);
// 其中 SKB_TRUESIZE(X) = SKB_DATA_ALIGN(X) +
//                         SKB_DATA_ALIGN(sizeof(struct skb_shared_info)) +
//                         sizeof(struct sk_buff)

truesize 的准确性直接影响 TCP 的流控和内存管理。如果 truesize 偏大,socket 缓冲区会”虚满”,导致正常包被丢弃(sk_rcvbuf 报满)。如果偏小,可能导致内存使用失控。GRO 聚合后 truesize 的计算是一个容易出 bug 的地方——合并多个包时必须正确累加 truesize


八、可观测性:用 bpftrace 追踪 sk_buff

追踪 skb 分配热点

# 统计 __alloc_skb 的调用者和调用频率(采样 5 秒)
bpftrace -e '
kprobe:__alloc_skb {
    @alloc[kstack(3)] = count();
}
END { print(@alloc, 10); }
' -d 5

追踪 skb 释放与丢包

# 追踪 kfree_skb_reason,统计丢包原因
bpftrace -e '
tracepoint:skb:kfree_skb {
    @drop_reason[args->reason] = count();
    @drop_location[ksym(args->location)] = count();
}
'

在 6.x 内核中,kfree_skb_reasonreason 字段是 enum skb_drop_reason,可以精确到”TCP 校验和错误”“conntrack 表满”“netfilter DROP”等具体原因。这比以前只知道”包被丢了”进步巨大。

观察 slab 缓存状态

# 查看 skbuff 相关 slab 缓存的使用情况
cat /proc/slabinfo | head -2
cat /proc/slabinfo | grep skbuff

输出示例(以下数据为示意):

# name            <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab>
skbuff_head_cache      12845     13056       256         32          2
skbuff_fclone_cache     3280      3360       576         28          4

skbuff_head_cache 是普通 sk_buff 的 slab 缓存,对象大小约 256 字节(具体取决于内核配置和对齐)。skbuff_fclone_cache 是 fast clone 的缓存,大小约为普通缓存的 2 倍(因为包含两个 sk_buff + 引用计数)。

追踪 skb 在收包路径中的生命周期

# 追踪一个收包 skb 经过的关键函数
bpftrace -e '
kprobe:__netif_receive_skb_core { @recv = count(); }
kprobe:ip_rcv { @ip = count(); }
kprobe:tcp_v4_rcv { @tcp = count(); }
kprobe:__skb_recv_datagram { @deliver = count(); }
kprobe:__kfree_skb { @free = count(); }
interval:s:1 {
    printf("recv=%d ip=%d tcp=%d deliver=%d free=%d\n",
        @recv, @ip, @tcp, @deliver, @free);
    clear(@recv); clear(@ip); clear(@tcp);
    clear(@deliver); clear(@free);
}
'

九、sk_buff 与 XDP 的关系

XDP(eXpress Data Path)的核心优势之一就是绕过 sk_buff 分配。在 XDP 处理路径中,包数据用更轻量的 struct xdp_buff 表示:

struct xdp_buff {
    void *data;
    void *data_end;
    void *data_meta;
    void *data_hard_start;
    struct xdp_rxq_info *rxq;
    struct xdp_txq_info *txq;
    u32 frame_sz;
    u32 flags;
};

xdp_buff 没有协议头偏移、没有 conntrack 指针、没有 socket 引用——它只有”一段数据+长度”。XDP 程序直接操作这段数据,用 XDP_DROP / XDP_TX / XDP_REDIRECT 做出转发决策。只有当包需要进入正常协议栈时(XDP_PASS),才会通过 xdp_build_skb_from_buff() 转换为 sk_buff

这意味着在 XDP 的 DROP 和 TX 路径中,sk_buff 分配的开销被完全跳过。在 DDoS 防御场景下,这个差异是每秒丢弃百万包和千万包的区别。

关于 XDP 的内核实现细节,参见本系列 21-xdp-internals。关于 XDP 的应用层面,参见 eBPF 系列:XDP


十、小结

sk_buff 是理解 Linux 内核网络栈的第一块拼图。总结几个关键判断:

  1. sk_buff 是元数据容器,不是数据容器。 它持有指向数据缓冲区的指针,而非数据本身。这个分离设计让 clone(共享数据)成为可能。

  2. 四大指针操作(skb_reserve/skb_put/skb_push/skb_pull)是整个协议栈避免 memcpy 的基础。 添加和剥离协议头不需要移动数据,只需要移动指针。

  3. 两层引用计数(users + dataref)支撑了 clone 机制。 skb_clone 只需一次 slab 分配,这是 tcpdump 零开销抓包的根本原因。

  4. truesize 驱动了 TCP/UDP 的内存管理。 它的准确性直接影响 socket 缓冲区水位判断,错误的 truesize 会导致”缓冲区报满但实际没满”。

  5. 在高 PPS 场景下,sk_buff 分配/释放本身就是性能瓶颈。 page_pool 回收、NAPI 批量分配、fast clone 都是针对这个瓶颈的优化。XDP 则直接绕过了 sk_buff

下一篇我们拆解 net_device 和网卡驱动模型——sk_buff 是怎么被创建出来的,取决于驱动怎么跟内核网络栈交互。


参考文献

  1. Linux 内核源码,include/linux/skbuff.h,6.6 LTS / 6.8
  2. Linux 内核源码,net/core/skbuff.c,6.6 LTS
  3. Jonathan Corbet, “Network buffers and memory management”, LWN.net, 2022
  4. Memory management for network devices, Linux kernel documentation, Documentation/networking/page_pool.rst

下一篇net_device 与网卡驱动模型:从硬件到内核的接口契约

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-20 · linux / networking

【Linux 网络子系统深度拆解】收包路径全解:从 NIC 中断到 socket 接收队列

一个网络包从网卡 DMA 到用户态 recvmsg(),要走过硬中断、NAPI 轮询、GRO 聚合、协议分发、IP 路由、Netfilter 钩子、TCP/UDP 处理、socket 队列八个阶段。本文从 Linux 6.6 内核源码出发,逐函数拆解完整的 RX 收包路径,量化每一跳的 CPU 开销,并用 bpftrace 实测各阶段延迟分布。

2026-04-20 · linux / networking

【Linux 网络子系统深度拆解】软中断与 ksoftirqd:网络包处理的调度引擎

网络包到达网卡后,真正消耗 CPU 的处理全部发生在软中断上下文。本文从 Linux 6.6 内核源码出发,拆解 softirq 10 向量优先级体系、__do_softirq() 主循环与 MAX_SOFTIRQ_RESTART 放弃策略、ksoftirqd 调度时机、Threaded NAPI 替代方案,以及 CONFIG_PREEMPT_RT 下的行为变化。最后用 bpftrace/perf 实测软中断延迟和 time_squeeze 饥饿。


By .