且 “硬件头”(hh
)已缓存(因为之前发送过数据并已生成它),则调用 neigh_hh_output
。否则,调用 output
函数。两条代码路径都以 dev_queue_xmit
结束,它传递 skb 到 Linux 网络设备子系统,在到达设备驱动程序层之前会进行更多处理。让我们跟随 neigh_hh_output
和 n->output
代码路径,直至 dev_queue_xmit
。
neigh_hh_output
如果目标是 NUD_CONNECTED
,并且硬件头已缓存,则调用 neigh_hh_output
,它在移交skb 给 dev_queue_xmit
之前执行一小段处理逻辑。 让我们从 ./include/net/neighbor.h 来看看:
static inline int neigh_hh_output(const struct hh_cache *hh, struct sk_buff *skb) { unsigned int seq; int hh_len; do { seq = read_seqbegin(&hh->hh_lock); hh_len = hh->hh_len; if (likely(hh_len <= HH_DATA_MOD)) { /* this is inlined by gcc */ memcpy(skb->data - HH_DATA_MOD, hh->hh_data, HH_DATA_MOD); } else { int hh_alen = HH_DATA_ALIGN(hh_len); memcpy(skb->data - hh_alen, hh->hh_data, hh_alen); } } while (read_seqretry(&hh->hh_lock, seq)); skb_push(skb, hh_len); return dev_queue_xmit(skb); }
这个函数有点难以理解,部分原因是同步读/写已缓存硬件头的锁定原语。 这段代码使用了一种叫做 seqlock 的东西。 你可以把上面的 do { } while()
循环想象成一种简单的重试机制,它将尝试执行循环中的操作,直到成功执行为止。
循环本身试图确定在复制之前是否需要对齐硬件头部的长度。 这是必需的,因为某些硬件报头(如 IEEE 802.11 报头)大于 HH_DATA_MOD
(16 字节)。
一旦数据被复制到 skb,并且 skb_push
更新了 skb 的内部指针跟踪数据,skb 就会传递给 dev_queue_xmit
进入 Linux 网络设备子系统。
n->output
如果目标不是 NUD_CONNECTED
或硬件头尚未缓存,则代码沿着 n->output
路径继续。 邻居结构的输出函数指针关联了什么 output
? 嗯,那要看情况了。 为了理解这是如何设置的,我们需要了解更多关于邻居缓存的工作原理。
一个 struct neighbour
包含几个重要的字段。 上面看到的 nud_state
字段,output
函数和 ops
结构。 回想一下之前看到的,如果在缓存中没有找到现有的条目,则从 ip_finish_output2
调用 __neigh_create
。 当调用 __neigh_creaet
时,邻居被分配,其 output
函数初始设置为 neigh_blackhole
。 随着 __neigh_create
代码执行,它根据邻居的状态调整 output
的值以指向适当的 output
函数。
例如,当代码确定要连接的邻居时,neigh_connect
设置 output
指针为 neigh->ops->connected_output
。 或者,在代码怀疑邻居可能关闭时(例如,如果自发送探测以来已经超过/proc/sys/net/ipv4/neigh/default/delay_first_probe_time
秒),neigh_suspect
设置 output
指针为 neigh->ops->output
。
换句话说:neigh->output
设置为 neigh->ops_connected_output
还是 neigh->ops->output
, 取决于邻居的状态。 neigh->ops
从何而来?
在分配邻居之后,arp_constructor
(来自 ./net/ipv4/arp.c)被调用来设置 struct neighbour
的一些字段。 特别地,此函数检查与邻居相关联的设备,并且如果该设备暴露包含cache
(以太网设备这样做)函数的 header_ops
结构 ,则 neigh->ops
被设置为 ./net/ipv4/arp. c 中定义的以下结构:
static const struct neigh_ops arp_hh_ops = { .family = AF_INET, .solicit = arp_solicit, .error_report = arp_error_report, .output = neigh_resolve_output, .connected_output = neigh_resolve_output, };
因此,无论邻居缓存代码是否视邻居为 “已连接”或“可疑”,都将关联 neigh_resolve_output
函数到 neigh->output
,并且在调用 n->output
时被调用。
neigh_resolve_output
此函数的目的是尝试解析未连接的邻居,或已连接但没有缓存硬件头的邻居。 让我们来看看这个函数是如何工作的:
/* Slow and careful. */ int neigh_resolve_output(struct neighbour *neigh, struct sk_buff *skb) { struct dst_entry *dst = skb_dst(skb); int rc = 0; if (!dst) goto discard; if (!neigh_event_send(neigh, skb)) { int err; struct net_device *dev = neigh->dev; unsigned int seq;
代码首先执行一些基本检查,然后继续调用 neigh_event_send
。 neigh_event_send
函数是__neigh_event_send
的简单包装。__neigh_event_send
实际完成解析邻居的繁重工作。 您可以在 ./net/core/neighbor.c 中阅读 __neigh_event_send
的源代码,但从代码中可以看出,用户最感兴趣的有三点:
- 假设
/proc/sys/net/ipv4/neigh/default/app_solicit
/proc/sys/net/ipv4/neigh/default/mcast_solicit
中设置的值允许发送探测,则NUD_NONE
状态(分配时的默认状态)的邻居将立即发送 ARP 请求(如果不允许,则标记状态为NUD_FAILED
)。 邻居状态被更新并设置为NUD_INCOMPLETE
。 - 更新状态为
NUD_STALE
的邻居为NUD_DELAYED
,并设置一个计时器以稍后探测它们(稍后:当前时间 +/proc/sys/net/ipv4/neigh/default/delay_first_probe_time
秒)。 - 检查
NUD_INCOMPLETE
的任何邻居 (包括上面第一点),以确保未解析邻居的排队数据包数量小于等于/proc/sys/net/ipv4/neigh/default/unres_qlen
。 如果有更多的数据包,则将数据包出队并丢弃,直到长度低于等于 proc 中的值。针对此类情况,邻居缓存统计中的统计计数器都将增加。
如果需要立刻发送 ARP 探测,它就会发送。__neigh_event_send
将返回 0
,指示邻居被视为“已连接”或“已延迟”的,否则返回 1
。 返回值 0
允许 neigh_resolve_output
函数继续执行:
if (dev->header_ops->cache && !neigh->hh.hh_len) neigh_hh_init(neigh, dst);
如果邻居关联的设备的协议实现(在此例子中是以太网)支持缓存硬件报头,并且它当前没有被缓存,则调用 neigh_hh_init
缓存它。
do { __skb_pull(skb, skb_network_offset(skb)); seq = read_seqbegin(&neigh->ha_lock); err = dev_hard_header(skb, dev, ntohs(skb->protocol), neigh->ha, NULL, skb->len); } while (read_seqretry(&neigh->ha_lock, seq));
接下来,使用 seqlock 同步访问邻居结构的硬件地址,当尝试为 skb 创建以太网报头时,dev_hard_header
将读取该地址。 一旦 seqlock 允许继续执行,就会进行错误检查:
if (err >= 0) rc = dev_queue_xmit(skb); else goto out_kfree_skb; }
如果以太网头被写入而没有返回错误,则 skb 被传递到 dev_queue_xmit
,以通过 Linux 网络设备子系统进行传输。 如果有错误,goto
将丢弃 skb,设置返回代码并返回错误:
out: return rc; discard: neigh_dbg(1, "%s: dst=%p neigh=%p\n", __func__, dst, neigh); out_kfree_skb: rc = -EINVAL; kfree_skb(skb); goto out; } EXPORT_SYMBOL(neigh_resolve_output);
在进入 Linux 网络设备子系统前,让我们看一下一些监控和调优 IP 协议层的文件。
监控:IP 协议层
/proc/net/snmp
读取 /proc/net/snmp
监控详细的 IP 协议统计信息。
$ cat /proc/net/snmp Ip: Forwarding DefaultTTL InReceives InHdrErrors InAddrErrors ForwDatagrams InUnknownProtos InDiscards InDelivers OutRequests OutDiscards OutNoRoutes ReasmTimeout ReasmReqds ReasmOKs ReasmFails FragOKs FragFails FragCreates Ip: 1 64 25922988125 0 0 15771700 0 0 25898327616 22789396404 12987882 51 1 10129840 2196520 1 0 0 0 ...
此文件包含多个协议层的统计信息。 首先显示 IP 协议层。第一行包含空格分隔的名称,每个名称对应下一行中的相应值。
在 IP 协议层中,您会发现统计计数器正在增加。计数器引用 C 枚举类型。 /proc/net/snmp
所有有效的枚举值和它们对应的字段名称可以在 include/uapi/linux/snmp.h 中找到:
enum { IPSTATS_MIB_NUM = 0, /* frequently written fields in fast path, kept in same cache line */ IPSTATS_MIB_INPKTS, /* InReceives */ IPSTATS_MIB_INOCTETS, /* InOctets */ IPSTATS_MIB_INDELIVERS, /* InDelivers */ IPSTATS_MIB_OUTFORWDATAGRAMS, /* OutForwDatagrams */ IPSTATS_MIB_OUTPKTS, /* OutRequests */ IPSTATS_MIB_OUTOCTETS, /* OutOctets */ /* ... */
一些有趣的统计数据:
OutRequests
:每次尝试发送 IP 数据包时增加。 看起来,每次是否成功,都会增加此值。OutDiscards
:每次丢弃 IP 数据包时增加。 如果数据追加到 skb(对于 corked 的套接字)失败,或者 IP 下面的层返回错误,就会发生这种情况。OutNoRoute
:在多个位置增加,例如在 UDP 协议层(udp_sendmsg
),如果无法为给定目标生成路由。 当应用程序在 UDP 套接字上调用 “connect” 但找不到路由时也会增加。FragOKs
:每个被分段的数据包增加一次。 例如,被分割成 3 个片段的数据包增加该计数器一次。FragCreates
:每个创建的片段增加一次。 例如,被分割成 3 个片段的数据包增加该计数器三次。FragFails
:如果尝试分段,但不允许分段,则增加(因为设置了 “Don’t Fragment” 位)。 如果输出片段失败,也会增加。
其他统计数据记录在接收端博客文章中。
/proc/net/netstat
读取 /proc/net/netstat
监控扩展 IP 协议统计信息。
$ cat /proc/net/netstat | grep IpExt IpExt: InNoRoutes InTruncatedPkts InMcastPkts OutMcastPkts InBcastPkts OutBcastPkts InOctets OutOctets InMcastOctets OutMcastOctets InBcastOctets OutBcastOctets InCsumErrors InNoECTPkts InECT0Pktsu InCEPkts IpExt: 0 0 0 0 277959 0 14568040307695 32991309088496 0 0 58649349 0 0 0 0 0
格式类似于 /proc/net/snmp
,不同之处在于行的前缀是 IpExt
。
一些有趣的统计数据:
OutMcastPkts
:每次发送目的地为组播地址的数据包时增加。OutBcastPkts
:每次发送目的地为广播地址的数据包时增加。OutOctects
:输出的数据包字节数。OutMcastOctets
:输出的组播数据包字节数。OutBcastOctets
:输出的广播数据包字节数。
其他统计数据记录在接收端博客文章中。
请注意,这些值都是在 IP 层的特定位置增加的。代码有时会移动,可能会出现双重计数错误或其他统计错误。如果这些统计数据对您很重要,强烈建议您阅读 IP 协议层源代码,了解您重要的指标何时增加(或不增加)。
Linux 网络设备子系统
在我们继续讨论 dev_queue_xmit
的数据包传输路径之前,让我们花一点时间来谈谈一些重要的概念,这些概念将出现在接下来的部分。
Linux 流量控制
Linux 支持一种叫做流量控制的特性。 此功能允许系统管理员控制如何从计算机传输数据包。 本文不会深入讨论 Linux 流量控制的各方面的细节。这篇文档提供了对系统、其控制和特性的深入研究。 有几个概念值得一提,以使下面看到的代码更容易理解。
流量控制系统包含几种不同的排队系统,它们为控制流量提供不同的功能。单个排队系统通常称为 qdisc
,也称为排队规则。您可以将 qdisc 视为调度程序;qdisc 决定何时以及如何传输数据包。
在 Linux 上,每个接口都有一个与之关联的默认 qdisc。对于仅支持单个传输队列的网络硬件,使用默认 qdisc pfifo_fast
。支持多个传输队列的网络硬件使用默认 qdisc mq
。您可以运行 tc qdisc
来检查您的系统。
还需要注意的是,有些设备支持硬件流量控制,这可以让管理员将流量控制卸载到网络硬件上,从而节省系统上的 CPU 资源。
现在这些想法已经介绍过了,让我们从 ./net/core/dev.c 继续沿着 dev_queue_xmit
进行。
dev_queue_xmit
和 __dev_queue_xmit
dev_queue_xmit
是 __dev_queue_xmit
的一个简单包装:
int dev_queue_xmit(struct sk_buff *skb) { return __dev_queue_xmit(skb, NULL); } EXPORT_SYMBOL(dev_queue_xmit);
在此之后,__dev_queue_xmit
是完成繁重工作的地方。 让我们一步一步地看一下这段代码,继续:
static int __dev_queue_xmit(struct sk_buff *skb, void *accel_priv) { struct net_device *dev = skb->dev; struct netdev_queue *txq; struct Qdisc *q; int rc = -ENOMEM; skb_reset_mac_header(skb); /* Disable soft irqs for various locks below. Also * stops preemption for RCU. */ rcu_read_lock_bh(); skb_update_prio(skb);
上面的代码开始于:
- 声明变量。
- 调用
skb_reset_mac_header
来准备要处理的 skb。 这将重置 skb 的内部指针,以便可以访问以太网报头。 - 调用
rcu_read_lock_bh
来准备读取 RCU 保护的数据结构。阅读更多关于安全使用 RCU 的信息。 - 如果正在使用网络优先级 cgroup,调用
skb_update_prio
来设置 skb 的优先级。
现在,我们将开始更复杂的数据传输部分 ;)
txq = netdev_pick_tx(dev, skb, accel_priv);
在这里,代码试图确定要使用哪个传输队列。 正如您将在本文后面看到的,一些网络设备公开了多个传输队列来传输数据。 让我们来详细看看这是如何工作的。
netdev_pick_tx
netdev_pick_tx
代码位于 ./net/core/flow_dissector.c 中。 我们来看一下:
struct netdev_queue *netdev_pick_tx(struct net_device *dev, struct sk_buff *skb, void *accel_priv) { int queue_index = 0; if (dev->real_num_tx_queues != 1) { const struct net_device_ops *ops = dev->netdev_ops; if (ops->ndo_select_queue) queue_index = ops->ndo_select_queue(dev, skb, accel_priv); else queue_index = __netdev_pick_tx(dev, skb); if (!accel_priv) queue_index = dev_cap_txqueue(dev, queue_index); } skb_set_queue_mapping(skb, queue_index); return netdev_get_tx_queue(dev, queue_index); }
正如您在上面看到的,如果网络设备只支持单个传输队列,则会跳过更复杂的代码,并返回单个传输队列。 在高端服务器上使用的大多数设备具有多个传输队列。 具有多个传输队列的设备有两种情况:
- 驱动程序实现
ndo_select_queue
,它可以以硬件或功能特定的方式更智能地选择传输队列,或者 - 驱动程序没有实现
ndo_select_queue
,所以内核应该自己选择设备。
截止 3.13 内核,实现 ndo_select_queue
的驱动程序并不多。 bnx2x 和 ixgbe 驱动程序实现了此功能,但它仅用于以太网光纤通道(FCoE)。 鉴于此,让我们假设网络设备不实现ndo_select_queue
和/或 FCoE 未被使用。 在这种情况下,内核将选择具有 __netdev_pick_tx
。
一旦 __netdev_pick_tx
确定了队列的索引,skb_set_queue_mapping
将缓存该值(稍后将在流量控制代码中使用),netdev_get_tx_queue
将查找并返回指向该队列的指针。 在回到 __dev_queue_xmit
之前,让我们看看 __netdev_pick_tx
是如何工作 。
__netdev_pick_tx
让我们来看看内核如何选择传输队列来传输数据。 来自 ./net/core/flow_dissector.c:
u16 __netdev_pick_tx(struct net_device *dev, struct sk_buff *skb) { struct sock *sk = skb->sk; int queue_index = sk_tx_queue_get(sk); if (queue_index < 0 || skb->ooo_okay || queue_index >= dev->real_num_tx_queues) { int new_index = get_xps_queue(dev, skb); if (new_index < 0) new_index = skb_tx_hash(dev, skb); if (queue_index != new_index && sk && rcu_access_pointer(sk->sk_dst_cache)) sk_tx_queue_set(sk, new_index); queue_index = new_index; } return queue_index; }
代码首先调用 sk_tx_queue_get
检查传输队列是否已经缓存在套接字上。如果没有缓存,则返回 -1
。
下一个 if 语句检查以下任一项是否为真:
- queue_index 小于 0。 如果尚未设置队列,则会发生这种情况。
ooo_okay
标志置位 。 如果设置了该标志,则意味着现在允许乱序数据包。 协议层必须适当地设置此标志。 在流的所有未完成数据包都已确认时,TCP 协议层会设置此标志。 当这种情况发生时,内核可以为该数据包选择不同的传输队列。 UDP 协议层不设置此标志-因此 UDP 数据包永远不会设置ooo_okay
为非零值。- 队列索引大于队列数。 如果用户最近通过
ethtool
更改了设备上的队列计数,则可能会发生这种情况。 稍后会详细介绍。
以上任一情况下,代码都会进入慢速路径以获取传输队列。首先调用 get_xps_queue
,它试图使用用户配置映射传输队列到 CPU。这称为“Transmit Packet Steering(XPS)”。我们稍后将更详细地了解 Transmit Packet Steering(XPS) 是什么以及它是如何工作的。
如果 get_xps_queue
返回 -1
,则此内核不支持 XPS,或系统管理员未配置 XPS,或配置的映射指向无效队列,则代码将继续调用 skb_tx_hash
。
一旦使用 XPS 或内核自动使用 skb_tx_hash
选择了队列,将使用 sk_tx_queue_set
缓存该队列到套接字对象上,并返回。在继续 dev_queue_xmit
之前,让我们看看 XPS 和 skb_tx_hash
是如何工作的。