Linux 中网络包的一生

write()recv() 的实用导览。

你运行了 curl http://example.com,现在在终端里得到了一些 HTML,但实际上发生了什么?Linux 会让你的字节经过一套明确的步骤:选定一条路径,查找邻居的 MAC 地址,把包放在,请求网卡发送,然后在另一端执行反向的操作。

这篇文章尽量简单地解释这条路。如果你用过 Linux,运行过 curl,或者试过 ip addr,你完全有能够读懂这篇文章。不需要多么高深的背景。

注意:当我在这篇文章中说“内核”时,我实际上指的是“Linux 内核及其网络栈”,即内核中运行并移动数据包的部分。

我们要讲的内容

以下是我们将介绍的简化路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
your app
write()/send()
TCP (segments your bytes)
IP (chooses where to send them)
Neighbor/ARP (find the next-hop MAC)
qdisc (queueing, pacing)
driver/NIC (DMA to hardware)
wire / Wi‑Fi / fiber
NIC/driver (other host)
IP (checks, decides it's for us)
TCP (reassembles, ACKs)
server app

第一部分 传输:从 write() 到网线

步骤1:你的应用将字节传递给内核

你在 TCP 套接字上调用 send()write()。 内核接受你的缓冲区并顺序发送。

  • TCP 会将大的缓冲区拆分成大小适合路径的 数据段 (segments)。通信双方会在 TCP 握手 期间通告各自的 最大数据段大小 (MSS),发送方会将自身的数据段大小限制在对方通告的 MSS 内,同时还要受到当前 路径最大传输单元 (Path MTU) 以及任何 IP/TCP 选项(如:时间戳)的进一步约束。
  • 它会为每个数据段标记 序列号 (sequence numbers),以便接收方能够按正确的顺序进行重组。

[!info] 🔌 套接字
套接字 只是你程序的一个 通信端点。对于 TCP 而言,内核会为每个套接字维护状态信息,包括:序列号拥塞窗口 (congestion window)定时器 等。

[!info] 🤝 TCP 握手 (TCP Handshake)
TCP 握手 在任何write()到达对端之前,TCP 会进行快速三步设置:1)客户端-> 服务器:SYN,并带有选项(MSS、SACK 允许、窗口规模、时间戳、ECN)。2)服务器 -> 客户端:SYN-ACK 及其选项。3) 客户端 -> 服务器:ACK.双方就初始序列号和选项达成一致;州已成立。TLS 说明:对于 HTTPS 来说,TLS 握手是在 TCP 建立后运行的。

[!todo] 试试看
下载东西时运行 ss -tni。你会看到随着数据在线路上传输并被应用消耗,TCP 的发送和接收队列大小会波动。

步骤2:内核决定将数据发送到哪里(路由)

内核会查看目标 IP 并选择最匹配的路由。在典型的主机上,问题归结为:这个 IP 是在我的本地网络上,还是我应该交给网关?

  • 如果地址位于直接连接的网络上,则会通过该接口发送。
  • 否则,它会连接到你的默认网关(通常是路由器)。

[!todo] 试试看
ip route get 192.0.2.10
它会打印接口、下一跳(如果有)以及内核将使用的源 IP。

[!info] 策略路由
内核可以使用 ip rule查询多个路由表(例如按源地址或标记选择路由)。大多数笔记本和服务器使用主路由表。

步骤 3:内核学习下一跳 MAC(邻居/ARP)

IP 路由选择下一跳。为了实际发送以太网帧,内核需要该跳的 MAC 地址。

  • 如果内核已经知道下一跳(在邻居/ARP 缓存里),那很好。
  • 如果没有,它会发送广播 ARP 请求:“谁拥有 10.0.0.1?告诉我你的 MAC。”回复已被缓存。

[!todo] 试试看
ip neigh show
你会看到像 10.0.0.1 lladdr 00:11:22:33:44:55 REACHABLE 这样的条目。

[!info] ARP vs NDP
IPv4 使用 ARP(广播)。IPv6 使用NDP(组播)。原理相同:找到你网络中某个 IP 的链路层地址。

步骤 4:数据包等待其轮到(qdisc)

在 NIC 发送任何内容之前,数据包会进入队列领域(qdisc)。你可以把它看作是一个小的等待队伍加上一个交通警察,内核可以:

  • 平滑突发流量,避免链路泛滥和缓冲膨胀(大队列->高延迟),
  • 在不同流之间公平共享带宽,
  • 如果你已经配置了整形/速率限制规则,请强制执行。

[!todo] 试试看
tc qdisc show dev eth0
tc -s qdisc show dev eth0 # same, but with counters/stats
eth0 替换成你的实际接口名称(例如 enp3s0,wlp2s0)。

