14.15 宏定义与工具函数
这一节虽然名字叫 "Macros"(宏),但核心问题只有一个:数据包是如何在内核中流动的?
如果你是一名网络驱动的开发者,当你写下一行 PCI 驱动注册宏时,你可能会好奇:这行代码到底是如何与内核庞大的设备模型挂钩的?更重要的是,当数据包像幽灵一样穿过硬件到达内核时,它变成了什么?
此外,本章作为终章的压轴部分,接下来的内容将带你深入 Linux 内核网络栈的**「基石」**:sk_buff 和 net_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 指针就是信封里当前能看到的那一页纸的顶行,而 head 和 end 就是这张纸的物理边界。如果 head 到了 data 的位置,说明没有空间写新地址了(Headroom 耗尽),这时候内核可能不得不重新找一张更大的纸(拷贝数据),这是性能杀手。
1. 指针管理:俄罗斯方块
这是 SKB 最精妙也最容易出错的地方。head、data、tail、end 这四个指针定义了数据包在内存中的布局。
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. 非线性数据区
- 线性数据区:从
head到end的区域是分配给 SKB 的整个线性缓冲区。 - 有效数据:从
data到tail的区域是当前有效的数据载荷。 - 协议头:
mac_header、network_header、transport_header是指向线性缓冲区内特定位置的指针,分别指向 L2、L3、L4 层的起始位置。
操作宏(修改 data 和 tail 指针):
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): 预留头部空间。data和tail指针同时下移。通常在分配 SKB 后立即调用,为了在前面留出足够的空间给下层协议添加头部。
Headroom 和 Tailroom
- Headroom:
data减去head。这是预留的头部空间。 - Tailroom:
end减去tail。这是预留的尾部空间。
这种设计使得内核可以在不拷贝数据的情况下,高效地在各层协议之间传递数据包,只需移动指针即可。
2. 机制拆解:一个包的旅程
光看指针定义容易晕,我们来推演一个数据包从网卡驱动(L2)到 TCP(L4)的过程中,SKB 内部到底发生了什么。
场景:主机收到一个 TCP 数据包
-
L2 入场(驱动接收)
- 驱动分配一个 SKB,通常使用
netdev_alloc_skb()。这步通常还会调用skb_reserve()预留头部空间。 - 驱动把数据 DMA 到
data位置。 - 此时,
data指向以太网头。
- 驱动分配一个 SKB,通常使用
-
L2 -> L3(网络层)
- 内核调用
eth_type_trans()。这个函数做了两件事:- 将
skb->protocol设置为ETH_P_IP( IPv4)。 - 调整
skb->mac_header指向 L2 头。 - 调用
skb_pull(skb, hlen)剥去以太网头。
- 将
- 关键点:此时
data指针跳过了以太网头,直接指向 IP 头。 - 这就是为什么 TCP 层不需要处理以太网头——因为内核在交货前,已经通过移动指针把“垃圾”扔掉了。
- 内核调用
-
L3 -> L4(传输层)
- IP 层处理完(检查校验和、路由等)后,调用
ip_rcv()。 - 同样,调用
skb_pull()剥去 IP 头。 - 此时
data指针指向 TCP 头。 - 最后,SKB 被放入 socket 的接收队列。
- IP 层处理完(检查校验和、路由等)后,调用
为什么要这样折腾?
为了效率。内核不需要复制数据,只需要移动 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)转换为蓝牙的控制结构。
- TCP 协议:通过宏
- 注意:这块区域是不透明的,一层写入后,下一层可能会覆盖或重新解释它。
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),也可以是虚拟设备(如 bridge0 或 vlan100)。
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 中,你不能随便拿一块内存地址就给远程机器写。你必须先注册这块内存。
- 注册过程:
- Pinning(钉住):防止内存被换出到磁盘。
- 翻译:将虚拟地址翻译为物理地址,并告知网卡 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_buff 的 data 指针之所以要这样跳来跳去,本质上是为了避免数据拷贝——因为拷贝内存是网络性能的头号杀手。
还记得开头那个问题吗——如何理解一个在网络栈中流动的数据包?
现在你应该能回答了:它不是一个静止的数据块,而是一个在不同协议层之间不断变化的「视角」。L2 看到的是帧,L3 看到的是包,L4 看到的是流,而内核通过巧妙地移动 sk_buff 中的指针,让所有层都能高效地处理同一个物理内存中的数据。那一行注册宏,仅仅是这一切的入场券;真正的演出,是在这些结构体中上演的。
下一章(如果有的话),我们将把这些知识带回现实,去解决那些真实世界中发生的、令人抓狂的 Bug。祝你好运。
练习题
走到这里,机制应该已经清楚了——或者你以为清楚了。 下面几道题难度递进,建议先不看提示独立想,卡住了再翻。 第三题如果做出来了,说明你真的懂了。
练习 15.1 模拟协议封装(指针演练)⭐(理解)
假设你刚刚在内核中分配了一个空的 SKB,data 和 tail 指针重合,且已经预留了 64 字节的 Headroom。
现在需要依次构建一个 TCP 数据包(暂不填充数据,只考虑头部预留):
- 调用
skb_push(skb, 20)预留 IP 头。 - 调用
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,直到用户空间处理完毕并重新注入。
问题:
- 为什么在这种情况下,内核倾向于克隆(Clone) SKB(
skb_clone()),而不是直接引用或拷贝? - 克隆出来的 SKB 和原始 SKB 在
users和cloned字段上会有什么变化? - 如果用户空间程序修改了数据包的内容(比如改写了 IP 地址),内核是如何处理这种“写时复制”的需求的?
答案与解析
答案:
- 原因:直接引用会导致竞争条件(如果驱动还在发送,用户空间同时读取会崩溃);完全拷贝(
skb_copy())则开销太大(需要复制所有数据和分页)。克隆是折中方案:它只复制sk_buff结构体本身(元数据),而共享同一块数据缓冲区。这样用户空间和内核可以各自拥有独立的元数据指针,但共享数据,大大提高了性能。 - 字段变化:
- 新旧 SKB 的
cloned标志位都会被置为 1。 - 原始 SKB 的
users引用计数会增加(因为有两个结构体指向同一份数据)。
- 新旧 SKB 的
- 写时复制:如果用户空间程序尝试修改数据内容(如
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) 作为访问凭证,确保只有授权的操作才能读写内存,这是高性能与安全性平衡的典范。