跳到主要内容

14.15 宏定义与工具函数


这一节虽然名字叫 "Macros"(宏),但核心问题只有一个:数据包是如何在内核中流动的?

如果你是一名网络驱动的开发者,当你写下一行 PCI 驱动注册宏时,你可能会好奇:这行代码到底是如何与内核庞大的设备模型挂钩的?更重要的是,当数据包像幽灵一样穿过硬件到达内核时,它变成了什么?

此外,本章作为终章的压轴部分,接下来的内容将带你深入 Linux 内核网络栈的**「基石」**:sk_buffnet_device

在此之前,我必须坦诚地告诉你:接下来的内容密度极高。如果你觉得之前的章节是在「讲故事」,那么附录 A 就是在「拆解引擎」。我们会把内核中最核心的两个数据结构——Socket Buffer(SKB)和网络设备——拆开来看,每一个字段、每一个指针、每一个比特位的含义都不会放过。

这不是为了让你背下来,而是为了让你在调试崩溃日志时,能像看地图一样看清内核的脉络。


15.1 注册:只是个开始

写 PCI 网络驱动的时候,你做的第一件事往往不是初始化硬件,而是向内核报到

你可以把这行宏想象成一个**「信封」**,上面写着「我负责处理这种 ID 的设备」。

但这个「信封」比喻有一个地方是错的:真正的信封寄出去就结束了,而注册宏只是故事的开端。当内核在 PCI 总线上发现匹配的设备时,它会唤醒你的驱动,但数据包的流动并不依赖驱动本身——而是依赖内核为这个设备精心准备的 net_device 结构,以及承载灵魂的 sk_buff

回到那个「信封」:你现在应该能看出来,注册宏只是把信封投进了邮箱,真正漫长的旅程,是在内核接过信封之后才开始的。接下来,我们将深入内核,看看它到底是怎么处理这封信的。


附录 A:深入内核数据结构

接下来的内容,是整本书的**「硬核附录」**。如果你真的想理解 Linux 网络栈是如何运作的,而不是仅仅停留在调参层面,这里是必经之路。

这里涵盖了 Linux 内核网络栈中最核心的两个数据结构:sk_buff(Socket Buffer)和 net_device

为了不让你迷失在 27 个字段的海洋里,我们不会像念说明书一样从头念到尾。相反,我们会沿着数据包在内核中的流动路径来拆解 sk_buff——从分配、到指针移动、再到最终释放。我们会看到哪些字段在控制数据包的生命,哪些字段在优化性能,以及哪些字段是专门为了「绕过拷贝」而设计的。

最后,我们还会简单看看 net_device 和 RDMA,这是连接物理世界和内核虚拟接口的桥梁。


A.1 sk_buff:数据包的灵魂

sk_buff 结构体代表一个网络数据包。SKB 是 Socket Buffer 的缩写。

想象一下,一个数据包可能由本地用户空间的 socket 产生(通过 HTTP 请求),也可能从物理网卡接收上来。它可能发往外部,也可能发给本机的另一个 socket。对于内核来说,无论它的来源和去向是什么,它最终都会被封装在一个 sk_buff 结构中,在网络栈的各层之间传递。

你可以把 sk_buff 想象成一个**「信封」**。

  • 信封背面写着谁寄的(sk)、寄给谁(dev)、走哪条路(dst)。
  • 信封里面装着信的内容(data 指针指向的数据)。

但这个「信封」比喻有一个地方是错的:真正的信封一旦封口就不能改了,而 sk_buff 的数据区是动态伸缩的。协议栈的每一层(TCP、IP、以太网)都会在信封里塞一层自己的「垫纸」(协议头),或者把上面那层的垫纸撕掉。sk_buff 最精妙的设计,就是用指针的移动来实现这种「塞垫纸」和「撕垫纸」的过程,而不是真的把数据搬来搬去。

回到那个「信封」:你现在应该能看出来,data 指针就是信封里当前能看到的那一页纸的顶行,而 headend 就是这张纸的物理边界。如果 head 到了 data 的位置,说明没有空间写新地址了(Headroom 耗尽),这时候内核可能不得不重新找一张更大的纸(拷贝数据),这是性能杀手。

1. 指针管理:俄罗斯方块

这是 SKB 最精妙也最容易出错的地方。headdatatailend 这四个指针定义了数据包在内存中的布局。

sk_buff_data_t transport_header; // L4 头部位置
sk_buff_data_t network_header; // L3 头部位置
sk_buff_data_t mac_header; // L2 头部位置

