跳到主要内容

4.2 接收 IPv4 数据包

上一节我们讲到了 IPv4 的“注册”过程——内核把 ip_packet_type 挂到了全局协议链表上,设定了 ip_rcv() 作为回调函数。当网卡把数据包交上来,以太网类型又是 0x0800 时,内核就会直奔这个函数。

这里有一个很直觉的假设:既然是“接收函数”,那它应该负责把数据包拆开、处理、送到上层吧?

恰恰相反。

ip_rcv() 的真实身份更像是一个看门人。它并不关心数据包里到底装的是 TCP 还是 UDP,它只关心一件事:这东西是不是合法的 IPv4 包?如果是,放行;如果不是,丢掉。真正的“处理”工作,其实是它交给下一任接力者 ip_rcv_finish() 去做的。而这两个接力者中间,还夹着一个极其重要的关卡——Netfilter 钩子。

走进接收大厅

我们来看一下这个“看门人”的具体工作流程。

先看函数签名:

int ip_rcv(struct sk_buff *skb, struct net_device *dev,
struct packet_type *pt, struct net_device *orig_dev)
{
struct iphdr *iph;

ip_rcv() 拿到 skb 时,网络层刚刚把以太网头部剥掉。现在 skb->data 指向的就是 IPv4 头部。首先,我们得把头部取出来,看看是不是一副“正常面孔”。

第一步:头部格式 sanity checks

IPv4 头部是有严格格式的。RFC 规定头部至少 20 字节,且版本号必须是 4。

内核里 struct iphdrihl 字段表示头部长度,但它有个坑——它的单位不是字节,而是 4 字节(32 位)。 所以,一个标准的 20 字节头部,ihl 应该是 5(5 x 4 = 20)。

如果有人发来一个包,ihl 小于 5,那说明这包连最基本的头部都凑不齐;或者 version 不是 4——比如这是个 IPv6 包却跑到了 IPv4 的槽位上。 对于这些畸形包,内核的态度很干脆:直接丢弃,并更新统计 IPSTATS_MIB_INHDRERRORS

/* 提取 IP 头部 */
iph = ip_hdr(skb);

/* 检查:头部长度够不够?版本号对不对? */
if (iph->ihl < 5 || iph->version != 4)
goto inhdr_error;

第二步:校验和

通过了格式检查,接下来是“防伪标志”——校验和。 根据 RFC 1122 的要求,主机必须对每一个接收到的数据报进行头部校验,并且默默地丢弃校验失败的包。这里不需要发错误包回告,因为发了也没用,链路层自己出了问题。

内核用 ip_fast_csum() 来做这件事。注意这个计算只针对 IP 头部,不包括后面的数据载荷。

/* 校验和计算:失败则返回非 0 */
if (unlikely(ip_fast_csum((u8 *)iph, iph->ihl)))
goto inhdr_error;

第三步:放行还是拦截?

如果包是健康的,ip_rcv() 的任务基本就结束了。接下来,它要调用 NF_HOOK 宏。

这行代码是连接“安检”和“分拣”的桥梁:

return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, skb, dev, NULL,
ip_rcv_finish);

这里稍微展开讲一下,因为 NF_HOOK 在网络栈里到处都是。

Netfilter 钩子(Netfilter Hooks):你可以把这里理解为 kernel 里的“安检插口”。内核允许你在数据包的五个关键节点插入自己的代码(比如 iptables、nf_conntrack)。 我们这里是 NF_INET_PRE_ROUTING 钩子点——也就是数据包刚进栈,还没做路由决定之前。

NF_HOOK 的逻辑很简单:

  1. 看看有没有人在这个点上注册了回调函数。
  2. 如果有,就调用它们。
  3. 如果没人注册,或者注册的回调返回“放行”(NF_ACCEPT),就继续调用最后一个参数指定的函数——在这里就是 ip_rcv_finish()

如果回调函数返回“丢弃”(NF_DROP),包的生命周期到此结束,ip_rcv_finish() 永远不会被调用。 还有一种返回值叫 NF_STOLEN,意思是钩子函数把这个包“偷走”了,后续的合法路径也不再执行。

为了理解顺畅,我们暂时假设没有任何 Netfilter 规则干扰,包一路绿灯,直接进到了 ip_rcv_finish()

真正的干活开始:ip_rcv_finish()

ip_rcv 只负责“看”,ip_rcv_finish 才负责“做”。

这一节的核心任务是决定这个包的命运:是留给自己?还是帮人转发? 决定这个答案的唯一依据是路由表。在查路由之前,我们手里只有一个 SKB,查完路由之后,SKB 上就会多挂一个关键的东西——dst_entry

