Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

4.2 物理层与数据链路层

本章回答

一根网线、一块网卡、一个交换机端口,到底各自负责什么?以太网帧的 14 字节首部里每个字段是谁填的?ARP 为什么必须存在,它的缓存为什么会「时好时坏」?VLAN 标签插在哪个位置,为什么它能让一个交换机当多个用?

读完你会

用 tcpdump -e 读出以太网帧的每个字段;用 ip neigh 判断 ARP 问题是解析失败还是缓存过期;用 bridge 和 ip link 在 netns 里搭出一个带 VLAN 的最小局域网;解释「同网段」在链路层到底意味着什么。

4.1 的分层地图上,链路层只写了一行:把帧送到同一个局域网内的下一跳。这一行背后是这一章要拆开的内容。IP 层交给它的是一个包和一个「下一跳地址」,它要解决的问题是:在这根线、这个交换机组成的广播域里,怎么把这段字节送到那台机器的网卡上。

链路层是唯一同时接触硬件和软件的层。往上,它要跟 IP、ARP、IPv6 打交道;往下,它要驱动网卡、DMA、中断。这种位置决定了它的两个特点:协议简单但实现复杂,标准统一但设备各异。以太网帧格式四十年没大变,但每一代网卡的卸载能力、队列模型、错误统计都不一样。

这一章的重点不是背帧格式,而是建立三个直觉:MAC 地址是「本地地址」,出了路由器就没用了;ARP 是「IP 到 MAC 的缓存」,缓存会过期;交换机是「自学习的透明设备」,它不配置也能工作,但会环路。这三个直觉后面在讲容器网络、讲 veth/bridge 时会反复用到。

4.2.1 物理层:介质、信号与速率

物理层负责把比特变成能在介质上传播的信号。它不关心这些比特是 IP 包还是别的什么,只关心四件事:用什么介质、用什么编码、跑多快、能传多远。

介质典型速率单段距离特点
双绞线(Cat5e/Cat6)1 / 2.5 / 5 / 10 Gbps100 m便宜、易布线、抗干扰一般
多模光纤10 / 40 / 100 Gbps数百米短距数据中心主力,成本中等
单模光纤100 Gbps / 400 Gbps数公里到数十公里长距骨干,收发器贵
同轴电缆历史介质—已基本退出局域网
无线电(Wi-Fi)依标准,共享介质数十米半双工、冲突、速率随信号变化

这张表里最该记住的是距离与速率的权衡。双绞线便宜但只能 100 m,所以办公楼每层要有配线间;光纤能跑几公里,所以楼宇之间、数据中心之间用它。这不是工程偏好,是物理限制:信号在铜里衰减快,在玻璃里衰减少。

编码与调制只需要建立一个概念:数字比特不能直接上线路,要先变成适合介质的波形。双绞线上的以太网用差分信号(两根线的电压差表示 0/1),抗共模干扰;光纤用光的有无或相位;Wi-Fi 用 OFDM,把数据分散到多个子载波上。这些细节在《计算机网络》第 2 章有完整推导,本书只需要知道「编码决定了「同样的带宽能跑多少比特」」。

速率的上限由两条定量结论给出。奈氏准则说:带宽为 W 的理想低通信道,码元速率不超过 2W Baud;每个码元有 M 种状态时,数据率上限是 2W·log2(M) bit/s,它约束的是「码元能传多快」,与噪声无关。香农公式把噪声算进来:C = W·log2(1 + S/N) bit/s,其中 S/N 是线性信噪比(30 dB 对应 1000),它约束的是「有噪声时最多能传多少比特」。两者合起来解释了一个常见现象:带宽翻倍,容量大致翻倍;信噪比提高 3 dB(功率翻倍),每赫兹只多约 1 bit/s。所以铜线到光纤的升级比换调制方式更彻底。

一个和排障直接相关的概念是双工(duplex)。半双工意味着同一时刻只能发或只能收,需要 CSMA/CD 检测冲突;全双工意味着收发各有一对线,不需要冲突检测。现代交换机端口都是全双工,如果一端被手工配成半双工,另一端自协商成全双工,就会出现延迟冲突(late collision)和大量 CRC 错误——这是机房里的经典故障,症状是「能 ping 通但吞吐极低」。

# 看网卡的速率、双工、自协商状态
ethtool eth0 | sed -n '1,20p'
# 看错误计数:CRC、frame、carrier 增长说明物理层有问题
ethtool -S eth0 | grep -Ei 'crc|frame|carrier|error' | head

ethtool 输出的 Speed: 1000Mb/s、Duplex: Full、Auto-negotiation: on 是三个必须一起看的字段。速度对但双工不对,比速度不对更难查。

4.2.2 以太网帧:14 字节首部与 4 字节尾部

以太网(Ethernet II,DIX 格式)的帧结构是这一章的核心。它只有五个字段,但每个字段都有故事。

 0        6        12   13  14                          46–1500        1500+4
┌────────┬────────┬────┬────┬───────────────────────────┬──────────────┐
│ 目的 MAC │ 源 MAC  │类型│    │          载荷              │     FCS      │
│ 6 字节  │ 6 字节  │2 B │    │  46–1500 字节(IP 包等)    │  CRC32 4 字节 │
└────────┴────────┴────┴────┴───────────────────────────┴──────────────┘
 ◀──────────── 14 字节首部 ────────────▶ ◀──── 最小 64 字节(含 FCS)────▶
                                         不足 46 字节时用 0 填充(padding)

读这张图要抓住三个数字:14、46、64。14 是首部长度(6 + 6 + 2),46 是载荷最小值,64 是整个帧的最小长度(不含前导码)。为什么要有最小值?因为早期以太网用 CSMA/CD,帧太短会导致冲突检测失效——发送方在发完之前必须还能听到冲突。1000BASE-T 之后 CSMA/CD 基本不用了,但 64 字节的下限作为兼容性约束保留至今。

各字段的归属很清楚:

  • 目的 MAC:由发送方填,是「下一跳」的 MAC,不是最终目的地的 MAC。跨网段时,这个字段是网关的 MAC。
  • 源 MAC:由发送方网卡填,是「这一跳的发送者」。
  • 类型(EtherType):0x0800 是 IPv4,0x86dd 是 IPv6,0x0806 是 ARP,0x8100 是 VLAN 标签。这个字段决定内核把载荷交给谁,是分用的第一层依据。
  • 载荷:46–1500 字节。小于 46 要填充,大于 1500 要分片(或由上层切成 MSS)。
  • FCS:CRC32 校验,由网卡硬件计算和校验。出错就丢,不重传——链路层只负责检测,不负责恢复。

最后一点常被误解。以太网发现 CRC 错误时直接丢帧,把恢复责任交给上层(TCP 重传)。所以 ethtool -S 里的 rx_crc_errors 增长,表现为「应用偶发超时」,而不是「收到坏数据」。

类型字段还有一个历史遗留问题:802.3 格式里这个位置是长度字段(值 ≤ 1500),而 Ethernet II 里是类型(值 ≥ 0x0600)。两种格式靠数值范围区分,大于 1500 就按类型解释。今天的网络几乎全是 Ethernet II,但这个区分在解析老抓包时仍会遇到。

定义 4.2.1 · 以太网帧与 MTU

以太网帧(Ethernet frame) 是以太网链路上传输的基本单位,由 14 字节首部、46–1500 字节载荷、4 字节 FCS 组成,最小 64 字节、最大 1518 字节(不含前导码和帧间隙)。MTU 指载荷的最大长度,以太网默认 1500。巨型帧(jumbo frame) 把 MTU 提到 9000,只在两端和中间所有设备都支持时才能启用,否则会出现「大包丢失、小包正常」。

关于以太网帧的四个误解
  1. 「帧里存的是最终目的地的 MAC」。跨网段时存的是网关 MAC。最终目的地的 MAC 只在最后一跳出现,前面每一跳都在改写这个字段。
  2. 「FCS 能纠错」。只能检错,不能纠错。收到坏帧就丢,靠上层重传。
  3. 「MTU 1500 就是帧长 1500」。帧长是 1518(含首部和 FCS),MTU 只是载荷。
  4. 「抓包看到的长度就是线上长度」。开启 TSO/GSO 时,抓包点看到的可能是 64 KB 的「超级帧」,网卡才把它切成 1500。所以抓包里的 length 不能直接当 MTU 证据。

4.2.3 MAC 地址:48 位里的三个标志

MAC 地址是 48 位(6 字节),通常写成 aa:bb:cc:dd:ee:ff。它不是随机分配的,前 3 字节是组织唯一标识符(OUI),由 IEEE 分配给厂商;后 3 字节由厂商自己分配。所以看到 00:1a:2b 开头能查到是哪家网卡。

更值得注意的是第一个字节里的两个标志位:

位名称含义例子
bit 0(最低位)I/G0 = 单播,1 = 组播/广播01:00:5e:... 是 IPv4 组播映射
bit 1U/L0 = 全球唯一(OUI 分配),1 = 本地管理容器网桥、虚拟网卡常用本地管理地址

广播地址是全 1:ff:ff:ff:ff:ff:ff,它是组播的特例。IPv4 组播地址 224.0.0.0/4 映射到 MAC 01:00:5e:00:00:00/24,只取 IP 的低 23 位,所以 32 个不同的组播 IP 会共享一个 MAC——这是组播在链路层需要过滤的原因之一。

下面这段程序判断一个 MAC 字符串是单播、组播还是广播,以及是不是本地管理地址。它演示了两件事:MAC 的解析方式,以及标志位怎么从第一个字节里取出来。

/* mac_classify.c:解析 MAC 字符串并判断 I/G、U/L 位 */
#include <stdio.h>
#include <stdint.h>
#include <string.h>

int main(int argc, char **argv)
{
    unsigned int b[6];
    if (argc != 2) {
        fprintf(stderr, "用法: %s aa:bb:cc:dd:ee:ff\n", argv[0]);
        return 2;
    }
    if (sscanf(argv[1], "%x:%x:%x:%x:%x:%x",
               &b[0], &b[1], &b[2], &b[3], &b[4], &b[5]) != 6) {
        fprintf(stderr, "格式错误: %s\n", argv[1]);
        return 2;
    }
    for (int i = 0; i < 6; i++) {
        if (b[i] > 0xff) {
            fprintf(stderr, "字节越界: %02x\n", b[i]);
            return 2;
        }
    }

    int is_broadcast = 1;
    for (int i = 0; i < 6; i++)
        if (b[i] != 0xff) is_broadcast = 0;

    int ig = b[0] & 0x01;          /* 0 单播,1 组播 */
    int ul = (b[0] >> 1) & 0x01;   /* 0 全球,1 本地管理 */

    printf("%02x:%02x:%02x:%02x:%02x:%02x\n",
           b[0], b[1], b[2], b[3], b[4], b[5]);
    printf("类型   : %s\n", is_broadcast ? "广播" : (ig ? "组播" : "单播"));
    printf("归属   : %s\n", ul ? "本地管理(虚拟/容器常见)" : "全球唯一(厂商 OUI)");
    printf("首字节 : 0x%02x  I/G=%d U/L=%d\n", b[0], ig, ul);
    return 0;
}

