4.6 分片
上一节我们说到,数据包最终会离开本地,踏上 ip_local_out() 的不归路。
但这里有一个现实问题摆在面前:路太窄了。
以太网的 MTU(Maximum Transmission Unit,最大传输单元)通常是 1500 字节,虽然有些网卡支持高达 9K 的 Jumbo Frames,但那是特例。如果你要发一个 4000 字节的 UDP 数据包,或者是一个没经过 MSS 协商限制的 TCP 巨包,物理设备吞不下去。
这时候只有两个选择:
- 别发这么大:发一个 ICMP 消息回去告诉对方「这包太大,我也没办法」(Path MTU Discovery)。
- 切碎了发:把这大包切成一堆符合 MTU 的小片,各自上路,到了目的地再拼起来。
这一节我们来看内核怎么处理第二种情况——分片。
在接收路径上处理这些碎片的任务叫做重组,那是下一节的内容。现在,让我们聚焦在发送路径上的 ip_fragment()。
切还是不切,这是个问题
ip_fragment() 并不是一个想调就调的函数。在动手切包之前,它必须先回答一个严肃的问题:这个包,允许被切吗?
在 IP 头部里,有一个标志位叫 DF(Don't Fragment)。如果这个位被置 1 了,就意味着发送方很霸气:「我要么整着过,要么不过,别给我切碎。」
内核的实现非常直白。在 ip_fragment() 的开头,你会看到这样一段逻辑:
int ip_fragment(struct sk_buff *skb, int (*output)(struct sk_buff *))
{
...
struct rtable *rt = skb_rtable(skb);
struct iphdr *iph = ip_hdr(skb);
// 检查 DF 标志位,或者是否设置了 frag_max_size 限制
if (unlikely(((iph->frag_off & htons(IP_DF)) && !skb->local_df) ||
(IPCB(skb)->frag_max_size &&
IPCB(skb)->frag_max_size > dst_mtu(&rt->dst)))) {
IP_INC_STATS(dev_net(dev), IPSTATS_MIB_FRAGFAILS);
// 发送 ICMP "Destination Unreachable; Fragmentation Needed"
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_FRAG_NEEDED,
htonl(ip_skb_dst_mtu(skb)));
kfree_skb(skb);
return -EMSGSIZE;
}
...
}
这里的逻辑很残酷:如果你设置了 IP_DF 标志,或者这个包明确超过了某些路径限制,内核不会帮你切,而是直接调用 icmp_send() 往回扔一个 ICMP_FRAG_NEEDED 报错,然后把包给扔了(kfree_skb)。
这也就是为什么有时候你配置防火墙或者 VPN 时不小心禁掉了 ICMP 协议,大包就死活发不出去的原因——内核试图告诉你路太窄了,但你把它的嘴给堵上了。
只有当包允许被分片时,代码才会往下走,进入真正动刀子的环节。
分片的两条路:快与慢
到了真正分片的时候,内核面临着两种截然不同的场景。
如果这个数据包在传输层(比如 UDP)或者本地生成的时候,就已经预留了一张「切片清单」——也就是 SKB 的 frag_list 不为空——那么内核的工作就很简单:把清单上的每一项拿出来,挂上 IP 头,打包送走。
这就是快路径。
反之,如果手里只有一个巨大的、连续的 SKB,并没有预先切好的碎片列表,内核就不得不自己动手:分配新内存,把数据一片片拷贝过去,计算偏移量。
这就是慢路径。
我们先看比较省心的快路径。
快路径:挂车发运
快路径的核心在于 skb_has_frag_list(skb)。如果这个函数返回真,说明 SKB 的 skb_shinfo(skb)->frag_list 上挂着一串已经切分好的数据片段。内核只需要把这些片段视为独立的「挂车」,给每节挂车贴上新的标签(IP 头)即可。
第一步,是整理「车头」。
原始的 SKB 会变成第一个分片。我们需要修正它的长度和头部信息:
hlen = iph->ihl * 4; // IP 头部长度
...
if (skb_has_frag_list(skb)) {
struct sk_buff *frag, *frag2;
int first_len = skb_pagelen(skb);
// 初始化 frag_list,准备把它拆散
skb_frag_list_init(skb);
// 修正主 SKB 的长度,它现在只承载第一个分片的数据
skb->data_len = first_len - skb_headlen(skb);
skb->len = first_len;
iph->tot_len = htons(first_len);
// 设置标志位:后面还有更多分片 (IP_MF)
iph->frag_off = htons(IP_MF);
// 头改了,校验和得重算
ip_send_check(iph);
这里的 IP_MF(More Fragments)标志位非常重要。它告诉接收方:「别急,这事儿没完,后面还有货。」只有最后一个分片,这个位才是 0。
接着,内核进入一个无限循环,处理 frag_list 里的每一节车厢:
for (;;) {
if (frag) {
frag->ip_summed = CHECKSUM_NONE;
skb_reset_transport_header(frag);
// 为这个分片腾出 IP 头的空间
// skb->data 原本指向传输层头,现在要往后推 hlen 字节
__skb_push(frag, hlen);
// 重置网络层头指针
skb_reset_network_header(frag);
// 把原始 IP 头拷贝过来
memcpy(skb_network_header(frag), iph, hlen);
// 获取这个新分片的 IP 头并修正总长度
iph = ip_hdr(frag);
iph->tot_len = htons(frag->len);
// 复制元数据(优先级、标记等)
ip_copy_metadata(frag, skb);
这里有个细节很有意思:偏移量的计算。
IP 协议规定,frag_off 字段的低 13 位表示偏移量,而且单位不是字节,是 8 字节块(64 bits)。这意味着所有分片(除了最后一个)的数据长度必须是 8 的倍数。
// 只有第一个分片才需要处理 IP 选项
if (offset == 0)
ip_options_fragment(frag);
// 计算当前偏移量(字节)
offset += skb->len - hlen;
// 转换为 8 字节单位并写入头部
iph->frag_off = htons(offset>>3);
// 只要后面还有分片(不是最后一个),就必须打上 MF 标志
if (frag->next != NULL)
iph->frag_off |= htons(IP_MF);
ip_send_check(iph);
}
这里 ip_options_fragment() 做了一件很聪明的事:IP 选项(比如 Record Route)只需要出现在第一个分片里。后续的分片如果不把这些选项替换成 IPOPT_NOOP,既浪费带宽又增加安全风险。
最后,把这些切好的分片送出门:
err = output(skb);
if (!err)
IP_INC_STATS(dev_net(dev), IPSTATS_MIB_FRAGCREATES);
if (err || !frag)
break;
// 取下一个分片
skb = frag;
frag = skb->next;
skb->next = NULL;
}
快路径之所以快,是因为它不需要拷贝数据。数据原本就在 frag_list 对应的内存页里,SKB 只是在指针之间跳来跳去,调整头部元数据。
慢路径:大刀阔斧
如果 SKB 没有 frag_list,内核手里就是一个实实在在的大块头。这时候没法偷懒,只能硬着头皮切。
慢路径的代码逻辑是这样的:先算出还剩多少数据没发,然后在一个 while (left > 0) 循环里,一块一块地割肉。
iph = ip_hdr(skb);
left = skb->len - hlen; // 剩余待发送的数据量
while (left > 0) {
len = left;
// 这一块不能超过 MTU
if (len > mtu)
len = mtu;
// 硬性规定:除了最后一块,所有块必须是 8 字节对齐的
// 如果这一块不是最后一块,就把末尾那几个零头切掉
if (len < left) {
len &= ~7;
}
这个 len &= ~7(二进制 ...11111000)是网络协议里的经典操作。它强制保证了分片的边界符合 8 字节粒度。
接下来,就是最昂贵的操作:分配新的 SKB 并拷贝数据。
// 分配新的 SKB,包含 IP 头和数据部分
if ((skb2 = alloc_skb(len+hlen+ll_rs, GFP_ATOMIC)) == NULL) {
NETDEBUG(KERN_INFO "IP: frag: no memory for new fragment!\n");
err = -ENOMEM;
goto fail;
}
// 复制元数据
ip_copy_metadata(skb2, skb);
skb_reserve(skb2, ll_rs);
skb_put(skb2, len + hlen);
skb_reset_network_header(skb2);
skb2->transport_header = skb2->network_header + hlen;
// 如果原 SKB 有属主(比如某个 socket),要把内存账算在它头上
if (skb->sk)
skb_set_owner_w(skb2, skb->sk);
// 拷贝 IP 头
skb_copy_from_linear_data(skb, skb_network_header(skb2), hlen);
// 拷贝数据片段(这是最慢的部分)
if (skb_copy_bits(skb, ptr, skb_transport_header(skb2), len))
BUG();
这段代码里有几个值得注意的工程细节:
- 内存分配用
GFP_ATOMIC:因为这时候可能持有锁,不能睡眠。如果分配失败,整个分片操作就会失败,并触发fail标签清理资源。 skb_set_owner_w:这是个很重要的记账操作。如果这个分片是为了某个 Socket 发的,必须把新 SKB 的内存使用量算在这个 Socket 的开销上,否则用户可能通过发送大量数据包耗尽系统内存而不受 Socket 缓冲区限制。
数据拷贝完成后,剩下的操作就和快路径差不多了:设置偏移量、处理 MF 标志、计算校验和、发送。
iph = ip_hdr(skb2);
iph->frag_off = htons((offset >> 3));
if (offset == 0)
ip_options_fragment(skb2); // 还是只处理第一个分片的选项
if (left > 0 || not_last_frag)
iph->frag_off |= htons(IP_MF); // 只要还有剩余,就是 MF
iph->tot_len = htons(len + hlen);
ip_send_check(iph);
err = output(skb2);
if (err)
goto fail;
IP_INC_STATS(dev_net(dev), IPSTATS_MIB_FRAGCREATES);
left -= len;
ptr += len;
offset += len;
} // end while
这里有一个逻辑陷阱:if (left > 0 || not_last_frag)。left > 0 好理解,就是还没分完。但为什么还要 not_last_frag?这是因为在某些特殊情况下(比如 IP Sec 加密后的分片),即使 left 算完了,可能还需要额外的处理来保证协议逻辑完整。不过在标准的分片逻辑里,通常就是看 left。
收尾
无论快路径还是慢路径,当所有分片都发送完毕后,如果一切顺利,内核会更新成功计数并返回:
consume_skb(skb); // 释放原始的大包 SKB
IP_INC_STATS(dev_net(dev), IPSTATS_MIB_FRAGOKS);
return err;
如果中间任何一步出错(比如慢路径里内存分配失败,或者发送函数返回错误),代码就会跳转到 fail 标签,清理掉所有已经生成但还没发出去的分片 SKB,并更新失败计数 IPSTATS_MIB_FRAGFAILS。
小结与伏笔
到这里,IP 层如何把一个过大的数据包「大卸八块」的过程就讲清楚了。
我们在这一节里看到,IP 层在处理分片时是非常谨慎的:它会先检查 DF 标志,如果禁止分片就直接放弃;它会利用 frag_list 走捷径(快路径)避免内存拷贝;但在必要时,它也会通过 alloc_skb 和 skb_copy_bits 这样的重操作(慢路径)来保证数据能发出去。
但这也留下了一个巨大的悬念:
既然我们把包切碎了发出去,那对端收到这一堆乱七八糟的碎片时,怎么知道它们是拼在一起的?万一中间丢了一片怎么办?万一它们乱序到达了怎么办?如果有人故意发送恶意碎片来攻击系统,内核该怎么防?
这正是 ip_fragment() 的逆向工程——重组——要解决的问题。下一节,我们将进入 ip_defrag() 的领地,看看内核是如何把碎片变回整包的。