跳到主要内容

4.8 包转发

上一节我们拼好了被打碎的镜子,看着那些碎片在 ip_defrag() 里重圆,最终递交给传输层。

但网络世界里,不是所有的包都是为了送达本机而生的。很多时候,Linux 机器的角色是一个中间人——一个路由器。包从左边进来,它的目的地在右边。这时候,内核不需要费心去重组它(除非是为了某些特殊需求),它只需要做一个决定:往哪扔?

这就是转发(Forwarding)。

转发的逻辑看似简单——接住,查表,送出——但如果你以为这只是个「搬运工」的工作,那就太小看内核了。在把包送出门之前,内核要面对一堆棘手的问题:这个包是不是太大?它的寿命(TTL)到了吗?它是不是被禁止分片?甚至,这个包是不是一种已经被硬件「特化」过的怪物?

让我们从 ip_forward() 开始,看看这趟中转旅程到底发生了什么。


核心函数:ip_forward()

ip_forward() 是转发路径的主宰者。当内核在 ip_rcv_finish() 里查完路由表,发现「这货不是给我的,是给别人送的」之后,就会走到这里。

首先登场的是老朋友,拿住包的头部和路由信息:

int ip_forward(struct sk_buff *skb)
{
struct iphdr *iph; /* Our header */
struct rtable *rt; /* Route we use */
struct ip_options *opt = &(IPCB(skb)->opt);

但还没等你开始处理逻辑,第一道关卡就来了。这个关卡不是协议规定的,而是硬件和软件妥协的结果。

拦截 LRO:硬件优化的陷阱

你应该听说过 LRO(Large Receive Offload)。这是个好东西——网卡为了减轻 CPU 负担,会把一连串的小包(比如 TCP 的小片段)合并成一个大包,然后一口气交给内核。这对于「接收后本地处理」的场景是完美的。

但在转发场景下?这是个灾难。

想象一下,网卡为了省事,把 10 个 1500 字节的包合并成了一个巨大的 SKB。现在你要转发它。你查了一下出门接口的 MTU——还是 1500 字节。这就尴尬了:你手里拿着一个 15000 字节的巨无霸,但出口只允许 1500 字节通过。

更糟糕的是,这个巨无霸可能已经被网卡缝合得严丝合缝,内核很难把它干净地拆回去。

所以,内核的态度很明确:转发路径里,坚决不碰 LRO 的包。

if (skb_warn_if_lro(skb))
goto drop;

这一行代码背后,是设计者的无奈:LRO 的设计之初就压根没考虑过转发这档子事。后来的 GRO(Generic Receive Offload)修正了这个短视,加上了转发能力,但 LRO 作为旧时代的遗产,必须在这里被无情拦截。

Router Alert:给路由器的加急电报

紧接着,有一个特殊选项需要处理。IPv4 头部有一个选项叫 IPOPT_RA(Router Alert)。当包里带着这个标记时,它的意思是:「喂,路径上的所有路由器,别只顾着转发,停下手里的活看看我!」

这通常用在 RSVP(资源预留协议)或者某些组播场景里。

内核怎么处理这个?它维护了一个叫 ip_ra_chain 的链表,所有通过 setsockopt() 设置了 IP_ROUTER_ALERT 的 Raw Socket 都挂在这上面。ip_call_ra_chain() 会把这个包喂给链表上的所有 Socket。

你可能会问:为什么不只发给一个 Socket?

因为 Raw Socket 不像 TCP 或 UDP 有端口号的概念,它是基于协议的。如果多个 Raw Socket 都对同一类协议(比如 IGMP)感兴趣,那它们都得收到这份拷贝。

if (IPCB(skb)->opt.router_alert && ip_call_ra_chain(skb))
return NET_RX_SUCCESS;

注意,如果某个 Raw Socket 认领了这个包(返回值非零),转发流程就直接结束了,包不会继续往下走。

基础安检:是不是发给我们的?

虽然路由表说「转发」,但还得再确认一下 skb->pkt_type。这个字段是网卡驱动调用 eth_type_trans() 时填写的。

如果 pkt_type 不是 PACKET_HOST(即不是发往本机 MAC 地址的),说明这包可能是广播或者 multicast 的某种异常情况,或者是链路层出了问题。反正,既然不是发给我们的,也不该我们转发,丢掉算了。

if (skb->pkt_type != PACKET_HOST)
goto drop;

生死时速:TTL 的审判

每一个 IP 包头顶上都顶着一个倒计时器:TTL(Time To Live)。这是为了防止包在网络里无限循环而设计的「自毁装置」。

每经过一个路由器,TTL 必须减 1。如果减到了 0,路由器就有义务终止它的生命,并给发送者发一张死亡通知书(ICMP Time Exceeded)。

if (ip_hdr(skb)->ttl <= 1)
goto too_many_hops;

如果 TTL 耗尽,内核会更新统计计数,并调用 icmp_send() 发送错误消息:

too_many_hops:
/* Tell the sender its packet died... */
IP_INC_STATS_BH(dev_net(skb_dst(skb)->dev), IPSTATS_MIB_INHDRERRORS);
icmp_send(skb, ICMP_TIME_EXCEEDED, ICMP_EXC_TTL, 0);
goto drop;

严格源路由的倔强

还记得 IP 选项里的「严格源路由」(SSRR)吗?这是一种极其霸道的要求:包必须严格按照我列出的 IP 地址走,一步都不能错。

但是,现实往往会打脸。假设包里开启了严格路由选项(is_strictroute),但我们的路由子系统查出来的下一跳是一个网关(rt_uses_gateway),这就意味着我们需要先把包发给网关,而不是最终目的地。

这就冲突了:严格路由不允许中间经过未列出的跳,但为了出门,我不得不经过网关。

怎么办?做不到,直接告诉发送者「Strict Routing Failed」。

rt = skb_rtable(skb);

if (opt->is_strictroute && rt->rt_uses_gateway)
goto sr_failed;

失败处理也是发 ICMP 消息:

sr_failed:
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_SR_FAILED, 0);
goto drop;

