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.1 网络分层与报文之旅

本章回答

一个字节从浏览器发出到对端进程收到,中间被套上了几层「信封」?为什么协议要分成四层而不是一层?MTU 和 MSS 差在哪,为什么它能让一次本该成功的请求卡住?时延、带宽、吞吐、丢包这四个数怎么组合起来描述一条链路?

读完你会

用封装图解释任意一个字段「归谁管」;用 C 结构体把三层首部的长度与偏移量出来;用 tcpdump 在同一次抓包里看到以太网、IP、TCP 三层首部;用 ping -M do 测出路径 MTU。

第 1 篇讲系统调用时,read 和 write 的对象是文件;到了网络,同样的两个系统调用面对的是一条双向字节流,对端在另一台机器上。中间发生了什么,是这一篇要回答的问题。第 4 篇的顺序是「协议 → 内核 → 优化」:先用六章把协议讲清楚,再用五章把内核路径拆开,最后一章把两边合起来做优化。

这一章是全篇的地基。它不教你配置任何东西,只做三件事:给你一张分层地图,让你知道每个字段归哪一层管;带你走一遍报文从应用到网卡再回来的完整旅程;教你用抓包工具把这张地图和真实字节对上。后面十一章反复回到这张地图上——讲 TCP 握手时回到运输层,讲 sk_buff 时回到封装,讲容器网络时回到「每一层都有一个设备」。

读法建议:开一个终端,把文中的 ip、ss、tcpdump 命令跑一遍。所有命令都在本机回环或自建 network namespace 里可复现,不需要外网。结论以 Linux 6.8、x86-64 为背景。

4.1.1 分层解决的是「变化隔离」

先看一个具体问题:你要把一段 JSON 发给另一台机器。这中间至少有四件互相独立的事要做。

  1. 把 JSON 变成字节,并让对端知道「这是 JSON,长度是多少」。
  2. 找到对方的机器,并让中间的路由器知道该往哪转发。
  3. 保证字节按序到达,丢了能重发,发太快了能刹车。
  4. 把字节变成电信号或光信号,送到网线上。

这四件事的变化频率完全不同。换一个应用协议(JSON 换 Protobuf),第 2、3、4 步一行代码都不用改;换一块网卡(千兆换万兆),第 1、2、3 步也不用改。分层的目的不是把问题切小,而是把变化隔离开:让每一层只依赖下一层提供的接口,而不依赖下一层怎么实现。

这个「接口」在协议栈里有个正式名字:服务原语。上层调用 send 时不需要知道下面是以太网还是 Wi-Fi,只需要知道「给我一个能把字节送出去的东西」。反过来,下层交付数据时也不关心上面是 HTTP 还是 SSH,只负责把一段字节交给正确的上层实体。

定义 4.1.1 · 协议、实体与对等层

协议(protocol) 是同层对等实体之间通信规则的集合,包含语法(字段格式)、语义(字段含义与动作)、时序(先后顺序)三要素。实体(entity) 是执行某层协议的活动元素,在实现里通常是一个函数集合或一个内核模块。对等层(peer layer) 指两台机器上处于同一层的实体,它们之间「逻辑上」直接通信,实际上数据要穿过下面所有层。

「逻辑上直接通信」这句话是理解分层的关键。两台机器的 TCP 实体看起来在直接对话,实际上每个 TCP 段都被装进 IP 包、装进以太网帧,经过交换机、路由器,一层层拆开再装回去。对等层之间的通信是虚拟的,只有最底层的物理介质上才有真实的信号传播。

为什么路由器只看到第三层

路由器要做的决定是「这个包往哪个口发」,这个信息在 IP 首部的目的地址里。它不需要知道这个包是 HTTP 还是 SSH,所以它只拆到第三层就重新封装转发。这正是分层的收益:中间设备只需要实现它关心的那几层。交换机同理,只看第二层的 MAC 地址。

分层也有代价:每一层都要复制或搬移一次数据,每一层都要加自己的首部,每一层都可能引入自己的队列和延迟。所以高性能场景里会出现「跨层优化」——比如 TSO 让 TCP 把 64 KB 的段直接交给网卡,由硬件切分,省掉协议栈里的分段循环。跨层优化不是否定分层,而是在明确知道边界在哪之后,有意识地绕过它。

4.1.2 OSI 七层与 TCP/IP 四层:两张地图

教科书里有两种分层模型。OSI 七层是 ISO 提出的参考模型,理论上更完整;TCP/IP 四层是实际互联网跑出来的模型,工程上更常用。两者的对应关系必须记牢,否则读文档时会把「表示层」和「应用层」混为一谈。

OSI 七层TCP/IP 四层典型协议Linux 里的落点
应用层 / 表示层 / 会话层应用层HTTP、DNS、TLS、SSH用户态进程 + 库(如 OpenSSL)
运输层运输层TCP、UDP、QUIC内核 net/ipv4/tcp*.c、udp.c
网络层网络层IPv4、IPv6、ICMP、ARP*内核 net/ipv4/、net/core/
数据链路层网络接口层以太网、Wi-Fi、PPP内核驱动 + net/core/dev.c
物理层网络接口层双绞线、光纤、无线电网卡硬件与 PHY 芯片

这张表的用法是定位,不是背诵。遇到一个字段,先问它在哪一行,就知道该去查哪个模块。表里 ARP 打了星号:它工作在链路层和网络层之间,把 IP 地址解析成 MAC 地址,严格说属于网络层的辅助协议,但报文直接封装在以太网帧里。

OSI 的表示层和会话层在 TCP/IP 里没有独立实现,它们的功能被并进了应用层。TLS 就是一个例子:它做加密和身份认证(表示层职责),但作为一个用户态库存在,压在自己的记录层协议上,跑在 TCP 之上。所以「TLS 是第几层」这个问题没有唯一答案,取决于你按哪个模型数。

各层的职责可以压缩成一句话,这四句话在排障时最有用:

层一句话职责出问题时的典型症状
应用层定义「这些字节是什么意思」返回 500、JSON 解析失败、证书过期
运输层在两端进程之间建立「哪条流」端口不通、连接被拒、CLOSE_WAIT 堆积
网络层把包从源主机送到目的主机ping 不通、路由环路、TTL 耗尽
链路层把帧送到同一个局域网内的下一跳ARP 解析失败、交换机环路、CRC 错误

这张表是从「现象反推层」的入口。看到「连接被拒绝」先想运输层,看到「网络不可达」先想网络层,看到「ARP 无响应」先想链路层。不要一上来就抓包,先用这张表把范围缩小。

一个常见的面试题

「HTTP 是哪一层的协议?」标准答案是应用层。但如果追问「HTTP/3 呢」,答案会变得微妙:HTTP/3 跑在 QUIC 上,QUIC 跑在 UDP 上,而 QUIC 自己实现了可靠传输和拥塞控制。按功能它横跨运输层和应用层。这类问题的价值不在于答案,而在于逼你说清楚「分层是按功能划分的,不是按代码位置划分的」。

4.1.3 封装:一层套一层的首部

数据从上往下走时,每一层在前面加一个首部(有时还在后面加尾部),这个过程叫封装(encapsulation)。

应用层数据                          "GET / HTTP/1.1\r\nHost: ...\r\n\r\n"
       │
       ▼  加 TCP 首部(20 字节起)
┌──────────────┬─────────────────────────────────────────────┐
│ TCP 首部      │ 应用数据                                     │
│ sport dport  │                                             │
│ seq  ack     │                                             │
└──────────────┴─────────────────────────────────────────────┘
       │
       ▼  加 IP 首部(20 字节起)
┌────────────┬──────────────┬──────────────────────────────────┐
│ IP 首部     │ TCP 首部      │ 应用数据                          │
│ src  dst   │              │                                  │
│ proto ttl  │              │                                  │
└────────────┴──────────────┴──────────────────────────────────┘
       │
       ▼  加以太网首部 + 尾部(14 + 4 字节)
┌─────────┬────────────┬──────────────┬──────────────┬─────────┐
│ 以太网   │ IP 首部     │ TCP 首部      │ 应用数据      │ FCS     │
│  dst src │            │              │              │ CRC32   │
│ type     │            │              │              │         │
└─────────┴────────────┴──────────────┴──────────────┴─────────┘
  ◀────────── 每层只关心自己那一段 ──────────▶

读这张图要抓住两点。第一,首部是给对等层看的,不是给上层看的。TCP 首部里的序号只有对端的 TCP 实体关心,应用读到的字节流里根本没有它。第二,封装是嵌套的,不是并列的:以太网帧的载荷整个就是 IP 包,IP 包的载荷整个就是 TCP 段。所以一个 1500 字节的以太网帧,真正能装应用数据的只有 1500 − 20 − 20 = 1460 字节(不算选项)。

首部的存在还解释了一个常见疑问:为什么小包传输效率低。一个 10 字节的 HTTP/2 帧,加上 TCP 和 IP 首部是 50 字节,加以太网是 68 字节,有效载荷占比不到 15%。这不是浪费,是分层的成本。高并发服务里减少往返次数(而不是减少字节数)之所以收益最大,就是因为每个往返都带着一整套首部。

定义 4.1.2 · 封装、解封装与协议数据单元

封装(encapsulation) 是上层数据加上本层首部后交给下层的过程。解封装(decapsulation) 是接收端逐层去掉首部、把载荷交给上层的过程。每层处理的数据单位有专门的名字:应用层叫报文(message),运输层叫段(segment,TCP)或数据报(datagram,UDP),网络层叫包(packet),链路层叫帧(frame)。日常口语里混着叫「包」没问题,但写文档和查 RFC 时要分清。

本节常见误解
  1. 「MTU 是 1500,所以一次能发 1500 字节数据」。错。1500 是帧的载荷,要减去 IP 和 TCP 首部才是数据量。IPv4 无选项时是 1460,IPv6 因为首部固定 40 字节,通常是 1440。
  2. 「分片是 TCP 做的」。错。TCP 只按 MSS 切段,分片是 IP 层的事,发生在包已经交给网络层之后。看到分片应该去查 MTU,而不是查 TCP。
  3. 「首部只是元数据,不影响性能」。错。40 字节首部对 1460 字节数据是 2.7% 的开销;对小包(比如 10 字节的请求)首部占比接近 80%。
  4. 「分层意味着实现也分开了」。不一定。Linux 里 sendmsg 一路调到驱动可能全在同一个进程上下文里完成,没有线程切换。分层的边界是逻辑边界,不是调度边界。