编译运行:

cc -std=c17 -Wall -Wextra -Werror -O2 mac_classify.c -o mac_classify
./mac_classify 01:00:5e:00:00:fb     # IPv4 组播映射
./mac_classify 0a:58:0a:f4:00:01     # 容器网桥的本地管理地址
./mac_classify ff:ff:ff:ff:ff:ff     # 广播

0a 的第一个字节是 00001010:bit 0 是 0(单播),bit 1 是 1(本地管理)。这就是为什么 docker0 和 cni0 的 MAC 常常以 0a、02、06 开头——它们都是本地管理地址,不会和真实厂商的 OUI 冲突。

OUI 还有一个实用价值:在虚拟化环境里,虚拟网卡的 MAC 常带厂商特征。VMware 的 OUI 是 00:0c:29、00:50:56,VirtualBox 是 08:00:27,Xen 是 00:16:3e。抓包时看到这些前缀,就能立刻判断「这是虚拟机流量」,从而想到「它可能经过 vSwitch、可能被宿主机上的安全组拦过一道」。这个判断能省下不少排查时间。

例 4.2.1 · 从 MAC 反推虚拟化环境

假设你在一个「物理机」上抓包,看到源 MAC 是 00:0c:29:1a:2b:3c。查 OUI 表可知这是 VMware,说明流量来自一台虚拟机,而不是物理网卡。接下来该查的不是交换机端口,而是宿主机的 vSwitch 和端口组配置。

再假设看到 0a:58:0a:f4:00:01。0a 开头的本地管理地址加上 0a:f4 的字节,很可能来自 10.244.x.x 这类 Kubernetes Pod 网段——这是 Calico 常见的 MAC 生成方式(0a:58 + IP 的四个字节)。此时该查的是 CNI 配置和路由表,而不是物理链路。

4.2.4 ARP:IP 到 MAC 的缓存

IP 包要交给链路层发送,必须知道下一跳的 MAC。这个映射从哪来?ARP(Address Resolution Protocol)。它的工作方式极其简单:在广播域里喊一声「谁是这个 IP,请把 MAC 告诉我」,然后等回答。

一个 ARP 请求的帧长这样:目的 MAC 全 1(广播),类型 0x0806,载荷 28 字节,包含发送方 MAC、发送方 IP、目标 MAC(全 0,未知)、目标 IP。收到请求的主机如果发现目标 IP 是自己,就回一个单播的 ARP 应答。

ARP 报文(28 字节,IPv4 over Ethernet)
┌──────────────┬──────────────┬──────────┬──────────┬──────────────┐
│ 硬件类型 2B   │ 协议类型 2B   │ 硬件长度 │ 协议长度 │  操作码 2B    │
│ 0x0001 以太网 │ 0x0800 IPv4  │   6      │    4     │ 1 请求 2 应答 │
├──────────────┴──────────────┴──────────┴──────────┴──────────────┤
│ 发送方 MAC 6B │ 发送方 IP 4B │ 目标 MAC 6B │ 目标 IP 4B            │
└──────────────────────────────────────────────────────────────────┘
 请求时目标 MAC 填 0,帧的目的 MAC 填 ff:ff:ff:ff:ff:ff(广播)
 应答时两个 MAC 都已知,帧的目的 MAC 是请求方的 MAC(单播)

ARP 的效率完全依赖缓存。Linux 的邻居表(neighbour table)不只是一个「IP → MAC」的映射,它带状态机:

状态含义如何进入
REACHABLE最近确认过可达收到 ARP 应答,或上层确认
STALE曾经可达,但超过 base_reachable_time 没确认超时降级
DELAY有数据要发,先等一小会儿看有没有上层确认从 STALE 发数据触发
PROBE主动发 ARP 请求探测DELAY 超时
FAILED探测失败,暂时不可达连续探测无应答
INCOMPLETE请求已发,还没收到应答首次解析
PERMANENT静态表项,永不超时ip neigh add ... nud permanent

这个状态机的意义是避免广播风暴。如果每次发包都要 ARP,一个繁忙的网关会被 ARP 淹没。缓存加状态机让 ARP 请求只在一段时间没确认后才发一次。

# 看邻居表:状态、MAC、出口
ip neigh show
# 只看到某个网关的
ip neigh show 192.168.1.1
# 手动加一条静态表项(容器/网关场景常用)
sudo ip neigh add 10.0.0.2 lladdr 02:42:ac:11:00:02 dev eth0 nud permanent
# 清空缓存,复现「第一次访问慢」
sudo ip neigh flush all

典型输出是 192.168.1.1 dev eth0 lladdr 00:11:22:33:44:55 REACHABLE。如果状态是 FAILED,说明 ARP 请求发出去没人应——可能是 IP 不存在、可能对端防火墙挡了 ARP、也可能不在同一个广播域。如果状态长期是 INCOMPLETE,通常是物理链路或 VLAN 配置问题。

ARP 相关的四个坑
  1. 「ARP 是 IP 协议的一部分」。它是独立协议,以太网类型是 0x0806,不封装在 IP 里。IPv6 用 NDP(Neighbor Discovery)替代它,走 ICMPv6。
  2. 「ARP 缓存永不过期」。默认 base_reachable_time 是 30 秒左右,之后降级为 STALE,再用时重新确认。容器频繁迁移时,过期缓存会导致短暂断流。
  3. 「免费 ARP 是免费的」。 gratuitous ARP 是主机主动广播「我的 IP 对应这个 MAC」,用于 IP 冲突检测和网关切换。它会刷新整个广播域的缓存,滥用会造成震荡。
  4. 「ARP 表越大越好」。邻居表有 gc_thresh1/2/3 三个阈值,超过 gc_thresh3(默认 1024)新表项会被拒绝,表现为「新连接建不上」。百万连接场景必须调这个值,4.11 会讲。

4.2.5 交换机:自学习、广播域与环路

集线器(hub)是物理层设备,收到什么就向所有端口转发。交换机(switch)是链路层设备,它维护一张转发表(FDB,forwarding database),记录「哪个 MAC 在哪个端口」。转发逻辑只有三条:

  1. 目的 MAC 在表里 → 只从对应端口发出(单播转发)。
  2. 目的 MAC 是广播/组播 → 从所有端口发出(泛洪)。
  3. 目的 MAC 不在表里 → 从所有端口发出(未知单播泛洪)。

表是自学习的:每收到一个帧,就把「源 MAC + 入端口」写进表,老化时间默认 300 秒。所以交换机不需要配置就能工作——插上线,几十秒内表就建好了。

这个设计有一个副作用:环路会导致广播风暴。如果两台交换机之间接了两根线,广播帧会在环里无限循环,表项也会被反复覆盖(MAC 漂移)。STP(生成树协议)通过阻塞冗余端口来防环,RSTP/MSTP 是它的快速版本。在 Linux 网桥里,这个功能由 brctl/ip link 的 stp_state 控制。

# 用 netns 搭两台「主机」+ 一个网桥,观察自学习
sudo ip netns add h1 && sudo ip netns add h2
sudo ip link add br0 type bridge
sudo ip link add veth1 type veth peer name eth0 netns h1
sudo ip link add veth2 type veth peer name eth0 netns h2
sudo ip link set veth1 master br0 && sudo ip link set veth2 master br0
sudo ip link set br0 up && sudo ip link set veth1 up && sudo ip link set veth2 up
sudo ip netns exec h1 ip link set eth0 up
sudo ip netns exec h2 ip link set eth0 up
sudo ip netns exec h1 ip addr add 10.9.0.1/24 dev eth0
sudo ip netns exec h2 ip addr add 10.9.0.2/24 dev eth0
# 从 h1 ping h2,然后看网桥学到了什么
sudo ip netns exec h1 ping -c 2 10.9.0.2
sudo bridge fdb show br0 | grep -v permanent

预期能看到两条动态表项,分别指向 veth1 和 veth2。bridge fdb show 里的 master br0 表示这条表项属于哪个网桥,permanent 表示静态表项(网桥自己的 MAC)。

Linux 网桥还支持一个对容器网络很重要的特性:vlan_filtering。打开后,网桥会按 802.1Q 标签做转发决策,而不是简单泛洪。这让一个 Linux 网桥可以模拟多个隔离的交换机,是 Kubernetes 多租户网络的底层机制之一。

4.2.6 VLAN:在一个交换机里切出多个广播域

一个扁平的二层网络有两个问题:广播域太大(所有主机的 ARP 都互相干扰),以及安全边界太弱(同一网段可以随便嗅探)。VLAN(Virtual LAN)用在帧里插一个 4 字节标签的办法解决这两个问题。

802.1Q 标签插在源 MAC 之后、类型字段之前:

普通以太网帧
┌────────┬────────┬──────┬─────────────┬─────┐
│ 目的MAC │ 源 MAC │ 类型  │    载荷      │ FCS │
└────────┴────────┴──────┴─────────────┴─────┘
​
带 802.1Q 标签的帧(注意插入位置)
┌────────┬────────┬────────┬──────────┬──────┬─────────────┬─────┐
│ 目的MAC │ 源 MAC │ TPID   │ TCI 2B   │ 类型  │    载荷      │ FCS │
│  6 B   │  6 B   │ 0x8100 │ PCP DEI VID│ 2B  │              │ 4B  │
└────────┴────────┴────────┴──────────┴──────┴─────────────┴─────┘
                              │  3   1   12
                              │  │   │   └── VID:0–4094,VLAN 号
                              │  │   └────── DEI:丢弃资格
                              │  └────────── PCP:优先级 0–7(QoS 用)
                              └───────────── 标签协议标识

读这张图要记住两点。第一,标签插在源 MAC 之后,不是帧首。这意味着交换机可以先看目的 MAC 做转发决策,再看 VID 做隔离决策。第二,TPID 是 0x8100,它占据了原来 EtherType 的位置,真正的类型字段后移 4 字节。所以解析时看到 0x8100 就要意识到「还有一层」。

