4.3 网络层:IP、路由与分片
IP 首部的 20 个字节里,哪几个字段真正决定一个包能不能到?同一个目的地址有两条路由时,内核凭什么选更具体的那个?分片为什么危险,「不分片」标志到底保护了什么?traceroute 只用 TTL 就能画出路径,原理是什么?
逐字段解释 IPv4 首部并手算校验和;用 ip route get 验证最长前缀匹配;在三个 netns 里搭一条两跳路由并抓包看 TTL 递减;说清 NAT 改了哪些字段、为什么它破坏了端到端原则。
链路层把帧送到同一个广播域里的下一跳,但它不知道「下一跳之后往哪走」。跨网段通信需要一张全局的地图,这就是网络层。它要解决三个问题:地址怎么编(IPv4/IPv6)、包怎么找到路(路由)、包太大怎么办(分片)。
这三个问题里,路由是核心。地址编址是为了让路由表能聚合,分片是为了让不同 MTU 的链路能互联,都服务于「把包送到」。所以这一章的顺序是:先看首部(地址在哪、TTL 在哪),再看转发(路由表怎么查),再看分片(路径 MTU 不匹配时发生什么),最后看两个特例(ICMP 和 NAT)。
网络层还有一个容易被忽略的特点:它是无连接的。每个包独立选路,内核不维护「这条流走过哪」的状态(NAT 和 conntrack 是例外,它们恰恰是反例)。这带来的好处是路由器可以做得极简,坏处是丢包、乱序、重复都要由上层处理。理解了这一点,再看 TCP 的设计就不会觉得它「啰嗦」。
4.3.1 IPv4 首部:20 字节里的十个字段
IPv4 首部固定部分 20 字节,选项最多再带 40 字节。每个字段都有明确的职责,其中五个直接决定包的行为。
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 ┌───────┬───────┬────────────────┬────────────────────────────────┐ │ 版本 4 │ 首部长 │ 区分服务 DSCP │ 总长度 16 │ │ 4 bit │ 4 bit │ 8 bit │ 字节,含首部 │ ├───────┴───────┼────────────────┼────────────────────────────────┤ │ 标识 16 │ 标志 3 │ 片偏移 13 │ │ 同一数据报的分片共享此值 │ DF MF │ 单位 8 字节 │ ├───────────────┼────────────────┼────────────────────────────────┤ │ TTL 8 │ 协议 8 │ 首部校验和 16 │ │ 每跳减 1 │ 6=TCP 17=UDP │ 只校验首部 │ ├───────────────┴────────────────┴────────────────────────────────┤ │ 源 IP 地址 32 │ ├────────────────────────────────────────────────────────────────┤ │ 目的 IP 地址 32 │ ├────────────────────────────────────────────────────────────────┤ │ 选项(0–40 字节,可选) │ └────────────────────────────────────────────────────────────────┘
读这张图要抓住三组字段。第一组是长度:首部长度 的单位是 4 字节(所以 5 表示 20 字节),总长度 的单位是 1 字节,最大 65535。两者相减才是载荷长度。第二组是分片:标识 让接收端把同一数据报的分片归到一起,DF 表示不许分片,MF 表示后面还有分片,片偏移 的单位是 8 字节。第三组是生命周期:TTL 每经过一个路由器减 1,减到 0 就丢弃并回 ICMP 超时——这是防止路由环路把网络打爆的最后一道保险。
协议 字段是分用依据:6 交给 TCP,17 交给 UDP,1 交给 ICMP,2 交给 IGMP,41 交给 IPv6(用于 6to4 隧道)。内核里这张映射表叫 inet_protos,ip_local_deliver_finish 查它。
首部校验和 只校验首部,不校验载荷。这个设计在今天是争议点:每跳都要重算(因为 TTL 变了),开销不小,保护却有限。IPv6 干脆取消了首部校验和,把检错完全交给链路层和上层。
下面这段程序按 RFC 1071 计算校验和,用的是「反码求和再取反」的经典算法:
/* ip_checksum.c:按 RFC 1071 计算 IPv4 首部校验和 */
#include <stdint.h>
#include <stdio.h>
static uint16_t ones_sum(const void *data, size_t len)
{
const uint8_t *p = data;
uint32_t sum = 0;
for (size_t i = 0; i + 1 < len; i += 2)
sum += (uint32_t)((p[i] << 8) | p[i + 1]);
if (len & 1) /* 奇数长度:末字节补 0 */
sum += (uint32_t)p[len - 1] << 8;
while (sum >> 16) /* 折叠进位 */
sum = (sum & 0xffff) + (sum >> 16);
return (uint16_t)~sum;
}
int main(void)
{
/* 一个真实的 IPv4 首部:20 字节,校验和字段先填 0 */
uint8_t hdr[20] = {
0x45, 0x00, 0x00, 0x3c, 0x1c, 0x46, 0x40, 0x00,
0x40, 0x06, 0x00, 0x00, 0xc0, 0xa8, 0x01, 0x0a,
0xc0, 0xa8, 0x01, 0x01,
};
uint16_t c = ones_sum(hdr, sizeof hdr);
printf("checksum = 0x%04x\n", c);
hdr[10] = (uint8_t)(c >> 8);
hdr[11] = (uint8_t)(c & 0xff);
printf("verify = 0x%04x (全 0 表示正确)\n", ones_sum(hdr, sizeof hdr));
return 0;
}
运行结果是 checksum = 0x9b1a,填入后再算一遍得到 0x0000。「校验和字段填入后整体再算一遍为 0」是反码求和的性质,接收端就是靠这个性质验证的,不需要知道原值。
0x45 这个首字节值得单独说:高 4 位 4 是版本,低 4 位 5 是首部长度(单位 4 字节,即 20 字节)。如果带 4 字节选项,就是 0x46。这个「一个字节装两个字段」的写法在协议里很常见,读的时候要按位拆。
4.3.2 分片与重组:为什么它是个坏消息
当一个 IP 包大于出接口 MTU,且 DF=0 时,发送端(或中间路由器)会把它切成多片。每片都有自己的 IP 首部,标识 相同,片偏移 递增,除最后一片外 MF=1。
分片的代价有三个:
- 重组要缓冲。接收端必须等所有片到齐才能交给上层,中间要占内存。攻击者可以只发第一片,把重组缓冲区耗尽。
- 丢一片就全废。TCP 重传的是整个段,但 IP 层已经把一片丢了,接收端永远拼不齐,只能等重组超时(Linux 默认 30 秒,
net.ipv4.ipfrag_time)。 - 中间设备负担重。路由器要参与分片,模糊了「网络核心只转发」的边界。
所以现代实践是尽量避免分片:TCP 用 MSS 协商保证段不超过 MTU,UDP 应用自己控制包大小,路径 MTU 发现(PMTUD)负责探测。
PMTUD 的工作方式是:发送端置 DF=1,包太大时路由器丢弃并回 ICMP type 3 code 4(Fragmentation Needed),里面带上下一跳的 MTU。发送端据此调小。如果这个 ICMP 被防火墙挡了,就出现经典的「黑洞」:小包正常,大包永远丢。
# 看当前的分片相关参数
sysctl net.ipv4.ipfrag_time net.ipv4.ipfrag_high_thresh net.ipv4.ipfrag_low_thresh
# 看分片统计:ReasmReqds 增长说明收到了分片,ReasmFails 增长说明重组失败
grep -E 'Reasm|Frag' /proc/net/snmp | head -4
/proc/net/snmp 里的 Ip: 行包含 ReasmReqds(收到的分片数)、ReasmOKs(重组成功)、ReasmFails(失败)、FragOKs(本地发出的分片)、FragFails(因 DF 无法分片)。ReasmFails 持续增长是一个明确的信号:路径上有 MTU 不匹配,或者有人在发分片攻击。
抓包时,分片长这样(-vv 才会打印 id、offset、flags):
sudo tcpdump -i eth0 -nn -vv -c 2 'ip[6:2] & 0x3fff != 0'
IP (tos 0x0, ttl 64, id 12345, offset 0, flags [+], proto UDP (17), length 1500)
10.0.0.1.40000 > 10.0.0.2.40000: UDP, length 1472
IP (tos 0x0, ttl 64, id 12345, offset 1480, flags [none], proto UDP (17), length 1020)
10.0.0.1 > 10.0.0.2: ip-proto-17
两片的 id 都是 12345,这是接收端归组的依据。第一片 flags [+] 表示 MF=1,offset 0;第二片 flags [none] 表示 MF=0(最后一片),offset 1480。tcpdump 打印的 offset 单位是字节,而首部里存的是 8 字节单位,所以首部里的值是 185,抓包里看到的是 1480。第二片看不到 UDP 首部,所以 tcpdump 只能标成 ip-proto-17——分片之后,除第一片外都没有运输层首部。
- 「TCP 会分片」。TCP 只按 MSS 切段,分片发生在 IP 层。抓包看到的
frag是 IP 层行为。 - 「分片在路由器上重组」。IPv4 允许中间重组,但实际几乎不做(除了 NAT 设备)。重组发生在最终目的主机。
- 「DF=1 就不会丢包」。DF 只是禁止分片,包太大照样被丢,只是会回 ICMP 而不是静默丢弃。ICMP 被挡时,DF 反而制造黑洞。
- 「IPv6 没有分片」。IPv6 允许源端分片(用扩展首部),只是禁止路由器分片。源端分片仍然存在,只是不再是路径行为。
4.3.3 CIDR 与子网:把地址切成可路由的块
IPv4 地址是 32 位,写成点分十进制只是为了好读。CIDR(Classless Inter-Domain Routing)用「地址/前缀长度」表示一个块,192.168.1.0/24 表示前 24 位固定、后 8 位可变的 256 个地址。
几个必须能心算的换算:
| 前缀 | 地址数 | 可用主机数 | 掩码 |
|---|---|---|---|
| /24 | 256 | 254 | 255.255.255.0 |
| /25 | 128 | 126 | 255.255.255.128 |
| /26 | 64 | 62 | 255.255.255.192 |
| /30 | 4 | 2 | 255.255.255.252 |
| /31 | 2 | 2(点对点特例) | 255.255.255.254 |
| /32 | 1 | 1(单主机路由) | 255.255.255.255 |
「可用主机数 = 地址数 − 2」是因为首地址是网络地址、末地址是广播地址。/31 是例外(RFC 3021),点对点链路两端各一个地址,不浪费;/32 表示单台主机,常用于主机路由和回环。
判断两个地址是否同网段,看「分别与掩码相与的结果是否相同」:
# 用 ipcalc 或 python 手算
python3 - <<'PY'
import ipaddress
net = ipaddress.ip_network("192.168.1.0/24")
for a in ("192.168.1.10", "192.168.2.10"):
ip = ipaddress.ip_address(a)
print(a, "in", net, "->", ip in net)
print("可用地址:", net.num_addresses - 2)
PY
这段输出会告诉你 192.168.1.10 在网内、192.168.2.10 不在。手算版本是:把最后一段换成二进制,前 24 位相同就在同一网段。
子网划分的本质是借位。一个 /24 要切成 4 个部门,就借 2 位,得到 4 个 /26:192.168.1.0/26、.64/26、.128/26、.192/26,每个 64 个地址。借的位越多,子网越多、每个越小。
公司分到 10.20.0.0/22,共 1024 个地址(10.20.0.0–10.20.3.255)。三个部门分别需要 500、300、100 台主机。
按「2 的幂 + 留网络/广播地址」算:
- 500 台 →
/23(512 地址,510 可用),够; - 300 台 →
/23(512 地址,510 可用),够;/24只有 254 可用,不够; - 100 台 →
/25(128 地址,126 可用),够。
三块合计 512 + 512 + 128 = 1152 个地址,超过 /22 的 1024。所以 /22 无法同时切出三个独立子网;要么升级到 /21(2048),要么把 300 台部门并入一个更大的共享网段/NAT,要么放弃按部门物理隔离、把 900 台主机塞进同一个 /22(1022 可用,但失去广播域隔离)。
先满足大的、按 2 的幂对齐,是划子网的两条基本规则;但还要先做「总地址数」的预算,不能只看单个子网够不够。
4.3.4 路由表与最长前缀匹配
路由表是一组「目的前缀 → 下一跳/出接口」的规则。内核收到一个包,拿目的 IP 去查表,选出匹配的前缀。如果多条规则都匹配,选前缀最长的那条——这就是最长前缀匹配(longest prefix match)。
为什么要有这条规则?因为路由表是分层的:默认路由 0.0.0.0/0 匹配一切,10.0.0.0/8 匹配私有网段,10.1.2.0/24 匹配某个具体子网。目的地址是 10.1.2.3 时,三条都匹配,但只有 /24 知道精确的下一跳。最长前缀匹配让「更具体的路由覆盖更一般的路由」成为自然结果,不需要额外的优先级机制。
Linux 的路由表用 LC-trie(压缩前缀树)实现,查找复杂度与地址位数相关,不随表项数量线性增长。这也是为什么 Linux 能承载几十万条路由(BGP 全表)而不显著变慢。
# 看主路由表
ip route show
# 看所有表(main/local/default 等)
ip route show table all | head -20
# 查一个目的地址会怎么走:出接口、源地址、下一跳
ip route get 10.1.2.3
# 看策略路由规则
ip rule show
ip route get 是这一节最有用的命令。它不只查表,还走完整的路由决策(包括策略路由、源地址选择),输出形如:
10.1.2.3 via 192.168.1.1 dev eth0 src 192.168.1.10 uid 1000
cache
这一行告诉你四件事:下一跳是 192.168.1.1,出接口是 eth0,选的源地址是 192.168.1.10,查询者是 uid 1000。源地址选择经常被忽略:一台机器有多个 IP 时,内核按「目的地址所在网段优先、否则用主地址」的规则选源,选错了会导致对端回包走错路。
路由表里的 scope 字段描述「目的地有多远」:
| scope | 含义 | 例子 |
|---|---|---|
host | 本机地址 | 127.0.0.1、本机网卡地址 |
link | 直连网段,不需网关 | 192.168.1.0/24 dev eth0 |
global | 需要经过网关 | default via 192.168.1.1 |
scope link 的路由不需要 ARP 网关,直接 ARP 目的地址;scope global 的路由要先 ARP 下一跳。这个区别在排查「为什么 ARP 的是网关而不是目的地址」时很关键。
路由类型(type)则决定「匹配后做什么」:
| 类型 | 行为 | 用途 |
|---|---|---|
unicast | 正常转发 | 默认 |
unreachable | 回 ICMP 网络不可达 | 黑洞替代,明确拒绝 |
blackhole | 静默丢弃 | 封禁网段,不回任何包 |
prohibit | 回 ICMP 管理禁止 | 策略性拒绝 |
local | 交给本机 | 本机地址自动生成 |
broadcast | 广播地址 | 自动生成 |
blackhole 和 unreachable 的区别很实用:前者让扫描器什么都拿不到(超时),后者会立刻收到「网络不可达」。安全场景常用前者,排障场景常用后者(快速失败)。
路由表里的条目从哪来?直连和静态路由由管理员或 DHCP 写入,动态路由则由路由协议计算。Linux 内核只负责按表转发,协议本身跑在用户态守护进程(FRR、BIRD)里,算完通过 netlink 写进内核 FIB:
| 协议 | 类型 | 度量 | 典型范围 | Linux 侧 |
|---|---|---|---|---|
| RIP | 距离向量 | 跳数(最大 15) | 小型网络、历史 | ripd(FRR) |
| OSPF | 链路状态 | 链路代价(通常按带宽) | 园区网、数据中心 underlay | ospfd、BIRD |
| BGP | 路径向量 | AS 路径 + 策略 | 域间、云/Calico underlay | bgpd、BIRD |
三者的差别可以记成「RIP 数跳、OSPF 算路、BGP 讲策略」。RIP 收敛慢、有 15 跳上限;OSPF 用链路状态数据库跑 SPF,收敛快、支持区域划分;BGP 不追求最短路径,而是在 AS 之间按策略选路——这也是为什么「BGP 全表」能到近百万条,以及 Calico 的 BGP 模式用它在 underlay 上通告 Pod 网段。
4.3.5 ICMP 与 traceroute:用 TTL 画路径
ICMP 是 IP 的「控制消息」协议,它不传数据,只传「出了什么事」。常用类型不多,但每一个都对应一类故障:
| 类型 | 名称 | 触发条件 | 排障含义 |
|---|---|---|---|
| 0 / 8 | Echo Reply / Request | ping | 连通性 |
| 3 | Destination Unreachable | 路由不可达、端口未监听、需分片 | code 3 = 端口不通,code 4 = 需分片 |
| 5 | Redirect | 有更好的下一跳 | 路由配置问题 |
| 11 | Time Exceeded | TTL 减到 0 | 路由环路、traceroute |
| 12 | Parameter Problem | 首部字段非法 | 协议实现问题 |
ping 用的是 type 8/0。它看起来简单,但有一个细节:ICMP 报文里带着原始数据的副本,所以 ping 的 payload 会被原样回显,可以用来测 MTU(4.1.7 用过)和测链路误码(payload 填特定模式,看回显是否一致)。
traceroute 的原理更巧妙:它发一串 TTL 递增的包,第一个 TTL=1,第一个路由器减到 0 就回 ICMP Time Exceeded,源端由此知道第一跳的地址;第二个 TTL=2,得到第二跳;依此类推,直到包到达目的端。
# 默认用 UDP 高端口,需要 root 或 cap_net_raw
traceroute -n -m 8 192.0.2.1
# 用 ICMP echo,很多网络对 UDP 高端口不友好
traceroute -I -n -m 8 192.0.2.1
# tracepath 不需要 root,会自动做 PMTU 探测
tracepath -n 192.0.2.1
输出里出现 * * * 表示那一跳没回 ICMP(可能被防火墙挡了,也可能路由器不产生 ICMP)。* 不代表断网,只代表「这一跳不说话」,后面的跳数仍然可能正常——这是读 traceroute 输出最常见的误解。
tracepath 比 traceroute 多一个能力:它在每一跳顺便探测 PMTU,输出里的 pmtu 就是当前跳能通过的最大包。排查「大包不通」时,tracepath 往往一步到位。
- 中途断:前几跳正常,某一跳开始全是
*,后面也不恢复 → 那一跳之后不通,查该跳的入/出接口和路由。 - 绕路:跳数明显多于预期,或地址在不应出现的地域 → 查 BGP 策略或策略路由。
- PMTU 黑洞:
tracepath显示某一跳pmtu突然变小,而业务大包失败 → 调 MTU 或放行 ICMP type 3 code 4。
4.3.6 NAT:改了哪些字段,破坏了什么
NAT(Network Address Translation)让多台主机共享一个公网 IP。它工作在网络层和运输层之间,因为它不只要改 IP 地址,还要改端口——否则多台内网主机用同一个源端口时,回包无法区分。
以 SNAT(源地址转换)为例,一个包从 192.168.1.10:51234 发往 93.184.216.34:80,经过 NAT 网关后变成 203.0.113.5:40001 发往同一目的。网关的 conntrack 表里记下这条映射:
原始: 192.168.1.10:51234 -> 93.184.216.34:80
转换: 203.0.113.5:40001 -> 93.184.216.34:80
回包到达时,网关查表反变换,把目的地址改回 192.168.1.10:51234。这张表就是 NAT 的全部秘密,也是它的全部问题:
- 状态在中间设备上。网关重启,所有连接断。这直接违反端到端原则。
- 入向连接不可能。除非手工配端口映射(DNAT),外部无法主动连内网。
- 协议感知。FTP 的 PORT 命令、SIP 的 SDP 里都嵌了 IP 地址,NAT 要改载荷里的地址,需要 ALG(应用层网关),而 ALG 经常出错。
- 端口耗尽。一个公网 IP 最多 65535 个端口,扣除保留端口,实际可用约 6 万。这就是为什么大规模 NAT 网关需要端口池。
Linux 的 NAT 由 netfilter 的 nat 表实现,MASQUERADE 是 SNAT 的特例(自动取出口地址):
# 查看 conntrack 表(需要 conntrack 工具)
sudo conntrack -L | head -5
# 统计当前连接数
sudo conntrack -C
# 一条典型的 MASQUERADE 规则(nftables 写法)
sudo nft list ruleset | grep -A3 masquerade
conntrack -C 的数字很关键:它是全表 conntrack 条目数(包含未做 NAT 的连接),不是「经过 NAT 的连接数」,接近 nf_conntrack_max 时新连接会被丢弃,日志里出现 nf_conntrack: table full, dropping packet。这是高并发网关的经典瓶颈,解法是调大表、缩短超时、或者用 NOTRACK 跳过不需要跟踪的流量。
NAT 破坏了端到端,理论上应该让 P2P、VoIP、游戏联机全部失效。但互联网活下来了,靠的是三种补丁:STUN/TURN/ICE 打洞、UPnP/NAT-PMP 自动开端口、以及大量应用退化成「客户端主动连服务器」。这说明架构原则不是铁律,工程实践会用补丁绕过它——代价是复杂度转移到了应用层。
4.3.7 IPv6:把首部简化,把地址放大
IPv6 最直观的变化是地址从 32 位变成 128 位,但真正的设计改进在首部。
| 特性 | IPv4 | IPv6 |
|---|---|---|
| 首部长度 | 20–60 字节,可变 | 固定 40 字节 |
| 首部校验和 | 有 | 无 |
| 分片 | 路由器和源端都可 | 只有源端(扩展首部) |
| 地址配置 | 手工 / DHCP | 手工 / DHCPv6 / SLAAC |
| 广播 | 有 | 无(用组播替代) |
| 选项 | 首部内 | 扩展首部链 |
| NAT | 普遍存在 | 设计上不需要(但实际仍有) |
固定 40 字节首部让路由器可以用固定偏移解析,硬件实现更简单。取消校验和是因为链路层(以太网 FCS)和上层(TCP/UDP 校验和)已经覆盖了检错,再算一遍收益很低。取消广播是因为广播会打断同一网段所有主机的 CPU,组播可以只唤醒订阅者。
地址写法上,:: 表示一段连续的 0,但只能出现一次;::1 是回环,fe80::/10 是链路本地地址(每个接口自动生成,只在同一链路有效),2001:db8::/32 是文档专用地址。
# 看 IPv6 地址与路由
ip -6 addr show
ip -6 route show
# 看邻居(IPv6 用 NDP,不是 ARP)
ip -6 neigh show
# ping6 与 ping 现在统一了
ping -6 -c 2 ::1
IPv6 没有 ARP,用 NDP(Neighbor Discovery Protocol),跑在 ICMPv6 上。功能对应关系是:ARP 请求 → Neighbor Solicitation,ARP 应答 → Neighbor Advertisement,路由器发现 → Router Solicitation/Advertisement。SLAAC(无状态地址自动配置)就是靠 RA 里的前缀 + 接口标识自动生成地址,不需要 DHCP 服务器。
对本书后面的内容,IPv6 只需要记住三点:首部固定 40 字节(所以 MSS 是 1440)、没有首部校验和(抓包看不到 checksum 字段)、NDP 替代 ARP(所以 ip neigh 对 v6 也适用)。
4.3.8 动手:用三个 netns 搭一条两跳路由
光看路由表不如自己造一个。下面这组命令建三个 namespace,中间那个当路由器,两端的包必须经过它转发。
# 三个 netns:左主机、路由器、右主机
for n in left router right; do sudo ip netns add $n; done
# 两条点对点链路:left--router、router--right
sudo ip link add l0 type veth peer name r0
sudo ip link set l0 netns left
sudo ip link set r0 netns router
sudo ip link add r1 type veth peer name h0
sudo ip link set r1 netns router
sudo ip link set h0 netns right
# 配地址:左边 10.0.1.0/24,右边 10.0.2.0/24
sudo ip -n left addr add 10.0.1.2/24 dev l0
sudo ip -n router addr add 10.0.1.1/24 dev r0
sudo ip -n router addr add 10.0.2.1/24 dev r1
sudo ip -n right addr add 10.0.2.2/24 dev h0
# 全部拉起
for n in left router right; do
sudo ip -n $n link set lo up
done
sudo ip -n left link set l0 up; sudo ip -n router link set r0 up
sudo ip -n router link set r1 up; sudo ip -n right link set h0 up
# 两端加默认路由,指向路由器
sudo ip -n left route add default via 10.0.1.1
sudo ip -n right route add default via 10.0.2.1
# 路由器打开转发(这是关键,默认是关的)
sudo ip netns exec router sysctl -w net.ipv4.ip_forward=1
现在从 left ping right,并在 router 上抓包看 TTL:
sudo ip netns exec router tcpdump -i any -nn -v 'icmp' &
sudo ip netns exec left ping -c 2 10.0.2.2
wait
-v 会打出 ttl 64(left 发出)和 ttl 63(router 转发后)。TTL 减 1 是转发发生的直接证据。如果看到 ttl 64 原样过去,说明包没有经过路由(可能走了别的路径),或者转发没开、包被丢弃了。
再验证最长前缀匹配。在 left 上加一条更具体的路由,看它是否覆盖默认路由:
sudo ip -n left route add 10.0.2.2/32 via 10.0.1.1 dev l0
sudo ip -n left route get 10.0.2.2
sudo ip -n left route del 10.0.2.2/32
sudo ip -n left route get 10.0.2.2
第一次查询会命中 /32(via 10.0.1.1,最具体),删掉后回落到 default。两条路由的下一跳相同,所以行为看起来一样;如果把 /32 的下一跳改成一个不存在的地址,就能看到「更具体的路由优先」导致不通——这正是线上「加了一条明细路由把流量带偏」的故障模型。
4.3.9 策略路由:当「目的地址」不够用
标准路由只看目的地址。但真实场景里经常需要「按源地址选路」「按端口选路」「按进程选路」。比如:一台机器有两块网卡,一块连内网、一块连外网,来自内网的流量必须从内网卡回,否则对端会因为源地址不匹配而丢包。这种需求用目的地址路由表达不了,要用策略路由(policy routing)。
Linux 的策略路由由两部分组成:规则(rule) 决定「用哪张表」,表(table) 里放具体的路由。默认有三条规则:
0: from all lookup local # 本机地址,最高优先级
32766: from all lookup main # 主表,ip route 默认写这里
32767: from all lookup default # 空表,兜底
ip rule 按优先级从小到大匹配,第一条命中的规则决定查哪张表。如果没有命中任何表,包被丢弃(unreachable)。
# 建一张自定义表 100,把 10.9.0.0/24 指到 eth1
sudo ip route add 10.9.0.0/24 dev eth1 table 100
# 来自 10.0.0.0/24 的流量先查表 100
sudo ip rule add from 10.0.0.0/24 table 100 pref 100
ip rule show
# 验证:指定源地址查询,看走哪张表
ip route get 10.9.0.5 from 10.0.0.2
pref 是优先级,数字越小越先匹配。新加的规则插在 local 之后、main 之前,所以「本机地址」仍然优先——这个顺序很重要,否则本机回环流量会被带偏。
策略路由的另一种用法是按防火墙标记选路。应用可以用 setsockopt(SO_MARK) 给 socket 打一个标记,内核在路由时用 fwmark 匹配:
sudo ip rule add fwmark 0x1 table 100 pref 100
# 应用侧:setsockopt(fd, SOL_SOCKET, SO_MARK, &mark, sizeof mark)
这个机制在容器网络里很常见:CNI 插件给 Pod 的流量打标记,策略路由据此决定走 overlay 还是走 underlay。Kubernetes 的 kube-proxy 在 IPVS 模式下也用它。
再进一步是 VRF(Virtual Routing and Forwarding):把「一张路由表 + 一组接口」打包成一个虚拟路由设备,不同 VRF 之间完全隔离,各自有独立的路由表。它相当于「一台机器里的多台路由器」,是运营商和云网络隔离租户的标准手段。Linux 用 ip link add vrf0 type vrf table 100 创建,然后把接口的 master 设成 vrf0。
- 规则顺序错。新规则如果插在
local之前,本机地址也会被带走,表现为「本机服务访问不了自己的 IP」。用pref明确指定位置。 - 只加规则不加路由。规则指向的表是空的,匹配后直接
unreachable,表现为「规则生效了但完全不通」。加完规则一定要ip route show table N确认表里有内容。 - 忘记
ip rule是全局的。它作用于整台机器的所有流量,包括管理 SSH。远程改策略路由前先开一个at或screen兜底,改错了还能救回来。
4.3.10 IPv4 首部逐字段:谁写、谁改、错了会怎样
4.3.1 给的是字段布局,这一节回答三个实现问题:每个字段由谁写入、转发路上谁会改它、值非法时内核在哪一行丢弃。把这三件事记住,读抓包和读 ip_rcv_core() 时就不会把「首部字段」当成一张静态表。
| 字段 | 位宽/偏移 | 谁写 | 路由器是否修改 | 非法值的后果 | tcpdump 表达式 |
|---|---|---|---|---|---|
| 版本 + 首部长度 | 4 + 4,字节 0 | 源主机 | 否 | 版本非 4 或 IHL < 5,ip_rcv_core 直接丢 | ip[0] |
| DSCP + ECN | 6 + 2,字节 1 | 源主机;ECN 可由路由器标记 | ECN 的 CE 位可被置 1 | 非法 DSCP 一般不丢,只影响队列 | ip[1] |
| 总长度 | 16,字节 2–3 | 源主机;分片时每片重写 | 分片时改 | 小于首部长度或大于 skb->len,丢 | ip[2:2] |
| 标识 | 16,字节 4–5 | 源主机(ip_select_ident) | 分片时保持不变 | 全 0 在 DF 包中合法(RFC 6864) | ip[4:2] |
| 标志 + 片偏移 | 3 + 13,字节 6–7 | 源主机;分片时改 | 是 | 偏移非 8 的倍数、MF 与长度矛盾,丢 | ip[6:2] |
| TTL | 8,字节 8 | 源主机(默认 64) | 每跳减 1 | 减到 0 丢包并回 ICMP 超时 | ip[8] |
| 协议 | 8,字节 9 | 源主机 | 否 | inet_protos 查不到,回 ICMP 协议不可达 | ip[9] |
| 首部校验和 | 16,字节 10–11 | 源主机;每跳重算 | 是(TTL 变) | 校验失败丢包,计数 InHdrErrors | ip[10:2] |
| 源地址 | 32,字节 12–15 | 源主机;NAT 改 | NAT 改 | 被判定为 martian 时丢 | ip[12:4] |
| 目的地址 | 32,字节 16–19 | 源主机;NAT 改 | 否(NAT 例外) | 无路由时回 ICMP 网络不可达 | ip[16:4] |
| 选项 | 0–40,字节 20 起 | 源主机 | 通常否 | 长度不是 4 的倍数、选项越界,丢 | ip[20:4] |
这张表的用法是「反查」:抓到一个包,先看 ip[0] 判断首部多长,再看 ip[9] 决定把载荷交给谁,最后才看地址。顺序错了会在带选项的包上算错偏移:IHL=6 时 TCP 首部从第 24 字节开始,不是第 20 字节。
版本 + 首部长度 共用一个字节,所以 0x45 读作「IPv4,20 字节首部」,0x46 读作「IPv4,24 字节首部」。内核在 ip_rcv_core() 里先做 pskb_may_pull(skb, sizeof(struct iphdr)),再检查 iph->ihl < 5 || iph->version != 4。IHL 的单位是 4 字节,最大值 15 给出 60 字节首部;选项最多 40 字节,正好是 60 − 20。
DSCP + ECN 这 8 位经常被当成一个整体。高 6 位是 DSCP,低 2 位是 ECN(Not-ECT、ECT(1)、ECT(0)、CE)。路由器在队列快满时可以只把 CE 置 1 而不丢包,这就是 ECN;接收端把 CE 回显给发送端,由 TCP 自己降速。Linux 的 net.ipv4.tcp_ecn 控制协商,IP_ECN_set_ce() 负责标记;tc 的 pedit 也能改这两个位。
总长度 是首部加载荷的字节数,最大 65535。它和 skb->len 的关系是接收路径上最常见的校验之一:skb->len < len 说明包被截断,len < ihl*4 说明长度字段自相矛盾,两者都丢。巨型帧不靠这个字段:MTU 9000 时一个 IP 包仍然只表达 9000 字节,GSO 产生的 64 KB「超级包」在协议栈内部用 skb_shinfo(skb)->gso_size 表达,不会写成 65535。
标识 的分配策略在 Linux 里经过几次变化。早期每个目的地址一个计数器,后来改成 ip_select_ident() 按目的地址哈希,现代内核用 ip_id_key 加随机偏移,防止攻击者猜出下一包的 ID。DF=1 且不需要分片时,ID 可以填 0,这是 RFC 6864 允许的优化;所以抓到一个 id 0 的包不代表实现有 bug。
标志 + 片偏移 这 16 位要按位拆:0x4000 是 DF(Don’t Fragment),0x2000 是 MF(More Fragments),低 13 位是片偏移,单位 8 字节。所以 ip[6:2] & 0x3fff != 0 的意思是「MF 置位或偏移非 0」,即「这是分片或还有后续分片」;ip[6:2] & 0x4000 != 0 只匹配 DF。
TTL 每跳减 1,减到 0 时路由器丢包并回 ICMP type 11 code 0。Linux 的默认值是 net.ipv4.ip_default_ttl = 64,ip_decrease_ttl() 在减 1 的同时用增量法更新首部校验和,不需要重算整个首部。TTL 的初值能暴露操作系统:Windows 常见 128,Linux 常见 64,网络设备常见 255;traceroute 正是利用这个字段逐跳逼出路由器。
协议 字段决定 ip_local_deliver_finish() 查哪张表:6 交给 TCP,17 交给 UDP,1 交给 ICMP,2 交给 IGMP,41 交给 IPv6(6to4),47 交给 GRE,50/51 交给 IPsec ESP/AH,132 交给 SCTP。查不到时内核回 ICMP type 3 code 2(协议不可达),并增加 InUnknownProtos。
首部校验和 只覆盖首部,不覆盖载荷。它的算法是反码求和再取反,接收端把校验和字段一起算,结果为 0 就通过。每跳改 TTL 都要更新它,这是 IPv4 被诟病的一个固定成本;IPv6 因此取消了首部校验和。
源地址 和 目的地址 在转发路径上通常不变,但 NAT 会改,而且改完之后必须重算校验和。内核还会检查 martian 地址:源地址是回环、组播、广播、0.0.0.0,或者源和目的都是本机地址,都可能被 ipv4_martian() 判定为 martian(日志里走 martian_source 路径)而丢掉。抓包里看到「包发出去了但没回」时,先确认源地址不是 martian。
选项 是 IPv4 最尴尬的部分。记录路由(RR)、时间戳(TS)、宽松/严格源路由(LSRR/SSRR)都定义过,但现代网络几乎不用。Linux 默认 net.ipv4.conf.all.accept_source_route = 0,收到源路由选项的包会丢弃;ip_options_compile() 负责解析和校验,选项长度必须是 4 的倍数,不足时用 IPOPT_END(0)填充。
- IHL 小于 5 不是「首部很短」,而是非法。最小首部就是 20 字节,小于它的包一律丢。
- 总长度字段不包含以太网填充。一个 20 字节的 IP 包放进最小帧会填充到 46 字节载荷,接收端靠
total_len把填充裁掉。 - DF=1 时 ID 可以为 0。不要用「ID 为 0」判断包损坏。
- 首部校验和不覆盖载荷。载荷坏了要靠 TCP/UDP 校验和或链路层 FCS,别指望 IP 首部校验和。
4.3.11 分片与重组:内核里的 ip_fragment 与 ip_defrag
4.3.2 讲了分片的代价,这一节跟一遍内核实现。分片发生在两个地方:源主机发送时(ip_fragment),以及 IPv4 路由器转发时(ip_forward 之后的 ip_fragment)。重组只发生在最终目的主机(ip_defrag),中间路由器不重组。
先看重组入口。ip_local_deliver() 在把包交给传输层之前,会检查它是不是分片:
/* net/ipv4/ip_input.c,Linux 6.8,节选 */
int ip_local_deliver(struct sk_buff *skb)
{
struct net *net = dev_net(skb->dev);
if (ip_is_fragment(ip_hdr(skb))) {
if (ip_defrag(net, skb, IP_DEFRAG_LOCAL_DELIVER))
return 0; /* 已排队,等其余分片 */
}
return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, net, NULL, skb,
skb->dev, NULL, ip_local_deliver_finish);
}
ip_defrag() 的返回值是关键:返回 0 表示「重组完成,skb 现在是完整包」;返回 -EINPROGRESS 表示「已放进重组队列,当前 skb 被消费掉」;返回其他负值表示「丢弃」。所以 ip_local_deliver() 看到非 0 就直接返回,不再往下走。
分片发送的核心是 ip_do_fragment()。它按出接口 MTU 算出每片能带多少载荷,循环克隆 skb、改首部、发出去:
/* net/ipv4/ip_output.c,简化 */
mtu = dst_mtu(skb_dst(skb));
hlen = iph->ihl * 4;
max = (mtu - hlen) & ~7; /* 除最后一片外必须是 8 的倍数 */
...
while (skb->len > max) {
skb2 = skb_clone(skb, GFP_ATOMIC);
...
iph2->tot_len = htons(max + hlen);
iph2->frag_off = htons((offset >> 3) | IP_MF);
iph2->check = 0;
iph2->check = ip_fast_csum((u8 *)iph2, iph2->ihl);
...
offset += max;
}
三个细节:
(mtu - hlen) & ~7会把每片载荷向下取整到 8 的倍数。MTU 1400 时,1400 - 20 = 1380,向下取整是 1376,所以每个满片实际只用 1396 字节,浪费 4 字节。分片不是「刚好塞满 MTU」。frag_off里存的是offset >> 3,所以片偏移字段的单位是 8 字节;tot_len是这一片的「首部 + 载荷」,不是原始总长。- 最后一片的
MF=0,其余片MF=1。接收端靠「MF=0 且偏移最大」判断收齐,不能只看数量。
重组侧的数据结构是 struct ipq(IP fragment queue),按 (源地址, 目的地址, ID, 协议) 哈希到 ipq_hash。每个 ipq 里挂一个按偏移排序的 skb 队列,ip_frag_queue() 负责插入和检查重叠,ip_frag_reasm() 负责把队列拼成一个 skb。
| 函数 | 文件 | 作用 |
|---|---|---|
ip_fragment / ip_do_fragment | net/ipv4/ip_output.c | 按 MTU 切片、改首部、发送 |
ip_defrag | net/ipv4/ip_fragment.c | 重组总入口,返回 0 或 -EINPROGRESS |
ip_frag_queue | 同上 | 插入分片、检查重叠、更新 meat/len |
ip_frag_reasm | 同上 | 队列收齐后拼包,交给上层 |
ip_expire | 同上 | 重组超时,丢弃整个队列并回 ICMP 超时 |
inet_frag_find / inet_frag_kill | net/ipv4/inet_fragment.c | 查找/销毁重组队列 |
ip_rt_update_pmtu | net/ipv4/route.c | 收到 ICMP Frag Needed 后更新 dst MTU |
重组队列有三个可调参数,全部在 /proc/sys/net/ipv4/ 下:
| 参数 | 默认值 | 作用 | 调大/调小的后果 |
|---|---|---|---|
ipfrag_high_thresh | 4 MiB | 重组内存上限,超过后开始丢最老的队列 | 调大更抗突发,但更容易被分片攻击耗尽内存 |
ipfrag_low_thresh | 3 MiB | 内存回落到此值以下才停止回收 | 与 high 太近会导致频繁回收 |
ipfrag_time | 30 s | 一个队列等多久还没收齐就丢弃 | 调小更快释放,但跨洲高延迟可能误丢 |
ipfrag_max_dist | 64 | 同一 ID 的包之间允许的最大序号差 | 防止 ID 复用导致的错误重组,调大更宽松 |
ipfrag_max_dist 值得单独说:IP 的 ID 只有 16 位,高速链路上很快就回绕。攻击者可以伪造同一个 ID 的分片,让接收端把不同数据报的片拼在一起。ipfrag_max_dist 用「分片到达顺序的序号差」做 sanity check,超过阈值就丢弃整个队列。它是 2018 年之后默认开启的缓解措施,不要为了方便排障把它调成 0。
分片和 NAT/conntrack 的关系也容易踩坑:conntrack 必须在重组之后才能看到完整的运输层首部。所以内核在 NF_INET_PRE_ROUTING 之前挂了 nf_defrag_ipv4(优先级 −400),先重组再交给 conntrack(优先级 −200)。如果你用 raw 表或 NOTRACK 跳过了 conntrack,分片仍然会被 defrag,除非显式关闭。
# 看分片/重组计数:ReasmReqds 增长说明收到了分片
grep -E '^(Ip|Icmp):' /proc/net/snmp
# 看当前重组队列占用的内存
cat /proc/net/sockstat | grep -i frag
# 看三个阈值
sysctl net.ipv4.ipfrag_high_thresh net.ipv4.ipfrag_low_thresh \
net.ipv4.ipfrag_time net.ipv4.ipfrag_max_dist
/proc/net/snmp 的 Ip: 行里,ReasmReqds 是收到的分片数,ReasmOKs 是重组成功数,ReasmFails 是失败数,FragOKs 是本地发出的分片数,FragFails 是因 DF 无法分片而失败的数量。ReasmFails 持续增长只有两种解释:路径 MTU 不匹配导致分片丢失,或者有人在发分片攻击。先看 ipfrag_high_thresh 是否被打满,再看链路上的 MTU。
分片(fragmentation) 是把一个 IP 数据报拆成多个更小的 IP 数据报,每个都有自己的首部、相同的标识、递增的片偏移;除最后一片外 MF=1。重组(reassembly) 是接收端按 (源, 目的, ID, 协议) 把分片拼回原数据报。IPv4 允许路由器分片和目的主机重组;IPv6 只允许源端分片,路由器遇到过大包只能回 ICMPv6 Packet Too Big。
4.3.12 邻居子系统:ARP 与 NDP 共用的一套状态机
4.2.4 讲了 ARP 报文和邻居状态,这一节从网络层看「一个 IP 包怎么找到下一跳的 MAC」。入口不是 ARP 模块,而是路由的下一跳对象:dst_neigh_lookup() 取出 struct neighbour,再调用 neigh_resolve_output() 把 skb 交给它。
struct neighbour 的关键字段:
| 字段 | 含义 | 谁维护 |
|---|---|---|
ha | 硬件地址(MAC),长度由 dev->addr_len 决定 | ARP/NDP 应答 |
nud_state | NUD 状态机 | neigh_update() |
ops | 地址族操作表(arp_hh_ops、ndisc_hh_ops) | 协议注册 |
arp_queue | 等待解析完成的 skb 队列 | __neigh_event_send() |
confirmed | 上次确认可达的 jiffies | neigh_confirm() |
used / refcnt | 使用计数与引用计数 | 邻居表核心 |
timer | 状态迁移定时器 | neigh_timer_handler() |
状态机的核心是「什么时候允许发数据、什么时候要等」。neigh_resolve_output() 的判断可以简化成:
/* net/core/neighbour.c,简化 */
int neigh_resolve_output(struct neighbour *neigh, struct sk_buff *skb)
{
if (!(neigh->nud_state & NUD_CONNECTED)) {
if (neigh_event_send(neigh, skb) != 0) {
kfree_skb(skb); /* 队列满或探测失败 */
return -EINVAL;
}
return 0; /* skb 已挂进 arp_queue */
}
neigh->ops->output(neigh, skb); /* 直接交给 dev_queue_xmit */
return 0;
}
neigh_event_send() 的分支决定四件事:
NUD_CONNECTED(PERMANENT | NOARP | REACHABLE):直接发,不再解析;DELAY不属于 CONNECTED,仍要等delay_first_probe_time。NUD_INCOMPLETE:已经在解析,把 skb 挂进arp_queue,不重复发请求。NUD_STALE:先进入NUD_DELAY,给上层一个「确认」的机会;如果在delay_first_probe_time内没有确认,再发探测。NUD_FAILED:直接丢包,不再重试,直到下一次arp_queue触发重新解析。
arp_queue 有长度限制(net.ipv4.neigh.default.unres_qlen,默认 3)。队列满时新 skb 被丢弃,表现为「第一个包丢,后面的包正常」——因为第一个包触发了 ARP,后面的包在 ARP 完成前也被挂起,队列一满就丢。如果你看到「每次清空 ARP 缓存后固定丢几个包」,先看 unres_qlen,不要怀疑网卡。
ARP 和 NDP 的对应关系:
| 功能 | IPv4 | IPv6 |
|---|---|---|
| 请求 | ARP Request(以太网类型 0x0806,广播) | Neighbor Solicitation(ICMPv6 type 135,组播) |
| 应答 | ARP Reply(单播) | Neighbor Advertisement(ICMPv6 type 136) |
| 邻居表 | arp_tbl(AF_INET) | nd_tbl(AF_INET6) |
| 地址配置 | DHCP / 手工 | SLAAC / DHCPv6 / 手工 |
| 冲突检测 | 免费 ARP / arping -D | Duplicate Address Detection(DAD) |
| 查看命令 | ip neigh | ip -6 neigh |
ip neigh 的输出字段值得逐列读:
192.168.1.1 dev eth0 lladdr 00:11:22:33:44:55 REACHABLE
dev eth0:这条表项绑定在哪个接口。同一 IP 在不同接口上可以有不同表项,因为广播域不同。lladdr:链路层地址。INCOMPLETE时没有这一列。REACHABLE:NUD 状态。STALE表示「曾经可达,需要时再确认」,不是「不可达」。router:这一项是路由器(由 RA 或ip neigh add ... router设置)。extern_learn:由外部协议(如 FRR/BIRD)学习,内核不老化它。use:被使用的次数;confirmed/updated需要ip neigh show加-s才看得到。
邻居表的全局参数在 net.ipv4.neigh.default.* 和 net.ipv6.neigh.default.* 下:
| 参数 | 默认值 | 作用 |
|---|---|---|
gc_thresh1/2/3 | 128 / 512 / 1024 | 邻居表回收的三个水位;超过 3 拒绝新表项 |
base_reachable_time_ms | 30000 | REACHABLE 的基准存活时间,实际值在这个的一半到一倍半之间随机 |
retrans_time_ms | 1000 | 重发 solicitation 的间隔 |
ucast_solicit | 3 | 单播探测次数 |
mcast_solicit | 3 | 组播/广播探测次数 |
app_solicit | 0 | 允许上层(如 ARP 守护进程)探测的次数 |
unres_qlen | 3 | 未解析时挂起的 skb 数 |
gc_stale_time | 60 | 多久检查一次 STALE 表项 |
gc_thresh3 是百万连接场景的隐形瓶颈:默认 1024 条邻居表项,一台机器如果要和上万台主机通信,超过阈值的新表项会被拒绝,表现为「新连接建不上,老连接正常」。调大它之前先确认内存,因为每个 neighbour 都占几百字节。
四个和 ARP 行为相关的 sysctl 经常被混淆:
| 参数 | 取值 | 行为 |
|---|---|---|
arp_ignore | 0 | 只要本机有这张网卡的 IP 就回 ARP |
| 1 | 只有目标 IP 配置在收到请求的接口上才回 | |
| 2 | 只有目标 IP 在收到请求的接口上,且发送方 IP 在同一子网才回 | |
arp_announce | 0 | 用任意本地地址回 ARP |
| 1 | 尽量用与目标同子网的地址 | |
| 2 | 总是用最佳本地地址,忽略请求里的源地址 | |
arp_filter | 0 | 不检查反向路径 |
| 1 | 只有反向路径也走同一接口才回 ARP(多网卡同网段时用) | |
proxy_arp | 0 | 不代答 |
| 1 | 代答其他主机的 ARP(相当于 ARP 代理) |
arp_ignore=1 是多网卡服务器最常见的配置:两张网卡在同一个网段时,如果不设它,一张网卡会替另一张网卡回 ARP,导致流量从错误的接口进来。arp_announce=2 则保证发出的 ARP 请求源地址总是「最合适的那个」,避免对端把回包送到错误的网卡。
免费 ARP(gratuitous ARP)是 ar_op=REQUEST 但发送方 IP 等于目标 IP 的包。它有两个用途:通告「这个 IP 现在在我这里」,以及检测 IP 冲突。arping -D -I eth0 -c 3 10.0.0.1 发的就是免费 ARP:如果收到应答,说明这个 IP 已经被占用。虚拟机克隆、容器批量部署后,这个检查能提前发现「时通时不通」的地址冲突。
4.3.13 路由查找:从 ip route get 到 FIB 的 LC-trie
ip route get 看起来只是一条查询命令,它背后走的是完整的内核路由决策:策略规则、FIB 表、最长前缀匹配、源地址选择、下一跳解析。这一节把这条链拆开。
先看用户命令到内核函数的对应:
| 用户命令 | netlink 消息 | 内核入口 | 关键函数 |
|---|---|---|---|
ip route add | RTM_NEWROUTE | inet_rtm_newroute() | fib_table_insert() → fn_trie_insert() |
ip route del | RTM_DELROUTE | inet_rtm_delroute() | fib_table_delete() |
ip route get | RTM_GETROUTE | inet_rtm_getroute() | ip_route_output_flow() / ip_route_input_noref() |
ip rule add | RTM_NEWRULE | fib_nl_newrule() | fib_rules_register() / fib_rules_lookup() |
ip neigh | RTM_GETNEIGH | neigh_dump_info() | neigh_fill_info() |
接收方向的入口是 ip_route_input_noref(),发送方向是 ip_route_output_flow()。两者最终都调用 fib_lookup():
/* net/ipv4/fib_rules.c,简化 */
int fib_lookup(struct net *net, struct flowi4 *flp, struct fib_result *res,
unsigned int flags)
{
struct fib_lookup_arg arg = { .result = res, .flags = flags };
int err;
err = fib_rules_lookup(net->ipv4.rules_ops, flowi4_to_flowi(flp), 0, &arg);
return err;
}
fib_rules_lookup() 按 pref 从小到大遍历规则,第一条匹配的规则决定查哪张表。默认三条规则:
0: from all lookup local # 本机地址,最高优先级
32766: from all lookup main # 主表,ip route 默认写这里
32767: from all lookup default # 空表,兜底
local 表由内核自动维护,包含所有本机地址和广播地址;main 表是管理员操作的表;default 表通常是空的。本机地址必须在 local 表里命中,否则一个包发给自己的 IP 会被当成「转发」而不是「上交」,这是策略路由改坏后最常见的症状。
fib_table_lookup() 在表内做最长前缀匹配。Linux 用 LC-trie(压缩前缀树)而不是线性遍历,查找复杂度与地址位数相关,不随表项数量线性增长。核心是 fib_table_lookup()(内部节点查找 fib_find_node()):它按目的地址的比特位逐层下降,遇到压缩节点就跳过公共前缀。这也是为什么 Linux 能装下 BGP 全表(近百万条)而不明显变慢。
匹配结果放在 struct fib_result 里:
| 字段 | 含义 |
|---|---|
table | 命中的 FIB 表 |
prefixlen | 前缀长度 |
type | RTN_UNICAST/RTN_LOCAL/RTN_BROADCAST/RTN_MULTICAST/RTN_UNREACHABLE/RTN_BLACKHOLE/RTN_PROHIBIT |
fi | struct fib_info,描述下一跳的共享信息(优先级、表、协议) |
nhc | 下一跳的公共部分(struct fib_nh_common *);6.8 没有 nh 字段,旧内核才有 |
nh_sel | 多路径时的选中项 |
scope | RT_SCOPE_HOST/LINK/UNIVERSE |
ip_route_input_slow() 拿到 fib_result 后做三件事:
- 判断目的地类型:
RTN_LOCAL交给本机,RTN_BROADCAST/RTN_MULTICAST走广播/组播路径,RTN_UNICAST走转发。 - 检查 martian 源:源地址是回环、组播、0.0.0.0,或者反向路径不对,丢包。
- 创建
struct rtable:把fib_result转成 per-packet 的路由对象,填rt_gateway、rt_src、dst.dev、dst.mtu。
struct rtable 里最常用的字段:
| 字段 | 含义 | 谁用 |
|---|---|---|
rt_gateway | 下一跳 IP | ARP/NDP 解析 |
rt_src | 选中的源地址 | IP 首部填充 |
dst.dev | 出接口 | 驱动 |
dst.mtu | 路径 MTU | 分片/PMTUD |
rt_flags | RTCF_LOCAL/RTCF_BROADCAST/RTCF_MULTICAST | 输入路径分流 |
dst.error | 负值表示不可达 | ip_local_deliver 前检查 |
ip route get 的输出正好对应这些字段:
10.1.2.3 via 192.168.1.1 dev eth0 src 192.168.1.10 uid 1000
cache
via 192.168.1.1:rt_gateway。dev eth0:dst.dev。src 192.168.1.10:rt_src,由inet_select_addr()按「目的地址所在网段优先、否则主地址」选出。uid 1000:查询者;带uid时会走fib_rules里按 uid 匹配的规则。cache:这是ip命令的旧标记,不代表内核还有路由缓存。Linux 3.6 之后路由缓存已经移除,每个包都重新查 FIB,dst_entry只是查表结果的承载对象。
发送方向的源地址选择经常被忽略。一台机器有 192.168.1.10 和 10.0.0.10 两个地址,去 10.1.2.3 的包如果选了 192.168.1.10 作源,对端回包会走默认路由,可能从另一张网卡回来,形成非对称路径。ip route get 10.1.2.3 from 10.0.0.10 可以显式验证某个源地址下的选路。
多路径(ECMP)是路由查找的另一个分支。ip route add 10.0.0.0/8 nexthop via 192.168.1.1 weight 1 nexthop via 192.168.2.1 weight 2 会创建两条下一跳,fib_select_path() 按 fib_multipath_hash_policy 选一条:
| 策略值 | 哈希字段 | 适用场景 |
|---|---|---|
| 0 | 源/目的 IP(L3) | 传统 ECMP,同一条流固定走一条路 |
| 1 | L3 + L4 端口 | 需要更均匀地分散连接 |
| 2 | 内层 L3(隧道) | VXLAN/GRE 场景 |
| 3 | 内层 L3 + L4 | 隧道内多流分散 |
如果哈希策略选错,同一条 TCP 流可能被分到不同路径,产生乱序(4.6.11 会讲后果)。看到 TCPOFOQueue 增长又找不到丢包时,先查 ECMP 哈希策略和链路聚合的哈希配置。
- 看
via:有via说明要经过网关,接下来 ARP 的是网关;没有via说明直连,ARP 的是目的地址。 - 看
dev:出接口决定 MTU、qdisc、以及是否走隧道。 - 看
src:源地址选错会导致回包走错接口;用from参数验证。 - 看
uid/mark:策略路由可能按用户或防火墙标记选表;不带这两个参数查到的结果可能和实际流量不同。
4.3.14 ICMP:错误报文怎么回到发送方
ICMP 不是「ping 专用协议」,它是 IP 的控制面:路由器丢包、目的主机端口不通、TTL 耗尽、需要分片,都靠 ICMP 把原因告诉源端。没有 ICMP,IP 就只是一个会静默丢包的协议。
ICMP 首部只有 8 字节:类型(1)、代码(1)、校验和(2)、以及 4 字节的「剩余部分」。差错报文(type 3、11、12)的剩余部分固定为 0,后面紧跟「原始 IP 首部 + 至少 8 字节载荷」;查询报文(type 0/8)的剩余部分是标识符和序列号。
| 类型 | 代码 | 名称 | 谁生成 | 典型触发 | IPv6 对应 |
|---|---|---|---|---|---|
| 0 / 8 | 0 | Echo Reply / Request | 目的主机 / ping | 连通性测试 | 129 / 128 |
| 3 | 0 | Net Unreachable | 路由器 | 路由表无匹配 | 1/0 |
| 3 | 1 | Host Unreachable | 路由器/主机 | ARP 失败、主机不可达 | 1/1 |
| 3 | 2 | Protocol Unreachable | 目的主机 | inet_protos 查不到 | 1/1 |
| 3 | 3 | Port Unreachable | 目的主机 | UDP 端口未监听 | 1/4 |
| 3 | 4 | Fragmentation Needed | 路由器 | DF=1 且包大于 MTU | 2(Packet Too Big) |
| 5 | 0–3 | Redirect | 路由器 | 有更好的下一跳 | 137 |
| 11 | 0 | TTL Exceeded | 路由器 | TTL 减到 0 | 3/0 |
| 11 | 1 | Fragment Reassembly Timeout | 目的主机 | 重组超时 | 3/1 |
| 12 | 0 | Parameter Problem | 路由器/主机 | 首部字段非法 | 4 |
这张表里最该记住的是 type 3 code 4 和 type 11 code 0。前者是 PMTUD 的全部依赖,后者是 traceroute 的全部依赖;两者被防火墙挡住,就会出现「小包通、大包死」和「traceroute 全是星号」两类经典故障。
ICMP 差错报文的生成受一套「不许回」规则约束(RFC 1122)。内核在 icmp_send() 里逐条检查,违反就静默丢弃:
- 不对 ICMP 差错报文回 ICMP 差错。否则两个路由器会互相回包,形成风暴。
- 不对广播/组播回差错。一个广播包出错,回差错会放大成 N 个。
- 不对非首片分片回差错。非首片没有运输层首部,无法定位端口。
- 源地址不是单播时不回。源地址是 0.0.0.0、回环、组播、广播,都不回。
- 包本身是链路层广播时不回。
- 包被分片且不是首片时不回(与第 3 条重叠,但实现里分开检查)。
这些规则解释了为什么「抓包看到包到了路由器,但没有 ICMP 回来」不一定是路由器坏了:可能这个包本身就不允许回差错。
icmp_rcv() 是接收入口,它按类型查 icmp_pointers[] 表:
| 类型 | 处理函数 | 做什么 |
|---|---|---|
| Echo Request | icmp_echo() | 生成 Echo Reply,交换源/目的,保留 payload |
| Echo Reply | ping_rcv() | 交给 ping 的 socket(SOCK_DGRAM ICMP) |
| Dest Unreach | icmp_unreach() | 解析内层 IP 首部,调用 tcp_v4_err()/udp_err()/raw_err() |
| Time Exceeded | icmp_unreach() | 同上,traceroute 靠它拿中间跳地址 |
| Redirect | icmp_redirect() | 更新路由/邻居,默认受 accept_redirects 限制 |
| Param Problem | icmp_unreach() | 解析出错的字段,交给上层 |
icmp_unreach() 的关键动作是「解引用内层首部」:ICMP 差错报文携带原始 IP 首部 + 8 字节载荷,内核从中取出源/目的 IP、协议、端口,找到对应的 socket,把错误挂到 sk->sk_err 上,并唤醒等待者。如果内层信息不足 8 字节,或者被 NAT 改过,错误就可能送错 socket——这是 NAT 环境下 ICMP 错误的常见问题。
ICMP 还有速率限制,防止被用作放大攻击:
| 参数 | 默认值 | 作用 |
|---|---|---|
net.ipv4.icmp_ratelimit | 1000 ms | 同一目的地址的最小间隔 |
net.ipv4.icmp_ratemask | 0x1818 | 哪些类型受速率限制(按位) |
net.ipv4.icmp_msgs_per_sec | 1000 | 全局每秒最多发多少 ICMP |
net.ipv4.icmp_msgs_burst | 50 | 全局突发上限 |
net.ipv4.icmp_echo_ignore_all | 0 | 忽略所有 Echo Request |
net.ipv4.icmp_echo_ignore_broadcasts | 1 | 忽略广播 Echo(防 Smurf) |
net.ipv4.icmp_ignore_bogus_error_responses | 1 | 忽略格式错误的差错报文 |
icmp_ratemask 的默认值 0x1818 对应 type 3、4、11、12 的若干 code;Echo 不受限,所以 ping 能正常跑,而 traceroute 可能因为 Time Exceeded 被限速而丢星号。排查「traceroute 时有时无」时,先看 icmp_ratelimit,不要先怀疑路由。
# 看 ICMP 计数:InDestUnreachs/OutDestUnreachs/InTimeExcds/OutTimeExcds
grep -E '^Icmp:' /proc/net/snmp
# 看当前速率限制参数
sysctl net.ipv4.icmp_ratelimit net.ipv4.icmp_ratemask \
net.ipv4.icmp_msgs_per_sec net.ipv4.icmp_msgs_burst
# 抓一次 traceroute 的 ICMP 往返
sudo tcpdump -i any -nn -vv 'icmp and (icmp[0] == 11 or icmp[0] == 3)'
ping 与 traceroute 对 ICMP 的用法不同:ping 发 type 8、收 type 0;traceroute -I 发 type 8 但把 TTL 设成 1、2、3……,收 type 11;traceroute 默认(UDP 模式)发高端口 UDP,收 type 3 code 3;tracepath 用 UDP + IP_RECVERR,能从 ICMP 错误里直接拿到 PMTU。四个工具测的是同一套 ICMP,但触发条件不同,结果不能互相替代。
4.3.15 NAT 的内核实现:conntrack、钩子与元组改写
4.3.6 讲了 NAT 的行为和代价,这一节看它怎么挂在 netfilter 上。NAT 不是一个独立的「地址转换模块」,它是 conntrack 的一个消费者:先由 conntrack 记录五元组,再由 NAT 在特定钩子上改写地址/端口,最后让 conntrack 记住改写后的映射。
netfilter 在 IPv4 路径上有五个钩子,NAT 和 conntrack 各占几个:
| 钩子 | 优先级 | 挂什么 | 作用 |
|---|---|---|---|
NF_INET_PRE_ROUTING | −400 | nf_defrag_ipv4 | 先重组分片,让后续能看到运输层首部 |
NF_INET_PRE_ROUTING | −200 | nf_conntrack_in | 为 NEW 连接创建 nf_conn |
NF_INET_PRE_ROUTING | −100 | nf_nat_ipv4_pre_routing | DNAT(改目的地址/端口) |
NF_INET_LOCAL_IN | 100 | nf_nat_ipv4_local_in | 本机入方向的 SNAT 修正 |
NF_INET_LOCAL_OUT | −100 | nf_nat_ipv4_local_out | 本机出方向的 DNAT |
NF_INET_POST_ROUTING | 100 | nf_nat_ipv4_out | SNAT/MASQUERADE(改源地址/端口) |
NF_INET_POST_ROUTING | INT_MAX | nf_conntrack_confirm | 确认连接,插入 conntrack 哈希表 |
这张表的顺序就是「包在 NAT 网关里的一生」:先重组,再建 conntrack,再做 DNAT,路由之后再做 SNAT,最后确认。DNAT 必须在路由之前,因为改完目的地址要重新查路由;SNAT 必须在路由之后,因为要等出接口确定后才能选源地址。这是 NAT 实现里最基本的两条时序约束。
conntrack 的核心是 struct nf_conn,它保存两个方向的元组:
/* include/net/netfilter/nf_conntrack.h,简化 */
struct nf_conn {
struct nf_conntrack_tuple_hash tuplehash[IP_CT_DIR_MAX];
unsigned long status; /* IPS_* 位 */
u32 timeout;
...
};
nf_conntrack_tuple 描述一个方向的五元组:
| 字段 | 含义 |
|---|---|
src.u3 / dst.u3 | 源/目的 IPv4 或 IPv6 地址 |
src.u.all / dst.u.all | 源/目的端口(或 ICMP ID/type/code) |
src.l3num | 地址族(AF_INET/AF_INET6) |
protonum | 运输层协议号(6/17/1/…) |
dir | 方向(IP_CT_DIR_ORIGINAL/IP_CT_DIR_REPLY) |
原始方向和回复方向各存一个元组。NAT 改写的是「元组里的地址/端口」,同时把改写记录在 nf_conn->nat 扩展里(struct nf_conn_nat),回包时按相反方向改回去。conntrack 表里的一行,本质上就是「原始元组 ↔ 回复元组」的映射。
conntrack 的状态机比 TCP 简单,只有五个状态:
| 状态 | 含义 | 什么时候进入 |
|---|---|---|
NEW | 只看到第一个方向的包 | 首个包到达,尚未确认 |
ESTABLISHED | 两个方向都看到过包 | 收到回包,nf_conntrack_confirm |
RELATED | 与已有连接相关的新连接 | FTP data、ICMP error 关联 |
INVALID | 不属于任何已知连接 | 状态非法、校验和错、超出窗口 |
UNTRACKED | 明确不跟踪 | raw 表 NOTRACK 或 CT --notrack |
NEW 和 ESTABLISHED 的区别经常被误解:不是 TCP 状态机的 ESTABLISHED,而是 conntrack 自己是否看到双向流量。一个只发了 SYN、还没收到 SYN-ACK 的连接,在 conntrack 里是 NEW;收到 SYN-ACK 后才变成 ESTABLISHED。所以用 conntrack -L 统计「已建立连接」时,看到的数字和 ss -s 的 estab 不是一回事。
NAT 的三种典型写法:
| 类型 | 规则位置 | 改什么 | 典型命令 |
|---|---|---|---|
| SNAT | POSTROUTING | 源地址/端口 | nft add rule ip nat postrouting ip saddr 10.0.0.0/24 snat to 203.0.113.5 |
| MASQUERADE | POSTROUTING | 源地址取自出接口 | nft add rule ip nat postrouting oifname "eth0" masquerade |
| DNAT | PREROUTING | 目的地址/端口 | nft add rule ip nat prerouting tcp dport 8080 dnat to 10.0.0.2:80 |
MASQUERADE 是 SNAT 的特例:它不写死源地址,而是在发包时查路由,取出接口的主地址。代价是每次都要查路由,性能略低;好处是拨号/动态地址环境不用改规则。有固定公网 IP 时优先用 SNAT,没有才用 MASQUERADE。
端口分配是 NAT 的第二个核心。多个内网连接映射到同一个公网 IP 时,必须保证「公网端口 + 协议 + 目的地址」唯一。内核在 nf_nat_l4proto_unique_tuple() 里做这件事:
- 如果原始端口可用,优先保留(
--persistent或nf_nat_used_tuple()判断)。 - 否则在
min_port–max_port范围内线性/随机扫描。 - 全被占用时返回
-ENOMEM/-EINVAL,包被丢,表现为「新连接建不上」。
--random 让端口选择随机化,--random-fully 连源端口一起随机化,能缓解「端口预测」攻击,但会增加端口冲突概率。高并发 NAT 网关通常要配端口池(snat to 203.0.113.5-203.0.113.10),否则 6 万个端口很快耗尽。
# 看 conntrack 表:状态、原始/回复元组、ASSURED 标记
sudo conntrack -L
# 只看某一目的地址
sudo conntrack -L -d 93.184.216.34
# 当前条目数与上限
sudo conntrack -C
sysctl net.netfilter.nf_conntrack_max net.netfilter.nf_conntrack_count
# 看超时:ESTABLISHED 默认 5 天,TIME_WAIT 默认 120 秒
sysctl net.netfilter.nf_conntrack_tcp_timeout_established \
net.netfilter.nf_conntrack_tcp_timeout_time_wait
conntrack -L 的一行典型输出:
tcp 6 431999 ESTABLISHED src=10.0.0.2 dst=93.184.216.34 sport=51234 dport=80
src=93.184.216.34 dst=203.0.113.5 sport=80 dport=40001 [ASSURED] mark=0 use=1
前半段是原始方向,后半段是回复方向。431999 是剩余超时秒数;[ASSURED] 表示「两个方向都确认过,可以提前淘汰」;use=1 是引用计数。回复方向的 dst 是 NAT 后的公网地址(原始 tuple 的 dst),不是内网地址;不要拿它判断 SNAT 是否生效。SNAT 改写后包会按 conntrack 的映射送回内网,真正的证据是 conntrack -L 的 reply tuple 与 NAT 前后的地址对应关系。
NAT 还有两个容易忽略的细节:
- ICMP 差错的内层地址也要改。一个内网主机发包给外网,路由器回
Frag Needed,这个 ICMP 里嵌着原始 IP 首部。NAT 网关必须同时改写外层 ICMP 的目的地址和内层原始首部的源地址,否则内网主机认不出这是给它的错误。内核由nf_nat_icmp_reply_translation()处理。 - 分片必须先重组。非首片没有端口,NAT 无法改端口;所以
nf_defrag_ipv4在 conntrack 之前运行。如果你在raw表里对分片做了NOTRACK,NAT 可能看到不完整的信息。
conntrack -C接近nf_conntrack_max时新连接被丢,日志出现nf_conntrack: table full, dropping packet;调大nf_conntrack_max还要同步调nf_conntrack_buckets(哈希桶),否则链表变长、查找变慢。MASQUERADE在出接口地址变化后不会自动更新已有连接,旧连接仍用旧地址,表现为「换 IP 后老连接断、新连接正常」。- NAT 只改 IP/端口,不改应用层地址。FTP 的 PORT、SIP 的 SDP 里嵌了 IP,需要 ALG;ALG 经常和 NAT 打架,现代做法是尽量用被动模式或 TLS。
NOTRACK能省 conntrack 开销,但也让 NAT 失效。对需要 NAT 的流量用NOTRACK,包会被直接转发而不改写地址。
4.3.16 程序一:打印分片计划并验证每片校验和
这段程序解决一个具体问题:给定 IP 数据报总长和 MTU,列出每一片的载荷长度、字节偏移、MF 标志、IP 总长和首部校验和。它验证三件事:满片载荷要向下取整到 8 的倍数、片偏移字段的单位是 8 字节、每片的首部校验和都不同。
/* frag_plan.c:按 RFC 791 打印 IPv4 分片计划,并算每片首部校验和 */
#include <arpa/inet.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct iphdr {
uint8_t vihl;
uint8_t tos;
uint16_t tot_len;
uint16_t id;
uint16_t frag_off;
uint8_t ttl;
uint8_t proto;
uint16_t check;
uint32_t saddr;
uint32_t daddr;
} __attribute__((packed));
_Static_assert(sizeof(struct iphdr) == 20, "IPv4 首部必须是 20 字节");
/* RFC 1071:按大端 16 位字求和,返回主机序校验和 */
static uint16_t inet_csum(const void *data, size_t len)
{
const uint8_t *p = data;
uint32_t sum = 0;
for (size_t i = 0; i + 1 < len; i += 2)
sum += (uint32_t)((p[i] << 8) | p[i + 1]);
if (len & 1)
sum += (uint32_t)p[len - 1] << 8;
while (sum >> 16)
sum = (sum & 0xffff) + (sum >> 16);
return (uint16_t)~sum;
}
static void fill(struct iphdr *ip, uint16_t len, uint16_t id,
uint32_t off, int mf)
{
memset(ip, 0, sizeof *ip);
ip->vihl = 0x45;
ip->tot_len = htons(len);
ip->id = htons(id);
ip->frag_off = htons((uint16_t)((mf ? 0x2000u : 0u) | (off / 8u)));
ip->ttl = 64;
ip->proto = 17; /* UDP,仅用于示例 */
ip->saddr = htonl(0x0a000001); /* 10.0.0.1 */
ip->daddr = htonl(0x0a000002); /* 10.0.0.2 */
ip->check = htons(inet_csum(ip, sizeof *ip));
}
int main(int argc, char **argv)
{
if (argc < 3 || argc > 4) {
fprintf(stderr, "用法: %s <IP总长> <MTU> [df]\n", argv[0]);
return 2;
}
long total = strtol(argv[1], NULL, 10);
long mtu = strtol(argv[2], NULL, 10);
int df = argc == 4 && strcmp(argv[3], "df") == 0;
if (total < 20 || total > 65535) {
fprintf(stderr, "IP 总长必须在 20..65535\n");
return 2;
}
if (mtu < 68) {
fprintf(stderr, "MTU 小于 68,IPv4 无法分片\n");
return 2;
}
long payload = total - 20;
printf("total=%ld payload=%ld mtu=%ld df=%d\n", total, payload, mtu, df);
if (total <= mtu) {
struct iphdr ip;
fill(&ip, (uint16_t)total, 0x1234, 0, 0);
printf("%4s %8s %7s %2s %7s %7s\n", "frag", "payload", "offset",
"MF", "ip_len", "csum");
printf("%4d %8ld %7d %2d %7ld 0x%04x\n", 1, payload, 0, 0, total,
ntohs(ip.check));
return 0;
}
if (df) {
printf("DF=1:丢弃并回 ICMP type 3 code 4,mtu=%ld\n", mtu);
return 1;
}
long max = (mtu - 20) & ~7L; /* 满片载荷,向下取整到 8 */
if (max < 8) {
fprintf(stderr, "MTU 太小,满片载荷不足 8 字节\n");
return 2;
}
printf("max_full_payload=%ld (waste=%ld bytes/fragment)\n",
max, (mtu - 20) - max);
printf("%4s %8s %7s %2s %7s %7s\n", "frag", "payload", "offset",
"MF", "ip_len", "csum");
long off = 0;
int frag = 1;
while (off < payload) {
long n = payload - off;
int mf = n > max;
if (mf)
n = max;
struct iphdr ip;
fill(&ip, (uint16_t)(20 + n), 0x1234, (uint32_t)off, mf);
printf("%4d %8ld %7ld %2d %7ld 0x%04x\n", frag, n, off, mf, 20 + n,
ntohs(ip.check));
off += n;
frag++;
}
return 0;
}
编译并运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g frag_plan.c -o frag_plan
./frag_plan 4000 1500
./frag_plan 4000 1400
./frag_plan 4000 1400 df
total=4000 payload=3980 mtu=1500 df=0
max_full_payload=1480 (waste=0 bytes/fragment)
frag payload offset MF ip_len csum
1 1480 0 1 1500 0x2edb
2 1480 1480 1 1500 0x2e22
3 1020 2960 0 1040 0x4f35
total=4000 payload=3980 mtu=1400 df=0
max_full_payload=1376 (waste=4 bytes/fragment)
frag payload offset MF ip_len csum
1 1376 0 1 1396 0x2f43
2 1376 1376 1 1396 0x2e97
3 1228 2752 0 1248 0x4e7f
DF=1:丢弃并回 ICMP type 3 code 4,mtu=1400
三处关键:
offset列打印的是字节偏移,程序在写首部时做了off / 8。所以第二片的字节偏移是 1480,首部里存的是 185;第三片是 2960/8 = 370。抓包时tcpdump -vv打印的是字节偏移,别和首部里的值混。- MTU 1400 时
max_full_payload=1376而不是 1380,因为 1380 不是 8 的倍数。每片浪费 4 字节,这是分片效率损失的来源之一;MTU 越接近 8 的倍数,浪费越小。 - 三片的
csum各不相同,因为tot_len、frag_off都变了。分片不是复制首部,而是每片重算。
df 参数演示 PMTUD 失败时内核的动作:ip_fragment() 返回 -EMSGSIZE,调用方发 ICMP type 3 code 4,包里带上下一条链路的 MTU。如果这个 ICMP 被防火墙挡住,发送端永远不知道要调小,形成黑洞。
4.3.17 实验:在三个 netns 里制造 PMTUD 黑洞与分片
4.3.8 搭的是两跳路由,这个实验在同一拓扑上把右侧链路 MTU 改成 1400,观察三种行为:DF=1 的大包被路由器拒绝、DF=1 的小包通过、DF=0 的大包被分片后重组。
#!/usr/bin/env bash
# frag_lab.sh:三个 netns,把右侧链路 MTU 改成 1400,验证 PMTUD 与分片
set -euo pipefail
NS_L=mtul
NS_R=mtur
NS_H=mtuh
CAP1=$(mktemp)
CAP2=$(mktemp)
cleanup() {
ip netns del "$NS_L" 2>/dev/null || true
ip netns del "$NS_R" 2>/dev/null || true
ip netns del "$NS_H" 2>/dev/null || true
rm -f "$CAP1" "$CAP2"
}
trap cleanup EXIT
cleanup
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "需要 root" >&2; exit 1; }
for n in "$NS_L" "$NS_R" "$NS_H"; do ip netns add "$n"; done
# 两条 veth:left--router、router--right
ip link add l0 type veth peer name r0
ip link set l0 netns "$NS_L"
ip link set r0 netns "$NS_R"
ip link add r1 type veth peer name h0
ip link set r1 netns "$NS_R"
ip link set h0 netns "$NS_H"
ip -n "$NS_L" addr add 10.44.1.2/24 dev l0
ip -n "$NS_R" addr add 10.44.1.1/24 dev r0
ip -n "$NS_R" addr add 10.44.2.1/24 dev r1
ip -n "$NS_H" addr add 10.44.2.2/24 dev h0
for n in "$NS_L" "$NS_R" "$NS_H"; do ip -n "$n" link set lo up; done
ip -n "$NS_L" link set l0 up
ip -n "$NS_R" link set r0 up
ip -n "$NS_R" link set r1 up
ip -n "$NS_H" link set h0 up
ip -n "$NS_L" route add default via 10.44.1.1
ip -n "$NS_H" route add default via 10.44.2.1
ip netns exec "$NS_R" sysctl -qw net.ipv4.ip_forward=1
# 关键:路由器右侧 MTU 1400,左侧仍是 1500
ip -n "$NS_R" link set r1 mtu 1400
echo "== MTU =="
ip -n "$NS_L" -br link show l0
ip -n "$NS_R" -br link show r0
ip -n "$NS_R" -br link show r1
ip -n "$NS_H" -br link show h0
# 1) DF=1、1500 字节:应该收到 ICMP Frag Needed
echo "== 1) DF=1, 1500 bytes =="
ip netns exec "$NS_R" timeout 5 tcpdump -i r1 -nn -vv -c 2 'icmp' >"$CAP1" 2>&1 &
TPID=$!
sleep 0.5
ip netns exec "$NS_L" ping -M do -s 1472 -c 1 -W 2 10.44.2.2 || true
wait "$TPID" || true
cat "$CAP1"
# 2) DF=1、1400 字节:应该通
echo "== 2) DF=1, 1400 bytes =="
ip netns exec "$NS_L" ping -M do -s 1372 -c 2 -W 2 10.44.2.2 || true
# 3) DF=0、1500 字节:路由器分片,目的端重组
echo "== 3) DF=0, 1500 bytes (fragments) =="
ip netns exec "$NS_H" timeout 5 tcpdump -i h0 -nn -vv -c 2 \
'ip[6:2] & 0x3fff != 0' >"$CAP2" 2>&1 &
TPID=$!
sleep 0.5
ip netns exec "$NS_L" ping -M dont -s 1472 -c 2 -W 2 10.44.2.2 || true
wait "$TPID" || true
cat "$CAP2"
echo "== 4) reassembly counters on right host =="
ip netns exec "$NS_H" grep -E '^Ip:' /proc/net/snmp
# 5) traceroute:第一跳 10.44.1.1,第二跳 10.44.2.2
echo "== 5) traceroute =="
if command -v traceroute >/dev/null; then
ip netns exec "$NS_L" traceroute -n -m 4 -I 10.44.2.2 || true
else
echo "traceroute 未安装,跳过"
fi
以 root 运行:chmod +x frag_lab.sh && sudo ./frag_lab.sh。
预期输出(关键行):
== MTU ==
== 1) DF=1, 1500 bytes ==
From 10.44.1.1 icmp_seq=1 Frag needed and DF set (mtu = 1400)
IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto ICMP (1), length 556)
10.44.1.1 > 10.44.1.2: ICMP 10.44.2.2 unreachable - need to frag (mtu 1400), length 556
== 2) DF=1, 1400 bytes ==
2 packets transmitted, 2 received, 0% packet loss
== 3) DF=0, 1500 bytes (fragments) ==
IP (tos 0x0, ttl 63, id 12345, offset 0, flags [+], proto ICMP (1), length 1396)
IP (tos 0x0, ttl 63, id 12345, offset 1376, flags [none], proto ICMP (1), length 124)
== 4) reassembly counters on right host ==
Ip: Forwarding DefaultTTL InReceives ... ReasmReqds ReasmOKs ReasmFails ...
读这段输出要抓住四点:
- 第一跳路由器的地址是
10.44.1.1,不是最终的10.44.2.2。ICMPFrag needed由「发现出接口 MTU 不够」的路由器发出,所以源地址是它的入接口地址。ping显示的是From 10.44.1.1,不要误以为对端在回包。 length 556不是原始包长。ICMP 差错只带原始 IP 首部 + 8 字节,加上 ICMP 首部和外层 IP 首部,总共 556 字节左右;不同内核/工具会略有差异。原始包是 1500 字节,这个数字对不上是正常的。- 分片的第二片
offset 1376。tcpdump 打印的是字节偏移;首部里的 13 位片偏移字段是 172(172 × 8 = 1376)。第二片载荷 104 字节,加 20 字节首部是 124 字节。 ReasmReqds增加 2、ReasmOKs增加 1。一次分片传输产生两片,所以 ReasmReqds +2;重组成功一次,所以 ReasmOKs +1。如果ReasmFails也在涨,说明有片丢了或超时了。
两个常见失败模式:ping: local error: message too long, mtu=1400 说明 iputils 用 IP_MTU_DISCOVER 在本地就报错,包根本没发;ping -M do 完全超时则说明 Frag needed 被中间设备或 icmp_ratelimit 丢了。
4.3.18 本章陷阱清单(新增 8 条)
- 把
ip route get当成「包一定能到」:它只做路由决策,不验证下一跳 ARP、不验证对端是否监听。ip route get成功但ping不通,先查邻居表和防火墙。 - 认为路由表按
ip route add的顺序匹配:内核按最长前缀匹配,与添加顺序无关;同样前缀的重复路由才会被新条目替换。 - 把
scope link理解成「不需要 ARP」:scope link只表示「目的在直连网段」,仍然要 ARP 目的地址;只有scope host才是本机。 - 认为
blackhole会回 ICMP:blackhole静默丢包,unreachable才回 type 3;安全封禁用前者,快速失败用后者。 - 把
DF=1当「保证送达」:DF 只禁止分片;包太大且 ICMP 被挡时,形成 PMTUD 黑洞,小包通、大包死。 - 把
ping -M do -s的数当 IP 总长:-s是 ICMP 载荷;IPv4 加 28,IPv6 加 48 才是 IP 包大小。 - 只看
conntrack -C不看nf_conntrack_max:-C是当前条目数,接近上限才丢包;还要看nf_conntrack_count与dmesg里的 table full。 - 把
traceroute的*当断点:*只表示那一跳没回 ICMP,包可能仍被转发;后续跳有响应就说明路径是通的。
4.3.19 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 逐字段解释 IPv4 首部 | 能说出长度单位与分片三位标志 | 4.3.1 |
| 手算首部校验和 | 知道「填入后再算为 0」 | 4.3.1 |
| 解释分片的三个代价 | 能说出重组缓冲、丢片全废、中间负担 | 4.3.2 |
| 给定前缀算地址数与掩码 | 心算 /24、/26、/30 不出错 | 4.3.3 |
用 ip route get 验证选路 | 能读出下一跳、出接口、源地址 | 4.3.4 |
| 说清 traceroute 的原理 | 知道 TTL 递增与 ICMP 超时 | 4.3.5 |
| 说清 NAT 改了哪些字段 | 知道 conntrack 与端口耗尽 | 4.3.6 |
| 逐字段说出 IPv4 首部的写入者与转发修改 | 能指出 TTL/校验和每跳变 | 4.3.10 |
跟一遍 ip_defrag/ip_fragment 路径 | 知道 -EINPROGRESS 与 8 字节对齐 | 4.3.11 |
| 说清 ARP 与 NDP 的对应 | 能解释 arp_ignore/announce | 4.3.12 |
跟一遍 fib_lookup 与 LC-trie | 能读出 fib_result 字段 | 4.3.13 |
| 解释 ICMP 错误的抑制规则 | 知道 type 3 code 4 的作用 | 4.3.14 |
| 说清 conntrack 钩子顺序 | 知道 DNAT 在路由前、SNAT 在后 | 4.3.15 |
用 frag_plan 算分片 | 能解释 offset 字节与 /8 | 4.3.16 |
| 在 netns 里复现 PMTUD | 能读 ICMP Frag Needed | 4.3.17 |
Q1 一个 IPv4 首部的 首部长度 字段是 6,总长度 是 1500。首部多少字节?载荷多少字节?还能带多少字节选项?
Q2 一个 4000 字节的 IP 包(含 20 字节首部)走 1500 MTU 的链路,DF=0。会分成几片?每片的 片偏移 是多少?
Q3 路由表里有 default via 10.0.0.1、10.0.0.0/8 dev eth0、10.1.0.0/16 via 10.0.0.2。目的地址 10.1.2.3 走哪条?为什么?
Q4 traceroute 输出中间全是 * * *,最后一跳却有响应。这说明什么?
Q5 NAT 网关的 conntrack 表满了,会发生什么?怎么确认?
Q6 IPv6 为什么取消首部校验和?会不会导致坏包被交给上层?
答案
A1 首部长度 单位是 4 字节,6 × 4 = 24 字节。载荷 = 1500 − 24 = 1476 字节。固定首部 20 字节,所以选项占 24 − 20 = 4 字节。
A2 每片除最后一片外,载荷必须是 8 的倍数且不超过 1480(1500 − 20)。第一片载荷 1480,offset=0, MF=1;第二片载荷 1480,offset=185(1480/8),MF=1;剩余 4000 − 20 − 2960 = 1020 字节,第三片 offset=370,MF=0。共 3 片。
A3 走 10.1.0.0/16。三条都匹配,前缀长度分别是 0、8、16,最长的是 16。这就是最长前缀匹配:更具体的路由优先,与配置顺序无关。
A4 那一跳的路由器配置为不发送 ICMP Time Exceeded(常见于云网络和防火墙策略),或者 ICMP 被限速/过滤。包本身仍然被正常转发,所以后面的跳数有响应。这不代表链路中断。
A5 新连接无法建立,内核日志出现 nf_conntrack: table full, dropping packet,表现为「新建连接超时、老连接正常」。用 conntrack -C 看当前数量,与 sysctl net.netfilter.nf_conntrack_max 对比确认。
A6 因为链路层(以太网 FCS、Wi-Fi CRC)和上层(TCP/UDP 校验和覆盖伪首部)已经提供了检错,网络层再算一遍收益很低,而每跳重算的代价不小。取消后坏包仍会被上层校验和或链路层丢弃,代价是「错误可能晚一层被发现」。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| W(谢希仁) | 第 4 章 网络层(IP、ARP、ICMP、路由、IPv6、VPN/NAT,PDF p126–221) | 4.3.1–4.3.17 | 完整:首部、分片、CIDR、路由、ICMP、NAT、IPv6;补充分片/邻居/FIB 实现 |
| N(深入理解 Linux 网络) | 第 5 章 5.2(跨机网络通信过程) | 4.3.4、4.3.8 | 前置:路由查找在发包路径中的位置 |
| K(Kerrisk) | 第 58 章 58.4–58.5(网络层:IP、IP 地址) | 4.3.1、4.3.3 | 精选:地址表示与子网划分的系统编程视角 |
| C(CSAPP) | 第 11 章 11.3(全球 IP 因特网) | 4.3.3、4.3.4 | 背景:IP 地址与路由的抽象 |
| R(奔跑吧 Linux 内核) | 第 5 章(中断管理,转发路径的软中断上下文) | 4.3.8 | 背景:转发包在软中断里完成 |
下一站:网络层把包送到了主机,可主机上跑着几百个进程,谁来收?进入 4.4 运输层:UDP 与 TCP,看端口、首部和两种截然不同的可靠性承诺。