4.1.4 分用:靠首部里的标识字段逐层上交

从下往上走时,每一层剥掉自己的首部,把剩下的内容交给上层,这个过程叫分用(demultiplexing)。分用的依据是首部里的标识字段,一层一层往上找。

层分用依据取值示例在内核里由谁做
链路层以太网类型字段0x0800 IPv4,0x86dd IPv6,0x0806 ARP__netif_receive_skb_core 里的 ptype 链
网络层IP 首部的协议号6 TCP,17 UDP,1 ICMPip_local_deliver_finish 查 inet_protos
运输层目的端口号80、443、8080tcp_v4_rcv 查 ehash,udp_rcv 查 udp_hash
应用层端口已被 socket 占用由 bind/listen 决定sk_receive_queue + 唤醒等待者

四层里有三层靠「一个整数字段」分用,只有应用层靠 socket。这解释了一个重要事实:端口是运输层唯一的多路复用标识。一台机器上所有 TCP 服务共享同一个 IP 地址,靠端口区分;如果没有端口,内核收到一个段就不知道该交给谁。

端口号是 16 位,范围 0–65535,分三段:

  • 0–1023:知名端口(well-known),绑定需要 CAP_NET_BIND_SERVICE 或 root。
  • 1024–49151:注册端口(registered),IANA 登记但不强制。
  • 49152–65535:临时端口(ephemeral),客户端 connect 时由内核自动分配。

/proc/sys/net/ipv4/ip_local_port_range 控制临时端口范围,默认通常是 32768 60999,约 2.8 万个。这意味着一台客户端对同一个目的 IP:端口,最多只能同时发起约 2.8 万条连接——这个数字在 4.11 讲百万连接时会成为关键约束。

# 看当前临时端口范围
cat /proc/sys/net/ipv4/ip_local_port_range
# 看哪些端口在监听,以及各自属于哪个进程
ss -lntp
# 看某个端口的连接来自哪些地址
ss -tn state established '( sport = :8080 )'

4.1.5 一次 HTTP 请求的报文之旅

把地图和真实路径对上,最好的办法是跟着一个请求走一遍。假设你在本机跑了一个服务,监听 127.0.0.1:8080,然后用 curl 请求它。

第一段:应用层生成字节。 curl 把请求行、首部、空行拼成一个字符串。此时它还只是一段内存里的字节,没有任何网络语义。

第二段:运输层分段。 write 系统调用进入内核,TCP 把这段字节放进发送缓冲区。如果字节数超过 MSS,就切成多个段;每段分配一个序号。序号不是从 0 开始,而是握手时随机选的初始序号(ISN),这一点在 4.5 会讲原因。

第三段:网络层加地址。 每个 TCP 段被装进一个 IP 包,填上源 IP 和目的 IP。如果目的地址是 127.0.0.1,内核查路由表会发现出接口是 lo,一个虚拟的回环设备。这一步决定了后面走的是「本机捷径」而不是真的网卡。

第四段:链路层封装。 回环设备有全 0 的 MAC(addr_len=6),以太网首部仍是 14 字节,类型字段 0x0800。真正的物理网卡上,这一步会填上下一跳的 MAC 地址(由 ARP 得到)。

第五段:驱动与网卡。 对回环设备,ndo_start_xmit 直接把 skb 挂到接收队列,绕过了 DMA 和硬件;对真实网卡,驱动把 skb 的物理地址写进 TX ring 的描述符,网卡 DMA 读走后发上线路。

第六段:对端收包。 数据从网卡进 RX ring,触发硬中断,NAPI 轮询把帧取出来,逐层分用,最后按目的端口找到 socket,把数据放进 sk_receive_queue,唤醒阻塞在 epoll_wait 或 read 上的进程。

这条路径里有两个地方最容易被忽视。一是回环不是「不经过协议栈」,它完整走了 IP 和 TCP,只是跳过了链路层和硬件,所以本机通信一样有 TCP 开销,只是没有网卡开销。二是发送和接收可能发生在同一个 CPU 上,本机回环甚至会直接在发送上下文里调用接收函数,这就是 4.11 要讲的「本机捷径」。

# 起一个最小服务端(任意目录,Python 3 自带)
python3 -m http.server 8080 --bind 127.0.0.1 &
# 另开一个终端:看这次请求的完整 TCP 交互
sudo tcpdump -i lo -nn -c 12 'tcp port 8080'
# 再发请求
curl -sS -o /dev/null http://127.0.0.1:8080/

预期能看到 Flags [S]、Flags [S.]、Flags [.]、Flags [P.] 依次出现——这就是三次握手加一次数据推送,4.5 会逐行解释。

报文旅程的六步排查法
  1. 应用有没有发出字节:strace -e trace=write,sendto,sendmsg -f <pid>,看返回值。
  2. TCP 有没有发出去:ss -tin state established,看 Send-Q 是否堆积、cwnd 是否被压到很小。
  3. IP 有没有选对路:ip route get <目的地址>,一条命令给出出接口、源地址、下一跳。
  4. 链路有没有通:ip neigh show <下一跳> 看 ARP 是否解析成功。
  5. 对端有没有收到:在对端 tcpdump -i any -nn 'tcp port <端口>',比对 seq。
  6. 对端应用有没有读走:ss -lnt 看 Recv-Q,队列堆满说明应用太慢而不是网络太慢。

4.1.6 用 C 把首部量一遍

背字段表不如自己描一遍。下面这段程序不依赖内核私有头文件,手工定义三层首部,把「偏移量」和「长度」用 printf 打出来。它能在任何 POSIX 系统上编译运行,编译期还会用 _Static_assert 卡住长度,一旦结构体写错就编译失败。

/* pkt_layout.c:手工描出三层首部,验证封装的偏移与长度 */
#include <arpa/inet.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>

struct eth_hdr {              /* RFC 894:14 字节 */
    uint8_t  dst[6];
    uint8_t  src[6];
    uint16_t ethertype;
} __attribute__((packed));

struct ipv4_hdr {             /* RFC 791:固定 20 字节 */
    uint8_t  ver_ihl;
    uint8_t  tos;
    uint16_t total_len;
    uint16_t id;
    uint16_t frag_off;
    uint8_t  ttl;
    uint8_t  proto;
    uint16_t checksum;
    uint32_t src;
    uint32_t dst;
} __attribute__((packed));

struct tcp_hdr {              /* RFC 9293:固定 20 字节 */
    uint16_t sport;
    uint16_t dport;
    uint32_t seq;
    uint32_t ack;
    uint8_t  data_off;
    uint8_t  flags;
    uint16_t window;
    uint16_t checksum;
    uint16_t urgptr;
} __attribute__((packed));

_Static_assert(sizeof(struct eth_hdr) == 14, "eth 首部必须是 14 字节");
_Static_assert(sizeof(struct ipv4_hdr) == 20, "IPv4 首部必须是 20 字节");
_Static_assert(sizeof(struct tcp_hdr) == 20, "TCP 首部必须是 20 字节");

int main(void)
{
    static unsigned char frame[128];
    const char *payload = "GET / HTTP/1.1\r\n\r\n";
    const size_t plen = strlen(payload);
    struct eth_hdr  *eth = (struct eth_hdr *)frame;
    struct ipv4_hdr *ip  = (struct ipv4_hdr *)(frame + sizeof(*eth));
    struct tcp_hdr  *tcp = (struct tcp_hdr *)((unsigned char *)ip + sizeof(*ip));
    unsigned char   *data = (unsigned char *)tcp + sizeof(*tcp);

    memset(frame, 0, sizeof frame);
    eth->ethertype = htons(0x0800);          /* 分用:交给 IPv4 */
    ip->ver_ihl    = (4u << 4) | 5u;         /* v4,20 字节首部 */
    ip->ttl        = 64;
    ip->proto      = 6;                      /* 分用:交给 TCP */
    ip->total_len  = htons((uint16_t)(sizeof(*ip) + sizeof(*tcp) + plen));
    inet_pton(AF_INET, "127.0.0.1", &ip->src);
    inet_pton(AF_INET, "127.0.0.1", &ip->dst);
    tcp->sport     = htons(51234);
    tcp->dport     = htons(8080);
    tcp->data_off  = 5u << 4;                /* 20 字节首部 */
    tcp->flags     = 0x18;                   /* PSH | ACK */
    tcp->window    = htons(65495);
    memcpy(data, payload, plen);

    printf("ethernet off=%2zu len=%2zu type=0x%04x\n", (size_t)0,
           sizeof(*eth), ntohs(eth->ethertype));
    printf("ipv4     off=%2zu len=%2zu proto=%u\n",
           (size_t)((unsigned char *)ip - frame), sizeof(*ip), ip->proto);
    printf("tcp      off=%2zu len=%2zu dport=%u\n",
           (size_t)((unsigned char *)tcp - frame), sizeof(*tcp), ntohs(tcp->dport));
    printf("payload  off=%2zu len=%2zu\n", (size_t)(data - frame), plen);
    printf("frame    total=%zu bytes\n", (size_t)(data - frame) + plen);
    printf("mss      ipv4=%zu ipv6=%zu\n", 1500 - sizeof(*ip) - sizeof(*tcp),
           1500 - 40 - sizeof(*tcp));
    return 0;
}

编译并运行:

cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g pkt_layout.c -o pkt_layout
./pkt_layout

输出与封装图逐行对应:

ethernet off= 0 len=14 type=0x0800
ipv4     off=14 len=20 proto=6
tcp      off=34 len=20 dport=8080
payload  off=54 len=18
frame    total=72 bytes
mss      ipv4=1460 ipv6=1440

关键在 off 那一列:14、34、54。每加一层,偏移量就增加上一层的首部长度。偏移量是理解封装最直接的证据——它告诉你「第 35 个字节开始是 TCP 段」,而不是「大概在中间」。

程序里有两个细节值得注意。ver_ihl 的高 4 位是版本、低 4 位是首部长度(单位 4 字节),所以 0x45 表示 IPv4、20 字节首部;如果带 4 字节选项,就是 0x46,共 24 字节。data_off 同理,高 4 位是 TCP 首部长度,5 << 4 表示 20 字节。长度字段的单位是 4 字节而不是 1 字节,这是很多人第一次读 RFC 时会卡住的地方。

例 4.1.1 · 用偏移量手算一个带选项的包