[!info] MTU 与 MSS
MTU 是链路能承载的最大 L2 负载(典型以太网为 1500 字节)。
MSS 是段内最大的 TCP 有效载荷,仅次于 IP + TCP 头部和选项。
在 TCP 握手过程中,双方都宣布自己可以接收的 MSS,发送方不会发送比对方通告 MSS 更大的段,并且也会遵守路径 MTU(PMTU)。
在 IPv4 常见的无选项情况下,MSS≈MTU−40 字节。选项进一步降低 MSS。

步骤 5:网卡驱动和 NIC 负责繁重工作

内核的网络驱动将你的数据包交给网卡(NIC),并将其放入一个小的传输队列,卡从中读取。NIC 随后:

  • 直接从内存(使用 DMA)提取字节,并将其转化为链路上的比特流、铜缆上的微小电压变化、光纤上的光脉冲,或者如果你用 Wi-Fi 时是无线电波。

那才是真正的“接线”时刻:内存中的数据变成了网络上的信号。

[!todo] 试试看
ip -s link show dev eth0
ethtool -S eth0 # NIC stats
ethtool -k eth0 # offloads enabled
eth0 替换成你实际的接口名称。

[!info] offloads 卸载
TSO/GSO:让网卡或栈将大型缓冲区拆分为 MTU 大小的帧。
校验和卸载:传输时,网卡在内核递交包后填写 IP/TCP 校验和,发送前,接收时网卡可以验证校验和并告知内核结果。GRO(接收时):将许多小数据包合并成更大的块以节省 CPU。

[!info] DMA
直接内存访问(DMA)允许网卡通过总线(例如 PCIe)直接读写你在 RAM 中的数据,而 CPU 无需复制字节。这就是网卡能高效地从transmit ring拉取帧(并放置接收帧)的原因。

步骤 6:上线

在以太网上,网卡发送一个帧,内容如下:

1
[ dst MAC | src MAC | EtherType (IPv4) | IP header | TCP header | payload | FCS ]

交换机关心以太网头部:它们查看目标 MAC 地址,并将帧转发到正确的端口。

路由器会查看 IP 头部,减少 TTL / Hop Limit,并在(IPv4)更新头部校验和后再将数据包转发到下一跳。

每台交换机和路由器逐跳重复此作,直到路由器最终获得直接到目的网络的路由,并将数据包传送到服务器的局域网。

[!info] frame vs packet 帧与包
数据包是 IP 级单元(IP 头部+TCP/UDP+有效载荷)。 帧是指该数据包在特定链路上(例如以太网)上通过 src/dst MAC 和校验和传输的方式。

第二部分 接收:从线路回传到你的应用

步骤 7:网卡将数据传递给内核(NAPI)

在服务器端,网卡将收到的帧写入receive rings(内存中的小队列)。Linux 内核随后使用 NAPI 高效拉取数据包:快速中断后切换为轮询,一次性处理一批数据包。

[!info] NAPI
如果每个数据包都触发了满中断,忙碌的网卡可能会让 CPU 不堪重负。NAPI 的诀窍是:

  • 发起一次中断,
  • 暂时切换到轮询以排空大量数据包,
  • 然后重新启用中断。

中断减少,吞吐量更好。

步骤 8:IP 检查数据包并决定下一步行动

内核会验证 IP 头部(版本、校验和、TTL 等),然后问:“这个包是给我的吗?”

  • 如果目标 IP 与服务器的某个地址匹配,则该 IP 是本地的,并在堆栈中向上移动。
  • 如果没有,且启用了 IP 转发,内核可能会将它转发, 类似 Linux 路由器的表现。
  • 否则,数据包会被丢弃。

如果你使用防火墙,这时像 PREROUTING 和 INPUT(nftables/iptables)这样的钩子可以过滤、记录或 DNAT 流量,然后才会被送到本地套接字。在 POSTROUTING 中会发生 SNAT/假面舞会。对于本地生成的数据包,DNAT 也可能出现在输出中。

[!todo] 试试看
sudo nft list ruleset
# or, with iptables:
sudo iptables -L -n -v
sudo iptables -t nat -L -n -v

步骤 9:TCP 重新组装、确认并唤醒应用

TCP 协议栈会将段排序,检查缺失部分,并发送 ACK。当数据准备好时,它会唤醒在 recv() 中等待的进程。

[!todo] 试试看
ss -tni 'sport = :80 or dport = :80'
随着应用读取,接收队列(Recv-Q)会随着增长和缩小。

简短实用笔记

回环很特别(而且速度快)

发送到 127.0.0.1 的数据包从未到达物理网卡。路由仍然会进行,但所有内容都保留在仅软件的 lo 接口内存中。

桥接与路由(同一盒子,不同角色)

如果盒子是桥接器(例如带有 br0),它会在第 2 层转发帧,不会改变 TTL。如果是路由,它会在第 3 层转发,TTL 下降一跳。

