4.4 运输层:UDP 与 TCP
IP 已经把包送到主机了,为什么还需要一层「运输层」?端口和 socket 是什么关系,五元组为什么能唯一定位一条连接?UDP 只加 8 个字节,凭什么能撑起 DNS 和视频?TCP 首部的 20 个字节里,哪几个真正决定「可靠」?什么时候该用 UDP 而不是 TCP?
用五元组解释一条连接的唯一性;逐字段读出 TCP 首部并算出首部长度;用一段 C 程序验证「UDP 有报文边界、TCP 没有」;用 ss -tin 读出 cwnd、rtt、重传;在 TCP 和 UDP 之间做出有理由的选择。
4.3 结尾留了一个问题:网络层把包送到了主机,可主机上跑着几百个进程,包该交给谁?答案分两步:IP 首部的协议号先区分「TCP 还是 UDP」,运输层首部的端口号再区分「哪个进程」。这两级分用,就是运输层存在的第一个理由。
第二个理由是复用。一个进程可能同时连多个对端,一条物理链路承载成千上万条流,运输层负责把它们分开、编号、按序交付。第三个理由是语义:UDP 提供「尽力而为的报文」,TCP 提供「可靠的字节流」。两种语义对应两类完全不同的应用需求,这是本章的主线。
TCP 的细节很多,但这一章只讲到「能读懂一条连接」为止。握手挥手在 4.5,可靠传输与拥塞控制在 4.6。这里先把两个协议的首部、语义和适用场景立起来,后面的机制才有地方挂。
4.4.1 端口、五元组与 socket
端口是 16 位的整数,0–65535。它标识「主机上的哪个进程(或哪组进程)」,但单独一个端口不足以定位一条连接。真正的标识是五元组:
(协议, 源 IP, 源端口, 目的 IP, 目的端口)
协议这一维经常被忽略,但它是必需的:TCP 的 80 端口和 UDP 的 80 端口是两个完全不同的东西,内核里是两张独立的哈希表。四层分用里,IP 首部的协议号选表,端口号选表项。
五元组决定了一条连接的唯一性。这解释了三个常见现象:
- 服务端只用固定端口(443),却能接受几万条连接——因为源 IP 或源端口不同,五元组不同。
- 同一个客户端对同一个服务端,最多只能建约 2.8 万条连接——源端口只有那么多(4.1.4 讲过)。
SO_REUSEPORT让多个进程绑同一个端口,靠的是「四元组哈希 + 每进程一个监听 socket」,而不是「多条连接共享一个 socket」。
在内核里,五元组对应 struct sock 的四个字段加协议类型,查找靠哈希表:
| 协议 | 哈希表 | 查找函数 |
|---|---|---|
| TCP(已建立) | ehash(established hash) | __inet_lookup_established |
| TCP(监听) | lhash2(listener hash) | __inet_lookup_listener |
| UDP | udp_hash | udp4_lib_lookup |
监听 socket 和已连接 socket 分开存,是一个重要的性能设计:新连接到达时先查 ehash(精确匹配,O(1)),查不到再查 lhash2(前缀匹配,需要遍历同端口的监听者)。这样绝大多数包只走一次哈希。
复用(multiplexing) 是多个进程共用同一个网络接口发送数据,靠端口区分。分用(demultiplexing) 是接收端根据首部字段把数据交给正确的进程。套接字(socket) 是进程与协议栈之间的编程接口,在 Linux 里由 struct socket(面向文件系统的抽象)和 struct sock(面向协议的实现)两层组成——4.10 会拆开看。
4.4.2 UDP:8 字节首部与三种典型用法
UDP 的首部只有 8 字节,四个字段:
| 字段 | 长度 | 作用 |
|---|---|---|
| 源端口 | 2 字节 | 回包时用作目的端口;不需要回包时可填 0 |
| 目的端口 | 2 字节 | 分用依据 |
| 长度 | 2 字节 | 首部 + 载荷,最小 8,最大 65535 |
| 校验和 | 2 字节 | 覆盖伪首部 + UDP 首部 + 载荷;IPv4 下可填 0 表示不校验 |
「伪首部」是 UDP/TCP 校验和的一个特殊设计:计算时临时拼一个包含源 IP、目的 IP、协议号、长度的 12 字节结构放在前面,但不实际传输。它的作用是让校验和覆盖 IP 地址,防止包被错误地投递到另一台主机却校验通过。
UDP 只做四件事:分用、可选的检错、保留报文边界、不保证任何顺序和送达。这种「几乎什么都不做」的设计,恰好适合三类场景:
- 一问一答且能重试:DNS 查询就是一个 UDP 包出去、一个回答回来,丢了客户端自己重发。用 TCP 反而要多两次握手。
- 实时媒体:视频、语音、游戏。丢一帧的代价小于等一帧的延迟,TCP 的重传和队头阻塞在这里是负担。
- 自己实现可靠性的协议:QUIC 在 UDP 上实现可靠传输和拥塞控制,因为它需要「自己控制重传时机」,而内核 TCP 不给这个控制权。
UDP 的坑也很明确:没有拥塞控制。应用如果不自限速,会把网络打到拥塞崩溃。DNS 放大攻击(伪造源 IP 发小请求、骗大响应)就是利用这一点。所以现代 UDP 服务通常要在应用层做速率限制,或者用 QUIC 这种「带拥塞控制的 UDP」。
长度字段 16 位,最大值 65535,减去 20 字节 IP 首部和 8 字节 UDP 首部,载荷最多 65507 字节。但真正能发多少取决于 MTU:超过路径 MTU 的 UDP 包会在 IP 层被分片,任何一片丢失都会导致整个数据报被丢弃(接收端无法重组)。所以工程上 UDP 载荷通常控制在 1200–1400 字节,既避开分片,又给隧道封装留余量。
# 看本机所有 UDP 监听端口
ss -uanp
# 用 nc 做一个 UDP 回声测试(本机)
nc -u -l 127.0.0.1 9999 &
printf 'hello' | nc -u -w1 127.0.0.1 9999
# 抓一个 UDP 包,注意长度字段
sudo tcpdump -i lo -nn -vv -c 2 'udp port 9999'
4.4.3 TCP 首部:20 字节里的可靠性线索
TCP 首部固定 20 字节,带选项最多 60 字节。它的字段比 UDP 多得多,因为「可靠」需要额外的信息:序号、确认号、窗口、标志位。
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ┌────────────────┬────────────────┬────────────────────────────────┐ │ 源端口 16 │ 目的端口 16 │ │ ├────────────────┴────────────────┴────────────────────────────────┤ │ 序号 seq 32 │ ├──────────────────────────────────────────────────────────────────┤ │ 确认号 ack 32 │ ├───────┬───────┬─┬─┬─┬─┬─┬─┬─┬─┬──────────────────────────────────┤ │首部长 │ 保留 │C│E│U│A│P│R│S│F│ 窗口 16 │ │4 bit │ 3 bit │W│C│R│C│S│S│Y│I│ (可缩放,见选项) │ │ │ │R│E│G│K│H│T│N│N│ │ ├───────┴───────┴─┴─┴─┴─┴─┴─┴─┴─┼──────────────────────────────────┤ │ 校验和 16 │ 紧急指针 16 │ ├────────────────────────────────┴──────────────────────────────────┤ │ 选项(0–40 字节)+ 填充 │ └──────────────────────────────────────────────────────────────────┘
读这张图要抓住四组字段。第一组定位:源端口、目的端口,和 UDP 一样用于分用。第二组编号:seq 是「本段第一个字节在字节流里的位置」,ack 是「我期望收到的下一个字节」。第三组控制:六个标志位,SYN/FIN/RST 管连接,ACK 管确认,PSH 提示「尽快交给应用」,URG 基本废弃。第四组流控:窗口是「我还能收多少字节」,它是接收缓冲区的剩余空间。
标志位里最容易被误解的是 PSH。它不是「必须立刻发出去」,而是「告诉接收端尽快把数据交给应用」。现代实现里接收端本来就会尽快交付,所以 PSH 的实际作用很小。真正的「立刻发」由 TCP_NODELAY 控制,那是发送端行为。
首部长度 字段(data offset)的单位是 4 字节,最小值 5(20 字节),最大值 15(60 字节)。选项部分的长度 = 首部长度 × 4 − 20。常见的选项有:
| 选项 | 长度 | 作用 |
|---|---|---|
| MSS | 4 | 握手时告知「我一次最多能收多少数据」 |
| 窗口缩放(wscale) | 3 | 把 16 位窗口左移最多 14 位,突破 64 KB 上限 |
| SACK permitted | 2 | 允许选择性确认,只重传真正丢的段 |
| 时间戳(TS) | 10 | 更精确的 RTT 测量 + 防序号回绕(PAWS) |
| 无操作(NOP) | 1 | 对齐填充 |
这些选项只在握手时协商一次,之后一直生效。所以改窗口缩放或 SACK 必须重新建连接,这解释了一个运维常识:调完 TCP 参数要重启服务或等连接自然更替。
抓到一个 TCP 段的前 20 字节(十六进制):
c0 6a 01 bb 00 00 00 01 00 00 00 00 50 02 20 00 00 00 00 00
逐字段拆:
| 字节 | 字段 | 值 | 解读 |
|---|---|---|---|
| 0–1 | 源端口 | 0xc06a = 49258 |
客户端临时端口 |
| 2–3 | 目的端口 | 0x01bb = 443 |
HTTPS |
| 4–7 | 序号 | 1 |
这是第一个字节,常见于 SYN |
| 8–11 | 确认号 | 0 |
SYN 阶段还没有可确认的数据 |
| 12 | 首部长度 | 0x50,高 4 位 = 5 |
5 × 4 = 20 字节,无选项 |
| 13 | 标志位 | 0x02 |
只有 SYN 置位 |
| 14–15 | 窗口 | 0x2000 = 8192 |
未协商缩放前的接收窗口 |
| 16–17 | 校验和 | 0x0000 |
抓包点未计算(offload) |
| 18–19 | 紧急指针 | 0 |
未使用 |
这个段是「SYN,无选项,窗口 8192」。真实握手的 SYN 会带 MSS、SACK、时间戳、窗口缩放四个选项,首部长度通常是 40 字节(0xa0),所以看到 0x50 反而说明对方关掉了所有选项——这在老旧设备或刻意精简的嵌入式协议栈里才会出现。
4.4.4 两种语义:报文边界 vs 字节流
UDP 和 TCP 最本质的差别不在可靠性,而在语义:UDP 保留报文边界,TCP 是连续字节流。这个差别用一段程序就能看出来。
下面这段程序在本机回环上做两件事:UDP 发 3 个 4 字节的报文,收 3 次;TCP 一次写 12 字节,用 5 字节的小缓冲读 3 次。
/* udp_vs_tcp.c:对比「报文边界」与「字节流」 */
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
/* UDP:发 3 个报文,收 3 次,每次正好一个报文 */
static int udp_demo(void)
{
int s = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in a = { .sin_family = AF_INET };
socklen_t alen = sizeof a;
if (s < 0) return -1;
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (bind(s, (struct sockaddr *)&a, sizeof a) < 0) return -1;
if (getsockname(s, (struct sockaddr *)&a, &alen) < 0) return -1;
for (int i = 1; i <= 3; i++) {
char buf[8];
int n = snprintf(buf, sizeof buf, "msg%d", i);
sendto(s, buf, (size_t)n, 0, (struct sockaddr *)&a, sizeof a);
}
for (int i = 0; i < 3; i++) {
char buf[64];
ssize_t n = recv(s, buf, sizeof buf, 0);
printf("udp recv #%d: %zd 字节\n", i + 1, n);
}
close(s);
return 0;
}
/* TCP:一次写 12 字节,用小缓冲分多次读,观察「写边界」不存在 */
static int tcp_demo(void)
{
int lfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in a = { .sin_family = AF_INET };
socklen_t alen = sizeof a;
int one = 1;
if (lfd < 0) return -1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (bind(lfd, (struct sockaddr *)&a, sizeof a) < 0) return -1;
if (listen(lfd, 1) < 0) return -1;
if (getsockname(lfd, (struct sockaddr *)&a, &alen) < 0) return -1;
int cfd = socket(AF_INET, SOCK_STREAM, 0);
if (connect(cfd, (struct sockaddr *)&a, sizeof a) < 0) return -1;
int afd = accept(lfd, NULL, NULL);
if (afd < 0) return -1;
if (write(cfd, "AAABBBCCCDDD", 12) != 12) return -1;
ssize_t total = 0;
for (int i = 0; i < 3; i++) {
char buf[5]; /* 故意用小缓冲 */
ssize_t n = read(afd, buf, sizeof buf);
if (n <= 0) break;
total += n;
printf("tcp recv #%d: %zd 字节(累计 %zd)\n", i + 1, n, total);
}
close(afd); close(cfd); close(lfd);
return 0;
}
int main(void)
{
if (udp_demo() != 0 || tcp_demo() != 0) {
perror("demo");
return 1;
}
return 0;
}
编译并运行,输出是确定的:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 udp_vs_tcp.c -o udp_vs_tcp
./udp_vs_tcp
udp recv #1: 4 字节
udp recv #2: 4 字节
udp recv #3: 4 字节
tcp recv #1: 5 字节(累计 5)
tcp recv #2: 5 字节(累计 10)
tcp recv #3: 2 字节(累计 12)
UDP 的三次 recv 各拿到一个完整报文,边界清清楚楚。TCP 一次写入 12 字节,却被读成了 5、5、2 三段。这不是 bug,是契约:TCP 只保证字节顺序,不保证「一次读对应一次写」。
这条契约直接决定了两类 bug:
- 用 TCP 传消息却不做长度前缀/分隔符,接收端按「一次 read 一条消息」处理,会在网络分片、粘包时出错。所谓「TCP 粘包」不是 TCP 的毛病,是应用假设错了。
- 用 UDP 传大文件,以为「发 100 个包就能收 100 个包」,会在丢包时永远凑不齐。UDP 不重传,也不保证顺序。
保留边界需要「每条消息带长度」和「接收端缓冲整条消息」,这会把「应用自己的协议」塞进内核。TCP 选择只做字节流,把消息边界留给应用(HTTP 用 Content-Length,gRPC 用长度前缀)。这是分层原则的体现:内核只提供最小必要语义,复杂语义交给上层。
4.4.5 序号、确认与「可靠」的含义
TCP 的「可靠」有精确含义,不是「一定送达」,而是四条承诺:
- 不丢:丢了会重传,直到确认或连接断开。
- 不重:重复的段会被序号去重,应用不会读到两份。
- 不乱:接收端按序号重组,应用读到的字节顺序与发送顺序一致。
- 有界:如果对端长时间不确认,连接会被判定超时并关闭,不会无限等待。
实现这四条的核心是序号和确认号。序号不是「第几个包」,而是「第几个字节」:
发送方: seq=1000, len=500 → 接收方期望下一个是 1500
接收方: ack=1500 → 表示 1000–1499 都收到了
因为是字节编号,所以「确认 1500」同时意味着「1499 及之前全收到」。这种累积确认简洁高效,但有一个缺点:如果 1000–1499 里只有 1200 那一个字节丢了,接收方只能反复 ack 1000(因为 1000 没到,后面都算空洞),发送方无法知道到底丢了哪一段。这就是 SACK(选择性确认)要解决的问题——接收方额外告诉发送方「我还收到了 1500–2000」。
序号的初始值(ISN)不是 0,而是随机选的。原因有两个:防止旧连接的延迟包被误认为新连接的数据;增加攻击者猜测序号的难度(否则可以伪造 RST 或注入数据)。Linux 用基于时间戳和四元组哈希的算法生成 ISN,所以同一对地址短时间内重连,ISN 也不会重复。
序号还有一个 32 位回绕的问题。一个 1 Gbps 的连接,32 位序号大约 34 秒就绕一圈。如果此时有旧的延迟包带着「看起来合法」的序号到达,接收端会把它当新数据。时间戳选项(PAWS,Protection Against Wrapped Sequences)就是为此设计的:每个段带上发送时间,接收端丢弃时间戳倒退的段。这就是为什么高带宽连接上时间戳选项几乎是必需的,而不只是为了更准的 RTT。
累积确认(cumulative ACK):ack 号表示「这个序号之前的所有字节都收到了」。它简单,但丢一个字节就会让后面的确认全部「卡住」。选择性确认(SACK):在选项里额外报告「我还收到了哪些不连续的块」,让发送方只重传真正缺失的部分。SACK 在 Linux 上默认开启,是高丢包链路性能的关键。
4.4.6 流量控制:别把接收端撑爆
流量控制解决的是「发送方太快、接收方来不及处理」。机制很简单:接收方在每个 ACK 里带一个窗口(window),表示「我还能接收多少字节」。发送方在途未确认的数据不能超过这个值。
窗口来自接收缓冲区的剩余空间:
可用窗口 = 接收缓冲区大小 - 已收到未读数据
SO_RCVBUF 设置缓冲区大小,但实际值会被内核翻倍(用于开销)并对齐。窗口字段只有 16 位,最大 65535;要突破这个限制必须用窗口缩放选项,把窗口值左移 wscale 位,最大 2¹⁴ 倍,也就是约 1 GB。
窗口缩放到 0 时会发生什么?发送方停止发送,启动零窗口探测(zero window probe):隔一段时间发一个 1 字节的探测包,问「现在能收了吗」。如果接收端应用一直不读,窗口一直是 0,连接就卡在那里——这就是经典的「Recv-Q 堆满、Send-Q 也堆满、双方都不动」的僵死状态。
# 看接收队列与发送队列:Recv-Q 是已收到未读,Send-Q 是已发未确认
ss -tn state established '( sport = :8080 )'
# 看窗口与拥塞窗口(-i 显示 tcp_info)
ss -tin state established '( sport = :8080 )' | head -4
ss -ti 的输出里,rwnd 是接收窗口,cwnd 是拥塞窗口,snd_wnd 是对方通告的窗口。实际能发多少 = min(cwnd, snd_wnd):前者受网络拥塞限制,后者受接收端处理能力限制。分不清这两个,就会把「接收端太慢」误判成「网络带宽不够」。
4.4.7 拥塞控制:一句话版本
流量控制防止「撑爆接收端」,拥塞控制防止「压垮网络」。两者的信号不同:流量控制用接收方通告的窗口,拥塞控制用丢包或延迟。
拥塞控制的核心是一个叫拥塞窗口(cwnd) 的变量。发送方维护它,实际发送量取 min(cwnd, rwnd)。算法分四个阶段:
| 阶段 | 触发 | 行为 |
|---|---|---|
| 慢启动 | 连接开始或超时后 | cwnd 从 1–10 个 MSS 开始,每个 RTT 翻倍(指数增长) |
| 拥塞避免 | cwnd 达到 ssthresh | 每个 RTT 加 1 个 MSS(线性增长) |
| 快重传 | 收到 3 个重复 ACK | 立刻重传丢失段,不等超时 |
| 快恢复 | 快重传之后 | ssthresh 减半,cwnd 从新 ssthresh 开始,避免回到慢启动 |
这四步是 TCP Reno 的经典模型。现代 Linux 默认用 CUBIC,在高带宽长距离链路上表现更好;Google 的 BBR 则改用「带宽 × 延迟」建模,不把丢包当拥塞信号。这些细节在 4.6 展开,这里只需要记住:cwnd 是发送方自己维护的,不是协议字段,所以不同内核版本的默认算法不同,表现也不同。
4.4.8 TCP 还是 UDP:一张决策表
选协议不是「TCP 可靠所以更好」,而是「哪种语义匹配应用」。下面这张表按需求组织:
| 需求 | 选 TCP | 选 UDP | 理由 |
|---|---|---|---|
| 数据不能丢(文件、数据库) | ✓ | 重传和保序是刚需 | |
| 延迟敏感(语音、游戏) | ✓ | 等重传比丢一帧更糟 | |
| 一问一答、可重试(DNS) | ✓ | 省掉握手,客户端自己重发 | |
| 需要广播/组播 | ✓ | TCP 只支持单播 | |
| 穿越 NAT、防火墙 | ✓ | 大多数网络放行 TCP 443 | |
| 自己实现拥塞控制(QUIC) | ✓ | 需要控制重传时机 | |
| 极低延迟的本地通信 | ✓ | Unix domain socket 更快,见 4.11 |
表里最容易被忽视的是「穿越 NAT」。UDP 没有连接状态,NAT 设备要靠超时来判断「这条映射还在不在」,通常 30 秒到 5 分钟。所以长连接的 UDP 应用必须自己发心跳保活,否则 NAT 映射会被回收,表现为「空闲一会儿就断」。
另一个常见误区是「UDP 比 TCP 快」。在没有丢包的链路上,UDP 的确少了握手和拥塞控制的开销,但差距通常只有百分之几。在有丢包的链路上,没有拥塞控制的 UDP 会把网络打得更糟,最终吞吐反而下降。UDP 快是因为它做得少,不是因为它更高效。
4.4.9 观测一条连接:ss -tin 字段详解
ss 是这一篇最重要的观测工具。-t 看 TCP,-i 看内部信息,-n 不解析名字。一条典型的输出:
ESTAB 0 0 127.0.0.1:51234 127.0.0.1:8080
cubic wscale:7,7 rto:204 rtt:0.05/0.02 ato:40 mss:65483
cwnd:10 bytes_acked:1 bytes_received:1 segs_out:5 segs_in:4
send 10.5Mbps lastsnd:120 lastrcv:120 lastack:120
pacing_rate 21.0Mbps delivery_rate 10.5Mbps
app_limited rcv_space:65495 rcv_ssthresh:65495 minrtt:0.03
这些字段分成四组:
| 字段 | 含义 | 异常时的解读 |
|---|---|---|
cubic | 拥塞控制算法 | 与服务端不一致会导致行为差异 |
rto:204 | 重传超时,毫秒 | 持续增大说明 RTT 抖动大 |
rtt:0.05/0.02 | 平滑 RTT / 方差,毫秒 | RTT 突然变大说明排队 |
cwnd:10 | 拥塞窗口,单位 MSS | 长期很小说明有丢包或应用发得慢 |
retrans | 重传段数 / 总段数 | 比例 > 1% 要查链路 |
bytes_acked | 已确认字节数 | 用来算实际吞吐 |
send 10.5Mbps | 当前发送速率估计 | 与带宽对比判断是否跑满 |
delivery_rate | 交付速率(BBR 用) | 判断瓶颈在发送端还是网络 |
app_limited | 发送受应用限制 | 说明瓶颈在应用,不在网络 |
app_limited 是最有价值的一个标志。它表示「发送方想发但应用没给数据」,也就是说网络没满,是应用自己慢。看到这个标志,优化网络参数毫无意义,该去查应用逻辑或上游依赖。
ss -s看全局:连接数、TIME_WAIT 数、orphan 数。数字异常先查连接管理。ss -lnt看监听队列:Recv-Q是 accept 队列长度,接近 backlog 说明应用 accept 太慢。ss -tn state established看Send-Q:堆积说明对端不收或网络不通。ss -tin看cwnd、rtt、retrans、app_limited:区分网络问题与应用问题。nstat -az | grep -E 'TcpRetrans|TcpExtTCPLostRetransmit'看累计重传,确认是否长期存在。
4.4.10 TCP 的四个定时器:时间维度
TCP 的可靠性不只靠字段,还靠四个定时器。它们定义了「多久算超时」「等多久算死」。
| 定时器 | 启动时机 | 默认值 | 超时后做什么 |
|---|---|---|---|
| 重传定时器 | 发出数据后 | RTO(动态计算,最小 200 ms) | 重传最早的未确认段,RTO 指数退避 |
| 坚持定时器 | 收到零窗口通告 | 逐渐退避到 60 s 上限 | 发零窗口探测,防止死锁 |
| 保活定时器 | 连接空闲 | tcp_keepalive_time = 7200 s | 发探测,连续失败后断开 |
| TIME_WAIT 定时器 | 主动关闭方进入 TIME_WAIT | 2 × MSL = 60 s | 关闭连接,释放四元组 |
重传定时器最关键。RTO 不是固定值,而是根据 RTT 动态估算的(Jacobson/Karels 算法):RTT 抖动大,RTO 就放大;连续超时,RTO 翻倍退避(1、2、4、8…),直到 tcp_retries2(默认 15 次,约 15 分钟)后放弃。这意味着一条已经断掉的连接,可能要十几分钟才被内核判定死亡——这是很多「连接假死」问题的根源。
坚持定时器解决零窗口死锁。接收端窗口为 0 后,如果它后来有了空间,会发一个窗口更新 ACK;但这个 ACK 如果丢了,双方就互相等:发送方等窗口更新,接收方等数据。坚持定时器让发送方定期探一下,打破这个僵局。
保活定时器默认 2 小时,太长了,实际业务通常自己发应用层心跳,或者调小:
# 看三个保活参数:空闲多久开始探、探测间隔、失败几次断开
sysctl net.ipv4.tcp_keepalive_time net.ipv4.tcp_keepalive_intvl net.ipv4.tcp_keepalive_probes
# 看重传上限:超过这个次数就放弃连接
sysctl net.ipv4.tcp_retries2
tcp_retries2 = 15 对应的实际时间取决于 RTO,通常是 13–30 分钟。对延迟敏感的服务,这个值太大——连接早就该断了,内核还在重传。把它调到 5–8 是常见的做法,代价是网络抖动时可能误断。
4.4.11 校验和与 offload:为什么 tcpdump 说校验和错误
UDP 和 TCP 的校验和都覆盖一个伪首部(pseudo-header)。它不是真实传输的字段,只在计算时临时拼出来:
伪首部(12 字节,仅参与校验和计算) ┌───────────────────────┬───────────────────────┐ │ 源 IP 地址 32 │ 目的 IP 地址 32 │ ├───────────┬───────────┼───────────────────────┤ │ 全 0 8 │ 协议 8 │ TCP/UDP 长度 16 │ │ │ 6 或 17 │ 首部 + 载荷 │ └───────────┴───────────┴───────────────────────┘ 实际计算顺序:伪首部 + TCP/UDP 首部(校验和字段填 0)+ 载荷
伪首部的作用是把 IP 地址纳入校验范围。如果没有它,一个包被错误地投递到另一台主机,只要 TCP 首部和载荷没坏,校验和仍然通过——数据就送错了地方。加上伪首部后,地址不匹配会导致校验失败。
这里有一个非常实用的坑:抓包时看到 incorrect checksum,大多数情况下不是真的错误。原因是校验和卸载(checksum offload):协议栈把校验和的计算推迟到网卡,抓包点在网卡之前,看到的是「还没算好」的字段。接收端网卡会重新校验,所以包其实是好的。
# 看网卡的校验和卸载能力
ethtool -k eth0 | grep -E 'tx-checksum|rx-checksum'
# 临时关掉(仅用于验证抓包结论)
sudo ethtool -K eth0 tx off
判断方法:如果只有发出的包校验和错误、收到的包正常,基本可以确定是 TX offload。如果收到的包也错,才需要怀疑链路或内存问题。这个区别能省下大量误判时间。
内核也记这类错误,只是计数名字不那么直观:nstat -az | grep -i csum 会列出 TcpInCsumErrors、UdpInErrors 等。这些计数增长说明接收到的包真的坏了,与抓包点的 offload 假象不是一回事。把两者分开看,才能判断该换网卡还是该改抓包参数。
IPv4 的 UDP 允许校验和字段为 0,表示「不校验」;TCP 不允许,校验和是强制的。IPv6 更严格:UDP 校验和也变成强制,因为 IPv6 首部没有校验和,链路层又不保证端到端,运输层必须自己兜底。所以同一个应用在 IPv4 上能跑、迁到 IPv6 就报错,原因可能只是某个老代码把 UDP 校验和写成了 0。
4.4.12 从字节到结构体:tcphdr、udphdr 与 tcp_sock
4.4.3 讲的是字段语义,这一节把它们与内核里的 C 声明对齐:抓包看到的每个字节,在 struct tcphdr 里都有确定的类型、位宽和字节序。读错类型不会编译失败,只会得到安静的错误答案。
struct tcphdr 定义在 include/uapi/linux/tcp.h,用户态包含 <linux/tcp.h> 就能看到:
| 字段 | 类型 | 位宽 | 字节序 | 含义 | 常见读错 |
|---|---|---|---|---|---|
source | __be16 | 16 | 网络序 | 源端口 | 直接当主机序打印,得到 0x6ac0 这类反值 |
dest | __be16 | 16 | 网络序 | 目的端口 | 同上 |
seq | __be32 | 32 | 网络序 | 本段首字节的序号 | 用 %u 打印,看似随机 |
ack_seq | __be32 | 32 | 网络序 | 期望收到的下一个序号 | 忘记 ACK 位为 0 时该字段无效 |
doff | __u16:4 | 4 | 主机序 | 首部长度,单位 4 字节 | 当成字节数,20 被读成 5 |
res1 | __u16:4 | 4 | 主机序 | 保留位;Linux 未实现 RFC 3540 的 NS | 把它当 ECN 位 |
cwr/ece | __u16:1 | 各 1 | 主机序 | ECN 信号 | 与 IP 首部 ECN 字段混为一谈 |
urg/ack/psh/rst/syn/fin | __u16:1 | 各 1 | 主机序 | 控制位 | 用 th->ack 判断「是不是 ACK 包」却不看 doff |
window | __be16 | 16 | 网络序 | 接收窗口(未缩放) | 忘记左移 wscale |
check | __be16 | 16 | 网络序 | 校验和 | 抓包点 offload 导致误判 |
urg_ptr | __be16 | 16 | 网络序 | 紧急指针 | 现代应用几乎不用 |
两个容易忽略的细节:
- 位域布局依赖字节序。内核用
#if defined(__LITTLE_ENDIAN_BITFIELD)把res1:4, doff:4与fin…cwr的声明顺序反过来写,保证小端机器上抓到的字节仍按 RFC 解释。你自己解析首部时不要照抄某一端的位域声明,老老实实按字节取位。 doff是首部长度,不是选项长度。选项长度 =doff × 4 − 20;doff < 5的段在内核里直接被丢弃(tcp_validate_incoming之前的长度检查)。
UDP 的首部简单到可以一次读完:
| 字段 | 类型 | 含义 | 边界 |
|---|---|---|---|
source | __be16 | 源端口 | 可为 0(不需要回包时) |
dest | __be16 | 目的端口 | 分用依据 |
len | __be16 | 首部 + 载荷 | 最小 8;与 IP 总长不一致时丢弃 |
check | __be16 | 校验和 | IPv4 可 0;IPv6 不可 |
用户态程序读这两个结构体,只需要一段 12 行的循环:
/* 从抓到的以太网帧偏移 34 字节处读 TCP 首部(无 VLAN、无选项) */
#include <linux/tcp.h>
#include <arpa/inet.h>
#include <stdio.h>
static void dump_tcp(const unsigned char *p)
{
const struct tcphdr *th = (const struct tcphdr *)p;
printf("sport=%u dport=%u seq=%u ack=%u doff=%u flags=%c%c%c%c%c%c win=%u\n",
ntohs(th->source), ntohs(th->dest),
ntohl(th->seq), ntohl(th->ack_seq), th->doff,
th->urg ? 'U' : '-', th->ack ? 'A' : '-', th->psh ? 'P' : '-',
th->rst ? 'R' : '-', th->syn ? 'S' : '-', th->fin ? 'F' : '-',
ntohs(th->window));
}
关键行是 ntohs/ntohl 与 th->doff:前者修正字节序,后者给出首部长度。把这段和 tcpdump -XX 的十六进制对照,能建立「字节 ↔ 字段」的直觉。
内核里,一条连接的「五元组 + 状态 + 缓冲区」并不在 tcphdr 里,而在三层结构体中:
| 结构体 | 头文件 | 管什么 | 关键字段 |
|---|---|---|---|
struct sock | include/net/sock.h | 与协议无关的套接字状态 | sk_state、sk_rcvbuf、sk_sndbuf、sk_receive_queue、sk_write_queue、sk_wq、sk_err |
struct inet_sock | include/net/inet_sock.h | IPv4 地址与端口 | inet_saddr、inet_daddr、inet_sport、inet_dport、inet_num |
struct inet_connection_sock | 同上 | 面向连接的公共部分 | icsk_accept_queue、icsk_retransmits、icsk_pmtu_cookie、icsk_rto |
struct tcp_sock | include/linux/tcp.h | TCP 专用状态机与拥塞控制 | rcv_nxt、snd_una、snd_nxt、snd_cwnd、srtt_us、rx_opt |
访问方式是逐层强转:inet_sk(sk)、inet_csk(sk)、tcp_sk(sk)。这些强转没有运行时检查,转错类型读到的是另一块内存——这是内核模块里最常见的崩溃来源之一。
struct tcp_sock 里与观测最相关的字段:
| 字段 | 类型 | 单位 | 谁改 | 对应 ss 字段 |
|---|---|---|---|---|
rcv_nxt | u32 | 序号 | 收包路径 | 不直接显示 |
snd_una | u32 | 序号 | 收到 ACK | 用于算在途字节 |
snd_nxt | u32 | 序号 | 发送 | 用于算在途字节 |
snd_cwnd | u32 | MSS 段数 | 拥塞控制 | cwnd |
snd_ssthresh | u32 | MSS 段数 | 拥塞控制 | ssthresh |
snd_wnd | u32 | 字节 | 对端窗口更新 | snd_wnd |
rcv_wnd | u32 | 字节 | 本端收包/应用读取 | rcv_space 的一部分 |
srtt_us | u32 | 微秒 × 8 | RTT 采样 | rtt(除以 8) |
mdev_us | u32 | 微秒 | RTT 采样 | rtt 的第二个数 |
rto | u32 | jiffies | RTO 计算 | rto(换算成毫秒) |
total_retrans | u32 | 段 | 重传 | retrans 的分子 |
bytes_acked | u64 | 字节 | 收到 ACK | bytes_acked |
delivered | u32 | 字节 | ACK 交付速率采样 | delivery_rate 的输入 |
app_limited | u8 | 布尔 | 发送路径 | app_limited |
mss_cache | u16 | 字节 | PMTU/MSS 协商 | mss |
这张表解释了一个常见困惑:ss 里的 cwnd:10 不是 10 字节,而是 10 个 MSS;在 MSS=1460 时约 14.6 KB。把它当字节会得出「窗口怎么这么小」的错误结论。
ss -i 的数据来自 getsockopt(TCP_INFO),返回 struct tcp_info。这个结构体只增不改,内核按调用者传入的 optlen 决定拷贝多少:
| 字段组 | 代表字段 | 内核版本起点 | 用途 |
|---|---|---|---|
| 基础 | tcpi_state、tcpi_ca_state、tcpi_retransmits、tcpi_probes、tcpi_backoff | 2.6 | 状态与重传 |
| 选项 | tcpi_options、tcpi_snd_wscale、tcpi_rcv_wscale | 2.6 | 协商结果 |
| 定时 | tcpi_rto、tcpi_ato、tcpi_last_data_sent | 2.6 | 超时量级 |
| 窗口 | tcpi_snd_cwnd、tcpi_snd_wnd、tcpi_rcv_wnd、tcpi_snd_ssthresh | 2.6 / 4.1 | 流控与拥塞 |
| 速率 | tcpi_delivery_rate、tcpi_busy_time、tcpi_rwnd_limited | 4.18 | BBR 与瓶颈判断 |
| 计数 | tcpi_bytes_acked、tcpi_bytes_received、tcpi_segs_out、tcpi_total_retrans | 4.1 / 4.2 | 吞吐与重传率 |
所以 ss 在旧内核上会少打几列,不是 bug,是 tcp_info 还没长到那么长。自己写程序时先传 sizeof(struct tcp_info),再用 getsockopt 返回的 optlen 判断内核填到哪;tcpi_options 里的 TCPI_OPT_SACK/TCPI_OPT_TS/TCPI_OPT_WSCALE/TCPI_OPT_ECN 位表示这次握手协商出了哪些选项。
sk_state与TCP_ESTABLISHED比较:sk_state是unsigned char,TCP_ESTABLISHED是枚举 1,比较没问题;但TCP_CLOSE是 7,别把「状态 7」当成「关闭中」。tcp_sk(sk)用在非 TCP socket 上:sk->sk_prot不是tcp_prot时强转读到的是别的协议私有区,字段全是垃圾。srtt_us的单位是「微秒 × 8」:直接除以 1000 得到的不是毫秒,而是毫秒 × 8。正确换算是srtt_us >> 3得微秒,再除 1000 得毫秒。
4.4.13 选项的真实编码:MSS、窗口缩放、SACK 与时间戳
4.4.3 列了选项的名字,这一节给出线格式。选项区是一串 TLV(type-length-value),type 1 字节,length 1 字节(包含 type 和 length 本身),value 变长。只有 EOL 和 NOP 是单字节。
| kind | 名称 | 长度 | value 内容 | Linux 常量 |
|---|---|---|---|---|
| 0 | End of Option List | 1 | 无 | TCPOPT_EOL |
| 1 | No-Operation | 1 | 无(用于对齐) | TCPOPT_NOP |
| 2 | MSS | 4 | 16 位最大段长 | TCPOPT_MAXSEG |
| 3 | Window Scale | 3 | 8 位移位数(0–14) | TCPOPT_WINDOW |
| 4 | SACK Permitted | 2 | 无 | TCPOPT_SACK_PERM |
| 5 | SACK | 变长 | n 个 8 字节块(左边界、右边界) | TCPOPT_SACK |
| 8 | Timestamps | 10 | TSval 32 位 + TSecr 32 位 | TCPOPT_TIMESTAMP |
| 28 | User Timeout | 4 | 16 位超时(秒) | TCPOPT_UTO |
| 34 | TCP Fast Open | 变长 | cookie | TCPOPT_FASTOPEN |
| 30 | MPTCP | 变长 | 多路径控制 | TCPOPT_MPTCP |
一个真实的 SYN 选项区(24 字节)逐字节拆开:
02 04 05 b4 01 03 03 07 01 01 04 02 01 01 08 0a 3f 2a 11 c4 00 00 00 00
| 字节 | 解读 |
|---|---|
02 04 05 b4 | kind=2,len=4,MSS=0x05b4=1460 |
01 03 03 07 | NOP + kind=3,len=3,shift=7 |
01 01 04 02 | 两个 NOP + kind=4,len=2(SACK permitted) |
01 01 08 0a … | 两个 NOP + kind=8,len=10,TSval=0x3f2a11c4,TSecr=0 |
24 字节选项 + 20 字节固定首部 = 44 字节,所以 doff 是 11(0xb0),抓包里看到 b0 不要以为坏了。选项区还可能补 NOP 让总长按 4 字节对齐,这就是那些「多余」的 01。
MSS 的算术。MSS 是「对方一次能收的最大 TCP 载荷」,不是 MTU:
| 链路 | MTU | IP 首部 | TCP 首部 | 通告 MSS |
|---|---|---|---|---|
| 以太网 + IPv4 | 1500 | 20 | 20 | 1460 |
| 以太网 + IPv6 | 1500 | 40 | 20 | 1440 |
| PPPoE + IPv4 | 1492 | 20 | 20 | 1452 |
| IPIP 隧道 + IPv4 | 1480 | 20 | 20 | 1440 |
| 回环 + IPv4 | 65536 | 20 | 20 | 65535 附近(受内核上限裁剪) |
MSS 在 SYN 里由双方各自通告;每个方向使用对端通告的值:A→B 用 B 通告的 MSS,B→A 用 A 通告的 MSS。实际发送还受本地 MTU/PMTU 约束,可能比通告值更小;不是「两边取较小者」。这解释了两个现象:容器 overlay 网络里 MSS 常常是 1452 或 1400;iptables -j TCPMSS --clamp-mss-to-pmtu 能修「能 ping 通但大包卡死」的问题。
窗口缩放。16 位窗口字段最大 65535,高带宽长延迟链路不够用。窗口缩放把窗口值左移 shift 位;shift 由双方在 SYN 里各自通告。发送方用对端通告的 shift 解释 snd_wnd,接收方用自己通告的 shift 通告 rcv_wnd;不是两边取 min:
实际窗口 = 首部 window 字段 << snd_wscale
最大窗口 = 65535 << 14 = 1,073,725,440 字节(约 1 GiB)
三个必须记住的边界:
- SYN 段本身的 window 不缩放。握手期间还没协商完,任何一方都不能假设对方已经应用了移位。
- shift 只协商一次。连接建立后再改
net.ipv4.tcp_window_scaling不影响已有连接,只影响新连接。 - 窗口字段为 0 时不缩放。零窗口通告就是 0,接收方靠窗口更新(window update)恢复。
SACK 块。SACK 选项的 value 是一组 32 位边界对,每对表示「这段我收到了」:
kind=5 len=18 | left=0x00001000 right=0x00002000 | left=0x00003000 right=0x00003800
40 字节选项区最多塞 4 个块(2 + 8×4 = 34,加两个 NOP 对齐到 36,仍有余量)。接收端在 tcp_sacktag_write_queue 里给 skb 打 sacked 标记,发送端据此只重传空洞。SACK 只报告「收到了什么」,不报告「丢了什么」——丢包判断仍靠重复 ACK 或 RACK/TLP。
时间戳。TSval 是发送方单调时钟(Linux 用 tcp_clock_ms(),带每 netns 的随机偏移,避免暴露 uptime),TSecr 回显对端最近一次 TSval。它有三个用途:
- 更准的 RTT:每个 ACK 都能配对,不必依赖重传歧义(Karn 算法)。
- PAWS:接收端记录
ts_recent,收到 TSval 更小的段直接丢弃,防止 32 位序号回绕后的旧包被当新数据。 - RTTM:只在有 TS 时才能测「被重传段的 ACK 到底确认的是哪一次发送」。
代价是每个段多 12 字节(10 字节选项 + 2 字节 NOP),在 64 字节小包场景下开销接近 20%。这也是某些游戏/交易场景关掉 net.ipv4.tcp_timestamps 的原因,代价是失去 PAWS 和 RTTM。
tcpdump -vvv 在 SYN 段打印:
Flags [S], seq 1234567890, win 64240, options [mss 1460,sackOK,TS val 123456 ecr 0,nop,wscale 7], length 0
反推:
- 本端通告 MSS 1460 → 出口 MTU 1500,走 IPv4。
wscale 7→ 本端接收窗口可放大到65535 << 7 ≈ 8 MB,与net.ipv4.tcp_rmem的最大值同一量级。win 64240是未缩放的窗口,64240 = 65535 − 1295,说明此时接收缓冲已用掉一部分(或受tcp_rmem默认值限制)。- 没有
ecn字样 → 本端没开 ECN,或对端不支持。
对端的 SYN-ACK 会带自己的 mss/wscale。两边取 min 后,ss -ti 里的 mss 与 wscale:7,7 就是最终结果。如果只看到 wscale:0,7,说明只有一个方向通告了缩放,另一个方向没开。
内核解析选项的入口是 tcp_parse_options()(net/ipv4/tcp_input.c),它把结果写进 struct tcp_options_received:
| 字段 | 含义 | 后续使用 |
|---|---|---|
mss_clamp | 对端通告的 MSS 上限 | tcp_sync_mss 算 mss_cache |
saw_mss | 是否收到 MSS 选项 | 没收到则用默认 536 |
wscale_ok / snd_wscale / rcv_wscale | 是否协商、两个方向的移位 | 窗口换算 |
sack_ok / sack_cnt | SACK 许可与块数 | 重传决策 |
tstamp_ok / ts_recent / rcv_tsval | 时间戳状态 | PAWS 与 RTT |
dsack | 是否允许 D-SACK | 检测伪重传 |
没收到 MSS 选项时,RFC 1122 规定默认 536 字节;Linux 对 SYN 段有 tp->advmss 兜底,但对端不发 MSS 仍按 536 处理。这是「老设备/中间盒吞选项」导致吞吐只有几百 KB/s 的常见原因:MSS 掉到 536,窗口再大也只能小步走。
同一条以太网链路,IPv4 的 MSS 是 1460,IPv6 是 1440,差的 20 字节就是 IP 首部从 20 变 40。如果同一台机器双栈访问同一服务,抓包会看到两个不同的 MSS,这不是两边配置不一致,而是地址族不同。判断 MTU 问题时先看地址族,再看隧道封装。
4.4.14 内核收包路径:ip_rcv → tcp_v4_rcv → tcp_rcv_established
这一节回答一个问题:一个 TCP 段从网卡进入内核后,经过哪些函数、在哪个点决定「交给哪条连接」、又在哪里唤醒进程。4.8 会把前半段(网卡、NAPI、GRO)拆开,这里从 IP 层开始。
先给全景表(Linux 6.8):
| 阶段 | 函数 | 文件 | 做的决定 | 失败计数 |
|---|---|---|---|---|
| IP 入口 | ip_rcv / ip_rcv_core | net/ipv4/ip_input.c | 版本、首部长度、校验和 | InHdrErrors、InCsumErrors |
| 预路由 | NF_HOOK(NF_INET_PRE_ROUTING) | netfilter | 目的地址是否本机 | 规则计数 |
| 路由 | ip_rcv_finish_core | 同上 | 查路由、选 dst_input | InNoRoutes |
| 本地交付 | ip_local_deliver | 同上 | 是否需要分片重组 | ReasmFails |
| 协议分用 | ip_local_deliver_finish → ip_protocol_deliver_rcu | 同上 | 按 iph->protocol 选 handler | InUnknownProtos |
| TCP 入口 | tcp_v4_rcv | net/ipv4/tcp_ipv4.c | 查 ehash/lhash2、校验、过滤 | TcpInErrs、ListenDrops |
| 连接处理 | tcp_v4_do_rcv | 同上 | 已建立走快路径,监听走握手 | TCPReqQFullDrop |
| 快路径 | tcp_rcv_established | net/ipv4/tcp_input.c | 纯 ACK / 按序数据 / 乱序 | TCPHPHits |
| 慢路径 | tcp_rcv_state_process | 同上 | 状态迁移(SYN/FIN/RST) | TCPAbortOn* |
| 入队 | tcp_data_queue | 同上 | 按序入 sk_receive_queue,乱序入 ofo_queue | TCPOFOQueue |
| 唤醒 | tcp_data_ready → sk_data_ready | 同上 / net/core/sock.c | 唤醒 sk_wq 上的等待者 | — |
ip_rcv 本身很短,主干是「校验 → 过 netfilter → 进路由」:
/* net/ipv4/ip_input.c,简化 */
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); /* 版本/ihl/校验和,失败即 kfree_skb */
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_finish 里最关键的一步是 ip_rcv_finish_core() 调用 ip_route_input_noref():如果目的地址是本机,dst_input 指向 ip_local_deliver;如果是转发,指向 ip_forward。同一个 ip_rcv,两种命运在这一行分叉。
ip_local_deliver 检查 ip_is_fragment(iph):有分片就先进 ip_defrag() 重组,重组失败返回 NF_DROP 并记 ReasmFails。重组完成后 ip_local_deliver_finish 把网络首部剥掉,交给 ip_protocol_deliver_rcu。
ip_protocol_deliver_rcu 在 inet_protos[] 数组里按协议号取出 handler:TCP 是 tcp_v4_rcv,UDP 是 udp_rcv,ICMP 是 icmp_rcv。数组是每 netns 一份,所以容器里注册的 raw socket 不会污染宿主机。
tcp_v4_rcv 是 TCP 的总入口,主干可以压成 20 行:
/* net/ipv4/tcp_ipv4.c,简化主干 */
int tcp_v4_rcv(struct sk_buff *skb)
{
const struct tcphdr *th = (const struct tcphdr *)skb->data;
struct sock *sk;
bool refcounted;
if (skb->pkt_type != PACKET_HOST) /* 不是发给本机的帧 */
goto discard_it;
if (th->doff < sizeof(struct tcphdr) / 4) /* 首部长度非法 */
goto bad_packet;
if (!pskb_may_pull(skb, th->doff * 4)) /* 线性区不够,拉取 */
goto discard_it;
if (skb_checksum_init(skb, IPPROTO_TCP, inet_compute_pseudo))
goto csum_error;
sk = __inet_lookup_skb(&tcp_hashinfo, skb, __tcp_hdrlen(th),
th->source, th->dest, &refcounted);
if (!sk)
goto no_tcp_socket; /* 回 RST */
process:
if (sk->sk_state == TCP_TIME_WAIT) /* TIME_WAIT 单独处理 */
goto do_time_wait;
if (sk->sk_state == TCP_LISTEN) { /* 监听:走握手 */
tcp_v4_do_rcv(sk, skb);
goto put_and_return;
}
tcp_v4_do_rcv(sk, skb); /* 已建立:快/慢路径 */
put_and_return:
if (refcounted) sock_put(sk);
return 0;
}
__inet_lookup_skb 先查 ehash(已建立连接,精确四元组),再查 lhash2(监听 socket,按端口 + 地址通配)。两次查找都失败就回 RST——这就是「连一个没监听的端口,立刻 connection refused」的内核动作。
tcp_v4_do_rcv 只做一次状态判断,然后分流:
/* net/ipv4/tcp_ipv4.c,简化 */
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
if (sk->sk_state == TCP_ESTABLISHED) { /* 绝大多数包走这里 */
tcp_rcv_established(sk, skb);
return 0;
}
if (sk->sk_state == TCP_LISTEN) { /* SYN / ACK / RST */
struct sock *nsk = tcp_v4_hnd_req(sk, skb);
...
}
return tcp_rcv_state_process(sk, skb); /* SYN_SENT/FIN_WAIT/... */
}
tcp_rcv_established 是性能关键。它先做「首部预测」(header prediction):如果段没有选项变化、序号正好等于 rcv_nxt、窗口有效、没有 URG/RST/SYN/FIN,就认为是一个「预期内的段」,只做两条最短路径之一:
- 纯 ACK(
len <= tcp_header_len):tcp_ack()更新snd_una、释放已确认的 skb、更新 RTT 与 cwnd,然后返回。 - 按序数据:
tcp_queue_rcv()把 skb 挂到sk_receive_queue,更新rcv_nxt,再调tcp_data_ready()唤醒进程。
任何条件不满足(乱序、带 FIN、带选项、序号不对)就落到 tcp_rcv_established 的慢路径:tcp_ack() → tcp_data_queue() → 必要时 tcp_ofo_queue()。快路径的意义不是「更快地做同一件事」,而是「避免进入完整的状态机」:少了标志位判断、选项解析和锁竞争。
tcp_data_ready 最终调到 sk->sk_data_ready,对普通 socket 就是 sock_def_readable:它取出 sk->sk_wq,调用 wake_up_interruptible_sync_poll(&wq->wait, EPOLLIN|EPOLLPRI|EPOLLRDNORM|EPOLLRDBAND)。
这一行唤醒就是 4.10 里 epoll 回调的触发点:sk_wq 上挂着阻塞的 read() 等待项和 epoll 的 ep_poll_callback,一次唤醒把它们都叫醒。收包路径和 epoll 的耦合只有这一个函数,理解了它,4.10 的 ep_poll_callback 就不神秘了。
TIME_WAIT 的处理是独立分支(tcp_timewait_state_process):它不查 tcp_sock,只看 inet_timewait_sock,能处理重复 FIN、新 SYN(可能重用连接)和非法段。TIME_WAIT 连接不占 struct tcp_sock 内存,这是它能堆到几万条还不崩的原因。
丢包点分布在整条路径上,每个点都有原因码(enum skb_drop_reason):
| 原因码 | 触发点 | 典型现象 |
|---|---|---|
SKB_DROP_REASON_NO_SOCKET | tcp_v4_rcv 查表失败 | 端口没监听,回 RST |
SKB_DROP_REASON_TCP_CSUM | 校验和失败 | 网卡/链路错误,TcpInCsumErrors |
SKB_DROP_REASON_TCP_FILTER | tcp_filter()(BPF cgroup) | 被 eBPF 主动丢 |
SKB_DROP_REASON_TCP_INVALID_SEQUENCE | 序号不在窗口内 | 收到旧包/攻击 |
SKB_DROP_REASON_SOCKET_FILTER | sk_filter()(经典 BPF) | tcpdump 之外的过滤器 |
SKB_DROP_REASON_TCP_ABORT_ON_MEMORY | 内存不足 | TCPAbortOnMemory 增长 |
排查「包到底在哪被丢」时,drop_monitor 或 bpftrace kprobe:kfree_skb_reason 能直接打印原因码,比逐个计数器猜快得多:
sudo bpftrace -e 'kprobe:kfree_skb_reason { @[args->reason] = count(); } interval:s:5 { print(@); clear(@); }'
这段脚本 5 秒打一次「按丢弃原因分类的计数」。如果 NO_SOCKET 占绝大多数,问题在服务没监听;如果 TCP_CSUM 增长,往网卡/链路查;如果 TCP_FILTER 增长,查 cgroup BPF 程序。
nstat -az | grep -E '^(Tcp|Ip|IpExt)'拿全量计数,先看增量而不是绝对值。TcpInErrs/TcpInCsumErrors增长 → 包坏了,查链路或 offload。ListenDrops/TCPReqQFullDrop增长 → 队列满,查somaxconn、tcp_max_syn_backlog和 accept 速度。TCPHPHits占比低(低于 80%)→ 快路径命中差,常见于大量小包带选项、或乱序严重。TCPOFOQueue增长 → 乱序,查多路径、bonding、GRO 与中间盒。TCPAbortOnMemory增长 → 协议栈内存压力,查tcp_mem与 cgroup 内存限制。
- 「
tcp_v4_rcv里查两次表」:__inet_lookup_skb内部先 ehash 后 lhash2,但对已建立连接只查一次 ehash,监听 socket 才会落到 lhash2。不要以为每个包都遍历监听表。 - 「快路径等于零拷贝」:快路径只是少走状态机,skb 仍然从环形缓冲区一路传到
sk_receive_queue,数据仍在页里,真正的零拷贝是MSG_ZEROCOPY/sendfile那一层(4.9、4.12)。 - 「
ip_rcv在进程上下文」:正常收包在软中断(NET_RX_SOFTIRQ)里,只有 RPS/backlog 或ksoftirqd才换上下文;在ip_rcv里睡眠会直接崩。 - 「
sk_data_ready只唤醒一个进程」:它唤醒sk_wq上所有等待者(除非用 exclusive wait),多进程阻塞在同一个 socket 上会出现惊群(4.10.14)。
4.4.15 UDP 的内核路径:无连接、错误队列与 ICMP
UDP 的收包路径比 TCP 短,但有两个 TCP 没有的分支:找不到 socket 时回 ICMP 端口不可达,以及错误通过错误队列而不是连接状态传递。
| 阶段 | 函数 | 文件 | 说明 |
|---|---|---|---|
| 入口 | udp_rcv | net/ipv4/udp.c | 直接调 __udp4_lib_rcv |
| 校验 | __udp4_lib_rcv | 同上 | 校验和、长度、分片 |
| 查找 | __udp4_lib_lookup | 同上 | 按端口哈希 + 四元组打分 |
| 入队 | udp_queue_rcv_skb → __udp_queue_rcv_skb | 同上 | 检查 sk_rcvbuf |
| 丢包 | sock_queue_rcv_skb_reason | net/core/sock.c | 返回 drop reason |
| 唤醒 | sk_data_ready | 同上 | 与 TCP 同一个回调 |
__udp4_lib_rcv 的第一步是校验和。UDP 校验和是可选的(IPv4 下 check == 0 表示不校验),但一旦非 0 就必须正确:
/* net/ipv4/udp.c,简化 */
int __udp4_lib_rcv(struct sk_buff *skb, struct udp_table *udptable, int proto)
{
struct sock *sk;
...
if (udp_lib_checksum_complete(skb))
goto csum_error; /* 记 UdpInErrors */
...
sk = __udp4_lib_lookup(net, iph->saddr, uh->source, iph->daddr, uh->dest,
inet_iif(skb), sdif, udptable, skb);
if (!sk) {
/* 没有 socket:回 ICMP 端口不可达,受 icmp_ratelimit 限制 */
__UDP_INC_STATS(net, UDP_MIB_NOPORTS, proto == IPPROTO_UDPLITE);
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0);
goto drop;
}
...
udp_queue_rcv_skb(sk, skb);
return 0;
}
__udp4_lib_lookup 不是一次精确哈希,而是「哈希桶 + 线性打分」。因为 UDP 允许 SO_REUSEPORT 多个 socket 绑同一端口,也允许绑 0.0.0.0 的通配 socket 与绑具体地址的 socket 共存:
| 候选 | 匹配度 | 选择规则 |
|---|---|---|
四元组完全匹配(已 connect) | 最高 | 直接命中 |
| 本地地址 + 端口匹配 | 高 | 优先于通配 |
| 通配地址 + 端口匹配 | 低 | 同分时按 SO_REUSEPORT 组哈希 |
| 端口都不匹配 | 无 | 回 ICMP 端口不可达 |
入队时要过 sk_rcvbuf 这一关。UDP 的缓冲记账按 skb 的 truesize,不是按载荷长度:一个 100 字节的 UDP 包,算上 sk_buff、页片段、对齐,实际占用可能是 768 字节甚至更多。所以 SO_RCVBUF=1 MB 并不等于「能排 1 万个 100 字节的包」:
/* net/ipv4/udp.c,简化 */
static int __udp_queue_rcv_skb(struct sock *sk, struct sk_buff *skb)
{
int rc = sock_queue_rcv_skb_reason(sk, skb, &reason);
if (unlikely(rc)) {
if (rc == -ENOMEM) __UDP_INC_STATS(..., UDP_MIB_RCVBUFERRORS, ...);
...
kfree_skb_reason(skb, reason);
}
return 0;
}
UdpRcvbufErrors 增长就是「应用读得太慢,接收缓冲满」。此时内核静默丢包,不会通知对端——UDP 没有重传,也没有窗口。
ICMP 错误怎么传回应用。当对端回「端口不可达」时,icmp_rcv 找到内层 UDP 首部,调 ip_icmp_error → udp_err:
- socket 未连接(
connect没调过):内核无法确定这个 ICMP 属于哪条「连接」,直接丢掉。 - socket 已连接且没开
IP_RECVERR:错误写进sk->sk_err,下一次recv/send返回ECONNREFUSED。这就是「UDP 是假连接,但 connect 之后能收到错误」的原因。 - socket 已连接且开了
IP_RECVERR:错误进sk_error_queue,用recvmsg(MSG_ERRQUEUE)读,能拿到sock_extended_err(含ee_errno、ee_origin、ee_type、ee_code、ee_info)。
/* 开启错误队列并读一条 ICMP 错误(Linux) */
int on = 1;
setsockopt(fd, IPPROTO_IP, IP_RECVERR, &on, sizeof on);
/* ... sendto 之后 ... */
struct msghdr msg = { .msg_control = cbuf, .msg_controllen = sizeof cbuf };
ssize_t n = recvmsg(fd, &msg, MSG_ERRQUEUE);
for (struct cmsghdr *c = CMSG_FIRSTHDR(&msg); c; c = CMSG_NXTHDR(&msg, c)) {
if (c->cmsg_level == IPPROTO_IP && c->cmsg_type == IP_RECVERR) {
struct sock_extended_err *ee = (struct sock_extended_err *)CMSG_DATA(c);
fprintf(stderr, "errno=%u origin=%u type=%u code=%u\n",
ee->ee_errno, ee->ee_origin, ee->ee_type, ee->ee_code);
}
}
ee_origin == SO_EE_ORIGIN_ICMP 表示来自网络,SO_EE_ORIGIN_LOCAL 表示本机生成(例如 EMSGSIZE)。大批量 UDP 服务必须处理错误队列,否则 ICMP 错误会不断把 sk_err 置位,表现为「偶发 ECONNREFUSED」或「send 成功但数据没到」。
UDP 相关的计数器集中在 /proc/net/snmp 的 Udp: 行:
| 计数器 | 含义 | 增长说明 |
|---|---|---|
InDatagrams | 成功交付的报文 | 正常 |
NoPorts | 无 socket 的报文 | 有人打错端口,或服务没起 |
InErrors | 校验/长度错误 | 链路问题或伪造包 |
RcvbufErrors | 接收缓冲满 | 应用读得慢 |
SndbufErrors | 发送缓冲满 | 应用发得太快 |
IgnoredMulti | 非组播 socket 收到组播 | 通常是配置问题 |
connect() 一个 UDP socket 并不发任何包,它只做三件事:绑定四元组、检查路由是否可达、允许内核把 ICMP 错误映射到这个 socket。此后可以用 send()/recv() 而不是 sendto()/recvfrom(),内核还允许 write()/read()。代价是一个 socket 只能对一个对端,收益是查找更快、错误可见。QUIC 实现普遍对每个连接用一个 connect 过的 UDP socket,就是为了拿到错误和 SO_REUSEPORT 的分流。
4.4.16 程序一:读 TCP_INFO,把一条连接的状态打出来
这段程序连接指定主机端口,然后连续两次调用 getsockopt(TCP_INFO):第一次在刚连上时,第二次在发了一个字节之后。它验证三件事:tcp_info 的结构随内核增长、cwnd 的单位是 MSS、rtt 是微秒而不是毫秒。
/* tcpinfo.c:读取并解释 struct tcp_info(Linux);非 Linux 平台只做连接测试 */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netdb.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>
#ifdef __linux__
#include <netinet/tcp.h>
#include <linux/tcp.h>
#endif
static int connect_tcp(const char *host, const char *port, char *peer, size_t peerlen)
{
struct addrinfo hints = { .ai_family = AF_UNSPEC, .ai_socktype = SOCK_STREAM };
struct addrinfo *res = NULL;
int fd = -1, rc = getaddrinfo(host, port, &hints, &res);
if (rc != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
return -1;
}
for (struct addrinfo *ai = res; ai != NULL; ai = ai->ai_next) {
fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
if (fd < 0)
continue;
if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) {
if (getnameinfo(ai->ai_addr, ai->ai_addrlen, peer, (socklen_t)peerlen,
NULL, 0, NI_NUMERICHOST | NI_NUMERICSERV) != 0)
snprintf(peer, peerlen, "?");
fcntl(fd, F_SETFD, FD_CLOEXEC);
break;
}
close(fd);
fd = -1;
}
freeaddrinfo(res);
return fd;
}
static void show_info(int fd, const char *tag)
{
#ifdef __linux__
struct tcp_info ti;
socklen_t len = sizeof ti;
memset(&ti, 0, sizeof ti);
if (getsockopt(fd, IPPROTO_TCP, TCP_INFO, &ti, &len) < 0) {
perror("getsockopt(TCP_INFO)");
return;
}
printf("[%s] state=%u ca_state=%u optlen=%u/%zu\n", tag,
ti.tcpi_state, ti.tcpi_ca_state, (unsigned)len, sizeof ti);
printf(" mss=%u rto=%uus ato=%uus rtt=%uus rttvar=%uus\n",
ti.tcpi_snd_mss, ti.tcpi_rto, ti.tcpi_ato, ti.tcpi_rtt, ti.tcpi_rttvar);
printf(" cwnd=%u ssthresh=%u snd_wnd=%u rcv_wnd=%u\n",
ti.tcpi_snd_cwnd, ti.tcpi_snd_ssthresh, ti.tcpi_snd_wnd, ti.tcpi_rcv_wnd);
printf(" unacked=%u retrans=%u total_retrans=%u\n",
ti.tcpi_unacked, ti.tcpi_retrans, ti.tcpi_total_retrans);
printf(" bytes_acked=%llu segs_out=%u delivery_rate=%llu B/s app_limited=%u\n",
(unsigned long long)ti.tcpi_bytes_acked, ti.tcpi_segs_out,
(unsigned long long)ti.tcpi_delivery_rate,
ti.tcpi_delivery_rate_app_limited);
#else
(void)fd;
printf("[%s] TCP_INFO 仅 Linux 提供;本机不是 Linux,跳过。\n", tag);
#endif
}
int main(int argc, char **argv)
{
char peer[NI_MAXHOST];
struct timespec ts = { .tv_sec = 0, .tv_nsec = 50 * 1000 * 1000 };
if (argc != 3) {
fprintf(stderr, "用法: %s <host> <port>\n", argv[0]);
return 2;
}
int fd = connect_tcp(argv[1], argv[2], peer, sizeof peer);
if (fd < 0) {
perror("connect");
return 1;
}
printf("connected to %s\n", peer);
show_info(fd, "after-connect");
if (write(fd, "x", 1) != 1)
perror("write");
nanosleep(&ts, NULL);
show_info(fd, "after-write");
close(fd);
return 0;
}
编译与运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 tcpinfo.c -o tcpinfo
# 本机起一个服务端,再连它
python3 -m http.server 18080 --bind 127.0.0.1 &
./tcpinfo 127.0.0.1 18080
预期输出(Linux 6.8,数值随内核与缓冲区变化):
connected to 127.0.0.1:18080
[after-connect] state=1 ca_state=0 optlen=232/232
mss=65483 rto=204000us ato=40000us rtt=52us rttvar=26us
cwnd=10 ssthresh=2147483647 snd_wnd=65483 rcv_wnd=65339
bytes_acked=1 segs_out=2 delivery_rate=0 B/s app_limited=0
四个关键读法:
state=1是TCP_ESTABLISHED(include/net/tcp_states.h),不是「1 号状态机」。cwnd=10是 10 个 MSS,约 640 KB(回环 MSS 大);在以太网上是 14.6 KB。ssthresh=2147483647是TCP_INFINITE_SSTHRESH,表示还没经历过拥塞,慢启动没有上限。optlen=232说明内核填满了整个结构;如果换成 4.9 内核,同一个程序会得到更小的值——getsockopt不会失败,只是后面的字段保持调用者缓冲区里的旧值。这是读tcp_info必须清零并检查optlen的原因。
非 Linux 平台(macOS/FreeBSD)走 #else 分支,只验证连接成功。同一个程序能编译、能跑,但拿不到 TCP 内部状态——这也是 ss 只存在于 Linux 的原因。
4.4.17 程序二:UDP 的假连接与 ICMP 错误队列
这段程序向一个没人监听的 UDP 端口发数据,然后分别用「不连接」「连接」「连接 + IP_RECVERR」三种方式观察错误。它验证一个反直觉的事实:UDP 的 connect() 不发包,但 connect() 之后内核对 ICMP 错误的处理完全不同。
/* udp_errq.c:观察 UDP 的 ICMP 端口不可达如何返回给应用 */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>
#ifdef __linux__
#include <linux/errqueue.h>
#endif
static void msleep(long ms)
{
struct timespec ts = { .tv_sec = ms / 1000, .tv_nsec = (ms % 1000) * 1000000L };
nanosleep(&ts, NULL);
}
static int make_udp(const char *ip, const char *port, int do_connect)
{
struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons((unsigned short)atoi(port)) };
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) {
perror("socket");
return -1;
}
if (inet_pton(AF_INET, ip, &a.sin_addr) != 1) {
fprintf(stderr, "bad address: %s\n", ip);
close(fd);
return -1;
}
if (do_connect && connect(fd, (struct sockaddr *)&a, sizeof a) < 0) {
perror("connect");
close(fd);
return -1;
}
return fd;
}
int main(void)
{
const char *ip = "127.0.0.1", *port = "9"; /* discard 端口,通常没人监听 */
/* 1. 不 connect:sendto 成功,ICMP 错误无人认领 */
int fd = make_udp(ip, port, 0);
if (fd < 0)
return 1;
struct sockaddr_in dst = { .sin_family = AF_INET, .sin_port = htons(9) };
inet_pton(AF_INET, ip, &dst.sin_addr);
ssize_t n = sendto(fd, "hello", 5, 0, (struct sockaddr *)&dst, sizeof dst);
printf("unconnected sendto -> %zd (%s)\n", n, n < 0 ? strerror(errno) : "ok");
msleep(20);
char buf[64];
n = recv(fd, buf, sizeof buf, MSG_DONTWAIT);
printf("unconnected recv -> %zd errno=%s\n", n, n < 0 ? strerror(errno) : "-");
close(fd);
/* 2. connect:第二次 send 很可能返回 ECONNREFUSED */
fd = make_udp(ip, port, 1);
if (fd < 0)
return 1;
n = send(fd, "hello", 5, 0);
printf("connected send #1 -> %zd errno=%s\n", n, n < 0 ? strerror(errno) : "-");
msleep(20);
n = send(fd, "hello", 5, 0);
printf("connected send #2 -> %zd errno=%s\n", n, n < 0 ? strerror(errno) : "-");
#ifdef __linux__
/* 3. IP_RECVERR:错误进错误队列,而不是覆盖 sk_err */
int on = 1;
if (setsockopt(fd, IPPROTO_IP, IP_RECVERR, &on, sizeof on) < 0)
perror("setsockopt(IP_RECVERR)");
(void)send(fd, "hello", 5, 0);
msleep(20);
for (int i = 0; i < 10; i++) {
char cbuf[256], data[64];
struct iovec iov = { .iov_base = data, .iov_len = sizeof data };
struct msghdr msg = {
.msg_iov = &iov, .msg_iovlen = 1,
.msg_control = cbuf, .msg_controllen = sizeof cbuf,
};
ssize_t r = recvmsg(fd, &msg, MSG_ERRQUEUE);
if (r < 0) {
if (errno == EAGAIN) { msleep(10); continue; }
printf("recvmsg(MSG_ERRQUEUE) -> %s\n", strerror(errno));
break;
}
for (struct cmsghdr *c = CMSG_FIRSTHDR(&msg); c != NULL; c = CMSG_NXTHDR(&msg, c)) {
if (c->cmsg_level == IPPROTO_IP && c->cmsg_type == IP_RECVERR) {
struct sock_extended_err *ee = (struct sock_extended_err *)CMSG_DATA(c);
printf("errqueue: errno=%u origin=%u type=%u code=%u info=%u\n",
ee->ee_errno, ee->ee_origin, ee->ee_type, ee->ee_code, ee->ee_info);
}
}
break;
}
#else
printf("IP_RECVERR/MSG_ERRQUEUE 仅 Linux 提供;本机跳过第 3 步。\n");
#endif
close(fd);
return 0;
}
编译运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 udp_errq.c -o udp_errq
./udp_errq
Linux 上的典型输出:
unconnected sendto -> 5 (ok)
unconnected recv -> -1 errno=Resource temporarily unavailable
connected send #1 -> 5 errno=-
connected send #2 -> -1 errno=Connection refused
errqueue: errno=111 origin=2 type=3 code=3 info=0
三个结论:
- 未连接 socket 的
sendto永远「成功」,ICMP 错误被内核丢弃,应用毫无感知。 connect后,ICMP 端口不可达把sk_err置为ECONNREFUSED,下一次 send/recv 才返回错误(第一次 send 仍然成功)。这是 UDP 服务「偶发 ECONNREFUSED」的根源:对端进程重启,端口暂时没人听。- 开了
IP_RECVERR后,错误进队列,ee_origin=2(SO_EE_ORIGIN_ICMP)、type=3(ICMP_DEST_UNREACH)、code=3(ICMP_PORT_UNREACH)。有了ee_info还能拿到触发错误的包的信息(如 MTU)。
4.4.18 实验:用 tcpdump、ss 与 bpftrace 读一条连接
实验一验证「首部字段与协商结果」,实验二验证「内核走的是快路径还是慢路径」。两个实验都只需要本机回环,不需要外网。
实验一:抓三次握手,逐项对上选项。
# 终端 1:起服务
python3 -m http.server 18080 --bind 127.0.0.1
# 终端 2:抓 6 个包,-S 显示绝对序号,-vvv 展开选项
sudo tcpdump -i lo -nn -S -vvv -c 6 'tcp port 18080'
# 终端 3:发起连接
curl -s -o /dev/null http://127.0.0.1:18080/
IP 127.0.0.1.51342 > 127.0.0.1.18080: Flags [S], seq 3912345678, win 65495,
options [mss 65495,sackOK,TS val 1234567 ecr 0,nop,wscale 7], length 0
IP 127.0.0.1.18080 > 127.0.0.1.51342: Flags [S.], seq 1122334455, ack 3912345679, win 65483,
options [mss 65495,sackOK,TS val 7654321 ecr 1234567,nop,wscale 7], length 0
IP 127.0.0.1.51342 > 127.0.0.1.18080: Flags [.], ack 1, win 128, length 0
读法:
-S让 seq/ack 显示绝对值,否则 tcpdump 显示相对值(第一个数据字节为 1),无法和ss对照。- SYN 的
win是未缩放的 65495;第三个包win 128已经缩放(128 × 2⁷ = 16384),因为握手完成后缩放生效。同一个连接窗口从 65495「变成」128,不是窗口变小,是显示口径变了。 mss 65495说明回环 MTU 65536;换到以太网会是 1460。TS val是毫秒时钟,ecr回显对端;nop,wscale 7里的 NOP 是对齐填充。
实验二:看快路径命中与收包耗时。
# 连接建立后,读出窗口、RTT、重传与快路径统计
ss -tinm 'sport = :18080'
nstat -az | grep -E 'TcpExtTCPHPHits|TcpExtTCPPureAcks|TcpRetransSegs|TcpInErrs'
# 用 bpftrace 统计快/慢路径调用次数与 tcp_v4_rcv 的耗时分布
sudo bpftrace -e '
kprobe:tcp_rcv_established { @fast[comm] = count(); }
kprobe:tcp_rcv_state_process { @slow[comm] = count(); }
kprobe:tcp_v4_rcv { @start[tid] = nsecs; }
kretprobe:tcp_v4_rcv /@start[tid]/ { @ns = hist(nsecs - @start[tid]); delete(@start[tid]); }
interval:s:5 { print(@fast); print(@slow); print(@ns); clear(@fast); clear(@slow); clear(@ns); exit(); }'
ss -tinm 输出里要盯住这几个字段:
| 字段 | 本次实验的预期 | 说明 |
|---|---|---|
wscale:7,7 | 两个方向都缩放 7 | 与抓包里的 wscale 7 对应 |
rtt:0.05/0.02 | 回环亚毫秒 | 单位是毫秒,rtt 用 srtt_us >> 3 换算 |
cwnd:10 | 初始 10 个 MSS | 慢启动起点,不是 10 字节 |
app_limited | 小文件下载时出现 | 说明瓶颈在应用/文件,不在网络 |
rcv_space | 约 6 MB 上限 | 受 net.ipv4.tcp_rmem 第三个数约束 |
bpftrace 的输出应该看到 @fast 的计数远大于 @slow:一个正常传输的连接,除了握手和挥手,几乎全部段都走 tcp_rcv_established。如果 @slow 比例很高,常见原因是大量短连接(每次握手都走 tcp_rcv_state_process)、频繁 RST、或大量带 FIN 的段。
@ns 的直方图能看出单次 tcp_v4_rcv 的耗时量级:回环通常在几百纳秒到几微秒;如果出现几十微秒的长尾,查 netfilter 规则数量、cgroup BPF 程序和 sk_filter。
4.4.20 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 用五元组解释连接唯一性 | 能说明为什么服务端能接几万连接 | 4.4.1 |
| 列出 UDP 首部四个字段 | 知道长度字段包含首部 | 4.4.2 |
| 逐字段读 TCP 首部 | 能算首部长度与选项长度 | 4.4.3 |
| 解释「TCP 没有报文边界」 | 能说清粘包是应用假设错误 | 4.4.4 |
| 区分流量控制与拥塞控制 | 知道 rwnd 与 cwnd 的来源 | 4.4.6、4.4.7 |
用 ss -tin 读出四个关键指标 | 会看 cwnd/rtt/retrans/app_limited | 4.4.9 |
| 说清四个定时器的量级 | 知道 RTO 最小值与 2MSL 是多少 | 4.4.10 |
| 解释抓包里的校验和错误 | 能区分 offload 与真实损坏 | 4.4.11 |
| 解析 SYN 选项并算出 MSS | 能从十六进制拆出 MSS/wscale/TS | 4.4.13 |
| 说出收包路径的三个分叉点 | 路由、协议号、ehash/lhash2 | 4.4.14 |
| 解释 UDP 的 ICMP 错误如何到应用 | 区分未连接/连接/IP_RECVERR | 4.4.15 |
读 tcp_info 并换算单位 | 知道 cwnd 是 MSS、rtt 是微秒 | 4.4.16 |
Q1 一台服务器监听 0.0.0.0:443,它能同时接受多少条 TCP 连接?限制因素有哪些?
Q2 UDP 首部的「长度」字段最小值是多少?如果把它设为 0 会发生什么?
Q3 一个 TCP 首部的 data offset 字段是 8,这个首部多少字节?选项占多少字节?
Q4 为什么 TCP 的初始序号要随机化?如果用固定的 0,会有什么风险?
Q5 ss -ti 显示 cwnd:10、snd_wnd:65495、app_limited。瓶颈在哪?该优化什么?
Q6 DNS 用 UDP,但响应超过 512 字节时怎么办?为什么现在很多 DNS 走 TCP?
答案
A1 理论上受五元组空间限制(约 2³² × 2¹⁶),实际受四个因素限制:文件描述符上限(ulimit -n / fs.file-max)、内核内存(每条连接约 2–3 KB 的 struct sock 加缓冲区)、accept 队列(somaxconn)、以及端口/内存的分配速度。4.11 会给出百万连接的具体算法。
A2 最小值是 8(首部本身)。长度字段为 0 在 IPv4 里是非法值(IPv6 允许 0 表示 jumbogram),大多数实现会直接丢弃并可能记入 InErrors。校验和字段为 0 才是合法的「不校验」。
A3 8 × 4 = 32 字节,选项占 32 − 20 = 12 字节。12 字节正好能放一个时间戳选项(10 字节)加 2 字节 NOP 填充。
A4 防止旧连接的延迟包被新连接误收(同一个四元组重用后,旧包可能还在网络里);同时增加攻击者伪造 RST 或注入数据的难度——如果 ISN 可预测,攻击者不需要看到握手就能猜出序号。RFC 6528 专门规定了 ISN 的生成算法。
A5 瓶颈在应用:app_limited 表示「有窗口但没数据可发」,cwnd 小是因为没发满,不是网络限制。该查应用的处理逻辑、上游依赖、线程模型,而不是调 TCP 参数。
A6 超过 512 字节时,传统 DNS 用截断标志(TC=1)让客户端改用 TCP 重查;EDNS0 把 UDP 上限提高到 4096 字节左右。现在很多 DNS 走 TCP 是因为 DoT/DoH 需要 TCP,且大响应(DNSSEC)在 UDP 下容易分片丢失。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| W(谢希仁) | 第 5 章 运输层(UDP、TCP 首部、可靠传输、流量控制、拥塞控制) | 4.4.1–4.4.8 | 完整:首部、语义、窗口、拥塞控制四阶段 |
| K(Kerrisk) | 第 58 章 58.6(传输层:端口、UDP、TCP) | 4.4.1、4.4.2 | 完整:端口分配与两种协议的编程视角 |
| K(Kerrisk) | 第 61 章 61.1–61.3、61.12(部分读写、recv/send、TCP vs UDP) | 4.4.4、4.4.8 | 精选:部分读写的成因与协议选择 |
| C(CSAPP) | 第 11 章 11.4(套接字接口) | 4.4.1、4.4.4 | 背景:socket API 与连接的建立 |
| N(深入理解 Linux 网络) | 第 7 章 7.3(TCP 连接相关内核对象) | 4.4.1 | 前置:struct sock 的字段来源 |
| W(谢希仁) | 第 5 章 5.5–5.9(TCP 首部、可靠传输、流量与拥塞控制,PDF p222–270) | 4.4.12、4.4.13 | 补充:选项编码与缩放算术 |
| K(Kerrisk) | 第 61 章 61.6.1–61.6.2(TCP 报文格式与序号,PDF p1084–1086) | 4.4.12、4.4.13 | 完整:字段定义与伪首部 |
| K(Kerrisk) | 第 61 章 61.7–61.8(netstat 与 tcpdump 观测,PDF p1092–1098) | 4.4.18 | 精选:观测方法 |
| N(深入理解 Linux 网络) | 第 2 章 2.2(网卡到协议栈,PDF p27–49)、第 7 章 7.3(TCP 内核对象,PDF p214–222) | 4.4.14、4.4.15 | 补充:路径与结构体 |
下一站:TCP 首部里的 SYN、ACK、FIN 三个标志位,组合出了一套连接生命周期。进入 4.5 TCP 连接建立与关闭,看三次握手、两个队列、四次挥手和 TIME_WAIT。