假设 TCP 开启时间戳选项(10 字节)并填充到 4 字节对齐,实际 12 字节。那么 TCP 首部是 20 + 12 = 32 字节,data_off = 32 / 4 = 8,二进制 1000,整个字节是 0x80。

以太网 14 + IP 20 = 34,TCP 首部 32 字节,所以应用数据从偏移 66 开始。若 MTU 1500,一个帧最多 1500 字节 IP 包,应用数据上限是 1500 − 20 − 32 = 1448。握手通告的 MSS 仍是 1460(MSS 只计 TCP 数据,不含选项);带 12 字节时间戳时,Linux 的 tcp_current_mss 会把有效数据段降到 1448。抓包里 mss 1460 与「每段 1448 字节数据」并不矛盾。

4.1.7 MTU、路径 MTU 与分片

MTU 是这一篇里最容易引发「玄学故障」的参数。典型症状是:小请求正常,大响应卡死;ping 通,但 curl 超时;SSH 能登录,scp 传大文件断掉。原因通常是路径上某一段链路的 MTU 小于发送端认为的值,而 ICMP 的「需要分片」消息被防火墙挡掉了。

先看本机 MTU:

ip link show lo | head -2
ip -br link show

回环的 MTU 是 65536,不是 1500——这也是为什么本机测试永远发现不了 MTU 问题。真实网卡通常是 1500,云环境的 VXLAN 网卡可能是 1450(减去 50 字节封装开销)。

测路径 MTU 的标准做法是用 ping 加「禁止分片」标志,二分查找最大可通尺寸:

# -M do 禁止分片,-s 指定 ICMP 载荷大小(不含 8 字节 ICMP 首部和 20 字节 IP 首部)
for s in 1472 1400 1200; do
  ping -M do -s $s -c 1 -W 1 192.0.2.1 >/dev/null 2>&1 \
    && echo "$s ok (MTU=$((s+28)))" || echo "$s too big"
done

1472 + 8 + 20 = 1500,所以 -s 1472 通就说明路径 MTU 至少 1500。这个式子要记住:ping 的 -s 是 ICMP 载荷,加 8 字节 ICMP 首部、20 字节 IP 首部才是 IP 包大小。

当 IP 包大于出接口 MTU 时,IPv4 有两个选择:分片,或者置 DF 位并返回 ICMP Fragmentation Needed。现代实现默认走后者(路径 MTU 发现,PMTUD),因为分片的重组成本高、且丢一片就整包重传。Linux 的相关开关是 /proc/sys/net/ipv4/ip_no_pmtu_disc,默认 0 表示启用 PMTUD。

分片本身长这样:一个 3000 字节的 IP 包(20 字节首部 + 2980 字节载荷)走 1500 MTU 的链路,会被切成三片:第一片 MF=1, offset=0,载荷 1480;第二片 MF=1, offset=185,载荷 1480;第三片 MF=0, offset=370,载荷 20。片偏移的单位是 8 字节,所以除首片外每片载荷必须是 8 的倍数。用 tcpdump 能直接看到这个标志:

# 在本机 netns 里发一个大包,制造分片
sudo ip netns add lab
sudo ip netns exec lab ip link set lo up
sudo ip netns exec lab ping -M dont -s 3000 -c 1 127.0.0.1
# 另一个终端抓分片:IP 首部第 6 字节起的 2 字节是标志 + 片偏移
sudo tcpdump -i any -nn 'ip[6:2] & 0x3fff != 0'

最后一条过滤表达式读作「标志位或片偏移不为 0」,即「这是分片或还有更多分片」。

关于 MTU 的四个坑
  1. ping 通但业务不通。ICMP 的 Fragmentation Needed 被中间防火墙丢了,PMTUD 失效,发送端一直以为能发 1500。解法是调小 MTU 或放行 ICMP type 3 code 4。
  2. 隧道越多,MTU 越小。VXLAN 减 50,GRE 减 24,IPsec 减 50–70。容器网络里频繁出现「宿主机通、Pod 不通」,多半是 overlay 的 MTU 没调。
  3. ip link set mtu 只改本机。路径上任何一跳的 MTU 都可能更小,改本机只解决「本机发不出去」,不解决「中间过不去」。
  4. IPv6 没有路由器分片。中间设备只会回 ICMPv6 Packet Too Big,如果这个报文被挡,表现是「TCP 能握手但大包永远卡住」,比 IPv4 更难查。
为什么 IPv6 取消了路由器分片

IPv4 允许中间路由器分片,这意味着路由器要维护重组状态、要处理分片攻击(大量不完整分片耗尽内存)。IPv6 把分片责任完全推给源端:中间路由器遇到过大包只能回 ICMPv6 Packet Too Big,由源端自己调整。这个设计把「状态」从网络核心推到了边缘,符合互联网的端到端原则。

4.1.8 时延、带宽、吞吐与丢包

描述一条网络路径,四个数就够了。它们的组合决定了你该优化什么。

指标定义典型值(实验环境)观测命令
传播时延信号在介质中走一趟的时间,受光速和距离限制同城 0.5 ms,跨洲 70 msping 的 RTT / 2
传输时延把比特推上链路的时间 = 包长 / 带宽1500 B / 1 Gbps ≈ 12 µs计算,不易直测
排队时延在 qdisc 或交换机缓冲区里等待的时间0 到数百 ms,随负载剧烈变化tc -s qdisc show
处理时延协议栈、驱动、应用的处理时间本机回环 20–60 µsperf、bpftrace

带宽(bandwidth) 是链路理论上限,单位 bit/s;吞吐(throughput) 是实际达到的速率,永远小于等于带宽。两者的差距来自协议开销、丢包、窗口限制和接收端处理能力。

一个必须记住的公式是带宽时延积(BDP):

BDP = 带宽 × 往返时延

一条 1 Gbps、RTT 40 ms 的链路,BDP = 1e9 × 0.04 / 8 = 5 MB。意思是:发送端必须能「在途」5 MB 数据,才能填满这条链路。如果 TCP 窗口只有 64 KB,吞吐上限就是 64 KB / 40 ms ≈ 13 Mbps,只有带宽的 1.3%。这就是为什么长肥管道(long fat network)上必须开窗口缩放——4.6 会讲。

丢包的影响取决于协议。对 TCP,丢包被解释为拥塞信号,触发重传和窗口收缩;对 UDP,丢包就是丢了,应用自己决定要不要重传。1% 的随机丢包对 TCP 吞吐的伤害远大于 1%,因为拥塞控制会把窗口砍半,而恢复要花好几个 RTT。

本机能看到的最直接的两个数,是 ss -ti 里的 rtt 和 retrans:

# 建一条本机连接,然后观察它的 RTT 与重传
python3 -m http.server 8080 --bind 127.0.0.1 &
curl -sS -o /dev/null http://127.0.0.1:8080/ &
ss -tin dst 127.0.0.1:8080 | head -4

输出里的 rtt:0.05/0.02 是「平滑 RTT / 方差」,单位毫秒;retrans:0/0 是「已重传段数 / 总段数」。这两个数在 4.6 会展开讲,这里先记住它们是判断「网络慢」还是「应用慢」的第一手证据。

例 4.1.2 · 一次跨洋请求到底慢在哪

从上海到法兰克福,直线距离约 9000 km,光纤中光速约 2×10⁸ m/s,单程传播时延 45 ms,RTT 至少 90 ms。一个 HTTPS 请求需要:TCP 握手 1 RTT + TLS 1.3 握手 1 RTT + HTTP 请求/响应 1 RTT = 270 ms,这还没算服务器处理时间。

如果改用 TLS 1.2,握手要 2 RTT,总时间变成 360 ms。如果 TCP 用了 TCP_FASTOPEN 且 TLS 用了 session resumption,可以压到 1 RTT 左右。这解释了为什么 CDN 的第一价值是「把 RTT 从 90 ms 降到 5 ms」,而不是「带宽更大」。

4.1.9 抓包:把分层地图和真实字节对上

tcpdump 是这个领域最重要的观测工具。它的原理值得先说清楚:它在链路层注册一个 packet_type,通过 AF_PACKET socket 收到进入协议栈之前的帧副本。所以在 ip_rcv 里被丢弃的包,tcpdump 照样能抓到;反过来,被网卡硬件过滤掉的包(比如目的 MAC 不是本机的单播),它抓不到。

先看一个最完整的抓包命令,把三层首部一次打出来:

# -e 显示以太网首部,-n 不做名字解析,-nn 端口也不解析,-X 打十六进制
sudo tcpdump -i lo -enn -c 4 -X 'tcp port 8080'

输出里每一行都对应封装图的一层:

12:01:02.345678 00:00:00:00:00:00 > 00:00:00:00:00:00, ethertype IPv4 (0x0800), length 74:
    127.0.0.1.51234 > 127.0.0.1.8080: Flags [S], seq 3123456789, win 65495,
    options [mss 65495,sackOK,TS val 1234567 ecr 0,nop,wscale 7], length 0
  • 00:00:00:00:00:00 > 00:00:00:00:00:00 是以太网首部。回环设备没有真实 MAC,全是 0。
  • ethertype IPv4 (0x0800) 是分用依据,告诉内核把载荷交给 IPv4。
  • 127.0.0.1.51234 > 127.0.0.1.8080 是 IP 首部的地址加 TCP 首部的端口。
  • Flags [S] 是 TCP 标志位,S 表示 SYN。
  • options [mss 65495,...] 是 TCP 选项,回环的 MSS 等于 MTU 减首部。

这里 mss 65495 值得注意:它等于 65536 减去 IP 首部、TCP 首部和一点对齐余量。如果这是一块 1500 MTU 的网卡,这里会是 1460。同一个协议在不同设备上的参数不同,抓包看到的值才是真相。

常用的过滤表达式按层组织,记住几个就够日常用:

sudo tcpdump -i any -nn 'tcp port 443 and host 192.0.2.10'   # 按五元组过滤
sudo tcpdump -i eth0 -nn 'icmp or arp'                        # 按协议过滤
sudo tcpdump -i eth0 -nn 'tcp[tcpflags] & tcp-syn != 0'       # 只抓 SYN
sudo tcpdump -i eth0 -nn -w /tmp/cap.pcap 'port 8080'         # 存盘后用 Wireshark 看

tcp[13] & 2 != 0 是最原始的 SYN 过滤写法,因为 SYN 标志位是 TCP 首部第 13 字节的 bit 1。tcp-syn 只是它的可读别名。理解这一点,就能自己写出任意标志位组合的过滤器。

