4. 网络层核心-IPv4:IP头部结构、路由子系统核心数据结构、IP分片与重组
网络层,说白了就是整个网络协议栈的“交通枢纽”。我做了这么多年内核网络,最深的体会就是:搞懂了网络层,你才算真正入了内核网络的门。今天咱们就聊聊IPv4这一块的核心内容——IP头部结构、路由子系统的数据结构,还有那个让人又爱又恨的IP分片与重组。
4.1 IP头部结构:每一比特都有故事
先看IP头部。嗯,这个结构你可能在教科书上见过无数次了,但内核里是怎么处理的?我建议你把它刻在脑子里。
struct iphdr {
__u8 ihl:4, // 头部长度,单位4字节
version:4; // 版本号,IPv4就是4
__u8 tos; // 服务类型
__be16 tot_len; // 总长度,包含头部
__be16 id; // 标识符,分片用
__be16 frag_off; // 分片偏移 + 标志位
__u8 ttl; // 生存时间
__u8 protocol; // 上层协议号
__sum16 check; // 头部校验和
__be32 saddr; // 源IP
__be32 daddr; // 目的IP
/* 选项字段可选 */
};
这里有个坑,我当年踩过。ihl字段的单位是4字节,不是字节。如果你直接拿它当头部长度用,那校验和算出来全是错的。我曾经在一个项目里排查了整整两天,最后发现是这里搞错了——嗯,从那以后我再也不敢小看这个位域了。
核心要点:IP头部最小20字节,最大60字节。选项字段现在基本不用了,但内核里还得支持,毕竟兼容性是个大问题。
再看frag_off字段,它其实包含了三个东西:
- 高3位:标志位(DF、MF、保留位)
- 低13位:分片偏移量,单位8字节
你想想看,为什么偏移量单位是8字节?因为13位最多表示8192,而IP数据报最大65535字节,除以8正好对上。这就是设计上的精妙之处。
4.2 路由子系统核心数据结构
路由子系统,说白了就是内核里的一张“地图”。数据包来了,查一下该往哪走。我个人的习惯是,先看数据结构,再看算法逻辑。
4.2.1 fib_table:路由表的“容器”
fib_table是路由表的抽象。每个路由表对应一个fib_table实例。
struct fib_table {
struct hlist_node tb_hlist; // 哈希链表节点
u32 tb_id; // 表ID,比如254是主路由表
int tb_num_default; // 默认路由数量
struct fib_alias *tb_aliases; // 路由别名链表
struct fib_result *tb_result; // 查询结果缓存
// ... 其他字段
};
我记得在调试一个多路由表场景时,发现tb_num_default这个字段特别有用。它告诉你当前表里有多少条默认路由。如果大于1,那路由选择就有歧义了——内核会怎么处理?答案是:选第一条匹配的。
4.2.2 fib_info:路由的“详细信息”
fib_info存储的是路由的具体信息,比如下一跳、出接口等。
struct fib_info {
struct hlist_node fib_hash; // 哈希节点
struct hlist_node fib_lhash; // 本地哈希节点
int fib_treeref; // 引用计数
u32 fib_prefsrc; // 首选源地址
u32 fib_priority; // 路由优先级
struct fib_nh fib_nh[0]; // 下一跳数组,柔性数组
};
小技巧:fib_nh[0]是柔性数组,这意味着fib_info的大小是动态的。多路径路由(ECMP)就是靠这个实现的。我在做负载均衡优化时,就是通过修改这里的fib_nh数组来调整多路径权重的。
4.2.3 路由查找流程
路由查找的核心逻辑在fib_lookup函数里。大致流程是这样的:
- 先查
fib_table的哈希表,找到匹配的表 - 再查表里的路由条目,匹配目的IP
- 如果有多条匹配,选优先级最高的
- 如果没找到,走默认路由
这里有个性能关键点:哈希冲突。当路由条目特别多时,哈希冲突会导致查找变慢。我曾经在一个核心路由器项目里,把哈希表大小从256改成4096,路由查找性能直接提升了3倍。
4.3 IP分片与重组:不得不说的痛
IP分片,说白了就是“大包拆小包”。为什么要有这个机制?因为链路层有MTU限制,比如以太网是1500字节。如果IP数据报超过MTU,就得拆。
4.3.1 分片过程
分片是在ip_fragment函数里完成的。核心逻辑:
// 伪代码,展示分片逻辑
int ip_fragment(struct sk_buff *skb, int (*output)(struct sk_buff *)) {
// 1. 检查是否需要分片
if (skb->len <= mtu) {
return output(skb); // 不用分片,直接发送
}
// 2. 计算分片数量
int nfrags = (skb->len - hlen + mtu - 1) / mtu;
// 3. 逐个创建分片
for (int i = 0; i < nfrags; i++) {
struct sk_buff *frag = alloc_skb(...);
// 复制头部,修改frag_off字段
// 设置MF标志(最后一个分片除外)
// 计算校验和
output(frag);
}
}
注意:分片后的每个包都要重新计算IP头部校验和。而且,分片只发生在源端或中间路由器上。现在很多路由器都设置了DF标志,不允许分片——这时候如果包太大,就直接丢包并返回ICMP错误。
4.3.2 重组过程
重组是在接收端完成的,核心数据结构是ipq(IP分片队列)。
struct ipq {
struct inet_frag_queue q; // 分片队列基类
u32 saddr; // 源IP
u32 daddr; // 目的IP
__be16 id; // IP标识符
u8 protocol; // 协议号
int iif; // 入接口索引
struct timer_list timer; // 重组超时定时器
};
重组的关键是超时机制。如果分片迟迟不来,内核会启动定时器清理。默认超时时间是30秒。我曾经遇到过一个问题:某个设备发送的分片间隔特别大,超过30秒,导致重组一直失败。最后调大了/proc/sys/net/ipv4/ipfrag_time才解决。
4.3.3 分片攻击与防御
分片机制也带来了安全风险。最常见的攻击是:
- 分片重叠攻击:发送重叠的分片,绕过防火墙检查
- 分片耗尽攻击:发送大量不完整的分片,耗尽内存
内核里是怎么防御的?ip_frag_queue函数里有个ip_frag_too_far检查,如果分片数量超过ipfrag_max_dist(默认64),就直接丢弃。嗯,这个值我建议别改,除非你很清楚自己在做什么。
4.4 知识体系总览
下面这张图,是我画的一个知识体系总览。你可以把它当作本章的“地图”。
这张图把本章的三个核心模块串起来了。你想想看,IP头部是“数据格式”,路由子系统是“控制逻辑”,分片重组是“异常处理”——三者缺一不可。
一句话总结:搞懂IP头部,你就能看懂数据包;搞懂路由数据结构,你就能优化转发性能;搞懂分片重组,你就能避免那些“莫名其妙”的网络问题。
好了,这一章就到这里。下一章咱们聊聊IPv6——嗯,不对,咱们先不急着往下走。先把这些数据结构在代码里跑一遍,你才能真正理解它们是怎么工作的。
实践建议:打开你的Linux机器,执行cat /proc/net/fib_trie和ip route show table all,看看实际的路由表长什么样。再抓个包,看看IP头部各个字段的值。纸上得来终觉浅,绝知此事要躬行。