sk_buff_data_t tail; // 数据的尾部
sk_buff_data_t end; // 缓冲区的尾部
unsigned char *head; // 缓冲区的起始
unsigned char *data; // 数据的起始

核心概念:线性数据区 vs. 非线性数据区

  • 线性数据区:从 headend 的区域是分配给 SKB 的整个线性缓冲区。
  • 有效数据:从 datatail 的区域是当前有效的数据载荷。
  • 协议头mac_headernetwork_headertransport_header 是指向线性缓冲区内特定位置的指针,分别指向 L2、L3、L4 层的起始位置。

操作宏(修改 datatail 指针):

  • skb_put(skb, len): 在尾部增加数据。tail 指针下移,len 增加。用于接收数据或构建协议头。
  • skb_push(skb, len): 在头部增加数据。data 指针上移,len 增加。用于添加协议头(例如,IP 层在发送前调用此函数添加 IP 头)。
  • skb_pull(skb, len): 从头部移除数据。data 指针下移,len 减少。用于剥离协议头(例如,IP 层处理完后,将 data 指针移过 IP 头,交给 TCP 层)。
  • skb_reserve(skb, len): 预留头部空间。datatail 指针同时下移。通常在分配 SKB 后立即调用,为了在前面留出足够的空间给下层协议添加头部。

Headroom 和 Tailroom

  • Headroom: data 减去 head。这是预留的头部空间。
  • Tailroom: end 减去 tail。这是预留的尾部空间。

这种设计使得内核可以在不拷贝数据的情况下,高效地在各层协议之间传递数据包,只需移动指针即可。

2. 机制拆解:一个包的旅程

光看指针定义容易晕,我们来推演一个数据包从网卡驱动(L2)到 TCP(L4)的过程中,SKB 内部到底发生了什么。

场景:主机收到一个 TCP 数据包

  1. L2 入场(驱动接收)

    • 驱动分配一个 SKB,通常使用 netdev_alloc_skb()。这步通常还会调用 skb_reserve() 预留头部空间。
    • 驱动把数据 DMA 到 data 位置。
    • 此时,data 指向以太网头
  2. L2 -> L3(网络层)

    • 内核调用 eth_type_trans()。这个函数做了两件事:
      • skb->protocol 设置为 ETH_P_IP( IPv4)。
      • 调整 skb->mac_header 指向 L2 头。
      • 调用 skb_pull(skb, hlen) 剥去以太网头。
    • 关键点:此时 data 指针跳过了以太网头,直接指向 IP 头
    • 这就是为什么 TCP 层不需要处理以太网头——因为内核在交货前,已经通过移动指针把“垃圾”扔掉了。
  3. L3 -> L4(传输层)

    • IP 层处理完(检查校验和、路由等)后,调用 ip_rcv()
    • 同样,调用 skb_pull() 剥去 IP 头。
    • 此时 data 指针指向 TCP 头
    • 最后,SKB 被放入 socket 的接收队列。

为什么要这样折腾? 为了效率。内核不需要复制数据,只需要移动 data 指针,就能改变当前层“看到”的内容。这也是为什么 SKB 结构体如此复杂——它必须精确记录每一层协议头的位置,以便在需要时(比如计算 TCP 校验和)能瞬间找回 IP 头。

3. 路由与设备:信封上的地址

当内核拿到这个 SKB,它首先需要知道:这是哪来的?要去哪?

struct net_device *dev; // 关联的网卡
struct dst_entry *_skb_refdst; // 路由缓存
  • dev: 这张「网卡」就是数据的出入口。如果是接收包,它代表进来的接口;如果是发送包,它代表出去的接口。
  • _skb_refdst: 这是内核的路由决策结果。内核查过一次路由表后,就把结果(下一跳、输出接口)缓存在这里。这样下次处理这个包(比如转发)时,就不用再查表了。这个字段的低几位被借用来做引用计数标记,这是内核省内存的经典技巧。

