IP 协议层
UDP 协议层简单地调用 ip_send_skb
传递 skbs 给 IP 协议,因此让我们从那开始,并掌握 IP 协议层!
ip_send_skb
ip_send_skb
函数位于 ./net/ipv4/ip_output.c 中,非常短。 它只是向下调用 ip_local_out
,如果 ip_local_out
返回某种错误,它就会增加错误统计信息。 我们来看一下:
int ip_send_skb(struct net *net, struct sk_buff *skb) { int err; err = ip_local_out(skb); if (err) { if (err > 0) err = net_xmit_errno(err); if (err) IP_INC_STATS(net, IPSTATS_MIB_OUTDISCARDS); } return err; }
如上所述,调用 ip_local_out
,然后处理返回值。 调用 net_xmit_errno
“翻译” 来自底层的错误为 IP 和 UDP 协议层可以理解的错误。 如果发生错误,将增加 IP 协议统计信息 “OutDiscards” 。 稍后我们将看到获得此统计信息要读取哪些文件。 现在,让我们继续探索,看看 ip_local_out
会把我们带到哪里。
ip_local_out
和 __ip_local_out
幸运的是,ip_local_out
和 __ip_local_out
都很简单。ip_local_out
只是向下调用 __ip_local_out
,并根据返回值调用路由层发送数据包:
int ip_local_out(struct sk_buff *skb) { int err; err = __ip_local_out(skb); if (likely(err == 1)) err = dst_output(skb); return err; }
可以从 __ip_local_out
的源代码中看到,该函数首先做了两件重要的事情:
- 设置 IP 数据包的长度
- 调用
ip_send_check
计算要写入 IP 数据包报头的校验和。ip_send_check
函数调用ip_fast_csum
来计算校验和。 在 x86 和 x86_64 体系结构上,此功能以汇编实现。 你可以在这里阅读 64 位的实现,在这里阅读 32 位的实现。
接下来,IP 协议层调用 nf_hook
向下调用 netfilter。传回 nf_hook
函数的返回值给 ip_local_out
。 如果 nf_hook
返回 1
,表明允许数据包通过,调用者应该自己传递它。 正如我们在上面看到的,实际正是如此:ip_local_out
检查返回值 1
,并调用 dst_output
传递数据包。 让我们来看看 __ip_local_out
的代码:
int __ip_local_out(struct sk_buff *skb) { struct iphdr *iph = ip_hdr(skb); iph->tot_len = htons(skb->len); ip_send_check(iph); return nf_hook(NFPROTO_IPV4, NF_INET_LOCAL_OUT, skb, NULL, skb_dst(skb)->dev, dst_output); }
netfilter 和 nf_hook
简洁起见,我决定跳过对 netfilter、iptables 和 conntrack 的深入研究。 你可以从 这里 和 这里 开始深入了解 netfilter 的源代码。
简版:nf_hook
是一个包装器,它调用 nf_hook_thresh
,首先检查指定的协议族和钩子类型(在本例中分别为 NFPROTO_IPV4
和 NF_INET_LOCAL_OUT
)是否安装了过滤器,并试图返回执行流程到 IP 协议层,以避免深入 netfilter 和在其下面的钩子,如 iptables 和 conntrack。
请记住:如果你有很多或非常复杂的 netfilter 或 iptables 规则,这些规则将在启动原始 sendmsg
调用的用户进程的 CPU 上下文中执行。 如果您设置了 CPU pinning 以限制此进程的执行到特定的 CPU(或一组 CPU),请注意 CPU 将花费系统时间处理出站 iptables 规则。 根据系统的工作负载,如果您在这里测量性能回归,您可能需要小心地固定进程到 CPU 或降低规则集的复杂性。
为了便于讨论,我们假设 nf_hook
返回 1
表示调用方(在本例中是 IP 协议层)应该自己传递数据包。
目标缓存
在 Linux 内核中,dst
代码实现了协议无关的目标缓存。 为了理解如何设置 dst
条目以继续发送 UDP 数据报,我们需要简要地探讨一下 dst
条目和路由是如何生成的。 目标缓存、路由和邻居子系统都可以单独进行极其详细的探讨。 出于我们的目的,我们可以快速查看一下这一切是如何结合在一起的。
我们上面看到的代码调用了 dst_output(skb)
。 这个函数只是查找 skb 附加的 dst
条目 skb
并调用 output 函数。 我们来看一下:
/* Output packet to network from transport. */ static inline int dst_output(struct sk_buff *skb) { return skb_dst(skb)->output(skb); }
看起来很简单,但 output 函数起初是如何被关联到 dst
条目的呢?
重要的是要了解,有许多不同的方式添加目标缓存条目。 到目前为止,我们在代码路径中看到的一种方式是从 udp_sendmsg
调用 ip_route_output_flow
。 ip_route_output_flow
函数调用 __ip_route_output_key
,后者调用 __mkroute_output
。 __mkroute_output
函数创建路由和目标缓存条目。 当它执行时,它会确定适合于此目标的输出函数。 大多数时候,这个函数是 ip_output
。
ip_output
因此,dst_output
执行 output
函数,在 UDP IPv4 情况下为 ip_output
。 ip_output
函数很简单:
int ip_output(struct sk_buff *skb) { struct net_device *dev = skb_dst(skb)->dev; IP_UPD_PO_STATS(dev_net(dev), IPSTATS_MIB_OUT, skb->len); skb->dev = dev; skb->protocol = htons(ETH_P_IP); return NF_HOOK_COND(NFPROTO_IPV4, NF_INET_POST_ROUTING, skb, NULL, dev, ip_finish_output, !(IPCB(skb)->flags & IPSKB_REROUTED)); }
首先,更新统计计数器 IPSTATS_MIB_OUT
。 IP_UPD_PO_STATS
宏增加字节数和数据包数。 我们将在后面的部分中看到如何获得 IP 协议层统计信息以及它们各自的含义。 接下来,设置传输此 skb 的设备、协议。
最后,调用 NF_HOOK_COND
传递控制权给 netfilter。 查看 NF_HOOK_COND
的函数原型有助于更清楚地解释它的工作原理。 来源为 ./include/linux/netfilter.h:
static inline int NF_HOOK_COND(uint8_t pf, unsigned int hook, struct sk_buff *skb, struct net_device *in, struct net_device *out, int (*okfn)(struct sk_buff *), bool cond)
NF_HOOK_COND
检查传入的条件。 在此情况下,条件是 !(IPCB(skb)->flags & IPSKB_REROUTED
。 如果条件为真,那么传递 skb
给 netfilter。 如果 netfilter 允许数据包通过,则调用 okfn
。 此情况下,okfn
是 ip_finish_output
。
ip_finish_output
ip_finish_output
函数也很简洁明了。 我们来看一下:
static int ip_finish_output(struct sk_buff *skb) { #if defined(CONFIG_NETFILTER) && defined(CONFIG_XFRM) /* Policy lookup after SNAT yielded a new policy */ if (skb_dst(skb)->xfrm != NULL) { IPCB(skb)->flags |= IPSKB_REROUTED; return dst_output(skb); } #endif if (skb->len > ip_skb_dst_mtu(skb) && !skb_is_gso(skb)) return ip_fragment(skb, ip_finish_output2); else return ip_finish_output2(skb); }
如果在此内核中启用了 netfilter 和数据包转换,会更新 skb
的标志,并通过 dst_output
将其发送回。 两种比较常见的情况是:
- 如果数据包的长度大于 MTU,并且数据包的分段不会卸载到设备,则调用
ip_fragment
以在传输之前对数据包进行分段。 - 否则,直接传递数据包到
ip_finish_output2
。
在继续内核学习之前,让我们稍微绕个圈子来讨论一下路径 MTU 发现。
路径 MTU 发现
Linux 提供了一个我前面避免提到的特性:路径 MTU 发现。 此功能允许内核自动确定特定路由的最大 MTU。 确定此值并发送小于或等于路由 MTU 的数据包意味着可以避免 IP 分段。 这是首选设置,因为数据包分段会消耗系统资源,而且似乎很容易避免:简单地发送足够小的数据包,就不需要分段。
调用 setsockopt
,您可以在应用程序中使用 SOL_IP
级别和 IP_MTU_DISCOVER
optname 调整每个套接字的路径 MTU 发现设置。optval 可以是 IP 协议手册页中描述的几个值之一。 您可能希望设置的值为:IP_PMTUDISC_DO
表示“始终执行路径 MTU 发现”。 更高级的网络应用程序或诊断工具可以选择自己实现 RFC 4821 ,以在应用程序启动时确定特定路由的 PMTU。 在这种情况下,您可以使用 IP_PMTUDISC_PROBE
选项,该选项告诉内核设置“Don’t Fragment”位,允许您发送大于 PMTU 的数据。
调用 getsockopt
,您的应用程序可以使用 SOL_IP
和 IP_MTU
optname 来检索 PMTU。 您可以使用它来帮助指导应用程序尝试在传输之前构造 UDP 数据报的大小。
如果已启用 PTMU 发现,则任何发送大于 PMTU 的 UDP 数据的尝试都将导致应用程序收到错误码 EMSGSIZE
。 然后,应用程序可以使用更少的数据重试。
强烈建议启用 PTMU 发现,因此我将避免详细描述 IP 分段代码路径。 当查看 IP 协议层统计信息时,我将解释所有统计信息,包括与分段相关的统计信息。 其中许多在 ip_fragment
。 无论是否分段,都调用了 ip_finish_output2
,所以让我们继续。
ip_finish_output2
ip_finish_output2
在 IP 分段之后被调用,并且也直接从 ip_finish_output
调用。 在向下传递数据包到邻居缓存之前,此函数增加各种统计计数器。 让我们看看它是如何工作的:
static inline int ip_finish_output2(struct sk_buff *skb) { /* variable declarations */ if (rt->rt_type == RTN_MULTICAST) { IP_UPD_PO_STATS(dev_net(dev), IPSTATS_MIB_OUTMCAST, skb->len); } else if (rt->rt_type == RTN_BROADCAST) IP_UPD_PO_STATS(dev_net(dev), IPSTATS_MIB_OUTBCAST, skb->len); /* Be paranoid, rather than too clever. */ if (unlikely(skb_headroom(skb) < hh_len && dev->header_ops)) { struct sk_buff *skb2; skb2 = skb_realloc_headroom(skb, LL_RESERVED_SPACE(dev)); if (skb2 == NULL) { kfree_skb(skb); return -ENOMEM; } if (skb->sk) skb_set_owner_w(skb2, skb->sk); consume_skb(skb); skb = skb2; }
如果与此数据包相关联的路由结构是组播类型,使用IP_UPD_PO_STATS
宏来增加 OutMcastPkts
和 OutMcastOctets
计数器。 否则,如果路由类型为广播,则增加 OutBcastPkts
和 OutBcastOctets
计数器。
接下来,执行检查以确保 skb 结构具有足够的空间添加任何需要的链路层报头。 如果没有,则调用 skb_realloc_headroom
来分配额外的空间,并且新 skb 的成本将计入相关套接字。
rcu_read_lock_bh(); nexthop = (__force u32) rt_nexthop(rt, ip_hdr(skb)->daddr); neigh = __ipv4_neigh_lookup_noref(dev, nexthop); if (unlikely(!neigh)) neigh = __neigh_create(&arp_tbl, &nexthop, dev, false);
继续,我们可以看到,下一跳是查询路由层,然后查找邻居缓存得到的。 如果找不到邻居,则调用 __neigh_create
创建一个。 例如,数据第一次发送到另一台主机时可能出现此情况。 请注意,此函数是调用 arp_tbl
(在 ./net/ipv4/arp.c 中定义),在 ARP 表中创建邻居条目。 其他系统(如 IPv6 或 DECnet)维护自己的 ARP 表,并传递不同的结构给 __neigh_create
。 本文并不旨在全面介绍邻居缓存,但如果必须创建邻居缓存,那么创建可能会导致缓存增长。 这篇文章将在下面的章节中介绍更多关于邻居缓存的细节。 无论如何,邻居缓存导出自己的统计信息,以便可以测量缓存增长。 有关详细信息,请参阅下面的监控部分。
if (!IS_ERR(neigh)) { int res = dst_neigh_output(dst, neigh, skb); rcu_read_unlock_bh(); return res; } rcu_read_unlock_bh(); net_dbg_ratelimited("%s: No header cache and no neighbour!\n", __func__); kfree_skb(skb); return -EINVAL; }
最后,如果没有返回错误,则调用 dst_neigh_output
沿着输出的旅程传递 skb。 否则,释放 skb 并返回 EINVAL。 此处的错误将产生连锁反应,并增加 ip_send_skb
中的 OutDiscards
。 让我们继续探索 dst_neigh_output
,并继续接近 Linux 内核的网络设备子系统。
dst_neigh_output
dst_neigh_output
函数为我们做了两件重要的事情。 首先,回想一下在这篇博客文章的前面,我们看到如果用户通过辅助消息指定 MSG_CONFIRM
给 sendmsg
函数,则会翻转一个标志,指示远程主机的目标缓存条目仍然有效,不应被垃圾回收。 该检查在这里发生,设置邻居的 confirmed
字段为当前的 jiffies 计数。
static inline int dst_neigh_output(struct dst_entry *dst, struct neighbour *n, struct sk_buff *skb) { const struct hh_cache *hh; if (dst->pending_confirm) { unsigned long now = jiffies; dst->pending_confirm = 0; /* avoid dirtying neighbour */ if (n->confirmed != now) n->confirmed = now; }
其次,检查邻居的状态,并调用适当的输出函数。 让我们来看看以下条件句,试着理解是怎么回事:
hh = &n->hh; if ((n->nud_state & NUD_CONNECTED) && hh->hh_len) return neigh_hh_output(hh, skb); else return n->output(n, skb); }
如果邻居被认为是 NUD_CONNECTED
,则意味着它是以下情况的一种或多种:
NUD_PERMANENT
:静态路由。NUD_NOARP
:不需要 ARP 请求(例如,目的地是组播或广播地址,或环回设备)。NUD_REACHABLE
:邻居是“可达的”。只要 ARP 请求 成功处理,目的地就会被标记为可达。