端口有两种模式:

  • Access 口:属于一个 VLAN,进出都不带标签。接主机用。
  • Trunk 口:承载多个 VLAN,帧带标签。交换机之间、交换机到路由器用。

Linux 里创建 VLAN 接口就是给父接口加一个标签处理层:

# 在 eth0 上创建 VLAN 100 的接口,出帧自动打标签,入帧自动剥标签
sudo ip link add link eth0 name eth0.100 type vlan id 100
sudo ip link set eth0.100 up
sudo ip addr add 10.100.0.1/24 dev eth0.100
ip -d link show eth0.100 | head -3

ip -d 会显示 vlan protocol 802.1Q id 100。这个接口在协议栈里是一个真实的 netdevice,MTU 默认继承父接口的 1500(标签在以太网头里,不占 IP MTU);它有自己的统计计数,但默认 noqueue,真正的排队在父设备上。

为什么云厂商爱用 VLAN 而不是纯 overlay

VLAN 是硬件支持的:交换机芯片线速打标签、剥标签,不需要 CPU 参与。overlay(VXLAN)要把整个帧再封装进 UDP,增加 50 字节开销和一次封装/解封装。所以在同一个数据中心内,VLAN 的性能更好;跨数据中心才用 overlay。代价是 VLAN 只有 12 位,最多 4094 个,大规摸多租户不够用——这是 VXLAN 用 24 位 VNI 的直接原因。

4.2.7 差错检测与链路层的可靠性边界

以太网的 FCS 是 CRC32,能检测出所有单比特错误、所有双比特错误、所有奇数个错误,以及大多数突发错误。它的检错能力很强,但不是 100%:某些多位错误组合可能碰撞,概率约 2⁻³²。

链路层发现错误后的处理是丢弃,不做重传。这一点和 Wi-Fi 不同:802.11 有链路层确认和重传(因为无线误码率高),所以 Wi-Fi 的「链路层」比以太网更复杂。有线以太网假设误码率极低(10⁻¹² 量级),重传的成本大于收益。

这个边界很重要,它决定了一个排查原则:如果 ethtool -S 里 CRC 错误在增长,先查物理层(线、口、光模块),不要查 TCP。TCP 的重传计数增长可能是链路错误的后果,而不是原因。

计数含义常见原因
rx_crc_errors收到的帧 CRC 校验失败线缆质量、接头氧化、光衰过大
rx_frame_errors帧对齐错误双工不匹配、PHY 问题
rx_length_errors帧长非法(< 64 或 > MTU)巨型帧配置不一致
rx_fifo_errors接收 FIFO 溢出中断延迟、CPU 忙、ring 太小
tx_carrier_errors发送时载波丢失线没插好、对端未上电
rx_dropped驱动/协议栈丢弃ring 满、内存不足、过滤规则

这张表是从「网卡计数」反推「哪一层出问题」的索引。rx_fifo_errors 和 rx_dropped 增长通常不是物理问题,而是收包能力不足——这正是 4.8 要讲的 NAPI、RPS 和 ring 大小。

4.2.8 无线与移动网络:链路层的另一种答案

有线以太网假设介质是独占的、误码率极低、拓扑是星形的。无线网络三条都不成立,所以它的链路层必须重新设计。这部分内容在《计算机网络》第 9 章有完整展开,本书只取三个对理解 Linux 网络有用的点。

第一,介质是共享的,所以需要 CSMA/CA。 有线用 CSMA/CD(冲突检测),无线不能一边发一边听(自己的信号会淹没对方的),只能冲突避免:先听,空闲再等一个随机退避,然后发。发完等 ACK,没收到就重传。

第二,有隐藏终端问题。 A 和 C 都能和 B 通信,但 A 和 C 互相听不到。同时发就会在 B 处冲突。解法是 RTS/CTS:发数据前先发一个短的 RTS,B 回 CTS,C 听到 CTS 就知道要沉默一段时间。

第三,802.11 帧最多有四个地址字段。 因为一个帧可能经过「源 → AP → 目的」两跳,需要区分「最终源/最终目的」和「这一跳的发送者/接收者」。这是无线与以太网帧结构最大的不同,也是抓 Wi-Fi 包时最容易困惑的地方。

802.11 数据帧的 MAC 首部(24 字节起,与以太网完全不同)
┌────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┐
│ Frame  │Duration│ Addr1  │ Addr2  │ Addr3  │  Seq   │ Addr4  │ 载荷    │
│Control │        │ 6B     │ 6B     │ 6B     │  2B    │ 6B(可选)│        │
│ 2B     │ 2B     │接收者   │发送者   │目的/源 │ 分片号  │ WDS 才用│        │
└────────┴────────┴────────┴────────┴────────┴────────┴────────┴────────┘
 Addr1 永远是「这一跳的接收者」(AP 或终端)
 Addr2 永远是「这一跳的发送者」
 Addr3 是最终目的地址(To DS 时)或最终源地址(From DS 时)

读这张图要抓住「一跳」和「端到端」的区别。有线以太网只有两个地址,因为链路上只有一跳;无线帧要同时表达「这一跳给谁」和「最终给谁」,所以地址字段变多。抓包时如果只看 Addr1 就以为是目的主机,会在 AP 转发的场景里得出错误结论。

第四,无线是半双工的共享介质,速率随信号质量变化。 同一个 AP 下,靠近的终端可能跑 866 Mbps,隔两堵墙的只有 54 Mbps,而 AP 的总容量是共享的。所以「Wi-Fi 慢」很少是「带宽不够」,多半是「空口竞争 + 重传」。这也是为什么有线环境里一条命令能复现的问题,在无线环境里往往复现不了。

对 Linux 来说,无线网卡通常由 mac80211 框架和 cfg80211 配置层管理,用户态用 iw 而不是 ethtool:

# 看无线接口与连接状态(需要无线网卡)
iw dev
iw dev wlan0 link
iw dev wlan0 station dump | head -12

移动网络(4G/5G)在链路层之上引入了移动性管理:手机换基站时 IP 不变,靠隧道把包转到新位置。这套机制对上层透明,但会带来额外的封装开销和切换延迟。本书不展开,只需要知道移动网络的「链路层」其实是一整套移动性协议。

4.2.9 链路层排障:从现象到证据

链路层的问题有两个特点:现象模糊(「网慢」「时通时不通」),证据分散(网卡计数、ARP 表、FDB、抓包)。下面这条链路把证据串起来。

链路层排障五步
  1. 看接口状态:ip -br link。UP 是管理状态,LOWER_UP 是物理层载波。只有 UP 没有 LOWER_UP,说明线没插好或对端没上电。
  2. 看速率与双工:ethtool eth0。确认 Speed 和 Duplex 两端一致,Auto-negotiation 打开。
  3. 看错误计数:ethtool -S eth0 | grep -Ei 'crc|frame|carrier|fifo|drop'。增长快的就是问题所在。
  4. 看邻居表:ip neigh show。FAILED 说明 ARP 不通,INCOMPLETE 说明请求发出去没回应。
  5. 抓包确认:tcpdump -i eth0 -enn -c 20 'arp or icmp'。看请求有没有发出、应答有没有回来、MAC 对不对。

这五步的顺序不能乱。先看物理层(第 1、2 步),再看统计(第 3 步),最后才抓包。反过来做,你会在抓包里看到一堆「包发出去了但没有回应」,却不知道是线的问题还是配置的问题。

一个真实的案例:某台机器 ping 网关时通时不通,ip neigh 显示网关时而是 REACHABLE 时而是 FAILED。第 1 步发现 LOWER_UP 正常,第 2 步发现速率协商成了 100M 半双工(对端是千兆全双工),第 3 步 rx_frame_errors 快速增长。换线后恢复。根因是双工不匹配导致帧对齐错误,而不是「网络不稳定」。

4.2.10 抓一个 ARP 往返:逐字段读帧

前面讲的状态机是抽象的,抓一次包就具体了。在 4.2.5 搭好的 netns 里,先清空邻居表,再 ping 一次:

sudo ip netns exec h1 ip neigh flush all
sudo tcpdump -i any -enn -c 4 'arp' &
sudo ip netns exec h1 ping -c 1 10.9.0.2
wait

输出会长这样:

1) 02:42:0a:09:00:01 > ff:ff:ff:ff:ff:ff, ethertype ARP (0x0806), length 42:
   Request who-has 10.9.0.2 tell 10.9.0.1, length 28
2) 02:42:0a:09:00:02 > 02:42:0a:09:00:01, ethertype ARP (0x0806), length 42:
   Reply 10.9.0.2 is-at 02:42:0a:09:00:02, length 28

逐字段对一遍:

  • ff:ff:ff:ff:ff:ff 是目的 MAC,广播。请求必须广播,因为发送方不知道对方在哪。
  • ethertype ARP (0x0806) 是类型字段,内核靠它把载荷交给 ARP 模块。
  • length 42 是帧长(不含 FCS):14 字节首部 + 28 字节 ARP 载荷 = 42。
  • length 28 是ARP 载荷长度,不是帧长。两个 length 含义不同,这是读 tcpdump 输出时最容易混的地方。
  • who-has 10.9.0.2 tell 10.9.0.1 是「目标 IP」和「发送方 IP」。
  • 应答变成单播,目的 MAC 是请求方的 MAC,因为此时双方地址都已知。

这里还能验证 4.2.2 说的最小帧长:42 字节小于 64,所以线上实际会有 22 字节填充。tcpdump 显示的是「有效长度」,不显示填充,所以看到的 length 会比线上字节数小。

arping 是专门测 ARP 的工具,比 ping 更适合定位「同一广播域内某 IP 是否有主机应答」:

sudo arping -I eth0 -c 3 10.9.0.2      # ARP 层探测,不经过 IP
sudo arping -D -I eth0 -c 3 10.9.0.1   # 免费 ARP 检测 IP 冲突

-D 是 duplicate address detection:如果收到应答,说明这个 IP 已经被别人用了。虚拟机和容器批量部署时,这个检查能提前发现 IP 冲突导致的「时通时不通」。

最后要注意 tcpdump 里偶尔出现的 [|ARP]。它表示抓到的帧被截断,通常是因为 -s 快照长度设得太小。默认 -s 262144 不会截断,但如果显式设置了 -s 96,ARP 载荷就会被砍掉一半。

4.2.11 以太网之外:PPP 与隧道封装

以太网统治了局域网,但它不是唯一的链路层。理解另外两类,才能看懂云环境里那些「多出来的一层」。