4. 协议私有控制区(cb[]

char cb[48];

这是一个**「控制缓冲区」**(Control Buffer)。它是内核留给各层协议存放私有信息的便签本。

  • 为什么存在:为了效率。内核不想为每个协议定义单独的 SKB 变体,所以预留了这块空间。
  • 谁在用
    • TCP 协议:通过宏 TCP_SKB_CB(__skb) 将其强制转换为 TCP 的控制结构 tcp_skb_cb,用来存储 TCP 序列号、确认号等信息。
    • 蓝牙协议:通过宏 bt_cb(skb) 转换为蓝牙的控制结构。
  • 注意:这块区域是不透明的,一层写入后,下一层可能会覆盖或重新解释它。

5. 数据长度管理

SKB 中的长度字段比较容易混淆,需要区分清楚:

unsigned int len; // 数据包的总字节数
unsigned int data_len; // 非线性数据的长度(即分页数据)
__u16 mac_len; // MAC 头部(L2)的长度
__wsum csum; // 校验和
  • len: 整个数据包的长度(包括线性数据和非线性分页数据)。
  • data_len: 如果数据包使用了分散/聚合,部分数据存储在单独的内存页中,data_len 就记录这部分的大小。如果 data_len 为 0,说明所有数据都是线性连续存储的。
  • mac_len: 链路层头部的长度(例如以太网头通常是 14 字节)。

6. 校验和与硬件卸载

现代网卡很聪明,它能帮你算校验和,甚至帮你分片。

__u8 ip_summed:2;
  • CHECKSUM_NONE: 硬件不支持校验,必须由软件完成。
  • CHECKSUM_UNNECESSARY: 不需要校验(例如回环设备)。
  • CHECKSUM_COMPLETE: 硬件已完成校验计算(接收路径)。
  • CHECKSUM_PARTIAL: 硬件将完成校验计算(发送路径,仅计算头部)。

7. 分片与克隆(cloned, users

atomic_t users;
__u8 cloned:1;
  • users: SKB 的引用计数。初始化为 1。
  • cloned: 标记该 SKB 是否是克隆出来的。
  • 机制:当内核只需要修改 SKB 的元数据(如 dev 指针),而不需要修改数据内容时,为了避免昂贵的内存拷贝,它会克隆 SKB 结构体本身,但共享底下的数据块。此时,原始 SKB 和克隆 SKB 的 cloned 标志都会被置为 1。

8. 非线性数据:skb_shared_info

当数据量很大(超过一页,通常 4KB)时,内核会将多余的数据存放在分散的内存页中,而不是挤在线性数据区。这些分散的页的信息就存储在 skb_shared_info 结构中。

它位于线性数据区的最末端(通过 skb_end_pointer() 访问)。

struct skb_shared_info {
unsigned char nr_frags; // 分散页的数量
// ... 其他字段 ...
skb_frag_t frags[MAX_SKB_FRAGS]; // 分散页数组
};
  • nr_frags: 当前使用了多少个分散页。
  • frags[]: 存储每个页的物理地址、偏移量和长度。
  • frag_list: 另一种非线性数据形式,直接挂一个 SKB 链表(用于 IP 分片重组)。

A.2 net_device:驻守要塞的将军

如果说 sk_buff 是流水的「兵」,那么 net_device 就是驻守要塞的「将」。

net_device 结构体代表一个网络接口设备。它可以是物理设备(如 eth0),也可以是虚拟设备(如 bridge0vlan100)。

1. 操作集:设备的灵魂

const struct net_device_ops *netdev_ops;

这是 net_device灵魂。它是一组函数指针,定义了内核如何指挥这个设备。当你在用户态执行 ip link set eth0 up 时,内核最终调用的是这里的 ndo_open()

  • ndo_open(): 启动设备。
  • ndo_stop(): 关闭设备。
  • ndo_start_xmit(): 最重要的函数。内核网络栈调用它来发送数据包。驱动必须将数据包写入硬件发送队列。
  • ndo_set_mac_address(): 修改 MAC 地址。
  • ndo_tx_timeout(): 发送卡死时的看门狗回调。

2. 硬件特性与卸载

驱动通过告诉内核它支持哪些硬件加速功能,内核才能决定是否把繁重的任务交给硬件完成。

netdev_features_t features; // 当前激活的特性
netdev_features_t hw_features; // 硬件支持的特性
  • NETIF_F_IP_CSUM: 硬件支持 IPv4 校验和计算。
  • NETIF_F_TSO: TCP Segmentation Offload。硬件负责把大的 TCP 段切分成符合 MTU 的小包。
  • NETIF_F_GRO: Generic Receive Offload。硬件或内核在接收时将多个小包合并成大包,减少 CPU 中断。

3. 状态与队列

unsigned long state;
struct netdev_queue *_tx;
  • state: 记录链路状态(如 __LINK_STATE_START 启动,__LINK_STATE_NOCARRIER 拔网线)。
  • _tx: 发送队列数组。现代高性能网卡通常有多个队列,以利用多核 CPU 的并行处理能力。

A.3 RDMA (Remote DMA)

RDMA 是一种高性能网络技术,允许一台计算机直接访问另一台计算机的内存,无需远程 CPU 的参与。这极大地降低了延迟并提高了吞吐量。

你可以把 RDMA 想象成**「专线快递」**。普通的网络通信像寄信,需要邮局(远程 CPU)分拣;RDMA 则是你直接把车开进对方的仓库搬货。

但这个「专线快递」有一个前提:安全。你不能随便让人进你的仓库。

1. Protection Domain (PD) - 安保边界

PD 就像是一个会员俱乐部。所有的资源(内存区域、队列对)都必须属于同一个俱乐部(PD)才能互相访问。如果你不属于这个俱乐部,就算有地址也不让进。

2. Memory Region (MR) - 仓库登记

在 RDMA 中,你不能随便拿一块内存地址就给远程机器写。你必须先注册这块内存。

  • 注册过程
    1. Pinning(钉住):防止内存被换出到磁盘。
    2. 翻译:将虚拟地址翻译为物理地址,并告知网卡 DMA 引擎。
  • Key(密钥):注册后,你会得到两个 Key:
    • LKey (Local Key):本地访问时使用。
    • RKey (Remote Key):远程机器访问这块内存时必须提供的凭证。

回到那个「快递」:MR 就像是你向仓库(内存)登记的货物清单。只有登记过的货物,并且持有对应 RKey 的快递员(网卡),才能搬运这批货。如果对方拿错了钥匙,或者试图搬运没登记的货物,RDMA 硬件会直接拒绝访问,保护你的内存安全。

3. Queue Pair (QP) - 通信通道

这是 RDMA 通信的核心通道。一个 QP 包含两个队列:

  • Send Queue (SQ): 发送工作请求。
  • Receive Queue (RQ): 接收工作请求。

QP 类型

  • RC (Reliable Connected): 可靠连接,类似 TCP。
  • UD (Unreliable Datagram): 不可靠数据报,类似 UDP。

4. Completion Queue (CQ) - 回执

RDMA 操作是异步的。当你发起一个发送请求时,函数会立即返回。你是怎么知道发送完成了呢?靠 CQ。硬件会将完成状态写入 CQ,你可以轮询或者等待通知。


本章回响

本章真正在做的事情,是建立数据包视角这一底层认知。

表面上我们在看一个个结构体字段,实际上我们在理解内核是如何权衡「拷贝的代价」与「指针的复杂度」的。sk_buffdata 指针之所以要这样跳来跳去,本质上是为了避免数据拷贝——因为拷贝内存是网络性能的头号杀手。

还记得开头那个问题吗——如何理解一个在网络栈中流动的数据包? 现在你应该能回答了:它不是一个静止的数据块,而是一个在不同协议层之间不断变化的「视角」。L2 看到的是帧,L3 看到的是包,L4 看到的是流,而内核通过巧妙地移动 sk_buff 中的指针,让所有层都能高效地处理同一个物理内存中的数据。那一行注册宏,仅仅是这一切的入场券;真正的演出,是在这些结构体中上演的。

下一章(如果有的话),我们将把这些知识带回现实,去解决那些真实世界中发生的、令人抓狂的 Bug。祝你好运。


练习题

走到这里,机制应该已经清楚了——或者你以为清楚了。 下面几道题难度递进,建议先不看提示独立想,卡住了再翻。 第三题如果做出来了,说明你真的懂了。

练习 15.1 模拟协议封装(指针演练)⭐(理解)

假设你刚刚在内核中分配了一个空的 SKB,datatail 指针重合,且已经预留了 64 字节的 Headroom。 现在需要依次构建一个 TCP 数据包(暂不填充数据,只考虑头部预留):

  1. 调用 skb_push(skb, 20) 预留 IP 头
  2. 调用 skb_push(skb, 20) 预留 TCP 头

问题:经过这两步操作后,data 指针相对于初始位置移动了多少字节?此时 data 指针指向的是哪一层协议的头部位置?

答案与解析

答案:移动了 40 字节(20+20)。此时 data 指针指向 TCP 头(L4)的起始位置。

解析skb_push 的作用是让 data 指针向低地址移动(向前扩展),并在头部腾出空间。 先 Push 20 字节(IP 头),data 指针上移 20 字节。 再 Push 20 字节(TCP 头),data 指针继续上移 20 字节。 总共移动 40 字节。 在构建发送包时,我们通常按照从 L4 到 L2 的顺序反向 push 头部,所以此时 data 指向最外层(也是最先构建)的 TCP 头。等到真正发送时,驱动会再次 push 以太网头。

练习 15.2 Headroom 耗尽⭐⭐(应用)

在构建数据包时,如果协议头过多或者预留空间不足,data 指针可能会撞上 head 指针(即 Headroom 耗尽)。

问题:当内核检测到 Headroom 不足时(例如 skb_push 时发现 data 即将小于 head),会发生什么?这是一个简单的操作还是昂贵的操作?为什么?

答案与解析

答案:内核会调用 pskb_expand_head() 函数。 这是一个极其昂贵(Expensive)的操作。 因为内核必须分配一块更大的新内存块,把原有数据(包括线性数据和分片数据)全部拷贝过去,然后释放旧的 SKB。这涉及到内存分配和大量的内存复制操作,会严重降低网络性能。

练习 15.3 SKB 与设备生命周期⭐⭐⭐(思考)

考虑 Netfilter 的 NF_QUEUE 机制:当内核将一个数据包通过 NFQUEUE 规则发送到用户空间的程序(如 Suricata)进行处理时,内核必须持有这个 SKB,直到用户空间处理完毕并重新注入。

问题

  1. 为什么在这种情况下,内核倾向于克隆(Clone) SKB(skb_clone()),而不是直接引用或拷贝?
  2. 克隆出来的 SKB 和原始 SKB 在 userscloned 字段上会有什么变化?
  3. 如果用户空间程序修改了数据包的内容(比如改写了 IP 地址),内核是如何处理这种“写时复制”的需求的?
答案与解析

答案

  1. 原因:直接引用会导致竞争条件(如果驱动还在发送,用户空间同时读取会崩溃);完全拷贝(skb_copy())则开销太大(需要复制所有数据和分页)。克隆是折中方案:它只复制 sk_buff 结构体本身(元数据),而共享同一块数据缓冲区。这样用户空间和内核可以各自拥有独立的元数据指针,但共享数据,大大提高了性能。
  2. 字段变化
    • 新旧 SKB 的 cloned 标志位都会被置为 1
    • 原始 SKB 的 users 引用计数会增加(因为有两个结构体指向同一份数据)。
  3. 写时复制:如果用户空间程序尝试修改数据内容(如 skb_store_bits() 或通过 queue 修改包体),内核会检测到数据页被共享。此时,它会触发“写时复制”机制,自动复制受影响的那部分数据页到新的内存位置,从而断开共享关系,确保修改不会影响其他持有者。

解析: 这题考察对 SKB 设计核心的深度理解。克隆机制是 Linux 网络栈高性能的关键之一,它体现了 Linux “引用计数 + 共享数据”的典型设计哲学。


要点提炼

sk_buff 是 Linux 网络栈中处理数据包的核心数据结构,其设计精髓在于通过指针操作skb_push, skb_pull, skb_reserve)而非数据拷贝来实现协议层间的传递。head, data, tail, end 四个指针界定了一个动态的线性数据区,而 protocol 字段则充当了层间传递的“接力棒”,告诉下一层该把 data 指针视为哪种协议头。

Headroom(头部预留空间)是 SKB 性能优化的关键。一旦预留的空间不足,内核必须进行昂贵的数据拷贝pskb_expand_head),涉及新内存分配和全量数据迁移,这是高性能网络处理中必须极力避免的陷阱。正确的预留策略(通常在分配时调用 skb_reserve)是驱动开发者的基本功。

net_device 结构体作为网络接口的抽象,其核心是 net_device_ops 函数集,它定义了内核控制硬件的具体行为(如启动、停止、发送)。通过 硬件特性卸载(如 NETIF_F_IP_CSUM, TSO, GRO),内核可以将繁重的计算任务(校验、分片、重组)下放到网卡硬件,从而释放 CPU 算力。

克隆(Clone)机制是解决多路径共享同一数据包的方案。当只需修改元数据(如 Netfilter 修改路由)时,内核使用 skb_clone() 仅复制 sk_buff 结构体,共享底层数据缓冲区,并设置 cloned 标志和增加 users 引用计数。只有当发生写操作时,内核才会触发真正的数据拷贝,即“写时复制”。

RDMA 技术通过绕过远程 CPU 实现了零拷贝网络传输。它依赖 Protection Domain (PD) 划定安全边界,Memory Region (MR) 完成内存注册(Pin 住虚拟地址并转换为物理地址供网卡访问),并使用 Local/Remote Key (LKey/RKey) 作为访问凭证,确保只有授权的操作才能读写内存,这是高性能与安全性平衡的典范。