抓包本身有代价:每个包都要复制到用户态,高流量下会丢包并推高 CPU。生产环境优先用 -w 存盘再离线分析,或者用 bpftrace 只统计不复制。

抓包相关的五个坑
  1. -i any 看不到以太网首部。Linux 的 any 是伪设备,聚合了所有接口,链路层首部在这一层已经被抹掉。要看 MAC 必须指定具体接口。
  2. 抓不到包不等于没有包。可能是网卡做了硬件过滤、可能是走了 offload(TSO 让大包在抓包点还没分段)、也可能是流量走了另一条路径(比如容器里的 veth)。
  3. 抓包时间戳不是精确的发送时间。它是在内核收包点打的,受中断合并、NAPI 批处理影响,微秒级抖动很正常。
  4. tcpdump 抓到的包可能比实际发出去的大。开启了 TSO/GSO 时,协议栈交给网卡的是超大包,网卡才切分。这类包在抓包里表现为 length 远大于 MTU。
  5. 过滤表达式写错不会报错,只会抓不到。port 80 和 tcp port 80 差别很大,前者同时匹配 TCP 和 UDP。写完先用 -c 5 试抓,确认有输出再放后台。

4.1.10 端到端原则:分层之上的设计哲学

分层是结构,端到端原则(end-to-end principle)是这套结构的价值观。它的原始表述是:一个功能如果必须由通信双方自己正确实现,那么把它放在网络中间实现是浪费——中间节点做同样的事,既不能减少端点的负担,又增加了自己的复杂度。

这条原则解释了互联网的很多设计选择:

  • 可靠性放在端点。IP 层不保证送达,TCP 在两端做重传。中间路由器不需要维护「哪个包到了、哪个没到」的状态。
  • 拥塞控制放在端点。路由器只做丢弃(或者 ECN 标记),由发送端根据丢包信号调整速率。
  • 分片责任在 IPv6 里推给源端。中间节点不再重组。
  • NAT 是反例。NAT 让中间设备维护连接状态,破坏了端到端可达性,代价是今天排查 P2P 和 VoIP 问题时要处理各种打洞。
为什么「智能终端 + 傻瓜网络」赢了

把复杂度放在端点,意味着网络核心只需要做转发,可以做得极快、极简单;新功能(加密、压缩、多路复用)只需要改两端,不需要升级所有路由器。反过来,把智能放进网络(比如 ATM 的复杂信令),每加一个功能都要全网升级。这不是技术优劣,是部署成本的算术。

理解这条原则,你在读后面的内核路径时会不断看到它的影子:内核协议栈本身也是「端点」,它只对自己这一端负责,不假设网络里有什么。

4.1.12 首部的字节账:封装到底花掉多少带宽

4.1.3 讲了「一层套一层」,这一节把每层的字节数算清。同样的应用载荷,封装不同,有效吞吐(goodput)能差一倍以上。

层字段字节备注
以太网目的 MAC + 源 MAC12不计前导码
以太网类型/长度20x0800=IPv4,0x86dd=IPv6,0x8100=VLAN
以太网载荷 + 填充46–1500最小帧 64 字节(含 FCS)
以太网FCS4网卡通常不交给主机
VLANTPID + TCI4802.1Q;QinQ 再 4 字节
IPv4固定首部20选项最多再加 40
IPv6固定首部40扩展首部另算
TCP固定首部20选项最多 40(时间戳+SACK 常见 12)
UDP首部8—
TLS 1.3记录头 + AEAD tag + 内层类型5 + 16 + 1每条记录都有
HTTP/2帧头9每帧
QUIC头 + AEAD tag1–30 + 16可变长头

按「以太网 + IPv4 + TCP(含 12 字节选项)」算,每包固定开销是 14 + 20 + 32 = 66 字节。有效载荷与 goodput 的关系:

应用载荷线上字节头部占比1 Gbps 链路的 goodput
64130(含最小帧填充)50.8%约 492 Mbps
51257811.4%约 886 Mbps
146015264.3%约 957 Mbps
8192(GSO)82580.8%约 992 Mbps

这张表解释了两件事:

  1. 小包是带宽杀手。64 字节载荷时一半带宽花在首部和填充上;金融行情、游戏、DNS 这类小包应用,PPS 往往比带宽更早成为瓶颈。
  2. GSO/TSO 的价值在减少「每包固定成本」,而不只是减少拷贝。一个 64 KB 的 GSO 包在协议栈里只走一次,到了网卡才切成 44 个帧;首部仍然要发,但函数调用、锁、qdisc 次数大幅下降。

再看时延。一个包从 A 到 B 的时间由四段组成:

总时延 = 传输时延 + 传播时延 + 排队时延 + 处理时延
传输时延 = 帧长 / 链路速率      (把比特推上线路的时间)
传播时延 = 距离 / 光速          (信号在介质里跑的时间)

以 10 Gbps、1000 km 光纤为例:

传输时延(1500 B)= 12000 bit / 10 Gbps = 1.2 µs
传播时延(1000 km,光速约 2×10^8 m/s)= 5 ms

传播时延比传输时延大三个数量级。所以长距离链路的瓶颈是「光速」,不是「带宽」;把 10 Gbps 升到 100 Gbps,RTT 一点也不会降。

BDP(带宽时延积)是「管道里能装多少数据」:

BDP = 带宽 × RTT
10 Gbps × 100 ms = 1 Gbps·s = 125 MB

要让这条链路跑满,发送方必须有至少 125 MB 的在途数据(窗口)。这就是 4.6 里 cwnd 与窗口缩放存在的理由:16 位窗口最大 64 KB,在 10 Gbps/100 ms 链路上只能发挥约 0.05% 的带宽(64 KiB / 125 MB)。

例 4.1.1 · 一次 HTTP 请求的字节账

假设 HTTP 响应 10 KB,TCP 已连接,MSS 1460:

  • 10 KB 需要 7 个 TCP 段(6×1460 + 1240)。
  • 每段线上开销 66 字节,7 段共 462 字节。
  • 加上 TLS 记录(假设 2 条记录,各 22 字节)与 HTTP 头(约 200 字节)。
  • 总线上字节 ≈ 10000 + 462 + 44 + 200 = 10706,goodput ≈ 93%。

如果响应只有 200 字节(一个 API 的 JSON),线上字节 ≈ 200 + 66 + 22 + 200 = 488,goodput 只有 41%。这就是「API 响应越小,越该压缩头部/复用连接」的定量依据。

4.1.13 内核里的分层:每层对应哪些结构体与函数

协议图是抽象的,内核里的分层是具体的:每一层都有「操作表 + 首部结构 + 分用键」。这张表把 4.1.2 的四层落到代码:

层首部结构操作表分用键入口函数
应用无(由库定义)struct proto_opsfdsock_sendmsg
传输struct tcphdr/udphdrstruct proto(tcp_prot/udp_prot)协议号 + 端口tcp_v4_rcv/udp_rcv
网络struct iphdr/ipv6hdrstruct packet_type(ip_packet_type)EtherTypeip_rcv
链路struct ethhdrstruct net_device_ops设备netif_receive_skb

三个「操作表」是关键抽象:

/* net/ipv4/tcp_ipv4.c,节选 */
struct proto tcp_prot = {
    .name        = "TCP",
    .owner       = THIS_MODULE,
    .close       = tcp_close,
    .connect     = tcp_v4_connect,
    .disconnect  = tcp_disconnect,
    .accept      = inet_csk_accept,
    .ioctl       = tcp_ioctl,
    .sendmsg     = tcp_sendmsg,
    .recvmsg     = tcp_recvmsg,
    .splice_read = tcp_splice_read,
    .shutdown    = tcp_shutdown,
    .setsockopt  = tcp_setsockopt,
    .getsockopt  = tcp_getsockopt,
    .sendpage    = tcp_sendpage,
    ...
};

socket() 创建时,inet_create() 按 SOCK_STREAM 找到 tcp_prot,把 sk->sk_prot 指向它;此后 sendmsg 走 sk->sk_prot->sendmsg,不需要每层 if/else。这就是「分层」在代码里的样子:不是函数调用栈,而是函数指针表。

网络层的分用靠 packet_type 链表:

/* net/ipv4/af_inet.c,节选 */
static struct packet_type ip_packet_type __read_mostly = {
    .type = cpu_to_be16(ETH_P_IP),
    .func = ip_rcv,
};

dev_add_pack() 把它挂进 ptype_base[] 哈希;netif_receive_skb() 按 EtherType 查找并调用 ip_rcv。注册与分用是分离的:驱动不需要知道 IP,只要把帧交给 netif_receive_skb。

链路层的分用靠 net_device 与 netdev_ops:

/* drivers/net/loopback.c,节选 */
static const struct net_device_ops loopback_ops = {
    .ndo_init      = loopback_dev_init,
    .ndo_start_xmit = loopback_xmit,
    .ndo_get_stats64 = loopback_get_stats64,
    .ndo_set_mac_address = eth_mac_addr,
};

ndo_start_xmit 是「把 skb 交给硬件」的唯一入口;不同设备实现不同,但上层只认这个函数指针。

把一次 write() 的路径按层连起来:

write()
  → sock_sendmsg → sk->sk_prot->sendmsg(tcp_sendmsg)
    → tcp_transmit_skb(加 TCP 首部)
      → ip_queue_xmit(加 IP 首部、查路由)
        → dev_queue_xmit(qdisc)
          → ndo_start_xmit(驱动)

接收方向反过来:

驱动中断 → napi_poll → netif_receive_skb(链路层分用)
  → ip_rcv(网络层)
    → tcp_v4_rcv(传输层分用)
      → sk_receive_queue → 唤醒进程

每一层只改自己那一层的首部,并把 skb 的指针前移/后移,数据本身不搬。这是 4.8.17 里 skb_push/pull 的意义,也是「分层不牺牲性能」的实现基础。

分层的代价

分层不是免费的:每层一次函数调用、一次首部检查、一次分用查找。一个 64 字节的包走完四层,固定开销可能超过载荷本身。DPDK/XDP 的思路就是「绕过部分层」:XDP 在链路层之前处理,DPDK 在用户态重实现协议栈。它们用「失去通用性」换「减少层数」,适合单一功能的转发面,不适合通用主机协议栈。

4.1.14 程序一:算一张 goodput 表

这段程序把 4.1.12 的字节账变成可执行的计算:给定 MTU、是否 VLAN、IP 版本、是否 TLS,打印不同载荷下的线上字节与 goodput。它验证「小包的有效吞吐远低于链路速率」。