PPP(Point-to-Point Protocol) 是为点对点链路设计的:拨号、专线、PPPoE。它没有 MAC 地址,因为链路上只有两个端点;它用字节填充(0x7e 是定界符,出现时要转义);它有 LCP 协商链路参数、NCP 协商网络层参数。家庭宽带拨号就是 PPPoE:把 PPP 帧装进以太网帧,多 8 字节开销(6 字节 PPPoE 首部 + 2 字节 PPP 协议号)。

隧道(tunnel) 是把一层的帧装进另一层的载荷。它出现的原因是「需要一个假的链路层」:overlay 网络要在一个三层可达的网络上模拟二层广播域,或者要把私有地址的包送过公网。

封装方式额外开销模拟的是什么典型用途
IP-in-IP20 字节三层点到点早期 VPN、简单隧道
GRE24 字节三层,可承载组播路由协议隧道
VXLAN50 字节二层广播域(VNI 24 位)容器 overlay、大二层
PPPoE8 字节点对点拨号家庭宽带

这张表里最该记住的是开销。VXLAN 的 50 字节意味着底层 MTU 1500 时,虚拟网络的 MTU 只能设 1450,否则大包会被丢弃或分片。这就是 4.1.7 说的「隧道越多,MTU 越小」,也是容器网络里 ping 通但大文件传不动的常见根因。

建一个 VXLAN 接口只要一行,内核会处理好封装与解封装:

# 本机实验:在 eth0 上建一个 VNI 42 的 VXLAN 接口
sudo ip link add vxlan42 type vxlan id 42 dstport 4789 dev eth0
sudo ip link set vxlan42 up
ip -d link show vxlan42 | head -4

ip -d 的输出会显示 vxlan id 42 ... dstport 4789 ... learning。id 42 就是 VNI,它决定了「哪些帧属于同一个虚拟网络」——和 VLAN 的 VID 是同一个思路,只是位宽从 12 位扩展到 24 位。

为什么云厂商的 MTU 常是 1450 或 1400

因为路径上可能叠了多层封装:VXLAN(50)+ 可能的 IPsec(50–70)+ 可能的 GRE(24)。每叠一层就少一截可用载荷。工程上的做法是按最坏情况设 MTU,并在网关上做 ICMP Fragmentation Needed 的透传,让 PMTUD 能工作。

4.2.13 以太网帧与 802.1Q 标签的位布局

4.2.2 给了 14 字节首部,这一节把帧的每一个字段、VLAN 标签的每一位、以及「帧为什么最小 64 字节」讲清。

以太网 II 帧的线格式:

字段字节内容备注
前导码 + SFD855…55 D5物理层同步,抓包看不到
目的 MAC6单播/组播/广播首字节最低位为 1 表示组播
源 MAC6发送方必须是单播
类型/长度2≥0x0600 为 EtherType0x0800 IPv4、0x86dd IPv6、0x8100 VLAN
载荷46–1500上层数据不足 46 字节要填充
FCS4CRC-32网卡校验后通常剥离
帧间隙 IFG12空闲不计入帧长

最小帧 64 字节(不含前导码)的由来:早期共享式以太网要保证「发送方在帧尾到达最远端之前仍在发送」,否则冲突检测失效。64 字节 = 6+6+2+46+4。小于 46 字节的载荷必须填充,这就是 4.1.12 里小包 goodput 只有 50% 的原因之一。

802.1Q 标签插在源 MAC 与 EtherType 之间,共 4 字节:

字段位宽含义
TPID16固定 0x8100(802.1Q);0x88a8 是 802.1ad QinQ
PCP3优先级 0–7,用于 QoS
DEI1丢弃 eligible,拥塞时可优先丢
VID12VLAN ID,0–4095;0 表示优先级标签,4095 保留

带 VLAN 的帧长上限从 1518 变成 1522。很多「MTU 1500 却 ping 不通大包」的问题,是因为链路上加了 VLAN 但 MTU 没跟着算:VLAN 标签占 4 字节,如果交换机按 1522 限制而主机按 1518 发,大包会被丢。

VLAN 的三种端口行为:

端口类型入方向出方向
Access打上 PVID,只接受无标签帧剥离标签
Trunk保留标签,按允许列表过滤保留标签
Hybrid按配置决定按配置决定

Linux 网桥的 VLAN 过滤(vlan_filtering 1)让网桥按 VID 转发,而不是把所有 VLAN 当普通帧泛洪。不开 vlan_filtering 时,网桥会把带标签的帧当未知单播泛洪到所有端口,这是容器/虚拟化环境里「VLAN 串线」的常见原因。

ip link 创建 VLAN 接口时,内核在发送路径上插入标签、接收路径上剥离:

# 在 eth0 上创建 VLAN 100,出帧自动打标签,入帧自动剥
sudo ip link add link eth0 name eth0.100 type vlan id 100
sudo ip link set eth0.100 up
ip -d link show eth0.100 | grep vlan

ip -d 的输出会显示 vlan protocol 802.1Q id 100,以及 LOOSE_BINDING、REORDER_HDR 等标志。REORDER_HDR 默认开启,表示在收发时调整首部顺序;关闭它会影响硬件加速与抓包看到的字节序。

帧与 VLAN 的四个坑
  1. 把 FCS 算进 MTU:MTU 1500 指载荷,帧长 1518(含 FCS);VLAN 再加 4。
  2. 认为 VLAN 标签一定在以太网头之后:QinQ 有两层标签,顺序是外层 TPID/TCI 再内层。
  3. 在网桥上混用 tagged/untagged 端口:必须显式配置 PVID 与 allowed 列表,否则会泛洪。
  4. 用 tcpdump 看不到 VLAN 标签就以为没有:某些驱动在抓包前已剥离标签,用 -e 或网桥镜像口确认。

4.2.14 链路层的内核对象:net_device、napi_struct、neighbour

链路层在代码里是三类对象:设备、轮询上下文、邻居表项。理解它们的字段,才能把 4.2.4 的 ARP 与 4.2.5 的网桥行为对上。

struct net_device 的关键字段:

字段类型含义谁维护
namechar[IFNAMSIZ]接口名创建时
mtuunsigned int最大传输单元ip link set mtu
flagsunsigned intIFF_UP、IFF_LOOPBACK、IFF_NOARP驱动/用户
dev_addrunsigned char *MAC 地址驱动
netdev_opsconst struct net_device_ops *打开/关闭/发送驱动
featuresnetdev_features_t卸载能力(TSO/GSO/checksum)驱动协商
napi_liststruct list_head该设备的 NAPI 实例netif_napi_add
num_tx_queues / num_rx_queuesunsigned int队列数驱动

struct napi_struct 是「一次轮询的上下文」:

字段含义
poll_list挂到 per-CPU poll_list 的节点
stateNAPI_STATE_SCHED、NAPI_STATE_DISABLE 等
weight每次轮询的预算(默认 64)
poll驱动的轮询函数
dev所属设备
gro_hash[]GRO 合并用的哈希表

struct neighbour 是 ARP/NDP 表项,状态机在 4.2.4 提过,这里补字段:

字段含义
ha硬件地址(MAC)
nud_stateNUD_INCOMPLETE/REACHABLE/STALE/DELAY/PROBE/FAILED
ops地址族操作(arp_hh_ops)
arp_queue等待解析完成的 skb 队列
confirmed上次确认时间
used / refcnt使用与引用计数

三个对象的调用关系:

驱动中断
  → napi_schedule(&napi->poll_list)       /* 把 napi 挂到 per-CPU 队列 */
    → net_rx_action                       /* 软中断 */
      → napi_poll → 驱动 poll             /* 收 ring 里的描述符 */
        → napi_gro_receive                /* GRO 合并 */
          → netif_receive_skb             /* 链路层分用 */
            → eth_type_trans              /* 剥以太网头,设 skb->protocol */
            → ip_rcv                      /* 交给网络层 */

eth_type_trans() 做三件事:把 data 前移 14 字节、设置 skb->protocol、根据目的 MAC 设置 skb->pkt_type(PACKET_HOST/PACKET_OTHERHOST/PACKET_BROADCAST/PACKET_MULTICAST)。pkt_type 不是 PACKET_HOST 的帧,协议栈通常会丢,这是「网卡处于混杂模式但 ping 不通」的原因之一。

ARP 的处理入口是 arp_rcv():

/* net/ipv4/arp.c,简化 */
int arp_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt,
            struct net_device *orig_dev)
{
    ...
    if (dev->flags & IFF_NOARP)          /* 回环等设备不处理 ARP */
        goto freeskb;
    if (!pskb_may_pull(skb, arp_hdr_len(dev)))
        goto freeskb;
    arp = arp_hdr(skb);
    if (arp->ar_hln != dev->addr_len || dev->type != ntohs(arp->ar_hrd))
        goto freeskb;
    ...
    switch (ntohs(arp->ar_op)) {
    case ARPOP_REQUEST:
        arp_process(arp, dev, ...);       /* 更新邻居表,必要时回包 */
        break;
    case ARPOP_REPLY:
        arp_process(arp, dev, ...);
        break;
    }
    ...
}

arp_process() 会先查/建邻居项,再决定是否回 ARP Reply。免费 ARP(gratuitous ARP) 就是 ar_op=REQUEST 但目的 IP 等于发送方 IP 的包,用于通告地址变更;它不走正常请求-响应流程,而是直接更新邻居表。

`neigh` 的 `arp_queue` 与「第一个包丢」

当邻居状态是 NUD_INCOMPLETE 时,neigh_resolve_output() 把 skb 挂进 arp_queue 并触发 ARP 请求。队列有长度限制(net.ipv4.neigh.default.unres_qlen),超限就丢。ARP 响应通常在毫秒级返回,所以「第一个包丢」只在 ARP 表为空或老化后出现;如果持续丢,查 VLAN/网桥是否隔离了广播。

4.2.15 程序一:手工构造一个带 VLAN 标签的帧

这段程序按以太网 II + 802.1Q 的布局拼一个帧,计算 FCS,并反向解析十六进制串。它验证「TPID/TCI 插在源 MAC 之后」以及 FCS 的字节序。

/* vlan_frame.c:构造/解析 802.1Q 帧并计算 FCS */
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static uint32_t crc32_eth(const uint8_t *p, size_t n)
{
    uint32_t crc = 0xFFFFFFFFu;
    for (size_t i = 0; i < n; i++) {
        crc ^= p[i];
        for (int b = 0; b < 8; b++)
            crc = (crc >> 1) ^ (0xEDB88320u & (uint32_t)(-(int32_t)(crc & 1)));
    }
    return ~crc;
}

