Skip to content

1.2 网络设备(The Network Device)

让我们把视线落到底层,也就是 Layer 2(L2)——链路层。

网络设备驱动就住在这里。虽然这本书的主角是内核网络协议栈,而不是教你写驱动(那本身就需要另一本厚书),但为了真正搞懂数据包是怎么进出的,我们必须先认识一下驱动和协议栈之间的「握手人」:net_device 结构体。

这就好比你要在高速公路上开车,得先懂收费站是怎么运作的——哪怕你不是收费员。

net_device:网卡的「身份证」

在内核眼里,一张网卡并不存在物理实体的感觉,它就是一个巨大的结构体实例——net_device

这个结构体里装着这张网卡的所有「身家性命」:

  • 硬件 IRQ 号:中断请求线,CPU 靠它知道网卡有活干了。
  • MTU(最大传输单元):以太网默认是 1500 字节。超过这个数,数据包就要被切分(Fragmentation)。
  • MAC 地址:网卡的物理身份证,48位。
  • 设备名称eth0wlan0 这些名字就在这里定义。
  • 标志位:网卡是 UP 还是 DOWN?是不是 RUNNING?
  • 组播地址列表
  • 混杂模式计数器:后面马上讲到,这是抓包工具的关键。
  • 硬件特性:比如是否支持 GSO、GRO 这些卸载功能。
  • net_device_ops 回调函数集:这是网卡的操作手册,包含了打开设备、停止设备、发送数据、更改 MTU 等函数指针。
  • ethtool 回调:这就是为什么你在命令行敲 ethtool eth0 能看到一堆寄存器信息的原因。
  • 多队列支持:现在的万兆网卡都有多个 Tx/Rx 队列。
  • 时间戳:记录最后一次收发包的时间。

我们先看一眼内核定义的片段,感受一下它的画风:

c
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,结果第一个工具还在跑,网卡却退出了混杂模式——这就乱了。

用计数器就能完美解决这个问题:

  1. tcpdump 启动,计数器 +1(变为 1),网卡进入混杂模式,开始接收所有经过线路的数据包(不管是不是发给本机的)。
  2. wireshark 启动,计数器 +1(变为 2)。
  3. tcpdump 退出,计数器 -1(变为 1)。网卡依然是混杂模式,因为 wireshark 还在用。
  4. 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」一节再细聊。

好了,现在我们对网卡这个「出口」已经有了足够的了解。接下来,我们来看看真正的重头戏:数据包是如何在内核中穿行的


数据包的旅程:收与发

网络设备驱动的一生主要就忙活两件事:

  1. 接收:把从线路上抓到的包,层层递交给网络层(L3),最终传给传输层(L4)。
  2. 发送:把本机产生的包,或者需要转发的包,塞进网卡送到线路上。

听起来很简单?中间的岔路可多着呢。

对于每一个经过内核的包,无论进还是出,路由子系统 都会进行一次查表。 这次查找决定了这张「门票」到底该送去哪里:是留给本机?还是从哪个网卡转发出去?这部分极其重要,我们会在第 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):

c
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),驱动会做两件重要的事:

  1. 判断类型 (eth_type_trans): 这个函数会根据以太网帧头部的目标 MAC 地址,来设置 SKB 的 pkt_type

    • 是发给本机的?→ PACKET_HOST
    • 是组播?→ PACKET_MULTICAST
    • 是广播?→ PACKET_BROADCAST。 同时,它还会读取以太网头的 Type 字段(比如 0x0800 是 IPv4,0x86DD 是 IPv6),填入 SKB 的 protocol 字段。
  2. 指针跳跃 (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。 注意:如果是转发的包,skNULL。因为它不是本地生的,只是个「过路客」。

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 地址,虽然你可以用 ifconfigip 命令篡改它。 当你的 Socket 想发个包给 192.168.1.5 时,它只知道 IP 地址。但是,以太网帧只认 MAC 地址。 这就需要一个翻译官:邻居子系统

  • IPv4ARP 协议。它会在局域网里喊一嗓子(广播):「谁有 192.168.1.5?告诉我你的 MAC!」
  • IPv6NDISC 协议(基于 ICMPv6),原理类似,但更复杂,而且用的是组播而不是广播。

这部分机制在第 7 章有专门讲解。如果你网络不通,先 ping 一下,然后再看 arp -n,往往就是这里出了问题。

内核与用户空间的通信:Netlink 内核怎么告诉用户空间路由变了?或者用户空间的工具怎么告诉内核「我要加一条路由」? 靠的是 Netlink Sockets。它像是一种特殊的电话线,一头连着内核,一头连着 iproute2 这种工具。这在第 2 章会详细讲。


特殊的子系统:无线、RDMA 与虚拟化

Linux 网络栈的大厦里,还有几间特殊的「VIP 包厢」。

  1. 无线子系统: 这是由 mac80211 框架驱动的。它处理的事情普通有线网卡见都没见过:省电模式、Mesh 组网(HWMP 路由协议)、Ad-hoc 网络。 尤其是 802.11n 的块确认机制,那是提高吞吐量的关键。这些内容留到第 12 章。

  2. RDMA / InfiniBand: 在高性能计算和数据中心里,传统的 CPU 搬运数据太慢了。RDMA 允许网卡直接读写远程主机的内存,绕过 CPU。 这种技术从内核 2.6.11 开始引入,现在在大型集群里遍地都是。第 13 章会讲。

  3. 虚拟化: 基于 Namespaces 的进程级虚拟化。 这不是那种 KVM 的全虚拟化,而是更轻量的「隔离」。Linux 现在有六种 Namespace。网络 Namespace 允许你在一个机器上跑多个完全独立的网络协议栈(多个 lo 接口,多个路由表)。 这是 Docker 等容器技术的基石。我们会在第 14 章深入讨论,还会顺带提一下 Bluetooth、PCIe 以及物联网领域的 6LoWPAN(把 IPv6 塞进 IEEE 802.15.4 这种低功耗帧里,想想都觉得挤)。


网络设备就介绍到这里。从下一节开始,我们将真正潜入代码,去看看 Linux 内核网络开发是如何进行的——那是另一个充满了 Git tree 和邮件列表江湖的地方。

基于 VitePress 构建