跳到主要内容

1.3 Linux 内核网络开发模型

网络子系统很复杂,而且它的变化非常快——快到如果你眨一下眼,可能就错过了一个 API 的变更。

上一节我们聊了设备、缓冲区和各种子系统,那都是「静态」的解剖学。现在是时候把这些东西扔进「动态」的江湖里了。Linux 内核的开发并不像写普通的业务代码,它有一套看起来很古老、甚至有点繁琐,但实际上异常高效的协作模型。

对于想深入内核网络的人来说,理解这个模型和理解代码一样重要。甚至更重要。

为什么呢?因为当你试图修复一个诡异的 Bug,或者想把你的代码适配到最新的内核时,你会发现,代码本身只是冰山一角。水面之下是 Git 树、邮件列表、补丁规范以及维护者们挑剔的眼光。如果你不懂这套规矩,你的代码写得再漂亮,也进不了主线。

开发前的装备检查

在这场游戏开始之前,我们需要清点一下装备。这里没有图形界面的 IDE 向导,只有命令行和文本编辑器。

在内核开发的江湖里,Git 不仅仅是一个工具,它是通用语言。

不管你的目的是什么——是为了修复一个让网卡崩溃的 Bug,是为了提交一个优化 sk_buff 路径的补丁,还是仅仅为了看懂某段代码的历史——你都必须掌握 Git。Linus Torvalds 当年开发 Git 就是为了管理内核代码,所以在这个领域里,没有比它更原生、更强大的工具了。

很多时候,你需要追踪一个 Bug 是在哪个版本引入的,这就需要你懂得如何用 Git 二分查找;或者你需要把一个特性移植回旧的内核版本,这需要你懂得如何回退和合并补丁。又或者,你想尝试最新的「bleeding-edge」功能,这时候你得知道从哪个 Git 树里拉取代码。

这里有一份基础生存技能清单:

  • 如何应用一个补丁:收到别人的 .patch 文件,怎么打到代码库里?
  • 如何解读一个补丁:别人发来的代码修改,你能一眼看出意图和潜在风险吗?
  • 如何定位问题补丁:系统突然挂了,怎么知道是哪次提交搞的鬼?
  • 如何回退补丁:发现问题后,怎么精准撤销这次修改,而不把别的改动搞乱?
  • 如何克隆 Git 树:怎么拿到 netnet-next 这两个核心仓库?
  • 如何变基:怎么让你的本地改动跟上主线的快节奏?

如果你对 Git 还不熟,我强烈推荐 Scott Chacon 的 Pro Git(免费的,网上随便搜)。这不仅仅是工具书,这是在这个圈子里混的通行证。

提交补丁:一场关于礼仪的博弈

好,假设你写了一个很酷的补丁,修复了网卡在满载时的丢包问题。现在你想把它提交给主线。这时候,真正的挑战来了。

内核社区有一套严格的、近乎刻板的礼仪。这不是为了刁难你,而是为了在每天数百封邮件中保持效率。

首先,你的代码必须通过 内核编码风格 的检查。这不是「建议」。 如果你的代码用 4 个空格缩进而不是 Tab,或者你的大括号位置不对,维护者看都不会看,直接打回。

其次,你需要测试。而且不能只在你的 PC 上测一遍。你得考虑不同的架构、不同的配置。

然后是发送方式。虽然有极少数人用 Gmail 的网页界面发补丁(这真的不推荐),但专业做法是配置好 git send-email。这会让你的补丁以正确的格式(inline,编码正确)出现在邮件列表里,方便别人直接引用和回复。

在这个过程中,有几个脚本是你必须知道的——它们是社区的安检门:

  • scripts/checkpatch.pl: 这是一个 Perl 脚本。在发送补丁之前,跑一遍。它会像严格的教导主任一样检查你的代码风格:有没有多余的空格?注释是不是符合规范?哪怕只是多了一个空格,它也会报错。别觉得烦,它能帮你省去很多被「吐槽」的尴尬。

  • scripts/get_maintainer.pl: 这个脚本至关重要。内核太大了,大到你根本不知道你的网卡驱动归谁管。运行这个脚本,它会分析你修改的代码路径,告诉你:「这个文件的主要维护者是 A,抄送列表包括 B、C、D」。如果你把补丁发错了地方,石沉大海是最好的结局,最坏的情况是被人在邮件列表里点名批评。

记住,提交补丁需要耐心。哪怕是一个只有五行的修复,也可能要等上好几天才会被采纳。这是常态。

两个世界:net 与 net-next

Linux 网络子系统的开发发生在一个双轨制的世界里。理解这一点,是你参与开发的第一步。

所有的网络开发,无论是补丁还是讨论(RFC),主要都汇聚在一个邮件列表里:

netdev mailing list: netdev@vger.kernel.org 这是一个高流量的列表。每天几百封邮件,大部分是补丁、代码审查和关于新功能的争论。