static size_t build(uint8_t *out, const uint8_t dst[6], const uint8_t src[6],
                    uint16_t vid, uint16_t ethertype, const uint8_t *payload, size_t plen)
{
    size_t o = 0;
    memcpy(out + o, dst, 6); o += 6;
    memcpy(out + o, src, 6); o += 6;
    out[o++] = 0x81; out[o++] = 0x00;                 /* TPID */
    out[o++] = (uint8_t)((vid >> 8) & 0x0F);          /* PCP/DEI 为 0,VID 高 4 位 */
    out[o++] = (uint8_t)(vid & 0xFF);
    out[o++] = (uint8_t)(ethertype >> 8);
    out[o++] = (uint8_t)(ethertype & 0xFF);
    memcpy(out + o, payload, plen); o += plen;
    while (o < 60)                                    /* 最小帧填充 */
        out[o++] = 0;
    uint32_t fcs = crc32_eth(out, o);
    out[o++] = (uint8_t)(fcs & 0xFF);                 /* FCS 小端 */
    out[o++] = (uint8_t)((fcs >> 8) & 0xFF);
    out[o++] = (uint8_t)((fcs >> 16) & 0xFF);
    out[o++] = (uint8_t)((fcs >> 24) & 0xFF);
    return o;
}

int main(void)
{
    uint8_t dst[6] = { 0xff, 0xff, 0xff, 0xff, 0xff, 0xff };
    uint8_t src[6] = { 0x02, 0x00, 0x00, 0x00, 0x00, 0x01 };
    uint8_t payload[16] = "hello-vlan";
    uint8_t frame[128];

    size_t n = build(frame, dst, src, 100, 0x0800, payload, sizeof payload);
    printf("帧长 %zu(含 FCS)\n", n);
    for (size_t i = 0; i < n; i++)
        printf("%02x%c", frame[i], (i + 1) % 16 ? ' ' : '\n');
    printf("\n");

    /* 反向解析:跳过 12 字节 MAC,读 TPID/TCI */
    if (n >= 18 && frame[12] == 0x81 && frame[13] == 0x00) {
        unsigned vid = ((unsigned)(frame[14] & 0x0F) << 8) | frame[15];
        unsigned pcp = (frame[14] >> 5) & 0x7;
        printf("TPID=0x8100 VLAN=%u PCP=%u EtherType=0x%02x%02x\n",
               vid, pcp, frame[16], frame[17]);
    }
    return 0;
}

编译运行:

cc -std=c17 -Wall -Wextra -Werror -O2 vlan_frame.c -o vlan_frame
./vlan_frame
帧长 64(含 FCS)
ff ff ff ff ff ff 02 00 00 00 00 01 81 00 00 64 08 00 68 65 6c 6c 6f 2d 76 6c 61 6e ...
TPID=0x8100 VLAN=100 PCP=0 EtherType=0x0800

关键行:

  • out[o++] = (vid >> 8) & 0x0F:TCI 高 4 位是 PCP(3)+DEI(1),VID 只占低 12 位,所以高字节要 & 0x0F。
  • FCS 按小端写入:先低字节。抓包里看到的最后 4 字节是 fcs & 0xff 在前。
  • 填充到 60 字节(不含 FCS)才计算 FCS:FCS 覆盖填充后的帧,不是只覆盖载荷。

4.2.16 程序二:用 netns 验证 VLAN 过滤

这段脚本建一个开了 vlan_filtering 的网桥,两个 netns 分别接 access 口和 trunk 口,验证「带标签/不带标签」的连通性。

#!/usr/bin/env bash
# vlan_lab.sh:网桥 VLAN 过滤实验
set -euo pipefail

BR="brvlan"
NS1="v1"
NS2="v2"
cleanup() {
    ip netns del "$NS1" 2>/dev/null || true
    ip netns del "$NS2" 2>/dev/null || true
    ip link del "$BR" 2>/dev/null || true
}
trap cleanup EXIT
cleanup

ip netns add "$NS1"
ip netns add "$NS2"
ip link add "$BR" type bridge
ip link set "$BR" type bridge vlan_filtering 1
ip link set "$BR" up

# v1 接 access 口:PVID 100,出方向剥标签
ip link add veth1 type veth peer name veth1-br
ip link set veth1 netns "$NS1"
ip link set veth1-br master "$BR"
ip link set veth1-br up
bridge vlan add dev veth1-br pvid untagged vid 100
ip -n "$NS1" addr add 10.20.0.1/24 dev veth1
ip -n "$NS1" link set veth1 up
ip -n "$NS1" link set lo up

# v2 接 trunk 口:允许 100,保留标签
ip link add veth2 type veth peer name veth2-br
ip link set veth2 netns "$NS2"
ip link set veth2-br master "$BR"
ip link set veth2-br up
bridge vlan add dev veth2-br vid 100
ip -n "$NS2" link add link veth2 name veth2.100 type vlan id 100
ip -n "$NS2" addr add 10.20.0.2/24 dev veth2.100
ip -n "$NS2" link set veth2 up
ip -n "$NS2" link set veth2.100 up
ip -n "$NS2" link set lo up

echo "== 网桥 VLAN 表 =="
bridge vlan show
echo "== access(10.20.0.1) -> trunk(10.20.0.2) =="
ip netns exec "$NS1" ping -c 3 -q 10.20.0.2 || true

运行:

chmod +x vlan_lab.sh
sudo ./vlan_lab.sh

预期:bridge vlan show 显示 veth1-br 的 100 PVID Egress Untagged,veth2-br 的 100;ping 通。把 bridge vlan add dev veth2-br vid 100 删掉再跑,ping 不通——trunk 口没放行 VID 时,带标签的帧被网桥丢弃,这就是 VLAN 隔离的最小验证。

注意 ip -n "$NS2" link add link veth2 name veth2.100 type vlan id 100:VLAN 子接口建在 netns 内的 veth2 上,出帧自动打标签,入帧自动剥。不要在宿主机上给 veth2 配 IP,否则会看到未打标签的流量。

4.2.17 实验:抓带 VLAN 的帧,读网桥表

实验一抓一个带标签的帧并逐字段读,实验二看网桥的 FDB 与 VLAN 表如何对应。

实验一:抓 VLAN 帧。

# 在 trunk 口抓,-e 显示以太网头;vlan 过滤器只匹配带标签的帧
sudo tcpdump -i veth2-br -e -nn -c 3 'vlan 100'
# 如果没有 veth,可在物理网卡的 trunk 口抓
sudo tcpdump -i eth0 -e -nn -c 3 'vlan and icmp'

输出形如:

12:00:00.000000 02:00:00:00:00:02 > 02:00:00:00:00:01, ethertype 802.1Q (0x8100), length 46:
    vlan 100, p 0, ethertype IPv4, 10.20.0.2 > 10.20.0.1: ICMP echo request

tcpdump 已经把 VLAN 标签解析成 vlan 100, p 0;p 是 PCP。如果只看到 ethertype 802.1Q 而没有 vlan 字段,说明 tcpdump 版本太老或过滤器没匹配到内层。

实验二:FDB 与 VLAN 表。

# 网桥学到的 MAC、端口、VLAN
bridge fdb show br brvlan
# 每个端口的 PVID 与允许列表
bridge vlan show dev veth1-br
bridge vlan show dev veth2-br
# 端口的入/出方向统计(需要较新内核)
bridge -s vlan show

判据:

现象含义
FDB 里同一 MAC 出现在两个端口环路或 MAC 漂移,查 STP
veth2-br 没有 100trunk 未放行,带标签帧被丢
veth1-br 的 100 带 PVID Egress Untaggedaccess 口配置正确
bridge -s vlan show 的 RX/TX 不增长流量没走这个 VLAN,或抓错了端口

再用 arping 验证邻居层:

# 从 v1 向 v2 发 ARP 请求(需要 iputils-arping)
ip netns exec v1 arping -c 3 -I veth1 10.20.0.2
# 看邻居状态:REACHABLE/STALE/DELAY
ip -n v1 neigh show dev veth1

arping 与 ping 的区别是它直接测 L2 连通性:即使 IP 层路由不对,只要 VLAN 通,ARP 就能到。排「VLAN 通但 IP 不通」时,先用 arping 把 L2 和 L3 分开。

4.2.18 本章陷阱清单

13 条高频错误
  1. 把 MTU 当帧长:MTU 是载荷,帧长 = 14 + 载荷 + 4(FCS),VLAN 再加 4。
  2. 忘记最小帧填充:小于 46 字节的载荷要填充到 60 字节(不含 FCS)。
  3. 把 VLAN 的 VID 当 16 位:VID 只有 12 位,高 4 位是 PCP/DEI。
  4. 在 access 口发带标签的帧:会被丢弃或按 PVID 二次打标。
  5. trunk 口没放行 VID:帧被网桥丢弃,表现为「同 VLAN 也不通」。
  6. 不开 vlan_filtering 就用 bridge vlan:网桥仍按普通桥泛洪,VLAN 隔离不生效。
  7. 把 ARP 当 IP 层协议:ARP 直接封装在以太网上,EtherType 0x0806,没有 IP 首部。
  8. 认为回环也有 ARP:lo 是 IFF_NOARP,不会发 ARP 请求。
  9. 把 pkt_type 忽略:非 PACKET_HOST 的帧会被协议栈丢弃,混杂模式抓到不等于能处理。
  10. 认为网桥一定学 MAC:FDB 有老化时间,长期不通信的表项会消失,第一个包会泛洪。
  11. STP 阻塞导致「时通时不通」:有环路的网络必须开 STP,或确认没有双上行。
  12. 用 ethtool -S 的 rx_errors 判断 VLAN 丢包:VLAN 过滤丢包通常不进 rx_errors,要看网桥/VLAN 统计。
  13. 在容器里看 VLAN 却看宿主机网桥:veth 两端在不同 netns,VLAN 子接口可能只在一端。

4.2.19 本章出口检查

检查项达标线没达标去哪
说出以太网帧的五个字段与长度能写出 14/46/64 三个数字的来历4.2.2
判断 MAC 是单播、组播还是广播会用第一个字节的 bit 0 和 bit 14.2.3
解释 ARP 请求与应答的差别知道什么时候广播、什么时候单播4.2.4
说清交换机自学习的三条转发规则能解释未知单播泛洪4.2.5
画出 802.1Q 标签的插入位置知道 TPID 和 VID 的位宽4.2.6
从 ethtool -S 反推故障层能区分物理错误与收包能力不足4.2.7、4.2.9

