Skip to content

4.6 分片

上一节我们说到,数据包最终会离开本地,踏上 ip_local_out() 的不归路。

但这里有一个现实问题摆在面前:路太窄了

以太网的 MTU(Maximum Transmission Unit,最大传输单元)通常是 1500 字节,虽然有些网卡支持高达 9K 的 Jumbo Frames,但那是特例。如果你要发一个 4000 字节的 UDP 数据包,或者是一个没经过 MSS 协商限制的 TCP 巨包,物理设备吞不下去。

这时候只有两个选择:

  1. 别发这么大:发一个 ICMP 消息回去告诉对方「这包太大,我也没办法」(Path MTU Discovery)。
  2. 切碎了发:把这大包切成一堆符合 MTU 的小片,各自上路,到了目的地再拼起来。

这一节我们来看内核怎么处理第二种情况——分片

在接收路径上处理这些碎片的任务叫做重组,那是下一节的内容。现在,让我们聚焦在发送路径上的 ip_fragment()


切还是不切,这是个问题

ip_fragment() 并不是一个想调就调的函数。在动手切包之前,它必须先回答一个严肃的问题:这个包,允许被切吗?

在 IP 头部里,有一个标志位叫 DF(Don't Fragment)。如果这个位被置 1 了,就意味着发送方很霸气:「我要么整着过,要么不过,别给我切碎。」

内核的实现非常直白。在 ip_fragment() 的开头,你会看到这样一段逻辑:

c
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 会变成第一个分片。我们需要修正它的长度和头部信息:

c
    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 里的每一节车厢:

c
        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 的倍数。

c
                // 只有第一个分片才需要处理 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,既浪费带宽又增加安全风险。

最后,把这些切好的分片送出门:

c
            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) 循环里,一块一块地割肉。

c
        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 并拷贝数据

c
            // 分配新的 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();

这段代码里有几个值得注意的工程细节:

  1. 内存分配用 GFP_ATOMIC:因为这时候可能持有锁,不能睡眠。如果分配失败,整个分片操作就会失败,并触发 fail 标签清理资源。
  2. skb_set_owner_w:这是个很重要的记账操作。如果这个分片是为了某个 Socket 发的,必须把新 SKB 的内存使用量算在这个 Socket 的开销上,否则用户可能通过发送大量数据包耗尽系统内存而不受 Socket 缓冲区限制。

数据拷贝完成后,剩下的操作就和快路径差不多了:设置偏移量、处理 MF 标志、计算校验和、发送。

c
            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


收尾

无论快路径还是慢路径,当所有分片都发送完毕后,如果一切顺利,内核会更新成功计数并返回:

c
        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_skbskb_copy_bits 这样的重操作(慢路径)来保证数据能发出去。

但这也留下了一个巨大的悬念:

既然我们把包切碎了发出去,那对端收到这一堆乱七八糟的碎片时,怎么知道它们是拼在一起的?万一中间丢了一片怎么办?万一它们乱序到达了怎么办?如果有人故意发送恶意碎片来攻击系统,内核该怎么防?

这正是 ip_fragment() 的逆向工程——重组——要解决的问题。下一节,我们将进入 ip_defrag() 的领地,看看内核是如何把碎片变回整包的。

基于 VitePress 构建