MTU 与 DF:进退两难的抉择

接下来是转发路径里最经典、也最容易让人抓狂的检查:MTU(最大传输单元)。

我们查到了出门接口的 MTU(dst_mtu(&rt->dst))。如果手里的包比这个 MTU 大,而且这个包不允许分片(DF 标志位 IP_DF 被置位),那我们该怎么办?

  • 不分片发出去?不行,超过了 MTU,物理层传不了。
  • 分片发出去?不行,人家说了 Don't Fragment

这真是进退维谷。唯一的解决办法是:放弃治疗,告诉发送人「你需要把包切小点(Fragmentation Needed)」。

这就是 PMTUD(Path MTU Discovery)机制的核心环节:通过丢包和 ICMP 消息,逼迫发送端降低包的大小。

if (unlikely(skb->len > dst_mtu(&rt->dst) &&
!skb_is_gso(skb) && (ip_hdr(skb)->frag_off & htons(IP_DF)))
&& !skb->local_df) {
IP_INC_STATS(dev_net(rt->dst.dev), IPSTATS_MIB_FRAGFAILS);
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_FRAG_NEEDED,
htonl(dst_mtu(&rt->dst)));
goto drop;
}

代码里的 skb_is_gso(skb) 检查是为了绕过 GSO(Generic Segmentation Offload)场景——那些包虽然看起来很大,但其实还没真正分片,交给硬件处理就行,别在这里误杀。

动手之前,先做个副本

如果你挺过了前面的所有检查,恭喜,这个包真的要被转发出去了。

但在动刀子之前,必须做一件事:复制 SKB(skb_cow)。

为什么?因为接下来的操作(TTL 减 1,校验和更新)要修改 IP 头部。而这个 SKB 可能是被别人共享的(比如之前被某些 tap 设备窥视过),或者内存布局不允许直接写。

skb_cow 会确保我们拥有一个可以安全写入的副本(如果原本只有我们在用,可能只是变成了可写模式,不一定要真的复制数据,所以叫 Copy-on-Write)。

/* We are about to mangle packet. Copy it! */
if (skb_cow(skb, LL_RESERVED_SPACE(rt->dst.dev)+rt->dst.header_len))
goto drop;
iph = ip_hdr(skb);