在这个列表的后面,挂着两个关键的 Git 仓库:

  1. net (http://git.kernel.org/?p=linux/kernel/git/davem/net.git): 这是修复的世界。 这里存放的是针对已经进入主线的内核代码的修复。如果你发现当前的 5.x 内核网卡不能用,你修好了它,那么你的补丁最终会被合入这个分支,并最终流向当前的稳定内核。

  2. net-next (http://git.kernel.org/?p=linux/kernel/git/davem/net-next.git): 这是未来的世界。 这里的代码是为下一个合并窗口 准备的。这里充满了新特性、实验性的协议重写和架构调整。如果你在开发一种全新的网络协议,或者重构整个 TCP 栈,你的目标就是这里。

这两个仓库由网络子系统的维护者 David Miller 掌管。每隔一段时间,他会把这两个仓库的改动通过 Pull Request 发送给 Linus,合并进主线。

这里有一个非常关键的时间点,很多新手会踩坑:合并窗口关闭期

当 Linus 开始合并 net-next 的内容进主线的时候,net-next 仓库会被冻结。 这时候,你不能往 net-next 提交新补丁。维护者会在 netdev 邮件列表上发公告:「Merge window is open/closed」。如果你在关闭期发了补丁,要么被忽略,要么被退回——因为此时主干正在剧烈变动,你的补丁可能瞬间就产生冲突了。

例外与分支

虽然 netdev 是网络开发的大本营,但并不是所有网络子系统都住在这里。

有些子系统太庞大、太专业,或者由特定的公司维护,它们拥有自己的 Git 仓库和邮件列表:

  • Wireless (无线):有自己的邮件列表和 Git 树。但是,它们的最终 Pull Request 还是会发到 netdev
  • Bluetooth:同理,独立维护,但最终归入网络体系。
  • IPsec:没有独立的邮件列表,直接在 netdev 上讨论。
  • IEEE 802.15.4 (6LoWPAN):也没有独立列表。

所以,当你想修改某个模块时,第一件事是用 scripts/get_maintainer.pl 确认它到底归谁管。

关于这本书的环境

最后,稍微务实地谈一下本书的代码环境。

软件开发最怕的一件事是:书里的代码和你电脑上的代码对不上号。API 改了,结构体字段改名了,你会觉得跟着书走像是在读天书。

为了解决这个问题,本书所有的代码片段和示例,除非特别说明,都基于内核 3.9 版本

这是 2013 年发布的版本。它既足够老,老到很多经典的机制已经定型;又足够新,新到包含了现代网络栈的大部分核心概念(虽然 eBPF 这种更现代的东西当时还没出生,但那是后话了)。

  • 获取源码:你可以去 kernel.org 下载 tar 包,或者直接用 Git 克隆刚才提到的 netnet-next 树,然后 git checkout v3.9
  • 浏览代码:如果你只是想查阅某个结构体定义或函数调用,强烈推荐 LXR (Linux Cross Reference)。 你可以去 lxr.free-electrons.com(或者现在的 lxr.linux.no 等),在线浏览代码。点击变量名,它就会带你跳转到所有引用它的地方——这在阅读像 sk_buff 这样复杂的结构体时,简直是救命稻草。
  • 本地 LXR:如果你修改了内核,想建立自己的交叉索引,你甚至可以在本地机器上安装一个 LXR 服务器。

好了,装备检查完毕,地图也有了。是时候潜入代码的深处了。


Chapter 1 回响:构建网络认知的基石

本章只是个引子,但我们覆盖了相当多的 territory。让我们把这些碎片拼回一张完整的地图,看看我们到底建立了什么。

表面上,我们在学习 Linux 网络子系统的架构。 但本质上,我们在建立一种**「分层」「数据流动」**的直觉。

我们首先看到了 OSI 模型在 Linux 里的具体投影——那是七个抽象层在 C 语言代码里的残酷现实。我们看到了 net_device 这个「硬件代言人」是如何管理网卡的,也看到了 sk_buff 这个「万能快递单」是如何在内核的各个关卡之间传递的。

如果你问我这一章最重要的认知是什么,我会说:内核网络栈不是一块铁板,而是一条由无数个挂钩组成的流水线

  • 从硬件驱动的 NAPI 中断轮询开始,数据包被抬起。
  • 经过 Netfilter 的防火墙规则过滤。
  • 穿过 路由子系统 的岔路口。
  • 经由 邻居子系统(ARP/NDISC)找到下一跳的 MAC 地址。
  • 最后落入 Socket,被用户空间程序接住。

这一章把这条流水线上的主要站点都指给你看了。

别忘了我们在引子里提到的那两个核心结构体——net_devicesk_buff。它们是这条流水线上的货币。附录 A 里详细列出了它们的每一行定义,那是你以后写驱动时的「字典」,遇到不懂的字段就去查那一章。

还记得上一节结尾提到的那些高级特性吗? RDMA 绕过 CPU 直接搬运内存,Namespaces 让一台机器变成了一千台虚拟网络。这些特性看似很炫,但它们依然构建在我们这一章建立的这套基础流水线之上。即使是 RDMA,也得先注册网卡;即使是 Namespace,也得有自己的 loopback 设备。

接下来的旅程,我们将不再只是「看」这条流水线。我们要开始动手操作它。

在第二章里,我们将从 Netlink Sockets 开始。 为什么是 Netlink?因为它是用户空间和内核空间之间的双向无线电。如果你想在运行时创建虚拟网卡、修改路由表、或者配置 VPN,你都得通过它。它是很多高级网络特性的总开关。

准备好了吗?我们要开始调试了。


练习题

练习 1:understanding

题目:sk_buff 结构体是 Linux 内核网络栈中用于管理网络数据包的核心数据结构。在数据包从 L2(网络设备驱动层)向 L3(网络层,如 IPv4)传递的过程中,驱动程序通常会调用 eth_type_trans() 方法。请问,在该方法调用成功完成后,sk_buff 结构体中的 data 指针指向的是数据包的哪一部分?这种行为设计的目的是什么?

答案与解析

答案:指向 L3(网络层)头部,例如 IP 头。目的是为了让 skb->data 始终指向当前协议层正在处理的头部,方便上层协议直接读取数据。

解析:根据文中对 The Socket Buffer 的描述,当数据包位于 L2 时,skb->data 指向以太网头。eth_type_trans() 方法通过调用 skb_pull_inline() 将 data 指针向前移动了 14 个字节(ETH_HLEN,即以太网头的大小)。这使得指针跳过了 L2 头部,直接指向 L3 头部。这种设计模式允许内核网络栈的各层协议(L3, L4)通过统一的接口访问属于自己层面的头部,而无需每次都手动计算偏移量。

练习 2:application

题目:假设你正在为一家嵌入式设备公司开发驱动。如果需要在网卡上启用混杂模式以支持 tcpdump 进行抓包分析,但在同时运行了两个抓包程序(tcpdump 和 wireshark)后,你手动关闭了其中一个,发现网卡仍然处于混杂模式。结合文中对 Promiscuity counter(混杂模式计数器)的描述,解释为什么网卡没有自动退出混杂模式?

答案与解析

答案:因为 promiscuity counter 是一个计数器,而不是布尔开关。当有多个抓包程序开启时,计数器大于 1。关闭其中一个程序只会将计数器减 1,而不会将其归零。

解析:文中明确指出,Linux 网络栈使用 promiscuity counter(混杂模式计数器)而非简单的布尔值来管理混杂模式。每当一个像 tcpdump 或 wireshark 这样的嗅探器启动时,计数器加 1;关闭时减 1。只有当计数器减到 0 时,网卡才会真正退出混杂模式。在这个场景中,两个程序开启时计数器变为 2,关闭一个后计数器为 1,因此网卡依然处于混杂模式以服务于另一个仍在运行的程序。这种机制支持了多个嗅探器并发运行的需求。

练习 3:thinking

题目:NAPI(New API)是一种旨在提高高负载下网络性能的混合中断/轮询机制。请对比 NAPI 与传统的“中断驱动”模式,分析为什么在数据包吞吐量极大的情况下,NAPI 能够表现出更好的性能?

答案与解析

答案:在高负载下,传统中断模式会导致“中断风暴”,消耗大量 CPU 资源用于上下文切换而无法高效处理数据;NAPI 转为轮询模式,批量处理数据包,显著减少了中断处理和上下文切换的开销。

解析:根据对 New API (NAPI) 的描述,旧的网络驱动每收到一个数据包就会触发一次中断。在高流量场景下,这会导致频繁的中断请求(即“中断风暴”),CPU 忙于保存和恢复现场(上下文切换),导致吞吐量下降。NAPI 的解决之道是:在高负载下,驱动不再为每个包产生中断,而是让内核定期轮询驱动程序,一次性批量获取数据包。这种方法虽然牺牲了微小的延迟,但极大地降低了 CPU 的中断处理开销,从而提升了整体的数据处理能力。


要点提炼

Linux 内核网络子系统的核心在于 L2 到 L4 的「铁三角」处理,它将物理层与应用层剥离,专注于数据包的高速流动、校验与转发。这一过程并非简单的线性传输,而是充满了路由决策、防火墙过滤、NAT 转换以及分片重组等复杂逻辑,内核本质上就是一个在协议栈各层间对数据包进行反复安检和修饰的精密工厂。

内核通过 net_device 结构体将物理网卡抽象为软件对象,利用其内部混杂模式计数器等机制巧妙管理多进程共享资源;同时配合 NAPI(New API)机制,根据网络负载动态在中断驱动与轮询模式间切换,有效解决了传统纯中断模式在高并发小包场景下因上下文频繁切换而导致的 CPU 活锁问题。

sk_buff(SKB)是数据包在内核中的唯一载体,通过维护 headdatatail 等指针灵活处理协议头部的剥除与添加。这套设计让数据包在从链路层向传输层传递时,能够通过 skb_pull 等函数高效地层层「剥皮」,确保了在不同协议层级间传递时零拷贝的高性能表现。

开发与调试网络代码必须深入理解 Git 双轨制(netnet-next)以及邮件列表协作文化,net 分支负责修复稳定版问题,而 net-next 则承载面向下一个合并窗口的新特性。掌握 scripts/checkpatch.plget_maintainer.pl 等脚本工具,遵循严格的代码风格与提交礼仪,是代码能被社区接受并融入主线的关键前提。