| 拆一个 802.1Q 标签 | 知道 VID 只有 12 位 | 4.2.13 | | 说出 net_device/napi/neighbour 的分工 | 能对上 ARP 状态机 | 4.2.14 | | 手工构造带 VLAN 的帧 | 会算 FCS 与填充 | 4.2.15 | | 用 netns 验证 VLAN 过滤 | 能解释 PVID 与 trunk | 4.2.16 |

即时自测 · 4.2

Q1 一个以太网帧承载 20 字节的 IP 包(比如一个空的 TCP ACK),帧长是多少?需要填充吗?

Q2 主机 A(10.0.0.1/24)要访问 10.0.1.5,但它的 ARP 缓存里没有网关的 MAC。它会先发什么?目的 MAC 是谁?

Q3 交换机收到一个目的 MAC 不在 FDB 里的单播帧,会怎么做?如果这个帧是 ARP 请求呢?

Q4 为什么 802.1Q 标签插在源 MAC 之后,而不是帧首?这样设计对交换机的转发有什么好处?

Q5 ethtool -S 里 rx_crc_errors 持续增长,但 ping 大部分时候正常,最可能的原因是什么?应该先查哪一层?

Q6 同一个 VLAN 里的两台主机,MAC 地址能不能相同?为什么?

答案

A1 14(首部)+ 20(IP 包)+ 4(FCS)= 38 字节,小于最小帧长 64,所以要填充 26 字节的零,最终线上是 64 字节。填充发生在链路层,IP 首部的 total_len 仍然是 20,接收端靠 IP 首部的长度字段丢掉填充。

A2 它发现目的地址不在 10.0.0.0/24 内,于是查路由表找到默认网关(比如 10.0.0.254),然后发 ARP 请求「谁是 10.0.0.254」。请求帧的目的 MAC 是广播 ff:ff:ff:ff:ff:ff,ARP 载荷里的目标 IP 是网关地址。它不会去 ARP 10.0.1.5,因为那个地址不在本地广播域。

A3 单播未知目的会泛洪到除入端口外的所有端口。ARP 请求的目的 MAC 本来就是广播,也泛洪。两者的区别在于:泛洪单播是「临时不知道」,学到之后就不再泛洪;广播是「永远不知道」,每次都要泛洪。

A4 因为交换机要先看目的 MAC 做转发决策,再看 VID 做隔离决策。如果标签在帧首,交换机得先解析标签才能拿到目的 MAC,解析逻辑会变复杂;插在源 MAC 之后,前 12 字节的布局和普通以太网完全一样,硬件可以复用同一套解析流水线。这是兼容性驱动的设计。

A5 最可能是物理层问题:线缆质量差、接头氧化、光模块衰减、或者电磁干扰。CRC 错误是链路层检错的结果,说明「帧到了但内容坏了」。应该先查第 1 层(换线、换口、看光功率),而不是查 TCP 重传。

A6 技术上可以,但会造成严重问题。同一个广播域里 MAC 必须唯一,否则交换机的 FDB 会在两个端口之间反复漂移(MAC flapping),导致流量时断时续。虚拟化环境里要保证每个 veth/容器的 MAC 不重复,通常由 IPAM 或 CNI 分配。

4.2.20 编码与调制:1 Gbps 的线路上跑多少 Baud

4.2.1 只说了「比特要先变成波形」。这一节把账算清:同样标称 1 Gbps,不同编码的符号率、线路开销、每符号承载的比特数都不一样。换光模块、判断 Cat5e 能不能跑 2.5G、读网卡自协商结果时,用的就是这套账。

三个概念先分开:

  • 比特率(bit rate):每秒多少比特,单位 bit/s,是用户关心的净速率。
  • 符号率(baud rate):每秒多少个符号,单位 Baud。一个符号可以携带 1 bit,也可以携带多个比特。
  • 编码开销:线路必须多跑多少符号,才能送出 1 bit 净数据。曼彻斯特编码每 bit 用两个符号,开销 100%;64B/66B 每 64 bit 加 2 bit 同步头,开销 3.125%。
标准介质线路编码/调制符号率并行通道编码后 bit/符号净速率开销
10BASE-TCat3 双绞线Manchester20 MBd10.510 Mbps100%
100BASE-TXCat5 双绞线4B/5B + MLT-3125 MBd10.8100 Mbps25%
1000BASE-X光纤8B/10B1.25 GBd10.81 Gbps25%
1000BASE-TCat5e 双绞线4D-PAM5125 MBd4 对21 Gbps0%
10GBASE-R光纤64B/66B10.3125 GBd164/6610 Gbps3.125%
25GBASE-R光纤64B/66B25.78125 GBd164/6625 Gbps3.125%

这张表说明三件事。第一,双绞线靠并行:1000BASE-T 的符号率只有 125 MBd,和 100BASE-TX 一样,靠 4 对线同时传、每符号 2 bit 才到 1 Gbps。第二,光纤靠高符号率:10GBASE-R 的线路符号率是 10.3125 GBd,不是整数 10 GBd,多出来的 0.3125 GBd 就是 64B/66B 的同步开销。第三,编码越简单,线路越费:曼彻斯特编码把 10 Mbps 的数据变成 20 MBd 的波形,好处是自带时钟、直流平衡,代价是占用双倍带宽。

通用换算式:

净速率 = 符号率 × 并行通道数 × 编码后每符号比特数
开销   = 编码前每符号比特数 / 编码后每符号比特数 - 1

把这张表算一遍的脚本如下,改几个常数就能评估「这条链路理论上能跑多少」:

#!/usr/bin/env python3
"""coding_budget.py:符号率 → 净速率与编码开销"""
SCHEMES = [
    # 名称, 符号率 MBd, 并行通道, 编码前 bit/符号, 编码后 bit/符号
    ("10BASE-T",    20.0,    1, 1.0, 0.5),
    ("100BASE-TX", 125.0,    1, 1.0, 0.8),
    ("1000BASE-X", 1250.0,   1, 1.0, 0.8),
    ("1000BASE-T", 125.0,    4, 2.0, 2.0),
    ("10GBASE-R",  10312.5,  1, 1.0, 64 / 66),
    ("25GBASE-R",  25781.25, 1, 1.0, 64 / 66),
]

print(f"{'标准':<12}{'符号率(MBd)':>12}{'通道':>6}{'净速率(Gbps)':>14}{'开销':>9}")
for name, baud, lanes, raw, coded in SCHEMES:
    net = baud * 1e6 * lanes * coded / 1e9
    overhead = (raw / coded - 1) * 100
    print(f"{name:<12}{baud:>12.1f}{lanes:>6}{net:>14.3f}{overhead:>8.1f}%")

运行 python3 coding_budget.py,输出:

标准              符号率(MBd)    通道     净速率(Gbps)       开销
10BASE-T            20.0     1         0.010   100.0%
100BASE-TX         125.0     1         0.100    25.0%
1000BASE-X        1250.0     1         1.000    25.0%
1000BASE-T         125.0     4         1.000     0.0%
10GBASE-R        10312.5     1        10.000     3.1%
25GBASE-R        25781.2     1        25.000     3.1%

调制是编码的另一半:编码解决「0/1 怎么变成电平跳变」,调制解决「一个符号有多少种状态」。PAM-5 有 5 个电平(其中 4 个用于数据、1 个用于前向纠错),4D-PAM5 在 4 对线上联合编码;Wi-Fi 6 的 1024-QAM 一个符号 10 bit,靠的是密集星座和信道估计;5G 的 256-QAM 一个符号 8 bit。星座越密,每符号比特越多,但抗噪能力越差——信号弱时网卡会自动降阶(比如从 1024-QAM 降到 QPSK),这就是「信号差时 Wi-Fi 速率骤降」的物理原因。

为什么 10GBASE-R 的时钟是 10.3125 GHz

64B/66B 每 64 个数据 bit 加 2 bit 同步头,线路必须跑 10 × 66/64 = 10.3125 Gbps,收发器时钟就锁在这个频率。看到 10.3125、25.78125 这类「不整」的数字,先想到 64B/66B,而不是链路故障。

4.2.21 CSMA/CD:64 字节最小帧是算出来的

共享式以太网的所有主机挂在同一根同轴电缆上,同一时刻只能有一台发。CSMA/CD(Carrier Sense Multiple Access with Collision Detection)用五步解决争用:

  1. 先听(carrier sense):信道忙就等,空闲才发。
  2. 边发边听(collision detection):发送过程中持续比较发送波形与接收波形。
  3. 冲突就停:检测到冲突立刻停止,改发 32 bit 的拥塞信号(jam signal),让所有站都知道发生了冲突。
  4. 随机退避:按截断二进制指数退避选一个时隙数,等够了再重试。
  5. 重试 16 次仍冲突就放弃,向上层报错。

第 3 步能成立的前提是:发送方在冲突信号传回来时还没发完。设最远两台主机的单程传播时延为 τ,冲突最晚在 2τ 时被感知,帧的发送时间必须大于 2τ:

L / C ≥ 2τ          L 是帧长,C 是速率
L_min = 2τ × C      τ 取最坏拓扑的传播时延

10 Mbps 以太网把往返时延上限定为 512 bit 时间(51.2 μs),于是 L_min = 512 bit = 64 字节。这不是拍脑袋的兼容值,是解出来的。

A:  ├──────────── 帧 ────────────┤
B:        ├──── 帧 ────┤
          ↑ τ 后 B 也开始发
                   ↑ 冲突在 2τ 内传回 A
A 必须仍在发送,才能检测到冲突并重传
          └──── 2τ ────┘

不同速率的以太网对「时隙」的定义不同,因为 bit 时间变了:

以太网时隙(bit 时间)时隙(时间)最小帧半双工跨度备注
10BASE551251.2 μs64 B2500 m / 5 段最多 4 个中继器
100BASE-TX5125.12 μs64 B约 205 m最多 2 个 II 类中继器
1000BASE-T40964.096 μs64 B + 载波扩展到 512 B约 200 m半双工才需要

1000 Mbps 的半双工模式把时隙扩大到 4096 bit 时间(4.096 μs),因为速率提高 10 倍后,同样的 512 bit 只够 0.512 μs,信号跑不了多远。为了让短帧也能占满时隙,千兆半双工引入了载波扩展(carrier extension):帧本身还是 64 字节,但 PHY 会在后面补无用符号,把线路占用撑到 512 字节;连续发送多个帧时用帧突发(frame bursting)把扩展开销摊薄。全双工普及后这套机制基本不用了,但 ethtool -S 里仍能看到相关计数。