/* goodput.c:按封装组合计算 goodput */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct encap {
    int eth;        /* 14 字节以太网头 */
    int vlan;       /* 4 字节 802.1Q */
    int ip;         /* 20 或 40 */
    int l4;         /* TCP 20 + 选项 12,或 UDP 8 */
    int tls;        /* TLS 记录开销:5 + 16 + 1 */
};

static int wire_len(const struct encap *e, int payload, int mtu)
{
    int hdr = e->eth + e->vlan + e->ip + e->l4 + e->tls;
    int len = hdr + payload;
    int min_frame = 60;             /* 不含 FCS 的最小帧 */

    if (len < min_frame)
        len = min_frame;            /* 以太网填充 */
    if (len > mtu + 14)
        return -1;                  /* 需要分片,本程序不处理 */
    return len + 4;                 /* 加 FCS */
}

int main(int argc, char **argv)
{
    int mtu = argc > 1 ? atoi(argv[1]) : 1500;
    int ipv6 = argc > 2 && strcmp(argv[2], "v6") == 0;
    int use_tls = argc > 3 && strcmp(argv[3], "tls") == 0;
    struct encap e = {
        .eth = 14,
        .vlan = 0,
        .ip = ipv6 ? 40 : 20,
        .l4 = 32,                   /* TCP 20 + 时间戳/SACK 12 */
        .tls = use_tls ? 22 : 0,
    };
    int payloads[] = { 64, 128, 256, 512, 1024, 1460, 8192, 16384 };

    printf("MTU=%d %s %s\n", mtu, ipv6 ? "IPv6" : "IPv4", use_tls ? "TLS" : "plain");
    printf("%8s %10s %10s %8s\n", "payload", "wire", "overhead", "goodput");
    for (size_t i = 0; i < sizeof payloads / sizeof payloads[0]; i++) {
        int w = wire_len(&e, payloads[i], mtu);
        if (w < 0) {
            printf("%8d %10s %10s %8s\n", payloads[i], "frag", "-", "-");
            continue;
        }
        double ratio = 100.0 * payloads[i] / w;
        printf("%8d %10d %9.1f%% %7.1f%%\n", payloads[i], w,
               100.0 - ratio, ratio);
    }
    return 0;
}

运行:

cc -std=c17 -Wall -Wextra -Werror -O2 goodput.c -o goodput
./goodput 1500 v4
./goodput 1500 v4 tls
./goodput 9000 v4
MTU=1500 IPv4 plain
 payload       wire  overhead  goodput
      64        134    52.2%    47.8%
     128        194    34.0%    66.0%
     512        578    11.4%    88.6%
    1460       frag        -       -
    8192       frag        -       -

(wire_len 对小于 60 字节的帧填充到 60,再加 4 字节 FCS;64 字节载荷的 wire=134 已含 14 以太网 + 20 IP + 32 TCP + 4 FCS。真实线上还有前导码 8 字节与帧间隙 12 字节,链路利用率还要再降。)

关键行是 min_frame 与 mtu + 14:前者体现「小包要填充到 60 字节」,后者体现「超过 MTU 要分片」。把 payload 改成 8192,程序会打印 frag——在 MTU 1500 下,8192 字节的应用写必须由 TCP 分段(MSS 1460),而不是 IP 分片;程序里没有建模 TCP 分段,只建模了单包封装,这正是「GSO 包在网卡前是 64 KB、在网卡后是 1500」的区别。

4.1.15 程序二:用 TCP 握手估 RTT 与 BDP

ping 测的是 ICMP RTT,很多网络对 ICMP 限速或屏蔽。更可靠的办法是测 TCP 握手:connect() 返回的时间约等于一个 RTT(SYN → SYN-ACK)。

#!/usr/bin/env python3
"""bdp.py:用 TCP 握手估 RTT,并算不同带宽下的 BDP(Linux/macOS 通用)"""
import socket
import sys
import time


def tcp_rtt(host: str, port: int, samples: int = 5) -> float:
    best = float("inf")
    for _ in range(samples):
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.settimeout(2.0)
        t0 = time.perf_counter()
        try:
            s.connect((host, port))
        except OSError as exc:
            print(f"connect {host}:{port} 失败: {exc}", file=sys.stderr)
            s.close()
            return -1.0
        dt = (time.perf_counter() - t0) * 1000.0
        best = min(best, dt)
        s.close()
    return best


def main() -> None:
    host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
    port = int(sys.argv[2]) if len(sys.argv) > 2 else 80
    rtt = tcp_rtt(host, port)
    if rtt < 0:
        sys.exit(1)
    print(f"{host}:{port} 握手 RTT(最小值)≈ {rtt:.3f} ms")
    print(f"{'带宽':>10} {'BDP':>14} {'64KB窗口可利用率':>18}")
    for mbps in (100, 1000, 10000, 40000):
        bdp_bytes = mbps * 1e6 / 8 * (rtt / 1000.0)
        util = min(1.0, 65535 / bdp_bytes) * 100 if bdp_bytes else 100
        print(f"{mbps:>8} Mbps {bdp_bytes / 1024:>12.1f} KiB {util:>16.2f}%")


if __name__ == "__main__":
    main()

运行:

python3 bdp.py 127.0.0.1 18080
# 对一个跨地域服务测(示例,需外网)
python3 bdp.py example.com 443
example.com:443 握手 RTT(最小值)≈ 12.480 ms
      带宽            BDP     64KB窗口可利用率
 100 Mbps          152.3 KiB             42.00%
1000 Mbps         1523.4 KiB              4.20%
10000 Mbps       15234.4 KiB              0.42%
40000 Mbps       60937.5 KiB              0.11%

这张表是「为什么长肥管道需要窗口缩放」的定量答案:RTT 12.5 ms、1 Gbps 时,BDP 约 1.5 MB,而 16 位窗口只有 64 KB,最多只能发挥 4.2% 的带宽。窗口缩放把上限抬到约 1 GB,才让高 BDP 链路可用。

注意程序用「最小值」而不是平均值:握手 RTT 受排队和调度影响,最小值最接近传播+传输时延,平均值会把拥塞排队算进去。

4.1.16 实验:把分层地图和真实字节对上

实验一用 tcpdump -e -X 逐层标注首部,实验二用 ss 与 bpftrace 把「时延/BDP」落到一条真实连接上。

实验一:一帧的逐层拆解。

# -e 显示以太网头,-X 打十六进制,-c 1 只抓一个包
sudo tcpdump -i lo -e -nn -X -c 1 'tcp port 18080'
# 抓一个带 VLAN 的包(需要 veth/物理网卡;回环没有 VLAN)
sudo tcpdump -i eth0 -e -nn -X -c 1 'vlan and tcp port 443'

回环上的典型输出:

12:00:00.000000 af:00:00:00:00:00 > af:00:00:00:00:00, ethertype IPv4 (0x0800), length 74:
    0x0000:  4500 003c 1c46 4000 4006 b1e6 7f00 0001  E..<.F@.@.......
    0x0010:  7f00 0001 9c40 1f90 0000 0001 0000 0000  .....@..........
    0x0020:  5018 2000 0000 0000 0000 0000            P. ..........

逐行读:

  • ethertype IPv4 是链路层的分用键;回环的 MAC 是 00:00:00:00:00:00(或 af:... 的伪 MAC),没有真实硬件地址。
  • 4500 是 IP 首部:版本 4、首部长度 5(20 字节)、TOS 0;003c 是总长 60。
  • 1c46 是标识;4000 是标志/片偏移(DF 置位);40 是 TTL 64;06 是协议号 TCP;b1e6 是首部校验和。
  • 7f00 0001 两次出现,是源/目的 127.0.0.1。
  • 9c40 1f90 是源/目的端口(40000 → 8080)。
  • 5018 2000 是 TCP 首部:50 表示首部 20 字节,18 是 PSH+ACK,2000 是窗口 8192。

实验二:时延与窗口。

# 握手 RTT 与 cwnd/snd_wnd
ss -tinm 'dport = :443'
# 用 ping 对比 ICMP RTT(可能被限速)
ping -c 5 example.com
# 用 bpftrace 看收包在协议栈里花了多久
sudo bpftrace -e '
kprobe:ip_rcv { @start[tid] = nsecs; }
kretprobe:ip_rcv /@start[tid]/ { @ns = hist(nsecs - @start[tid]); delete(@start[tid]); }
interval:s:5 { print(@ns); clear(@ns); exit(); }'

判据:

  • ss -tinm 的 rtt 与 ping 的 avg 差很多:说明 ICMP 与 TCP 走了不同队列/策略,或 ping 被限速。
  • cwnd × mss 远小于 BDP:窗口是瓶颈,查 snd_wnd(接收端)与 cwnd(网络)。
  • ip_rcv 的直方图长尾到几十微秒:netfilter 规则过多或 cgroup BPF 拖慢。

4.1.17 本章陷阱清单

12 条高频错误
  1. 把带宽当吞吐:带宽是链路速率,吞吐是实际 goodput;小包、协议开销、丢包都会让吞吐远低于带宽。
  2. 把 RTT 当「带宽的倒数」:RTT 由传播距离与排队决定,升级带宽不会降低 RTT。
  3. 用 16 位窗口算高 BDP 链路:64 KB 在 1 Gbps/12 ms 上只能发挥 4%,必须窗口缩放。
  4. 忽略以太网最小帧填充:小于 60 字节的帧要填充,小包开销比首部账更大。
  5. 把 MTU 当 MSS:MSS = MTU − IP 首部 − TCP 首部;IPv6 要减 40。
  6. 在回环上测 goodput 推断物理网卡:回环 MTU 65536、无 VLAN、无前导码,开销模型完全不同。
  7. 只看首部字节,不算前导码与帧间隙:每个以太网帧还有 8 字节前导码 + 12 字节 IFG,线速利用率上限约 98.7%。
  8. 认为分层一定慢:分层的成本是函数调用与分用查找,DPDK/XDP 绕过分层换性能,但失去通用性。
  9. 把 ping 的 RTT 当 TCP RTT:ICMP 可能被限速、走不同队列,甚至被中间盒伪造。
  10. 用一次 ss 快照判断窗口:窗口随 ACK 动态变化,要看趋势或 tcp_info 采样。
  11. 忽略 TLS/HTTP2 的每记录开销:TLS 每条记录 22 字节、HTTP/2 每帧 9 字节,小响应下占比很高。
  12. 把「带宽升级」当性能优化首选:先看 app_limited、cwnd、snd_wnd 与重传,很多时候瓶颈不在带宽。

