1.2 网络设备(The Network Device)
让我们把视线落到底层,也就是 Layer 2(L2)——链路层。
网络设备驱动就住在这里。虽然这本书的主角是内核网络协议栈,而不是教你写驱动(那本身就需要另一本厚书),但为了真正搞懂数据包是怎么进出的,我们必须先认识一下驱动和协议栈之间的「握手人」:net_device 结构体。
这就好比你要在高速公路上开车,得先懂收费站是怎么运作的——哪怕你不是收费员。
net_device:网卡的「身份证」
在内核眼里,一张网卡并不存在物理实体的感觉,它就是一个巨大的结构体实例——net_device。
这个结构体里装着这张网卡的所有「身家性命」:
- 硬件 IRQ 号:中断请求线,CPU 靠它知道网卡有活干了。
- MTU(最大传输单元):以太网默认是 1500 字节。超过这个数,数据包就要被切分(Fragmentation)。
- MAC 地址:网卡的物理身份证,48位。
- 设备名称:
eth0、wlan0这些名字就在这里定义。 - 标志位:网卡是 UP 还是 DOWN?是不是 RUNNING?
- 组播地址列表。
- 混杂模式计数器:后面马上讲到,这是抓包工具的关键。
- 硬件特性:比如是否支持 GSO、GRO 这些卸载功能。
net_device_ops回调函数集:这是网卡的操作手册,包含了打开设备、停止设备、发送数据、更改 MTU 等函数指针。- ethtool 回调:这就是为什么你在命令行敲
ethtool eth0能看到一堆寄存器信息的原因。 - 多队列支持:现在的万兆网卡都有多个 Tx/Rx 队列。
- 时间戳:记录最后一次收发包的时间。
我们先看一眼内核定义的片段,感受一下它的画风:
struct net_device {
unsigned int irq; /* 中断号 */
. . .
const struct net_device_ops *netdev_ops; /* 操作方法集 */
. . .
unsigned int mtu; /* 最大传输单元 */
. . .
unsigned int promiscuity; /* 混杂模式计数器 */
. . .
unsigned char *dev_addr; /* 硬件地址 (MAC) */
. . .
};
注:我在附录 A 里详细解剖了这个庞然大物。当你读源码卡壳的时候,不妨回去翻翻。
这里有个特别有趣且容易被忽略的细节:混杂模式计数器。
为什么它是个 int 计数器,而不是一个 bool 标志位?
如果是布尔值,那当一个抓包工具(比如 tcpdump)打开时,标志位置 1;此时如果另一个抓包工具(比如 wireshark)也打开了,它想关闭时把标志位置回 0,结果第一个工具还在跑,网卡却退出了混杂模式——这就乱了。
用计数器就能完美解决这个问题:
tcpdump启动,计数器 +1(变为 1),网卡进入混杂模式,开始接收所有经过线路的数据包(不管是不是发给本机的)。wireshark启动,计数器 +1(变为 2)。tcpdump退出,计数器 -1(变为 1)。网卡依然是混杂模式,因为wireshark还在用。wireshark退出,计数器 -1(变为 0)。此时内核确认没人偷窥了,网卡退出混杂模式,恢复正常工作。
这个设计虽然简单,但它是处理「多用户共享资源状态」的经典教科书案例。
性能的救赎:NAPI(New API)
在浏览内核网络核心代码时,你会频繁撞见一个缩写——NAPI。
它是现代网络驱动不可或缺的一部分。要理解它,得先看看旧世界是什么样的。
旧方案:中断驱动 早期的网卡驱动工作模式很简单:来一个包,发一个中断。 包来了 → 触发中断 → CPU 停下手里的活,保存上下文 → 跑到中断处理函数 → 拿包 → 恢复上下文。 平时上网没问题,但如果你遭遇了 DDoS 攻击,或者是在处理海量小包流量,CPU 就会崩溃。 每秒几十万个中断意味着什么?意味着 CPU 光是用来「进场」和「退场」(保存/恢复寄存器和上下文)就耗尽了算力,根本没时间干正事。这在操作系统里叫 中断活锁。
新方案:NAPI(混合模式) 为了解决这个问题,内核开发者引入了 NAPI(New API)。它的核心思想是:根据负载情况动态切换策略。
- 低负载时:依然使用中断。没有包就不打扰 CPU,省电且响应快。
- 高负载时:切换到轮询。中断触发后,驱动会暂时关闭该中断,然后告诉内核「我现在有一堆包,你自己定期来轮询我拿,别等下一个中断了」。
这样就把「处理 N 个包需要 N 次中断上下文切换」变成了「处理 N 个包只需要 1 次中断 + 轮询」。性能提升立竿见影。
不过,世间安得双全法。轮询虽然吞吐量大,但延迟会增加——因为 CPU 不会立刻响应,而是等轮询周期到了才去拿包。
对于那些对延迟极其敏感、且愿意为此挥霍 CPU 资源的应用(比如高频交易),Linux 从内核 3.11 开始引入了 Busy Polling on Sockets(套接字忙轮询)。这属于更偏门的优化,我们留到第 14 章「Busy Poll Sockets」一节再细聊。
好了,现在我们对网卡这个「出口」已经有了足够的了解。接下来,我们来看看真正的重头戏:数据包是如何在内核中穿行的。
数据包的旅程:收与发
网络设备驱动的一生主要就忙活两件事:
- 接收:把从线路上抓到的包,层层递交给网络层(L3),最终传给传输层(L4)。
- 发送:把本机产生的包,或者需要转发的包,塞进网卡送到线路上。
听起来很简单?中间的岔路可多着呢。
对于每一个经过内核的包,无论进还是出,路由子系统 都会进行一次查表。 这次查找决定了这张「门票」到底该送去哪里:是留给本机?还是从哪个网卡转发出去?这部分极其重要,我们会在第 5 章和第 6 章深入探讨 IPv4 和 IPv6 的路由子系统。
但路由决定并不是唯一的阻碍。数据包在栈中穿行时,还会遭遇「路检」:
1. Netfilter 钩子
这其实就是防火墙和 NAT 背后的机制。内核在网络栈的五个关键节点安插了「检查站」。
- 收到的包还没查路由表前,会经过
NF_INET_PRE_ROUTING。 - 发出的包即将离开网卡前,会经过
NF_INET_POST_ROUTING。
这些检查站由 NF_HOOK() 宏触发。如果你的 iptables 规则说这包不合法,回调函数会返回 NF_DROP,数据包当场就会被丢弃,内核甚至懒得给它写讣告。如果是 NF_ACCEPT,那就放行,继续下一站。
关于 Netfilter、连接跟踪以及 iptables 的底层机制,第 9 章会详细拆解。
2. IPsec
如果数据包匹配了 IPsec 策略,它还会被送去加密或解密。IPsec 提供了网络层的安全(用 ESP 或 AH 协议)。 虽然 IPv6 规范强制要求支持 IPsec,IPv4 是可选的,但 Linux 在两者上都实现了完整支持。IPsec 有两种模式:
- 传输模式:只加密载荷。
- 隧道模式:把整个原包加密并套在一个新 IP 包里(VPN 的常用方式)。 IPsec 和 NAT 经常打架(因为加密后改不了地址),所以又有了一种「NAT 穿透」方案。这些都留给第 10 章。
3. TTL 生存时间
如果一个包是被转发的,它的 IPv4 头部里有个叫 TTL(Time To Live)的字段。每经过一个路由器,这个值就减 1。
当它减到 0 时,路由器会直接丢弃这个包,并回送一个 ICMP 「Time Exceeded」 报文。这能防止因为路由环路导致数据包在网络里长生不老。
同样的,每改一次 TTL,还得把 IPv4 的校验和 重算一遍,挺费工夫。
在 IPv6 里,这个字段改名叫 Hop Limit(跳数限制),意思一样,名字更文雅一点。
此外,数据包的旅途还充满了变数:
- 分片与重组:大包(超过 MTU)会被切碎,接收端还得拼起来。这在第 4 章讲。
- 组播:包不是发给一个人的,是发给一群人的。这涉及 IGMP 协议和组播路由守护进程(像
pimd),这通常比单播要复杂得多,第 6 章会聊到。
为了在这么多复杂的岔路中还能有条不紊,内核必须有一个统一的方式来描述和管理这些数据包。这个核心数据结构,就是传说中的 SKB。
核心:Socket Buffer (sk_buff)
sk_buff(我们通常简称为 SKB)是 Linux 内核网络栈里最核心、最复杂、也最令人头秃的数据结构。
它就是数据包在内核里的「肉身」。无论这个包是刚刚被网卡驱动捞上来,还是正准备从 TCP 层发出去,它都是一个 SKB。
我们先看一眼它的定义(只列了一部分重点成员,完整版请看附录 A):
struct sk_buff {
. . .
struct sock *sk; /* 拥有这个包的套接字 */
struct net_device *dev; /* 关联的网卡设备 */
. . .
__u8 pkt_type:3; /* 包类型(单播/组播/广播)*/
. . .
__be16 protocol; /* 协议类型 */
. . .
sk_buff_data_t tail; /* 尾部指针 */
sk_buff_data_t end; /* 结束指针 */
unsigned char *head,
*data; /* 头部和数据指针 */
sk_buff_data_t transport_header; /* L4 头部位置 */
sk_buff_data_t network_header; /* L3 头部位置 */
sk_buff_data_t mac_header; /* L2 头部位置 */
. . .
};
操作 SKB 的黄金法则
千万不要试图去手动 skb->data++ 这种直接操作指针!SKB 的内部管理有一套严格的 API:
- 想把
data指针往后挪(剥掉头部)?用skb_pull()或skb_pull_inline()。 - 想往前挪(预留头部空间)?用
skb_push()。 - 想拿传输层(L4)头?用
skb_transport_header(skb)。 - 想拿网络层(L3)头?用
skb_network_header(skb)。 - 想拿 MAC 头?用
skb_mac_header(skb)。
遵守这套 API 是为了管理 SKB 内部那个精妙的线性数据区和分页结构,千万别自作聪明。
SKB 的诞生(接收路径)
当一个数据包从网线进来时,驱动程序会通过 netdev_alloc_skb()(老代码里可能用 dev_alloc_skb())分配一个 SKB。
在链路层(L2),驱动会做两件重要的事:
-
判断类型 (
eth_type_trans): 这个函数会根据以太网帧头部的目标 MAC 地址,来设置 SKB 的pkt_type:- 是发给本机的?→
PACKET_HOST。 - 是组播?→
PACKET_MULTICAST。 - 是广播?→
PACKET_BROADCAST。 同时,它还会读取以太网头的 Type 字段(比如0x0800是 IPv4,0x86DD是 IPv6),填入 SKB 的protocol字段。
- 是发给本机的?→
-
指针跳跃 (
skb_pull_inline): 这是新手最容易晕的地方。以太网帧进到内存时,skb->data是指向 L2 头部的起始位置的。 但是! 当驱动把这个包交给网络层(L3)的那一刻,内核希望skb->data指向 L3 头部(IP 头)。 所以eth_type_trans()会调用skb_pull_inline(skb, 14),把指针往后挪 14 字节(正好是以太网头ETH_HLEN的长度),跳过 L2 头。想象一下:你在剥洋葱。剥掉一层(L2),手里剩下的应该刚好是下一层(L3)。
skb_pull就是这个剥皮动作。
(此处插入原书 Figure 1-3 示意图:一个标准的 UDPv4 数据包,从 L2 的 14 字节,到 L3 的 20 字节,再到 L4 的 8 字节)
SKB 的流浪
每一个 SKB 都有一个 dev 指针,记录了「我是谁家的」(进来的包记录输入网卡,出去的包记录输出网卡)。这很重要,因为内核需要根据这个网卡的 MTU 来决定要不要切片。
每个传输出去的 SKB 还有一个 sk 指针,指向产生这个包的那个 Socket。
注意:如果是转发的包,sk 是 NULL。因为它不是本地生的,只是个「过路客」。
SKB 的归宿
接收到的包最终会被分发到对应的协议处理函数:
- IPv4 的包会被扔给
ip_rcv()。 - IPv6 的包会被扔给
ipv6_rcv()。
这些函数是怎么注册进去的?用 dev_add_pack() 方法。这个我们会在 IPv4 和 IPv6 的专门章节(第 4 章和第 8 章)细看。
以 ip_rcv() 为例,它先做一大堆健康检查(Sanity Checks),然后——如果没被 Netfilter 的 PRE_ROUTING 钩子拦截——就会进入 ip_rcv_finish()。
在这里,内核会去查路由子系统,构建一个 dst_entry(目标缓存项),这个条目决定了这个包下一步往哪走。关于路由子系统,第 5 和第 6 章有硬核分析。
链路层的细节:以太网与邻居
最后,我们得提一下 L2 层的那些琐碎但致命的事。
MAC 地址与 ARP
每个网卡出厂时都带个 48 位的 MAC 地址,虽然你可以用 ifconfig 或 ip 命令篡改它。
当你的 Socket 想发个包给 192.168.1.5 时,它只知道 IP 地址。但是,以太网帧只认 MAC 地址。
这就需要一个翻译官:邻居子系统。
- IPv4 用 ARP 协议。它会在局域网里喊一嗓子(广播):「谁有 192.168.1.5?告诉我你的 MAC!」
- IPv6 用 NDISC 协议(基于 ICMPv6),原理类似,但更复杂,而且用的是组播而不是广播。
这部分机制在第 7 章有专门讲解。如果你网络不通,先 ping 一下,然后再看 arp -n,往往就是这里出了问题。
内核与用户空间的通信:Netlink
内核怎么告诉用户空间路由变了?或者用户空间的工具怎么告诉内核「我要加一条路由」?
靠的是 Netlink Sockets。它像是一种特殊的电话线,一头连着内核,一头连着 iproute2 这种工具。这在第 2 章会详细讲。
特殊的子系统:无线、RDMA 与虚拟化
Linux 网络栈的大厦里,还有几间特殊的「VIP 包厢」。
-
无线子系统: 这是由
mac80211框架驱动的。它处理的事情普通有线网卡见都没见过:省电模式、Mesh 组网(HWMP 路由协议)、Ad-hoc 网络。 尤其是 802.11n 的块确认机制,那是提高吞吐量的关键。这些内容留到第 12 章。 -
RDMA / InfiniBand: 在高性能计算和数据中心里,传统的 CPU 搬运数据太慢了。RDMA 允许网卡直接读写远程主机的内存,绕过 CPU。 这种技术从内核 2.6.11 开始引入,现在在大型集群里遍地都是。第 13 章会讲。
-
虚拟化: 基于 Namespaces 的进程级虚拟化。 这不是那种 KVM 的全虚拟化,而是更轻量的「隔离」。Linux 现在有六种 Namespace。网络 Namespace 允许你在一个机器上跑多个完全独立的网络协议栈(多个
lo接口,多个路由表)。 这是 Docker 等容器技术的基石。我们会在第 14 章深入讨论,还会顺带提一下 Bluetooth、PCIe 以及物联网领域的 6LoWPAN(把 IPv6 塞进 IEEE 802.15.4 这种低功耗帧里,想想都觉得挤)。
网络设备就介绍到这里。从下一节开始,我们将真正潜入代码,去看看 Linux 内核网络开发是如何进行的——那是另一个充满了 Git tree 和邮件列表江湖的地方。