冲突后的退避不是纯随机。第 i 次冲突(i 从 1 开始)从 [0, 2^min(i,10) - 1] 里取一个整数 k,等 k 个时隙再试;16 次仍失败就丢帧。这个「截断二进制指数退避」让重负载时退避窗口快速变大,轻负载时又能快速抢到信道。

一个和排障直接相关的后果是延迟冲突(late collision):如果帧长超过时隙,冲突在 512 bit 时间之后才被检测到,发送方可能已经把帧发完、上层以为成功,不会重传。症状是 ethtool -S 里 tx_late_collisions 增长、大包丢失、小包正常。根因通常是双工不匹配或线缆过长,而不是「交换机坏了」。

# 看冲突与延迟冲突计数(不同驱动字段名略有差异)
ethtool -S eth0 | grep -Ei 'collision|late|defer|carrier'
# 确认双工:两端必须一致
ethtool eth0 | grep -E 'Speed|Duplex|Auto-negotiation'

全双工链路上没有争用:收发各用一对线,交换机端口之间是点对点,CSMA/CD 不参与。此时 64 字节最小帧仍然保留,但原因变成了「接收端需要足够的字节做地址过滤和 FCS 校验」,以及兼容老设备。

4.2.22 以太网帧字段表:谁填、何时改、错了会怎样

4.2.2 给了布局,4.2.13 给了位宽。这一节换一个维度:每个字段由谁写、在哪一跳被改写、写错时故障长什么样。排障时对着这张表逐字段排除,比背格式有用。

字段相对帧首偏移长度谁写何时改写写错/损坏的后果
前导码 + SFD−88 B发送端 PHY每帧重新生成接收端无法位同步,整帧丢弃
目的 MAC06 B发送方协议栈(查邻居表)每一跳改写发错端口;广播域被泛洪
源 MAC66 B发送方网卡每一跳改写FDB 学错端口,MAC 漂移
TPID(可选)122 BVLAN 层打/剥标签时被当成 EtherType,帧解析错位
TCI(可选)142 BVLAN 层打/剥标签时VID 错 → 串 VLAN 或隔离
EtherType12 或 162 B上层协议不改写分用失败,内核不知道交给谁
载荷 + 填充14 或 1846–1500 B上层不改写小于 46 必须补零,否则成残帧
FCS帧尾4 B发送端 PHY每一跳重新生成CRC 错,整帧丢弃,不重传

这张表里有三处容易忽略:

  • 源 MAC 也会被改写。经过路由器时,新帧的源 MAC 是路由器的出接口,不是原始主机。抓包看到「源 MAC 是网关」,不代表包是网关发的。
  • FCS 在每一跳由发送端 PHY 重新生成。交换机通常不改帧内容,但出口 MAC 会基于发出的字节重算 FCS;如果它改了字段(比如 NAT/代理),更必须重算,否则下游全部 CRC 错。
  • TPID 和 EtherType 共用同一个偏移。带标签时,12–13 字节是 0x8100,真正的 EtherType 在 16–17 字节;不带标签时,12–13 字节就是 EtherType。

把线上字节算全,得到三个常用数字:

线上长度 = 前导码 8 + 帧长 + 帧间隙 12
帧长     = 14(首部)+ 载荷 + 填充 + 4(FCS)
无标签最小帧:8 + 64 + 12 = 84 B
无标签最大帧:8 + 1518 + 12 = 1538 B
带 VLAN 最大帧:8 + 1522 + 12 = 1542 B

tcpdump -e 打印的 length 是不含 FCS 的帧长,所以 1500 字节 IP 包显示 length 1514,带 VLAN 显示 length 1518。线上还要各加 4 字节 FCS 和 8 字节前导码。把 tcpdump 的 length 当线上字节数,会少算 12 字节,算带宽利用率时误差约 0.8%。

EtherType 是分用的第一级索引,常见值如下:

EtherType上层协议备注
0x0800IPv4最常见
0x0806ARP不封装在 IP 里
0x86ddIPv6
0x8100802.1Q VLAN后面还有 4 字节标签
0x88a8802.1ad QinQ外层标签
0x8847MPLS 单播标签栈在以太网头之后
0x8863 / 0x8864PPPoE 发现 / 会话家庭宽带
0x88ccLLDP邻居发现,不上送 IP

抓包时看到不认识的 EtherType,先查这张表,再怀疑「网卡是不是开了某种 offload」。比如 0x88cc 的 LLDP 帧会被内核默认丢弃,但 tcpdump 能抓到——它不是故障,是交换机在发邻居信息。

4.2.23 MTU 的三本账:接口 MTU、IP 包长、路径 MTU

「MTU 1500」这句话至少有三种含义,混用是链路层最常见的沟通事故:

名称覆盖范围典型值查看方式改动的影响
接口 MTU链路层载荷,不含以太网头1500ip link show只影响本端收发,不改变路径
IP 包长IP 首部 + 载荷≤ MTUip route get、抓包超过 MTU 且 DF=1 时被拒
MSSTCP 载荷,不含 TCP/IP 首部MTU − 40ss -ti只影响 TCP,不影响 UDP
路径 MTU整条路径上最小的接口 MTU1500 / 1400 / 9000tracepath、ping -M do决定能否发大包
帧长以太网头 + 载荷 + FCS1518 / 1522tcpdump -e交换机按帧长限制,可能丢包

换算关系只有几条,记熟就够:

IPv4 + TCP:MSS   = MTU - 20 - 20 = MTU - 40
IPv4 + UDP:载荷  = MTU - 20 - 8  = MTU - 28
IPv6 + TCP:MSS   = MTU - 40 - 20 = MTU - 60
带一层 VLAN:严格链路上可用 MTU = 父接口 MTU - 4

以 1500 为例:TCP MSS 1460、UDP 最大载荷 1472、ping -s 最大 1472(ICMP 头 8 字节)。所以「ping -s 1472 通、-s 1473 不通」几乎就等价于「路径 MTU 是 1500」。

# 看接口 MTU 与状态
ip -br link show
ip link show dev eth0 | head -2
# 查路由给出的源地址、出口与 MTU
ip route get 192.0.2.1
# 用 DF 位扫路径 MTU:1472 通、1473 不通说明 PMTU=1500
ping -M do -s 1472 -c 1 192.0.2.1
ping -M do -s 1473 -c 1 192.0.2.1
# 自动发现路径 MTU(UDP 探测,不需要 root)
tracepath -n 192.0.2.1

ip link set dev eth0 mtu 1400 只改本端。对端如果仍是 1500,它会按 1500 发,你的网卡收到超长帧时可能直接丢——同网段 MTU 不一致不会产生 ICMP,只会静默丢大包。这也是 4.2.24 实验要验证的核心现象。

PMTUD(路径 MTU 发现)靠 DF 位和 ICMP Fragmentation Needed 工作:路由器发现出接口 MTU 小于包长且 DF=1,就回 ICMP type 3 code 4,包里带上下一跳 MTU;发送端据此调小。如果这个 ICMP 被防火墙挡住,就形成 PMTUD 黑洞:小包通、大包死、ping 正常但 SSH 卡在认证阶段。排查时先用 tracepath 或 ping -M do 找到「从哪个尺寸开始不通」,再查中间设备是否放行 ICMP。

VLAN 的 4 字节常被算错。Linux 创建 VLAN 接口时,MTU 默认继承父接口的 1500,不会自动减 4;带标签的帧在线上是 1500 + 14(以太网) + 4(VLAN) + 4(FCS) = 1522 字节。多数网卡和交换机支持 1522,所以能正常工作。但如果链路上有设备严格限制 1518,就必须把 VLAN 接口 MTU 设成 1496,使 1496 + 18 + 4 = 1518 落到限制内。「VLAN 接口 MTU 默认 1496」是一个流传很广的错误说法,用 ip link show 一看便知。

4.2.24 实验:用 ip link 改 MTU,用 tcpdump 量帧长

这个实验在一对 veth 上做三件事:确认 1500 时帧长 1514、把一端 MTU 改成 1400 后大包静默丢失、加 VLAN 后同一载荷的帧长变成 1518。它不依赖物理网卡,也不需要外网。

#!/usr/bin/env bash
# mtu_frame_lab.sh:veth 两端改 MTU、加 VLAN,量帧长
set -euo pipefail
NS_A=mtua
NS_B=mtub

cleanup() {
    ip netns del "$NS_A" 2>/dev/null || true
    ip netns del "$NS_B" 2>/dev/null || true
}
trap cleanup EXIT
cleanup
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "需要 root" >&2; exit 1; }

ip netns add "$NS_A"
ip netns add "$NS_B"
ip link add a0 type veth peer name b0
ip link set a0 netns "$NS_A"
ip link set b0 netns "$NS_B"
ip -n "$NS_A" addr add 10.30.0.1/24 dev a0
ip -n "$NS_B" addr add 10.30.0.2/24 dev b0
for n in "$NS_A" "$NS_B"; do ip -n "$n" link set lo up; done
ip -n "$NS_A" link set a0 up
ip -n "$NS_B" link set b0 up

echo "== 1) MTU 1500,1472 字节 ICMP 载荷 =="
ip netns exec "$NS_A" timeout 3 tcpdump -i a0 -e -nn -c 1 'icmp' &
CAP=$!
sleep 0.3
ip netns exec "$NS_A" ping -M do -s 1472 -c 1 -q 10.30.0.2 || true
wait "$CAP" || true

echo "== 2) 只把 B 端 MTU 改成 1400,再发 1472 =="
ip -n "$NS_B" link set b0 mtu 1400
ip netns exec "$NS_A" ping -M do -s 1472 -c 1 -W 1 10.30.0.2 || true
echo "== 2b) 改成 1372 字节载荷 =="
ip netns exec "$NS_A" ping -M do -s 1372 -c 1 -q 10.30.0.2 || true

echo "== 3) 恢复 1500,两端建 VLAN 100,再发 1472 =="
ip -n "$NS_B" link set b0 mtu 1500
ip -n "$NS_B" link add link b0 name b0.100 type vlan id 100
ip -n "$NS_B" addr add 10.31.0.2/24 dev b0.100
ip -n "$NS_B" link set b0.100 up
ip -n "$NS_A" link add link a0 name a0.100 type vlan id 100
ip -n "$NS_A" addr add 10.31.0.1/24 dev a0.100
ip -n "$NS_A" link set a0.100 up

ip netns exec "$NS_A" timeout 3 tcpdump -i a0 -e -nn -c 1 'vlan 100' &
CAP=$!
sleep 0.3
ip netns exec "$NS_A" ping -M do -s 1472 -c 1 -q 10.31.0.2 || true
wait "$CAP" || true
ip -n "$NS_B" -d link show b0.100 | sed -n '1,2p'