4.1.18 时延的四个分量:一次可复现的测量

这一节把 4.1.12 的时延公式变成可测量的四步。目标是让你在看到「慢」时,能说出慢在哪一段,而不是笼统地说「网络问题」。

分量典型量级怎么测能否优化
传输时延1.2 µs(1500 B @ 10 Gbps)iperf3 看线速,按帧长算提高带宽、加大包
传播时延5 ms / 1000 kmping 的 RTT 下限换路径/CDN,无法超过光速
排队时延0 到数百 mstc -s qdisc 的 backlog、ss 的 rtt 方差降载、AQM、调队列
处理时延几 µs 到几十 µsbpftrace 打点、perf减少 netfilter/BPF、绑核

一次测量练习:

# 1) 传播时延的下限:ping 的最小 RTT(越小越接近传播+传输)
ping -c 20 -q example.com | tail -1
# 2) 排队时延:给回环加 50 ms 延迟,观察 RTT 与 cwnd 的变化
sudo tc qdisc add dev lo root netem delay 50ms
ping -c 5 127.0.0.1
ss -tinm state established | head -3
sudo tc qdisc del dev lo root
# 3) 处理时延:抓 ip_rcv 的耗时分布
sudo bpftrace -e 'kprobe:ip_rcv { @s[tid]=nsecs; }
kretprobe:ip_rcv /@s[tid]/ { @ns=hist(nsecs-@s[tid]); delete(@s[tid]); }
interval:s:3 { print(@ns); exit(); }'

读法:

  • ping 的 min 接近「传播 + 传输 + 固定处理」,avg - min 主要是排队与调度。
  • netem delay 50ms 后 RTT 变成约 100 ms(往返各 50 ms),ss 的 rtt 同步变大,cwnd 可能因 BDP 变大而显得不够。
  • ip_rcv 的直方图若在 10 µs 以上,先查 netfilter 规则数与 cgroup BPF,再看是否开了 CONFIG_PREEMPT_NONE(软中断不能被抢占)。

排队时延是最容易被忽视的一项。在没有拥塞的空闲链路上,RTT 几乎等于传播时延;一旦交换机队列堆满,RTT 会先于丢包上升。看到「RTT 缓慢上升、吞吐不变」,通常是缓冲区在积累(bufferbloat),而不是链路变长。这时 tc -s qdisc 的 backlog 会持续非 0,ss 的 rtt 方差也会变大。

4.1.19 首部字段速查:写过滤器时该数第几个字节

4.1.3 给的是封装图,这一节给一张「偏移表」。它的用途很具体:当你要写 tcpdump 过滤器、用 bpftrace 读某个字段、或者手工解析一段十六进制时,能立刻说出「这个字段在第几个字节、占几位、掩码是多少」。

协议字段位宽相对偏移常用过滤器说明
以太网目的 MAC480ether[0:6]下一跳的 MAC,不是最终目的
以太网源 MAC486ether[6:6]发送方网卡
以太网类型1612ether[12:2]0x0800 IPv4、0x86dd IPv6、0x0806 ARP、0x8100 VLAN
IPv4版本 + IHL4 + 40ip[0]0x45 = v4 + 20 字节首部
IPv4DSCP + ECN6 + 21ip[1]低 2 位是 ECN
IPv4总长度162ip[2:2]首部 + 载荷,最大 65535
IPv4标识164ip[4:2]分片归组用;DF 包可为 0
IPv4标志 + 片偏移3 + 136ip[6:2]0x4000 DF、0x2000 MF、低 13 位偏移
IPv4TTL88ip[8]每跳减 1
IPv4协议89ip[9]6 TCP、17 UDP、1 ICMP
IPv4首部校验和1610ip[10:2]只覆盖首部
IPv4源地址3212ip[12:4]NAT 会改
IPv4目的地址3216ip[16:4]NAT 会改
TCP源端口160tcp[0:2]相对 TCP 首部
TCP目的端口162tcp[2:2]分用键
TCP序号324tcp[4:4]字节流编号
TCP确认号328tcp[8:4]期望的下一个字节
TCP首部长度 + 标志4 + 1212tcp[12:2]高 4 位是长度,单位 4 字节
TCP标志字节813tcp[13]0x02 SYN、0x10 ACK、0x01 FIN、0x04 RST
TCP窗口1614tcp[14:2]未缩放时的接收窗口
TCP校验和1616tcp[16:2]覆盖伪首部 + 首部 + 载荷
TCP紧急指针1618tcp[18:2]基本废弃
UDP源端口160udp[0:2]可填 0
UDP目的端口162udp[2:2]分用键
UDP长度164udp[4:2]首部 + 载荷
UDP校验和166udp[6:2]IPv4 可填 0 表示不校验

这张表有两个容易踩的偏移陷阱:

  • ip[...] 的偏移从 IP 首部第一个字节算起,tcp[...] 从 TCP 首部算起。但 TCP 首部的位置取决于 IHL:IHL=5 时 TCP 从帧偏移 34 开始,IHL=6 时从 38 开始。tcpdump 的 tcp[...] 关键字会自动按 IHL 计算,所以写 tcp[13] 不用手工加 IHL;如果你用 ether[34] 这种绝对偏移,就会在带选项的包上读错。
  • ip[6:2] 是一个 16 位大端整数。0x4000 是 DF,0x2000 是 MF,低 13 位是片偏移。所以「分片」的过滤器是 ip[6:2] & 0x3fff != 0,不是 ip[6] & 0x20(后者只看了 MF 的高字节)。

几个常用过滤器与它们的「字节等价写法」:

语义可读写法字节写法
TCP 443tcp port 443tcp[0:2] = 443 or tcp[2:2] = 443
只抓 SYNtcp[tcpflags] & tcp-syn != 0tcp[13] & 0x02 != 0
只抓 ICMPicmpip[9] = 1
只抓 ARParpether[12:2] = 0x0806
VLAN 100vlan 100ether[12:2] = 0x8100 and ether[14:2] & 0x0fff = 100
分片ip[6:2] & 0x3fff != 0同上(没有更短的可读别名)
DF 置位ip[6:2] & 0x4000 != 0同上

tcpdump -d 可以把过滤器编译成 BPF 指令,-ddd 输出十进制指令列表。想确认「过滤器到底匹配了哪几个字节」,这是最直接的办法:

# 看 tcp[13] & 2 != 0 编译成哪些 BPF 指令
tcpdump -i lo -d 'tcp[13] & 2 != 0'
# 看 VLAN 过滤器如何先检查 0x8100,再读 TCI
tcpdump -i lo -d 'vlan 100'

读 -d 的输出不用懂每条指令,只要确认它先 ldh 了哪个偏移、再和什么常量比较。能对上偏移表,就说明过滤器写对了。

4.1.20 端到端时延:把四个分量算成一个数

4.1.8 给了时延的四个分量,4.1.18 给了测量方法。这一节回答一个更实际的问题:给定一条路径的距离、速率、负载和处理开销,怎么算出「一个 1500 字节的包从 A 到 B 要多久」,以及哪一项占主导。

先统一符号。一个包经过 n 跳,每跳的距离为 d_i,链路速率为 C_i,包长为 L 字节,处理时延为 P_i,到达速率为 λ_i:

传输时延  tx_i  = 8L / C_i
传播时延  prop_i = d_i / v          (v ≈ 2×10^8 m/s,光纤)
排队时延  q_i   = ρ_i / (μ_i (1-ρ_i)),ρ_i = λ_i/μ_i,μ_i = C_i/(8L)
处理时延  proc_i = P_i
端到端     T = Σ(tx_i + prop_i + q_i + proc_i)

排队公式是 M/M/1 的平均排队时间。它有两个边界:ρ → 0 时 q → 0;ρ → 1 时 q → ∞。真实网络不是 M/M/1(包长不是指数分布、到达不是泊松),但这个公式足以说明「负载接近 1 时,排队会指数级上升」,以及「为什么拥塞时 RTT 先涨、丢包后到」。

把默认参数代进去:3 跳,每跳 1000 km、1 Gbps、负载 0.7、处理 5 µs。

tx   = 1500×8 / 1e9 = 12 µs
prop = 1000×1000 / 2e8 = 5 ms
μ    = 1e9 / 12000 = 83333 包/秒
q    = 0.7 / (83333×0.3) = 28 µs
T    = 3×(12 + 5000 + 28 + 5) µs = 15.135 ms

单程 15.135 ms,RTT 约 30.27 ms。传播时延 15 ms 占了 99% 以上,传输、排队、处理加起来不到 0.2 ms。长距离链路上,优化协议栈的收益远小于换一条更近的路径;这也是 CDN 把缓存放到离用户 5 ms 的地方,比把服务器 CPU 升级一倍更有效的原因。

把链路换成 10 Gbps,其他不变:

tx   = 1.2 µs
μ    = 833333 包/秒
q    = 0.7 / (833333×0.3) = 2.8 µs
T    = 3×(1.2 + 5000 + 2.8 + 5) µs = 15.027 ms

只少了 0.108 ms。带宽翻十倍,时延几乎不变——因为瓶颈是光速,不是速率。这个结论在 4.1.8 出现过一次,这里用数字再确认一次。

下面这段程序把公式变成可执行的计算器。每跳参数用 距离,速率,负载,处理时延 表示,输出每一项的微秒数和总计,并算 BDP。

#!/usr/bin/env python3
"""e2e_delay.py:逐跳累加传输、传播、排队、处理时延,并算 BDP。

用法:
  ./e2e_delay.py                      # 默认 3 跳,每跳 1000 km / 1 Gbps / 负载 0.7
  ./e2e_delay.py 1000,1,0.7,5 200,10,0.3,2
每跳参数:距离(km),速率(Gbps),负载(0-1),处理时延(us)
"""
import sys

LIGHT = 2.0e8          # 光纤中的光速,m/s
PKT_BYTES = 1500       # 单个 IP 包长度

def one_hop(d_km: float, rate_gbps: float, load: float, proc_us: float):
    bits = PKT_BYTES * 8
    rate = rate_gbps * 1e9
    tx_us = bits / rate * 1e6
    prop_us = d_km * 1000.0 / LIGHT * 1e6
    mu = rate / bits                     # 包/秒
    if not 0.0 <= load < 1.0:
        raise ValueError(f"load 必须在 [0,1) 内: {load}")
    queue_us = load / (mu * (1.0 - load)) * 1e6
    return tx_us, prop_us, queue_us, proc_us