NAT hairpin(为什么内部客户端会访问外部 IP)

通过路由器的公共 IP 从同一局域网访问服务需要“发夹式 NAT”。如果在这种情况下连接重置,请检查预路由后路由 NAT 规则。

IPv6

把 ARP 换成新民主党。否则,路径是相同的:

1
2
ip -6 route
ip -6 neigh

UDP 是故意的不同

UDP 不做排序、重传或拥塞控制。发送路径使用 udp_sendmsg,接收路径传输完整的数据报。你的应用自己负责处理数据丢失。

亲自看看(10个快速指令)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 1) Where would the kernel send a packet?
ip route get 192.0.2.10
# 2) What routes and rules exist?
ip route; ip rule
# 3) Who's my next hop?
ip neigh show
# 4) What's my firewall/NAT doing?
sudo nft list ruleset
# or:
sudo iptables -L -n -v
sudo iptables -t nat -L -n -v
# 5) Which sockets are active?
ss -tni
# 6) What's on the wire (swap eth0/host as needed)?
sudo tcpdump -ni eth0 -e -vvv 'host 192.0.2.10 and tcp port 80'
# 7) Are my queues healthy?
tc -s qdisc show dev eth0
# 8) Is my NIC happy?
ip -s link show dev eth0
ethtool -S eth0
# 9) Are counters hinting at a problem?
nstat -a | grep -E 'InErrors|OutErrors|InNoRoutes|InOctets|OutOctets'
# (Use `-z` instead of `-a` if you explicitly want to zero the counters.)
# 10) Is the path MTU safe?
tracepath 192.0.2.10 # discovers PMTU via ICMP: IPv4 "Fragmentation Needed" (Type 3, Code 4) / IPv6 "Packet Too Big" (Type 2)

ARP/邻居问题

IP neigh 显示失败或不断切换状态——> L2 可达性、VLAN 标记或交换机过滤问题。

MTU / PMTU 黑洞

小 ping 可以正常,大传输会卡顿——>MTU 不匹配或 ICMP 被阻。

允许 PMTU 信号通过防火墙(IPv4:ICMP 类型 3 代码 4“需要分段”,IPv6:ICMPv6 类型 2“数据包过大”)或修复 MTU。

反向路径滤波器的痛点

非对称路由 + rp_filter=1 会丢弃返回流量。使用 rp_filter=2(松散)或使路由对称。

NAT 的惊喜

SNAT/MASQUERADE 错误地重写了来源,所以回复无效。检查 NAT 规则和 conntrack -L。

Backlog/accept pressure

新连接在大负载下重置 -> 增加应用backlognet.core.somaxconn,确保应用能及时处理accept

爆发产生的缓冲膨胀

如果遇到队列过大严重延迟尖峰的问题,请选择 fq_codel(或 fq)作为 队列规则 (qdisc),并且如果应用程序支持,请启用 数据包限速 (pacing)

内核调用路径(如果你感兴趣的话)发送(典型的 TCP 路径):

1
2
3
4
5
6
7
8
9
10
tcp_sendmsg
-> tcp_push_pending_frames
-> __tcp_transmit_skb
-> ip_queue_xmit
-> ip_local_out / ip_output
-> ip_finish_output
-> neigh_output
-> dev_queue_xmit
-> qdisc / sch_direct_xmit
-> ndo_start_xmit (driver)

接收(典型的 IPv4 TCP 路径):

1
2
3
4
5
6
7
8
9
napi_gro_receive / netif_receive_skb
-> __netif_receive_skb_core
-> ip_rcv
-> ip_rcv_finish
-> ip_local_deliver
-> ip_local_deliver_finish
-> tcp_v4_rcv
-> tcp_v4_do_rcv
-> tcp_data_queue (wake reader)

一份小清单,随时备着

  • Socket - Your program’s handle for network I/O.
  • MTU / MSS - Max link payload / max TCP payload.
  • ARP / NDP - Find the link-layer address (IPv4 / IPv6).
  • qdisc - Per-device queueing policy (fairness, shaping).
  • NAPI - Efficient receive: interrupt, then poll a batch.
  • TSO/GSO/GRO - Offloads to split/merge packets and save CPU.
  • Conntrack - Kernel’s flow table (used by NAT and filtering).
  • PREROUTING/INPUT/OUTPUT/POSTROUTING - Firewall hook points.
  • DMA (Direct Memory Access) - Hardware reads/writes RAM without CPU copies, NICs use this for TX/RX rings.
  • TTL / Hop Limit - Per‑packet counter decremented by each router (TTL in IPv4, Hop Limit in IPv6). When it hits zero, the packet is dropped.
  • FCS (Frame Check Sequence) - Link‑layer CRC at the end of an Ethernet frame, used to detect bit errors on the wire.