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 路由子系统。