4.1 IPv4 头部与协议注册
有一类问题,表面上看是「配置不对」,实际上是「结构认知偏差」。
我们在本章要处理的,正是这样一个问题。你可能会觉得自己知道 IP 头部——无非就是源地址、目的地址、校验和——但当你真正站在内核代码面前,试图通过一个 Raw Socket 发送一个伪造的包,或者抓包分析一个奇怪的切片故障时,你会发现,教科书上那行 20 字节的定义里,藏着许多被忽略的细节。
本章的任务,就是把这些细节摊开来看。我们将从 IPv4 头部的每一个比特开始,像解剖青蛙一样,把这个协议头部的每一根神经都理清楚。这不仅是看懂 wireshark 输出的基础,更是理解后续 Netfilter 过滤、路由查找、分片重组的必要前提。
准备好了吗?让我们从那张最熟悉的「脸」开始。
IPv4 头部(IPv4 Header)
如果网络是一个邮政系统,那么 IPv4 头部就是信封。但这是一个非常特殊的信封:它的格式是固定的,但长度却是可变的;它不仅要告诉你信从哪来往哪去,还要告诉中间的分拣员(路由器)这封信有多急(TOS)、能不能拆开寄(DF)、如果拆开了哪一块是这一截。
在 Linux 内核眼里,这个信封被抽象成了一个 C 语言结构体 struct iphdr。它定义在 include/uapi/linux/ip.h 里。
先别急着看字段,先看这张图(脑子里有个印象就行):
0 16 31
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|Version| IHL | Type of Service | Total Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Identification |Flags| Fragment Offset |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Time to Live | Protocol | Header Checksum |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Address |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Destination Address |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Options (optional) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
这就是内核处理每一个 IP 数据包时看到的“面孔”。代码层面的定义如下:
struct iphdr {
#if defined(__LITTLE_ENDIAN_BITFIELD)
__u8 ihl:4,
version:4;
#elif defined (__BIG_ENDIAN_BITFIELD)
__u8 version:4,
ihl:4;
#else
#error "Please fix <asm/byteorder.h>"
#endif
__u8 tos;
__be16 tot_len;
__be16 id;
__be16 frag_off;
__u8 ttl;
__u8 protocol;
__sum16 check;
__be32 saddr;
__be32 daddr;
/*The options start here. */
};
你会发现第一眼有点别扭——这个 ihl 和 version 挤在一个字节里,还要看字节序。这是为了压榨每一个比特的空间。我们逐个拆解来看。
1. 版本与头部长度(version / ihl)
version 很简单,必须是 4。如果不是 4,内核会直接把这个包扔掉,因为它根本不认识。
ihl(Internet Header Length)是关键。它告诉我们这个 IPv4 头部到底有多长。
这里有个反直觉的点:IPv4 头部的长度不是固定的。
不像 IPv6 那样固定 40 字节,IPv4 头部最小 20 字节(没有选项时),最大 60 字节。因为它是按 4 字节(32 位)为一个单位来计数的,所以 ihl 字段存的是「单位数」,而不是字节数。
- 最小 20 字节 →
ihl= 5 - 最大 60 字节 →
ihl= 15
为什么要这样设计?
为了兼容性。IPv4 头部后面跟着一个可选的 Options 字段。虽然现在很少用了,但当年的网络协议设计者们认为,也许有一天我们需要在头部塞进更多路由信息或者安全参数。这个设计把长度“参数化”了,只要有 ihl,内核就能算出真正的头部在哪里结束,数据载荷从哪里开始。
回到那个信封的类比:ihl 就是告诉分拣员,这封信的“信封纸”折叠了几层。如果不看这个直接撕,可能把里面的信纸内容给撕坏了。
2. 服务类型与拥塞通知(tos / DSCP / ECN)
tos(Type of Service)这 8 个比特在历史上经历了多次“再利用”。
- 最初(RFC 791):它用于 QoS。你可以把包标记为“最小延迟”、“最大吞吐量”等,希望路由器能优先处理。这就像在信封上盖个“加急”章。
- 后来(RFC 2474):人们发现这种太粗糙了,于是重新定义了前 6 位,称之为 DS Field(Differentiated Services Field),或者叫 DSCP。现在的网络设备主要看这 6 位来做流量控制(比如 QoS 标记)。
- 最后(RFC 3168):剩下的 2 位也没闲着,被拿来做 ECN(Explicit Congestion Notification,显式拥塞通知)。
这 2 位(第 6 和 7 位)非常有意思。它们允许路由器在拥塞时“不打架”(不丢包),而是把这一位标记为 1,告诉接收端:“嘿,刚才有点堵,你们慢点发”。这是现代网络对抗拥塞的一个重要机制。
3. 总长度(tot_len)
tot_len 是整个 IP 包的长度,包括头部和数据。16 位,意味着最大 64KB。
这里有个坑点:以太网 MTU 通常是 1500 字节。如果你发一个 3000 字节的 IP 包,它必须在链路层被切成片。但是,tot_len 记录的是切片前的总长度。接收端看到这些碎片时,依靠的都是这个总长度来判断重组是否完成。
4. 标识与分片(id / frag_off)
这是 IPv4 最麻烦的部分之一。
id(Identification)是一个 16 位的 ID。当一个 IP 包被切分成多个碎片时,所有的碎片必须拥有相同的 id。接收端重组时,就像拼图一样,先找所有 id 一样的碎片,然后按顺序拼起来。
frag_off(Fragment Offset)更绝,它既要存偏移量,又要存标志位,挤在 16 位里:
- 低 13 位:偏移量。注意单位是 8 字节。所以第一个分片的 offset 是 0。如果第二个分片从第 1400 字节开始,这里的值是 1400 / 8 = 175。
- 高 3 位:标志位。
这些标志位在内核里定义成了宏:
- IP_MF (More Fragments, 0x01):值为 001。表示“后面还有兄弟”。除了最后一个分片,其他分片都得把这个位置 1。
- IP_DF (Don't Fragment, 0x02):值为 010。表示“别切我!”。如果一个包设置了 DF 标志,但在路上遇到了 MTU 太小的链路,路由器会直接丢弃它,并发回一个 ICMP “Fragmentation Needed” 消息。这就是 PMTU(Path MTU Discovery) 的基石。
- IP_CE (Congestion, 0x04):值为 100。这是留给 ECN 用的拥塞标志。
⚠️ 踩坑预警 很多新手抓包时看到
frag_off的值是 8192(十进制),以为偏移量很大,其实是因为看了 DF 标志位(0x2000)。看分片时一定要记得做掩码操作,把高 3 位剥离掉,只看低 13 位。
5. 生存时间(ttl)
ttl(Time To Live)本质上是一个“倒计时器”。每经过一个路由器,它就减 1。当它变成 0 时,包就被销毁。
这就是为了防止路由环路导致的“死包”在网络里永远转圈。你会遇到一个很有趣的 ICMP 报错叫 Time Exceeded,当你用 traceroute 时,靠的就是故意构造 TTL 逐渐递增的包来探测路径。
6. 协议与校验和(protocol / check)
protocol 告诉内核,这个 IP 包肚子里装的是什么。
IPPROTO_TCP(6):TCPIPPROTO_UDP(17):UDPIPPROTO_ICMP(1):ICMPIPPROTO_ICMPV6(58):ICMPv6- ...还有几十个其他的,定义在
include/linux/in.h里。
check 是校验和。注意,它只校验头部。如果包在传输过程中哪怕错了一个比特,校验和就对不上,内核会直接丢弃。这里有个细节:因为 TTL 每经过一跳都要变,所以路由器转发时必须重新计算校验和。
7. 地址(saddr / daddr)
32 位的源地址和目的地址。这是网络层最核心的路由依据。
协议注册:内核是怎么认领 IPv4 包的?
看完了头部长什么样,我们得退一步问一个更基础的问题:
当网卡收到一个数据帧,把它从 DMA 缓冲区拽上来,交给内核后,内核怎么知道这是一个 IPv4 包,而不是 ARP 或者 IPv6 包?
答案在以太网头部的 type 字段里。IPv4 的类型是 0x0800。
内核需要一种机制,把“0x0800”这个数字和“IPv4 处理函数”绑定起来。这就是 ip_packet_type 做的事。
这是真正的“上号”代码,定义在 net/ipv4/af_inet.c:
static struct packet_type ip_packet_type __read_mostly = {
.type = cpu_to_be16(ETH_P_IP), // 0x0800
.func = ip_rcv, // 处理函数指针
};
这东西怎么生效?在 IPv4 协议栈初始化的时候:
static int __init inet_init(void)
{
...
dev_add_pack(&ip_packet_type);
...
}
dev_add_pack() 这个函数做了一件极其重要的事:它把 ip_packet_type 挂到了内核全局的协议处理链表上(通常是 ptype_base 哈希表)。
从此以后,每一个从网卡进来的包,内核都会看一眼它的以太网类型。
- 如果是
0x0800,内核就会找到ip_packet_type,然后调用.func—— 也就是ip_rcv()。
这就是 IPv4 故事的起点。ip_rcv 就像 IPv4 王国的“海关”,所有的包都得先过它这一关。它负责校验(Version 是不是 4?Checksum 对不对?)、分发(是给我的吗?是要转发的吗?),如果一切顺利,它才会把包交给下一站。
我们下一节就站在 ip_rcv 这个入口,看看 IPv4 的接收路径到底是怎么跑起来的。