以 root 运行 chmod +x mtu_frame_lab.sh && sudo ./mtu_frame_lab.sh。预期输出(时间戳与 MAC 因环境而异):

== 1) MTU 1500,1472 字节 ICMP 载荷 ==
12:00:00.100000 02:...:01 > 02:...:02, ethertype IPv4 (0x0800), length 1514:
    10.30.0.1 > 10.30.0.2: ICMP echo request, id 1, seq 1, length 1480
== 2) 只把 B 端 MTU 改成 1400,再发 1472 ==
1 packets transmitted, 0 received, 100% packet loss, time 0ms
== 2b) 改成 1372 字节载荷 ==
1 packets transmitted, 1 received, 0% packet loss
== 3) 恢复 1500,两端建 VLAN 100,再发 1472 ==
12:00:00.300000 02:...:01 > 02:...:02, ethertype 802.1Q (0x8100), length 1518:
    vlan 100, p 0, ethertype IPv4 (0x0800), 10.31.0.1 > 10.31.0.2: ICMP echo request
6: b0.100: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP
    link/ether ... brd ff:ff:ff:ff:ff:ff
    vlan protocol 802.1Q id 100 <REORDER_HDR>

四个关键点:

  1. length 1514 是 14 字节以太网头 + 1500 字节 IP 包,不含 FCS。线上实际 1518 字节(加 4 字节 FCS),再加 8 字节前导码和 12 字节帧间隙。
  2. 第 2 步是同网段 MTU 不一致的典型表现:100% 丢包,没有 ICMP。A 按 1500 发,B 的 veth 在 is_skb_forwardable() 里按 mtu + hard_header_len 检查,超长直接丢。只有经过路由器时,才会收到 Frag needed。
  3. 第 3 步同一份 1472 字节载荷,帧长从 1514 变成 1518,多出来的 4 字节就是 802.1Q 标签。线上是 1522 字节(含 FCS),这就是「VLAN 把最大帧从 1518 推到 1522」的实证。
  4. b0.100 的 MTU 仍是 1500,没有自动减 4。ip -d link 同时显示 vlan protocol 802.1Q id 100 和 REORDER_HDR,后者控制收发时是否调整标签位置。

脚本用 trap cleanup EXIT 清理 netns。中途 Ctrl-C 也能清干净;如果没有这个 trap,残留的 netns 会让下一次运行 ip netns add 直接失败。

4.2.25 内核路径:从 dev_queue_xmit 到 ndo_start_xmit(VLAN 在哪插入)

4.2.14 跟的是收包路径,这一节跟发包路径。它回答一个具体问题:VLAN 标签到底在协议栈的哪一步被写进帧里,为什么 tcpdump 有时看不到它。

发包主干(Linux 6.8):

阶段函数文件做什么
IP 输出ip_finish_output2net/ipv4/ip_output.c查邻居,准备链路层头
邻居输出neigh_outputnet/core/neighbour.c解析 MAC,未解析时排队
入队dev_queue_xmit → __dev_queue_xmitnet/core/dev.c选 qdisc、加锁、统计
qdisc__dev_xmit_skb → __qdisc_runnet/sched/sch_generic.c排队或直接发送
驱动入口dev_hard_start_xmit → validate_xmit_vlan → ndo_start_xmitnet/core/dev.c按需插入 VLAN 标签,交给网卡
回收TX 完成中断 → dev_kfree_skb_any驱动释放 skb、唤醒队列

VLAN 接口不是「在以太网头上改一个字节」,而是一个独立的 netdevice,有自己的 ndo_start_xmit。发送时 VLAN 设备先给 skb 打上标签元数据,真正的 4 字节要等到驱动入口之前、或者由网卡插入:

/* net/8021q/vlan_dev.c,简化 */
static netdev_tx_t vlan_dev_hard_start_xmit(struct sk_buff *skb,
                                            struct net_device *dev)
{
    struct vlan_dev_priv *vlan = vlan_dev_priv(dev);
    u16 vlan_tci = vlan->vlan_id |
                   (vlan->vlan_priority << VLAN_PRIO_SHIFT);

    __vlan_hwaccel_put_tag(skb, vlan->vlan_proto, vlan_tci);
    skb->dev = vlan->real_dev;                /* 改回真实设备 */
    return dev_queue_xmit(skb);               /* 再走一遍 qdisc */
}

这段代码有三个信息量:

  • __vlan_hwaccel_put_tag() 只写元数据(skb->vlan_proto、skb->vlan_tci),不改帧。真正的插入发生在 validate_xmit_vlan():它检查网卡是否支持 NETIF_F_HW_VLAN_CTAG_TX,不支持才调 __vlan_hwaccel_push_inside() → __vlan_insert_tag(),把 4 字节压进 data。
  • skb->dev 被改成 real_dev,然后重新 dev_queue_xmit()。这意味着 VLAN 设备自己通常没有 qdisc 排队,真正的排队发生在父设备上。
  • 硬件卸载优先:如果网卡能插标签,内核只保留元数据,由网卡在发送时插入。此时在主机内任何抓包点都看不到标签,只有在交换机镜像口或对端才能看到。

收包方向对称:__netif_receive_skb_core() 先调 skb_vlan_untag() 把标签从帧里摘出来,存在 skb->vlan_tci;再调 vlan_do_receive() 找到对应的 VLAN 子接口,最后 deliver_skb() 把 skb 交给 vlan_dev,由它进入 ip_rcv()。

网卡 RX → __netif_receive_skb_core
  → skb_vlan_untag            /* 摘标签,记录 vlan_tci */
    → vlan_do_receive         /* 找 vlan_dev */
      → deliver_skb           /* 改 skb->dev 为 vlan_dev */
        → ip_rcv              /* 进入网络层 */
为什么 `tcpdump -i eth0` 抓不到 VLAN 标签

三种可能:网卡做了 VLAN offload(标签在硬件里,抓包点看不到);抓的是 VLAN 子接口而不是父接口(子接口上的 skb 已经剥了标签);或者交换机在出方向剥了标签。判断方法是在父接口和子接口各抓一次,对比 -e 输出里的 ethertype。

4.2.26 本章新增陷阱:八个容易复现的错误

编码、CSMA/CD、MTU 与 VLAN 的八个新坑
  1. 把「带宽」当「符号率」:1000BASE-T 的符号率只有 125 MBd,靠 4 对线和 PAM-5 才到 1 Gbps。看到「125 MHz 的线缆带宽」就断言跑不了千兆,是把两个概念混了。
  2. 认为 64 字节最小帧只是为了「对齐」:它是 2τ × C 解出来的冲突检测窗口。全双工后冲突检测不需要了,但最小值作为兼容约束保留。
  3. 把 tx_late_collisions 当普通冲突:延迟冲突说明帧已经发完才检测到冲突,不会重传,直接表现为大包丢失。先查双工和线长。
  4. 用 ip link set mtu 改了一端就以为路径 MTU 变了:MTU 是接口属性,路径 MTU 由整条路径的最小值决定。同网段不一致只会静默丢包。
  5. 把 ping -s 的数当 IP 包长:-s 是 ICMP 载荷,IPv4 要加 28 才是 IP 包长;-s 1472 对应 1500。
  6. 认为 VLAN 接口的 MTU 会自动减 4:Linux 默认继承父接口的 1500,带标签帧线上是 1522。严格 1518 的链路要手动设 1496。
  7. 在 lo 上验证 MTU 与分片:回环 MTU 是 65536,不出物理链路,永远看不到 1500 的限制。用 veth 或 netns。
  8. 看到 0x8100 就报 VLAN ID:0x8100 只是 TPID,VID 在 TCI 的低 12 位;QinQ 外层是 0x88a8,两层标签要连读两次。

4.2.27 本章出口检查(增补)

检查项达标线没达标去哪
区分比特率、符号率与编码开销能算出 1000BASE-T 与 10GBASE-R 的线路符号率4.2.20
推导 64 字节最小帧能写出 L_min = 2τ × C 并解释时隙4.2.21
逐字段说出「谁写、何时改」能指出源 MAC 在每一跳被改写4.2.22
分清接口 MTU、IP 包长、MSS、路径 MTU能算 IPv4/TCP 与 IPv4/UDP 的最大载荷4.2.23
用 veth 复现 MTU 不一致与 VLAN 帧长能解释 1514 与 1518 的差值4.2.24
跟一遍 VLAN 的发送与接收路径能指出 __vlan_insert_tag 与 offload 的分界4.2.25

母本对应

母本章节本书落点程度
W(谢希仁)第 2 章 物理层(介质、编码、信道复用)4.2.1精选:介质与速率的权衡,编码只留概念
W(谢希仁)第 3 章 数据链路层(帧、差错检测、PPP、CSMA/CD、交换机)4.2.2–4.2.7完整:帧格式、CRC、自学习、VLAN
W(谢希仁)第 9 章 无线网络和移动网络(802.11、移动 IP)4.2.8背景:只取 CSMA/CA、隐藏终端、四地址
K(Kerrisk)第 58 章 58.3(数据链路层)4.2.2、4.2.6对照:分层视角下的链路层职责
N(深入理解 Linux 网络)第 10 章 10.2–10.4(veth、netns、Bridge)4.2.5、4.2.6前置:为 4.12 的容器网络铺垫设备模型
W(谢希仁)第 3 章(数据链路层:以太网、VLAN,PDF p82–125)4.2.13–4.2.17补充:位布局与实验
N(深入理解 Linux 网络)第 2 章 2.2(网卡到协议栈,PDF p27–49)4.2.14补充:NAPI 与 net_device
W(谢希仁)第 2 章(物理层:编码、调制、奈氏准则与香农公式,PDF p53–81)4.2.20补充:符号率、编码效率与线路开销
W(谢希仁)第 3 章 3.2–3.4(CSMA/CD、以太网帧、VLAN,PDF p82–125)4.2.21–4.2.24补充:时隙推导、字段归属与 MTU 实验
N(深入理解 Linux 网络)第 4 章 4.4(用户进程到网卡的发送路径,PDF p108–130)4.2.25补充:dev_queue_xmit 与驱动入口
N(深入理解 Linux 网络)第 2 章 2.2(收包分用与 VLAN 处理,PDF p27–49)4.2.25补充:收包方向的标签剥离

下一站:链路层把帧送到了下一跳,可它怎么知道「下一跳之后往哪走」?进入 4.3 网络层:IP、路由与分片,看 IP 首部、路由表和最长前缀匹配。