def main() -> int:
    if len(sys.argv) > 1:
        hops = []
        for spec in sys.argv[1:]:
            parts = spec.split(",")
            if len(parts) != 4:
                print(f"参数格式错误: {spec}", file=sys.stderr)
                return 2
            hops.append(tuple(float(x) for x in parts))
    else:
        hops = [(1000.0, 1.0, 0.70, 5.0)] * 3

    total = [0.0, 0.0, 0.0, 0.0]
    print(f"{'hop':>3} {'serialize':>10} {'propagate':>10} {'queue':>10} {'process':>9}")
    print(f"{'':>3} {'us':>10} {'us':>10} {'us':>10} {'us':>9}")
    for i, (d, r, load, p) in enumerate(hops, 1):
        v = one_hop(d, r, load, p)
        total = [a + b for a, b in zip(total, v)]
        print(f"{i:>3} {v[0]:>10.2f} {v[1]:>10.2f} {v[2]:>10.2f} {v[3]:>9.2f}")
    print(f"{'sum':>3} {total[0]:>10.2f} {total[1]:>10.2f} {total[2]:>10.2f} {total[3]:>9.2f}")

    one_way_ms = sum(total) / 1000.0
    rtt_ms = one_way_ms * 2.0
    bottleneck = min(r for _, r, _, _ in hops)
    bdp_bytes = bottleneck * 1e9 * rtt_ms / 1000.0 / 8.0
    print(f"\none-way = {one_way_ms:.3f} ms   RTT = {rtt_ms:.3f} ms")
    print(f"bottleneck = {bottleneck:g} Gbps   BDP = {bdp_bytes/1e6:.2f} MB "
          f"({bdp_bytes/1024/1024:.2f} MiB)")
    print(f"64 KiB 窗口可利用率 = {min(1.0, 65535.0/bdp_bytes)*100:.2f}%")
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

运行:

python3 e2e_delay.py
python3 e2e_delay.py 1000,10,0.7,5 1000,10,0.7,5 1000,10,0.7,5
hop  serialize  propagate      queue   process
            us         us         us        us
  1      12.00    5000.00      28.00      5.00
  2      12.00    5000.00      28.00      5.00
  3      12.00    5000.00      28.00      5.00
sum      36.00   15000.00      84.00     15.00

one-way = 15.135 ms   RTT = 30.270 ms
bottleneck = 1 Gbps   BDP = 3.78 MB (3.61 MiB)
64 KiB 窗口可利用率 = 1.73%

最后一行是 BDP 的直接用途:RTT 30.27 ms、瓶颈 1 Gbps 时,管道里能装 3.78 MB;16 位窗口只有 64 KB,最多发挥 1.73% 的带宽。这就是 4.6 讲窗口缩放的原因。

程序里有两个刻意的选择。用 min(rate) 算 BDP,而不是第一跳的速率:端到端管道由最慢的一跳决定,虽然排队时延按每跳分别算。用 RTT 而不是单程时延:BDP 的定义是「发送方在收到第一个 ACK 之前能发多少数据」,所以乘的是往返时间。

例 4.1.1 · 一条 10 Gbps、80 ms 的链路能跑多快

BDP = 10e9 × 0.08 / 8 = 100 MB。如果接收窗口只有 6 MB(默认 tcp_rmem 上限的常见值),单流吞吐上限 = 6 MB / 80 ms = 75 MB/s = 600 Mbps,只有带宽的 6%。要跑满,窗口至少 100 MB,或者用多条流并行。这个例子说明「带宽升级」和「单流吞吐」是两件事。

4.1.21 实验:ping、traceroute、ss 三件套互证

ping 测 ICMP、traceroute 测路径、ss 测 TCP 端点状态。三者测的不是同一个东西,所以数值对不上是常态;对得上反而说明路径简单。这个实验用一条脚本把三个视角放在一起,训练「看到差异能解释」的能力。

#!/usr/bin/env bash
# rtt_trio.sh:用 ping / traceroute / ss 三种视角测同一条路径
set -euo pipefail

HOST="${1:-127.0.0.1}"
PORT="${2:-443}"

command -v ping >/dev/null || { echo "需要 ping" >&2; exit 1; }

echo "== 1) ICMP: ping =="
ping -c 20 -q -W 2 "$HOST" | tail -1

echo "== 2) path: traceroute/tracepath =="
if command -v tracepath >/dev/null; then
    tracepath -n -m 8 "$HOST" || true
elif command -v traceroute >/dev/null; then
    traceroute -n -m 8 "$HOST" || true
else
    echo "未安装 tracepath/traceroute,跳过"
fi

echo "== 3) TCP: ss -ti =="
if command -v ss >/dev/null; then
    ss -tinm "dst $HOST:$PORT" || true
else
    echo "未安装 ss,跳过"
fi

用法:

chmod +x rtt_trio.sh
./rtt_trio.sh example.com 443
# 如果没有已建立的连接,先在一个终端保持一条:
#   curl -sS -o /dev/null https://example.com/

示例输出(数值因环境而异):

== 1) ICMP: ping ==
20 packets transmitted, 20 received, 0% packet loss, time 19032ms
rtt min/avg/max/mdev = 11.982/12.345/13.001/0.321 ms
== 2) path: traceroute/tracepath ==
 1?: [LOCALHOST]                      pmtu 1500
 1:  192.168.1.1                                          0.512ms
 2:  10.0.0.1                                             2.104ms
 3:  203.0.113.9                                         11.820ms
== 3) TCP: ss -ti ==
ESTAB 0 0 192.168.1.10:51234 93.184.216.34:443
     cubic wscale:7,7 rto:204 rtt:12.4/0.8 ato:40 mss:1448 cwnd:10 ...

三个数对不上时,按这张表解释:

现象可能原因下一步
ping 12 ms,ss 的 rtt 40 msTCP 连接空闲过,rtt 是历史平滑值;或应用层排队ss -ti 看 lastsnd/lastrcv,在传输中再采一次
ping 12 ms,traceroute 第 3 跳 11.8 ms正常:第 3 跳已经接近目的—
traceroute 中间全是 *,最后有响应中间路由器不回 ICMP 或限速不代表断,继续看后续跳
ss 的 rtt 0.05 ms,应用延迟 40 ms回环/Nagle/延迟 ACK看 4.6.8,查 TCP_NODELAY
ping 通,ss 没有连接连接没建立,或 ss 过滤条件写错先 ss -tn 不加过滤,确认五元组

脚本里用 ping -W 2 是 Linux 语义(秒);macOS 的 -W 单位是毫秒,跨平台跑要改。tracepath 比 traceroute 更适合这个实验,因为它在每跳顺带做 PMTU 探测,输出里的 pmtu 能直接告诉你路径 MTU。

三个工具的正确使用顺序
  1. 先 ping:确认三层可达,拿到 RTT 的下限。ICMP 被禁时直接跳到第 2 步。
  2. 再 traceroute/tracepath:确认路径和 PMTU,区分「不通」与「绕路」。
  3. 最后 ss:确认 TCP 端点状态、窗口、重传。只有连接存在时才有意义。

4.1.22 内核路径:一个帧从 ip_rcv 到 tcp_v4_rcv 的十个检查点

4.1.13 把四层映射到结构体和操作表,这一节跟一遍接收路径,重点不是「调用了哪些函数」,而是「每一步可能因为什么被丢」。排障时你看到的 InErrors、InDiscards、InUnknownProtos,都对应这条链上的某个检查点。

#阶段函数文件检查/动作失败计数
1驱动收包napi_poll驱动从 RX ring 取描述符,建 skbrx_dropped
2GRO 合并napi_gro_receivenet/core/dev.c合并同流的小包—
3链路分用__netif_receive_skb_core同上按 skb->protocol 查 ptype_baserx_dropped
4剥以太网头eth_type_transnet/ethernet/eth.c前移 14 字节,设 pkt_type—
5IPv4 入口ip_rcv → ip_rcv_corenet/ipv4/ip_input.c版本、IHL、长度、首部校验和InHdrErrors
6路由决策ip_rcv_finish → ip_route_input_noref同上 / route.c查 FIB,决定本机/转发/丢弃InAddrErrors
7分片重组ip_local_deliver → ip_defrag同上 / ip_fragment.c有分片就先重组ReasmFails
8协议分用ip_local_deliver_finish同上查 inet_protos[protocol]InUnknownProtos
9TCP 入口tcp_v4_rcvnet/ipv4/tcp_ipv4.c查 ehash/lhash2,找 socketTCPReqQFullDrop 等
10交付应用tcp_rcv_established → tcp_data_queuenet/ipv4/tcp_input.c序号检查、入接收队列、唤醒TCPOFOQueue/TCPOFODrop

ip_rcv() 本身很短,真正的检查在 ip_rcv_core():

/* net/ipv4/ip_input.c,Linux 6.8,节选 */
int ip_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt,
           struct net_device *orig_dev)
{
    struct net *net = dev_net(dev);

    skb = ip_rcv_core(skb, net);
    if (skb == NULL)
        return NET_RX_DROP;

    return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, net, NULL, skb,
                   dev, NULL, ip_rcv_finish);
}

ip_rcv_core() 依次检查:

  1. pskb_may_pull(skb, sizeof(struct iphdr)):连 20 字节首部都不够,丢。
  2. iph->ihl < 5 || iph->version != 4:版本或首部长度非法,丢。
  3. ip_fast_csum((u8 *)iph, iph->ihl):首部校验和错误,丢。
  4. len = ntohs(iph->tot_len);skb->len < len 说明被截断,丢;len < ihl*4 说明长度自相矛盾,丢。
  5. skb->len > len 时 pskb_trim_rcsum(skb, len),把以太网填充裁掉。

这五步对应 InHdrErrors 的增长。如果 tcpdump 能抓到包但 InHdrErrors 在涨,说明包到了协议栈但首部坏了——先查网卡 offload、VLAN 剥离、抓包点的快照长度,不要先怀疑上层。

ip_rcv_finish() 做路由决策。它调用 ip_route_input_noref(),根据 fib_result 把 skb 分成三类:

  • RTN_LOCAL:目的地址是本机,走 ip_local_deliver()。
  • RTN_UNICAST 且 ip_forward 打开:走 ip_forward(),TTL 减 1,重新查路由。
  • RTN_BROADCAST/RTN_MULTICAST:走广播/组播路径,可能复制多份。

