2. sk_buff 数据结构深度剖析:头部、数据区、分片与元数据管理
sk_buff 是 Linux 网络子系统中最重要的核心数据结构,它贯穿协议栈的每一层,承载着从套接字到网卡驱动的全部网络数据。理解 sk_buff 的内部布局、数据管理机制和元数据字段,是进行队列调度优化和驱动发包协同的前提。本章将从内存布局、数据指针、分片管理和关键元数据四个维度进行深度剖析。
2.1 sk_buff 整体内存布局
每个 sk_buff 实例在内存中由两部分组成:控制结构体(struct sk_buff)和数据缓冲区(skb->data 指向的线性内存区域)。控制结构体本身不存储数据载荷,而是通过指针和偏移量管理数据区、分片(frag_list)和分片页(frags)。
关键设计原则:sk_buff 通过“头部预留 + 数据共享 + 分片零拷贝”机制,避免数据在协议层间移动时的冗余拷贝,提升吞吐性能。
2.2 数据指针体系:head / data / tail / end
sk_buff 通过四个核心指针管理线性数据区,实现协议头的动态添加和移除,无需移动数据内容:
| 指针 | 指向位置 | 作用 | 典型操作 |
|---|---|---|---|
| head | 数据区起始 | 标记整个缓冲区的边界起点 | skb_reserve() 预留头部空间 |
| data | 当前协议层数据起始 | 指向有效载荷的起始位置 | skb_push() / skb_pull() |
| tail | 当前协议层数据结束 | 指向有效载荷的末尾 | skb_put() / skb_trim() |
| end | 数据区末尾 | 标记整个缓冲区的边界终点 | skb_shared_info 紧接其后 |
性能提示:在驱动发包路径中,data 指针的移动次数直接影响 cache 命中率。优化时尽量保持 data 指针在协议层间连续移动,避免跨 cacheline 跳跃。
2.3 分片管理:frags 与 frag_list
当数据包超过线性区容量或需要零拷贝传递时,sk_buff 使用两种分片机制:
- frags[] 数组(页分片):存储在 skb_shared_info 结构中,每个 frag 指向一个物理页框(struct page)及偏移量。适用于大包发送(如 TCP GSO)和接收(GRO 合并)。
- frag_list 链表(skb 分片):将多个 sk_buff 实例链接成一个逻辑包。适用于 IP 分片重组或某些虚拟设备(如 bridge)的转发场景。
2.4 关键元数据字段
除了数据指针和分片管理,sk_buff 还包含大量元数据字段,用于控制包的处理路径、优先级和统计。以下是在队列调度和驱动发包中最重要的字段:
| 字段 | 类型 | 作用 | 优化关注点 |
|---|---|---|---|
| len | __u32 | 数据包总长度(线性区 + 所有分片) | 驱动 DMA 长度计算 |
| truesize | __u32 | sk_buff 实际占用的内存总量(含协议头开销) | 内存压力控制、QoS 限速 |
| priority | __u32 | 包的优先级(SO_PRIORITY 设置) | 队列调度分类(tc) |
| mark | __u32 | 防火墙标记(netfilter) | 路由策略、QoS 分类 |
| hash | __u32 | 包哈希值(RSS / flow hash) | 多队列分发、CPU 亲和性 |
| queue_mapping | __u16 | 发送队列索引(XPS / 驱动队列选择) | 驱动发包队列绑定 |
| skb_mstamp_ns | u64 | 时间戳(纳秒,用于 pacing / BQL) | Byte Queue Limits 计算 |
| destructor_arg | void * | 析构回调参数(socket 内存释放) | 零拷贝发送完成通知 |
2.5 skb_shared_info 结构
在 sk_buff 数据区的末尾(end 指针之后),紧跟着一个 skb_shared_info 结构。该结构不直接属于线性数据区,而是通过 end 指针偏移访问。其核心成员包括:
- nr_frags:当前分片页的数量(最大 MAX_SKB_FRAGS,通常为 17)。
- frags[]:分片页描述符数组,每个元素包含 page、page_offset、size。
- frag_list:指向下一个 sk_buff 的链表指针(用于分片链)。
- tx_flags:发送标志位(如 SKBTX_HW_TSTAMP 硬件时间戳)。
- gso_segs:GSO 分段后的段数(用于统计和 TSO 卸载)。
常见陷阱:驱动在遍历 frags 时,必须检查 nr_frags 是否超过硬件支持的 DMA 段数。如果超过,需要先线性化(skb_linearize()),否则会导致 DMA 描述符溢出或硬件异常。
2.6 数据操作 API 速查
以下是在协议栈和驱动中常用的 sk_buff 数据操作函数,理解它们对 data/tail 指针的影响是正确编程的基础:
| 函数 | 操作 | 指针变化 | 典型用途 |
|---|---|---|---|
| skb_reserve(skb, len) | 在 data 前预留空间 | data += len; tail += len | 协议头预留(L2/L3/L4) |
| skb_put(skb, len) | 在 tail 后添加数据 | tail += len; len += len | 接收数据填充 |
| skb_push(skb, len) | 在 data 前添加协议头 | data -= len; len += len | 发送时添加头部 |
| skb_pull(skb, len) | 从 data 后移除协议头 | data += len; len -= len | 接收时剥离头部 |
| skb_trim(skb, len) | 截断数据到指定长度 | tail = data + len | 接收截断 |
| skb_copy_bits | 从 skb 拷贝指定偏移数据 | 无变化 | 协议头解析 |
2.7 驱动发包视角的 sk_buff 遍历
在网卡驱动发送函数(如 ndo_start_xmit)中,驱动需要将 sk_buff 中的数据映射到 DMA 描述符。典型的遍历逻辑如下:
static netdev_tx_t my_start_xmit(struct sk_buff *skb, struct net_device *dev) {
struct my_priv *priv = netdev_priv(dev);
struct my_tx_desc *desc;
dma_addr_t dma;
int i, frags;
// 1. 映射线性数据区
dma = dma_map_single(dev->dev.parent, skb->data, skb_headlen(skb), DMA_TO_DEVICE);
desc = &priv->tx_ring[priv->tx_next];
desc->addr = dma;
desc->len = skb_headlen(skb);
// 2. 遍历分片页
frags = skb_shinfo(skb)->nr_frags;
for (i = 0; i < frags; i++) {
const skb_frag_t *frag = &skb_shinfo(skb)->frags[i];
dma = skb_frag_dma_map(dev->dev.parent, frag, 0,
skb_frag_size(frag), DMA_TO_DEVICE);
desc = &priv->tx_ring[(priv->tx_next + 1 + i) % TX_RING_SIZE];
desc->addr = dma;
desc->len = skb_frag_size(frag);
}
// 3. 处理 frag_list(如果有)
struct sk_buff *frag_skb = skb_shinfo(skb)->frag_list;
while (frag_skb) {
// 递归或链式映射 frag_list 中的每个 skb
// 注意:frag_list 场景较少,驱动需谨慎处理
frag_skb = frag_skb->next;
}
// 4. 触发 DMA 发送
priv->tx_next = (priv->tx_next + 1 + frags) % TX_RING_SIZE;
// ... 写 doorbell 寄存器
return NETDEV_TX_OK;
}
优化要点:在遍历 frags 时,尽量保持 DMA 映射和描述符写入的 cache 局部性。对于多队列网卡,利用 skb->queue_mapping 直接选择发送队列,避免在驱动中再次计算哈希。
2.8 本章小结
sk_buff 的数据结构设计体现了 Linux 网络栈对性能和灵活性的极致追求:
- 指针体系实现了零拷贝的协议头操作;
- 分片管理支持大包零拷贝和高效内存利用;
- 元数据字段为队列调度、QoS、时间戳和硬件卸载提供了基础。