代码逻辑是这样的:

static int ip_rcv_finish(struct sk_buff *skb)
{
struct iphdr *iph = ip_hdr(skb);
struct rtable *rt; // rtable 是路由表项的结构体

/* 如果 SKB 上还没挂 路由结果,就去查一下表 */
if (!skb_dst(skb)) {
int err = ip_route_input_noref(skb, iph->daddr, iph->saddr,
iph->tos, skb->dev);
if (unlikely(err)) {
/* 这里有个特殊情况:Reverse Path Filter (RPF) */
if (err == -EXDEV)
NET_INC_STATS_BH(dev_net(skb->dev), LINUX_MIB_IPRPFILTER);
goto drop;
}
}
/* ... 省略了统计更新 ... */
return dst_input(skb);
}

路由查找:决定命运

ip_route_input_noref() 是做路由查询的核心函数。 它拿着三个关键信息去问路由子系统:

  1. 目的地址 (daddr)
  2. 源地址 (saddr)
  3. 服务类型 (tos)

路由子系统查表后,会给这个 SKB 绑定一个 dst 对象。这个 dst 对象非常关键,它不仅包含了下一跳的信息,更重要的是,它携带了动作指令

这个指令藏在 dst->input 这个回调函数指针里:

  • 如果是发给本机的:查表结果会把 dst->input 设为 ip_local_deliver()。意思是:“这货是我们的,送上去给传输层吧。”
  • 如果是需要转发的:查表结果会把 dst->input 设为 ip_forward()。意思是:“这是经过我家的,帮它送去隔壁。”
  • 如果是组播(特殊场景):在某些条件下,会被设为 ip_mr_input(),进入组播的处理逻辑。

最后的 dst_input(skb) 实际上就是执行了那个函数指针:

static inline int dst_input(struct sk_buff *skb)
{
return skb_dst(skb)->input(skb);
}

这就是 C 语言多态的经典用法——路由表查的是“数据”,返回的却是“代码”。

那个奇怪的 -EXDEV (RPF)

上面代码里有个 err == -EXDEV 的判断。这其实是一个安全机制的报错,叫 Reverse Path Filter (反向路径过滤)

它的逻辑是这样的:

“这个包声称它是从 IP A 发过来的,且经由接口 eth0 进来。但是查一下我自己的路由表,如果要发包给 IP A,我应该走 eth1 才对。既然进来的路和回去的路不一致,说明这包有问题(可能是伪造源地址的攻击包),丢掉!”

如果你开启了 RPF(通常在 procfs 里配置),内核就会在这个查表环节返回 -EXDEV 错误,并累加 LINUX_MIB_IPRPFILTER 计数器。

处理 IP 选项

ip_rcv_finish 里,还有一段容易被忽略的逻辑,处理的是 IPv4 头部里的选项

还记得头部的 ihl 字段吗?正常情况下是 5(20 字节)。如果它大于 5,说明后面还有额外的选项字段(比如 Record Route, Timestamp 等)。

虽然现代网络里 IP 选项用得很少(因为它会导致路由器性能下降),但内核还是得支持:

/* 如果头部长度大于 5,说明有选项,需要解析 */
if (iph->ihl > 5 && ip_rcv_options(skb))
goto drop;

ip_rcv_options() 会把这些选项读出来,做必要的处理(比如更新路由记录)。如果选项格式有误,这里会返回非零,导致包被丢弃。

尘埃落定

走完了上面所有的检查:

  1. 头部格式没问题。
  2. 校验和通过了。
  3. 路由查到了(决定是本地收还是转发)。
  4. IP 选项(如果有)处理完了。

这时候,包终于被移交到了下一站。

  • 如果是 本地投递:它会进入 ip_local_deliver(),然后在那里处理分片重组(如果有的话),最后把 TCP 或 UDP 数据扔给传输层。
  • 如果是 转发:它会进入 ip_forward(),TTL 减 1,重新算校验和,然后查表送到出口网卡。
  • 如果是 组播:它会进入 ip_mr_input(),交给组播转发缓存(MFC)处理。

看起来步骤挺多,但在千兆网卡的驱动下,这一套流程在微秒级就能跑完。每一个包都像流水线上的零件,必须严丝合缝地通过每一道关卡。任何一个环节 goto drop,这个包就从世界上消失了,留下的只有 /proc/net/snmp 里冰冷的计数器跳动。

下一节,我们来看看这些被判定为“转发”的包,是怎么经过 TTL 衰减、校验和重算,最终被送出这个主机的。