没有路由时,ip_rcv_finish() 会调用 icmp_send() 回 type 3 code 0(网络不可达),并增加 InAddrErrors。如果 InAddrErrors 在涨,先看 ip route 是否有默认路由,而不是看 TCP。

ip_local_deliver() 先处理分片(4.3.11 会展开),再交给 ip_local_deliver_finish()。后者查 inet_protos[protocol],查不到就回 ICMP type 3 code 2,并增加 InUnknownProtos。抓到一个 proto=47(GRE)的包但没有 GRE 模块,就是这条路径丢的。

最后是 tcp_v4_rcv():它先用四元组查 ehash(已建立连接),查不到再查 lhash2(监听 socket)。两步都查不到就回 RST,并增加 TCPReqQFullDrop 或 TCPAbortOnNoListener 之类的计数(具体计数器随内核版本不同)。找到 socket 后,tcp_rcv_established() 检查序号,tcp_data_queue() 把数据放进 sk_receive_queue,最后 sk_data_ready() 唤醒 epoll_wait/read。

# 一次看完整条链的丢弃计数
nstat -az | grep -E 'InHdrErrors|InAddrErrors|InUnknownProtos|InDiscards|ReasmFails|TCPReqQFullDrop'
# 用 tracepoint 看内核丢包原因(Linux 5.10+ 的 skb_drop_reason)
sudo bpftrace -e 'tracepoint:skb:kfree_skb { @[args->reason] = count(); }'

bpftrace 的 args->reason 是 enum skb_drop_reason,输出可能是数字或符号;常见值对应 SKB_DROP_REASON_IP_INHDR、IP_NOPROTO、IP_CSUM、TCP_INVALID_SEQUENCE、SOCKET_FILTER 等。先用它把「丢在哪一层」缩小,再去看那一层的代码,比盲目加日志快得多。

`tcpdump` 为什么能看到被 `ip_rcv` 丢掉的包

tcpdump 通过 AF_PACKET 在 __netif_receive_skb_core() 里注册 packet_type,拿到的是「交给协议栈之前」的帧副本。ip_rcv_core() 的丢弃发生在它之后,所以 tcpdump 照样能看到。反过来,被网卡硬件过滤(目的 MAC 不是本机、VLAN 过滤、RSS 分流到别的队列)的帧,tcpdump 看不到。这个顺序解释了「抓包有、协议栈没收到」和「抓包没有、但应用收到了」两类怪现象。

4.1.23 八个新坑:把「测出来的数」用对

时延、MTU 与抓包的八个新坑
  1. 把 ping -s 当 IP 包长:-s 是 ICMP 载荷;IPv4 加 28 字节(20 IP + 8 ICMP),IPv6 加 48 字节。算路径 MTU 时漏掉这 28/48 字节,会得出「MTU 1472」这种错误结论。
  2. 把 traceroute 每跳 RTT 相加当端到端 RTT:每跳探针是独立发的,路径可能不同,路由器对 ICMP 的限速也不同;端到端 RTT 要用 ping 或 TCP 握手测。
  3. 把 ss 的 rtt 当应用层延迟:rtt 是 TCP 的平滑 RTT,不含应用排队、调度、Nagle/延迟 ACK;应用慢要看 app_limited 和 lastrcv。
  4. 把 BDP 当 socket 缓冲区目标:BDP 是在途字节,缓冲区还要容纳「已收到但应用没读」的数据;tcp_rmem 自动调优会自己找平衡,手动设死反而可能更慢。
  5. 在回环上验证 MTU/分片:lo 的 MTU 是 65536,永远不会因为 1500 而分片;MTU 实验必须在 veth 或物理网卡上做。
  6. 把以太网 FCS 计入 MTU:MTU 是 L3 载荷,FCS 是 L2 尾部;MTU 1500 对应帧长 1518(含 14 首部 + 4 FCS),不是 1500。
  7. 用线速算小包 PPS:64 字节帧加 20 字节前导码/帧间隙是 84 字节,1 Gbps 线速约 1.488 Mpps,不是 1.953 Mpps;小包场景先算 PPS,再算带宽。
  8. 把 tcpdump 的 length 当线上字节:它不含 FCS、前导码、帧间隙;开启 TSO/GSO 时抓到的还是 64 KB 的「超级包」,网卡才切分。

4.1.24 本章出口检查

检查项达标线没达标去哪
说出四层各自负责什么能举出每层一个协议和一个设备4.1.2
画出一次请求的封装过程标出每层首部长度与关键字段4.1.3
用 C 量出三层首部的偏移能解释 off=14/34/54 怎么来的4.1.6
解释 MTU 与 MSS 的关系会算 IPv4/IPv6 下的典型 MSS4.1.3、4.1.7
用 BDP 判断窗口是否够大给定带宽和 RTT 能算出所需窗口4.1.8
用 tcpdump 同时看到三层首部能指出输出里哪段是哪层4.1.9
说清 -i any 与 -i eth0 的差别知道链路层首部在哪一层丢失4.1.9

| 算一次封装的 goodput | 能说出 64 B 与 1460 B 的差别 | 4.1.12 | | 把四层落到结构体与函数 | 知道 sk_prot/packet_type 的作用 | 4.1.13 | | 用 TCP 握手估 RTT 与 BDP | 能算出窗口利用率 | 4.1.15 | | 区分时延的四个分量 | 知道哪一项能优化 | 4.1.18 | | 用偏移表写 tcpdump 过滤器 | 能解释 ip[...] 与 tcp[...] 的相对基准 | 4.1.19 | | 算一条路径的端到端时延 | 能指出传播时延占主导 | 4.1.20 | | 用 ping/traceroute/ss 互证 | 能解释三个数值的差异 | 4.1.21 | | 跟一遍 ip_rcv 到 tcp_v4_rcv | 知道每一步的丢包计数 | 4.1.22 |

即时自测 · 4.1

Q1 一个以太网帧长 1514 字节(含 14 字节首部和 4 字节 FCS),MTU 1500。它承载的 TCP 段里,应用数据最多多少字节?如果加了 12 字节的 TCP 时间戳选项呢?

Q2 为什么 ping -s 1472 能通就说明路径 MTU 至少 1500?如果对端是 IPv6,这个数应该是多少?

Q3 一条 10 Gbps、RTT 20 ms 的链路,BDP 是多少字节?如果 TCP 窗口只有 256 KB,吞吐上限是多少?

Q4 tcpdump -i any 抓到的包没有 MAC 地址,这是工具的限制还是内核的限制?为什么?

Q5 同一个 HTTP 请求,走 127.0.0.1 和走真实网卡,协议栈路径差在哪几层?

Q6 端口号只有 16 位,为什么一台服务器能同时接受几万条连接?瓶颈到底在哪?

答案

A1 1514 − 14 − 4 = 1496 字节是 IP 包,减去 20 字节 IP 首部、20 字节 TCP 首部,剩 1456;再减 12 字节选项(选项按 4 字节对齐),应用数据最多 1444 字节。注意这里用的是「帧长 1514」,如果按 MTU 1500 算则是 1460 和 1448。

A2 ping 的 -s 是 ICMP 载荷,加 8 字节 ICMP 首部、20 字节 IP 首部,共 1472 + 28 = 1500,正好等于 MTU。能通说明这一整包没有被分片就过去了。IPv6 的 IP 首部是 40 字节,ICMPv6 首部 8 字节,所以最大 -s 是 1500 − 48 = 1452。

A3 BDP = 10e9 × 0.02 / 8 = 25 MB。窗口 256 KB 时,吞吐上限 = 256 KB / 20 ms = 12.8 MB/s ≈ 102 Mbps,只有带宽的 1%。

A4 是内核的限制,但根源在设备抽象。any 是一个 packet_type 挂在所有设备之上的伪设备,它在 netif_receive_skb 之后才拿到包,此时链路层首部已经被解析并剥离。要看 MAC 必须绑定到具体设备,在驱动层之前抓。

A5 回环走完整的 IP 和 TCP,但跳过以太网的 ARP 解析、网卡的 DMA 和 TX/RX ring,也跳过 qdisc 的排队(回环默认 noqueue)。此外回环的发送和接收在同一个软中断上下文里完成,没有跨 CPU 的中断开销。所以本机 RTT 是几十微秒,而真实网卡是几十到几百微秒。

A6 服务端不靠「端口号多少」区分客户,而靠四元组:源 IP、源端口、目的 IP、目的端口。服务端只用固定端口(如 443),每条连接的源 IP 或源端口不同,四元组就不同。理论上限是 2³² × 2¹⁶ 量级,实际瓶颈在文件描述符数量、内核内存和 somaxconn,这三件事 4.11 逐个拆。

母本对应

母本章节本书落点程度
W(谢希仁)第 1 章 概述(分层、协议、时延、体系结构)4.1.1–4.1.8完整:四层模型、封装分用、四类时延、BDP
N(深入理解 Linux 网络)第 1 章 绪论(工作中的困惑、内容结构)4.1 导语、4.1.5背景:把「协议知识」转成「内核问题」的动机
C(CSAPP)第 11 章 11.1–11.3(客户端-服务器模型、网络、全球 IP 因特网)4.1.2、4.1.5精选:分层视角与 socket 抽象的前置
K(Kerrisk)第 58 章 58.2–58.4(联网协议和层、数据链路层、网络层)4.1.2、4.1.4完整:分层与端口多路复用的系统编程视角
K(Kerrisk)第 61 章 61.8(使用 tcpdump 监视 TCP 流量)4.1.9改写:过滤表达式与抓包边界
W(谢希仁)第 1 章(概述:分层、时延、带宽,PDF p12–52)4.1.12–4.1.21补充:字节账、端到端时延与测量
K(Kerrisk)第 58 章 58.2(协议与层,PDF p1013–1019)4.1.13背景:分层的内核映射
N(深入理解 Linux 网络)第 1 章(术语与约定,PDF p17–24)4.1.13背景:收发包路径的术语
N(深入理解 Linux 网络)第 2 章 2.2(网卡到协议栈,PDF p27–49)4.1.22补充:ip_rcv 到 tcp_v4_rcv 的检查点

下一站:分层地图上最底下那两层——网线、网卡、MAC 地址、ARP 和交换机——是报文真正「落地」的地方。进入 4.2 物理层与数据链路层,看一个帧在局域网里怎么找到它的下一跳。