TTL 减 1 与校验和更新

终于到了这一步。ip_decrease_ttl() 会把 TTL 减 1,并且顺带把校验和(Checksum)给更新了。

这里有个细节:既然只改了一个字节,为什么要重算整个校验和?其实内核利用了 RFC 1624 提到的数学技巧,不需要遍历整个包头,只需要根据旧的校验和做一次差值更新即可。效率极高。

/* Decrease ttl after skb cow done */
ip_decrease_ttl(iph);

ICMP Redirect:好邻居指南

包马上要送出去了,但路由子系统突然觉得:「哎,其实你没必要找我转发,你自己直接找隔壁那台机器(Next Hop)更近。」

这就是 ICMP Redirect 消息的用意。如果路由缓存里设置了 RTCF_DOREDIRECT 标志,而且没开严格路由(srr),也没启用 IPsec(skb_sec_path),内核就会大发善心,告诉发送者:「下次别绕路了,直接去那个 IP 吧。」

/*
* We now generate an ICMP HOST REDIRECT giving the route
* we calculated.
*/
if (rt->rt_flags&RTCF_DOREDIRECT && !opt->srr && !skb_sec_path(skb))
ip_rt_send_redirect(skb);

优先级:谁先走?

在 QoS(服务质量)的世界里,包是有三六九等的。通常,发送端的 Socket 会设置一个优先级(SO_PRIORITY),这个优先级会顺着 SKB 一路流传到驱动层。

但是,转发的包根本没有 Socket(它是从外面进来的),那它的优先级谁来定?

内核这里用了一个查表法:rt_tos2priority。它根据 IP 头部的 tos(服务类型)字段,映射到一个内部的优先级值。

skb->priority = rt_tos2priority(iph->tos);

最后一脚:Netfilter 与 ip_forward_finish

所有的逻辑处理完毕,最后一步就是把它交出去。中间还要经过 Netfilter 的 NF_INET_FORWARD 钩子点(这是我们配置防火墙最常拦截的地方)。

return NF_HOOK(NFPROTO_IPV4, NF_INET_FORWARD, skb, skb->dev,
rt->dst.dev, ip_forward_finish);

如果 firewall 放行,就会进入 ip_forward_finish()。这里没什么花活了,更新一下统计,处理一下 IP 选项(如果有),然后调用 dst_output() 把包送入发送路径。

static int ip_forward_finish(struct sk_buff *skb)
{
struct ip_options *opt = &(IPCB(skb)->opt);

IP_INC_STATS_BH(dev_net(skb_dst(skb)->dev), IPSTATS_MIB_OUTFORWDATAGRAMS);
IP_ADD_STATS_BH(dev_net(skb_dst(skb)->dev), IPSTATS_MIB_OUTOCTETS, skb->len);

if (unlikely(opt->optlen))
ip_forward_options(skb);

return dst_output(skb);
}

至此,一个 IP 包完成了它的中转任务。它没有被重组,没有被上层协议看见,只是悄悄地在内核里转了一圈,被修改了一个 TTL,然后被推向了下一个路口。这就是路由器日夜不息的工作。


本章小结

本章我们深入探讨了 IPv4 协议的方方面面——从数据包的构建、头部结构到复杂的 IP 选项处理。我们见证了 IPv4 协议处理器是如何被注册进内核的,也完整地走通了接收路径和发送路径。

在这个过程中,我们不得不面对网络碎片化的现实:当数据包超过 MTU 时,内核如何在发送端把它们切割,又在接收端把它们重新拼起来。我们见识了慢速路径和快速路径的分片与重组算法,也了解了分片机制带来的安全隐患(如 Teardrop 攻击)。

最后,我们学习了 IPv4 的转发机制——让 Linux 机器成为一台路由器,在不同接口间搬运数据包。我们看到了在这个过程中,哪些情况会导致包被无情丢弃,哪些情况又会触发 ICMP 重定向消息。

这一切看似只是比特位的搬运,但正是这些精确到比特的规则,构成了现代互联网互连互通的基石。

下一章,我们将把视线转向那个决定包到底该往哪走的幕后英雄:IPv4 路由子系统