JasonWang's Blog

ARP cache不更新导致的网络问题

字数统计: 3.7k阅读时长: 16 min
2021/09/28

最近碰到了一个很奇怪的问题, Android系统(Linux内核4.15)唤醒后, SoC(高通平台)跟TBox(Telematics Box)TCP的连接会偶发变慢, 需要等超过10s才能连接上. 发送ping包给TBox, 通过strace看进程一直提示EAGAIN的错误.从字面意义来说EAGAIN(Resource temporarily unavailable)是内核告知ping进程当前没有可用的数据包可以接收. 可是, 问题来了:

  • 为什么ping一直会收到EAGAIN的错误? 内核在什么时候会返回该错误?
  • 为什么ping收不到数据包, TBox回包到底去了哪里?

幸亏好这个问题比较容易重现, 折腾了两天才最终把问题的的来龙去脉搞清楚. 接下来就来看看这个问题的现象以及背后发生的根因, 最后给出几个相应的对策.

问题现象

发生问题的网络框架图大致如下. SoC(Android系统)通过基于USB的RNDIS(参考MSFT Overview of RNDIS)与TBox进行连接, SoC对应的网卡usb0的IP地址为192.168.1.3, 而TBox与SoC相对应的网口名字是rndis0, 实际该网口的数据都是通过桥接口bridge0(对应IP地址为192.168.1.1)转发给外部网络.

network diagram

问题具体现象是, SoC短暂休眠唤醒后, 在不到5s内识别到了TBox, 并且usb0都正常配置了IP地址, 网卡也处于UP状态.但发送ping包, 跟建立TCP连接都会发生概率性的延迟, 比正常的时候慢了接近20s, 而另一方面通过arping向TBox发送ARP包正常得到了响应. 这个就说明, 物理层肯定是没有问题. 那问题只能出现在上层了. 那么, 究竟是IP网络层还是数据链路层出问题了? 我们就一起来看看这个问题.

抓包分析

网络问题第一步就是要抓到对应的tcpdump包, 因为packets donot lie, 数据包里有所有数据传输的原始信息.在系统唤醒后网卡UP时执行tcpdump -i usb0 -s 0 -n -w /data/test.pcap, 打开test.pcap看ping包有正常发送接收了, 但ping进程却一直收到的是EAGAIN的错误, 没有收到任何ICMP的回应:

ping pcap

TCP连接有点类似: SoC发了SYN包, TBox也回送了SYN-ACK包, 但TCP传输层却没有收到任何的SYN-ACK, 所以在超时时间后立刻进行SYN的重传, 由于TBox没有收到SoC的ACK包同样重传了SYN-ACK, 三次握手一直没有正常完成.

TCP pcap

为什么会用户进程没有收到数据包, 而TCP传输层的三次握手为什么没法完成了? 那么, 这些数据包究竟去了哪里? 是在内核某个地方被丢弃了吗?点击看每个包, IP地址跟校验码都是正常.难道是IP网络层(L3)的路由发生问题, 导致数据一直没法正常路由到TCP层吗? 又或者是L2(数据链路层)的把数据包直接丢了? 但是通过tcpdump抓包为何有了? 只能一步步看内核代码了. 这么一看, 才发现忽略了一个重要的信息. 这个先按下不表.

我们先来看下内核代码为何ping会收到EAGAIN的错误码.

EGAIN错误从何而来

对于ping来说, 使用的是icmp协议, 内核在初始化的时候会注册一个struct proto ping_prot的ping协议接口, 用户进程通过rcvmsg系统调用接收ping数据回包时最终会调用到ping_prot->recvmsg这里.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
   // af_inet.c
{
.type = SOCK_DGRAM,
.protocol = IPPROTO_ICMP,
.prot = &ping_prot,
.ops = &inet_sockraw_ops,
.flags = INET_PROTOSW_REUSE,
},

rc = proto_register(&ping_prot, 1);

struct proto ping_prot = {
.name = "PING",
.owner = THIS_MODULE,
.init = ping_init_sock,
.close = ping_close,
.connect = ip4_datagram_connect,
.disconnect = __udp_disconnect,
.setsockopt = ip_setsockopt,
.getsockopt = ip_getsockopt,
.sendmsg = ping_v4_sendmsg,
.recvmsg = ping_recvmsg,
.bind = ping_bind,
.backlog_rcv = ping_queue_rcv_skb,
.release_cb = ip4_datagram_release_cb,
.hash = ping_hash,
.unhash = ping_unhash,
.get_port = ping_get_port,
.obj_size = sizeof(struct inet_sock),
};
EXPORT_SYMBOL(ping_prot);

ping_rcvmsg首先调用skb_recv_datagram尝试获取sock上接收到的数据包, 收到包后调用skb_copy_datagram_msg拷贝到数据到struct msghdr消息体上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

// ping.c
int ping_recvmsg(struct sock *sk, struct msghdr *msg, size_t len, int noblock, int flags, int *addr_len)
{
struct inet_sock *isk = inet_sk(sk);
int family = sk->sk_family;
struct sk_buff *skb;
int copied, err;

pr_debug("ping_recvmsg(sk=%p,sk->num=%u)\n", isk, isk->inet_num);

err = -EOPNOTSUPP;
if (flags & MSG_OOB)
goto out;

if (flags & MSG_ERRQUEUE)
return inet_recv_error(sk, msg, len, addr_len);

skb = skb_recv_datagram(sk, flags, noblock, &err);
if (!skb)
goto out;

copied = skb->len;
if (copied > len) {
msg->msg_flags |= MSG_TRUNC;
copied = len;
}

/* Don't bother checking the checksum */
err = skb_copy_datagram_msg(skb, 0, msg, copied);
if (err)
goto done;

sock_recv_timestamp(msg, sk, skb);

/* Copy the address and add cmsg data. */
if (family == AF_INET) {
DECLARE_SOCKADDR(struct sockaddr_in *, sin, msg->msg_name);

if (sin) {
sin->sin_family = AF_INET;
sin->sin_port = 0 /* skb->h.uh->source */;
sin->sin_addr.s_addr = ip_hdr(skb)->saddr;
memset(sin->sin_zero, 0, sizeof(sin->sin_zero));
*addr_len = sizeof(*sin);
}

if (isk->cmsg_flags)
ip_cmsg_recv(msg, skb);
...
}
EXPORT_SYMBOL_GPL(ping_recvmsg);

skb_recv_datagram最后会调用__skb_recv_datagram: 如果ping进程通过阻塞方式接收数据, 并且尝试接收数据时返回了EAGAIN的错误(socket没有设置接收超时时间的话, 超时时间就是LONG的最大值), 则会一直等待来自网络层的数据.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

// core/datagram.c
struct sk_buff *__skb_recv_datagram(struct sock *sk, unsigned int flags,
void (*destructor)(struct sock *sk,
struct sk_buff *skb), int *peeked, int *off, int *err)
{
struct sk_buff *skb, *last;
long timeo;

timeo = sock_rcvtimeo(sk, flags & MSG_DONTWAIT);

do {
skb = __skb_try_recv_datagram(sk, flags, destructor, peeked,
off, err, &last);
if (skb)
return skb;
//如果错误码是EAGAIN, 则一直尝试
if (*err != -EAGAIN)
break;
} while (timeo &&
!__skb_wait_for_more_packets(sk, err, &timeo, last));

return NULL;
}
EXPORT_SYMBOL(__skb_recv_datagram);

函数__skb_try_recv_datagram在接收队列sk_receive_queue没有数据时就会返回EAGAGIN的错误. 这正是用户空间接收到这个错误码的来源了.就是说用户进程确实是没有收到任何数据. 也就是说,从TBox来的数据很可能是在网络层L3之下就丢掉了, 所以一直没有数据发送给用户进程.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

// core/datagram.c
struct sk_buff *__skb_try_recv_datagram(struct sock *sk, unsigned int flags,
void (*destructor)(struct sock *sk,
struct sk_buff *skb),
int *peeked, int *off, int *err,
struct sk_buff **last)
{
struct sk_buff_head *queue = &sk->sk_receive_queue;
struct sk_buff *skb;
unsigned long cpu_flags;
/*
* Caller is allowed not to check sk->sk_err before skb_recv_datagram()
*/
int error = sock_error(sk);

if (error)
goto no_packet;

*peeked = 0;
do {
/* Again only user level code calls this function, so nothing
* interrupt level will suddenly eat the receive_queue.
*
* Look at current nfs client by the way...
* However, this function was correct in any case. 8)
*/
spin_lock_irqsave(&queue->lock, cpu_flags);
skb = __skb_try_recv_from_queue(sk, queue, flags, destructor,
peeked, off, &error, last);
spin_unlock_irqrestore(&queue->lock, cpu_flags);
if (error)
goto no_packet;
if (skb)
return skb;

if (!sk_can_busy_loop(sk))
break;

sk_busy_loop(sk, flags & MSG_DONTWAIT);
} while (READ_ONCE(sk->sk_receive_queue.prev) != *last);

error = -EAGAIN;

no_packet:
*err = error;
return NULL;
}
EXPORT_SYMBOL(__skb_try_recv_datagram);

ARP cache才是罪魁祸首

明确了ping数据包在L3层丢弃的这个事实, 那不妨来追查下问题的真正原因.从tcpdump抓的包来看, 可以找到Tbox的ping回包的, 就是说TBox的数据肯定是传到了网口, 也就是说物理层L1跟数据链路层L2是能正常接收到数据的, 否则tcpdump是无法抓取到任何数据的, 因为tcpdump接收数据就是在所有数据传递给网络层L3之前的(参考core/dev.c的函数__netif_receive_skb_core)发送给tcpdump用户进程的.

那ping回包究竟为何被丢弃? 代码看了好几遍, 也在关键路径加了日志, 但还是毫无头绪.最终在一次重现问题的时候, 偶然发现, 只要发生出现的时候, 在TBox侧通过arping发送ARP包, 网络瞬间就畅通了. 这无疑为问题的定位找到了关键的线索.

问题应该就出现在TBox的ARP状态上. 继续重现, 同步看下SoC跟TBox的网卡与ARP状态:

tbox arp cache mismatch

图中上半部分是ifconfig usb0获取到的SoC网卡的状态, 下半部分是cat /proc/net/arp得到TBox的ARP cache状态, 可以看到TBox的桥接口bridge0并没有正常更新SoC网卡192.168.1.3对应的MAC地址.就是说, TBox使用了错误的(旧的)MAC地址进行数据的传输, 自然这些数据没法到达SoC的协议栈了.

再回来看之前测试抓到的tcpdump数据, SoC发的ping请求包的源MAC地址与TBox的ping回包的目标MAC地址是不一样的:

ping request/reply

为什么TBox的ARP cache数据会更新慢了? 原因主要有两个:

  • SoC的rndis网卡每次休眠唤醒的时候都会随机生成一个MAC地址, 而SoC并没有正常通知MAC地址的变化
  • 正常情况都是SoC给TBox发送数据, TBox ARP cache只有在TBox向SoC发送数据时才会更新

到这里, 还有一个谜底没有揭开: 内核是在什么地方判断MAC地址的不匹配并丢掉该数据包的了? 不妨再看看代码.

ping回包丢弃之谜

看内核源码, 数据从链路层L2传到网络层L3的入口是ip_rcv(参考af_inet.c), 协议栈初始化的时候会把这个接口传递给数据链路层(参考dev.c的函数dev_add_pack):

1
2
3
4
5
6
7
8
   //af_inet.c
dev_add_pack(&ip_packet_type);

static struct packet_type ip_packet_type __read_mostly = {
.type = cpu_to_be16(ETH_P_IP),
.func = ip_rcv,
};

查看ip_rcv这个函数, 可以看到, 如果包本身的类型是PACKET_OTHERHOST就会直接丢弃, 就是说很可能来自TBox的数据包因为MAC地址不匹配被标记为其他主机的包而被丢弃了. 而从加的日志来看, TBox的回包没有继续往下传. 那内核究竟是在何时标记了sk_buff->pkt_type包类型?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98

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

/* When the interface is in promisc. mode, drop all the crap
* that it receives, do not try to analyse it.
*/
if (skb->pkt_type == PACKET_OTHERHOST)
goto drop;


net = dev_net(dev);
__IP_UPD_PO_STATS(net, IPSTATS_MIB_IN, skb->len);

skb = skb_share_check(skb, GFP_ATOMIC);
if (!skb) {
__IP_INC_STATS(net, IPSTATS_MIB_INDISCARDS);
goto out;
}

if (!pskb_may_pull(skb, sizeof(struct iphdr)))
goto inhdr_error;

iph = ip_hdr(skb);

/*
* RFC1122: 3.2.1.2 MUST silently discard any IP frame that fails the checksum.
*
* Is the datagram acceptable?
*
* 1. Length at least the size of an ip header
* 2. Version of 4
* 3. Checksums correctly. [Speed optimisation for later, skip loopback checksums]
* 4. Doesn't have a bogus length
*/

if (iph->ihl < 5 || iph->version != 4)
goto inhdr_error;

BUILD_BUG_ON(IPSTATS_MIB_ECT1PKTS != IPSTATS_MIB_NOECTPKTS + INET_ECN_ECT_1);
BUILD_BUG_ON(IPSTATS_MIB_ECT0PKTS != IPSTATS_MIB_NOECTPKTS + INET_ECN_ECT_0);
BUILD_BUG_ON(IPSTATS_MIB_CEPKTS != IPSTATS_MIB_NOECTPKTS + INET_ECN_CE);
__IP_ADD_STATS(net,
IPSTATS_MIB_NOECTPKTS + (iph->tos & INET_ECN_MASK),
max_t(unsigned short, 1, skb_shinfo(skb)->gso_segs));

if (!pskb_may_pull(skb, iph->ihl*4))
goto inhdr_error;

iph = ip_hdr(skb);

if (unlikely(ip_fast_csum((u8 *)iph, iph->ihl)))
goto csum_error;

len = ntohs(iph->tot_len);
if (skb->len < len) {
__IP_INC_STATS(net, IPSTATS_MIB_INTRUNCATEDPKTS);
goto drop;
} else if (len < (iph->ihl*4))
goto inhdr_error;

/* Our transport medium may have padded the buffer out. Now we know it
* is IP we can trim to the true length of the frame.
* Note this now means skb->len holds ntohs(iph->tot_len).
*/
if (pskb_trim_rcsum(skb, len)) {
__IP_INC_STATS(net, IPSTATS_MIB_INDISCARDS);
goto drop;
}

iph = ip_hdr(skb);
skb->transport_header = skb->network_header + iph->ihl*4;

/* Remove any debris in the socket control block */
memset(IPCB(skb), 0, sizeof(struct inet_skb_parm));
IPCB(skb)->iif = skb->skb_iif;

/* Must drop socket now because of tproxy. */
skb_orphan(skb);

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

csum_error:
__IP_INC_STATS(net, IPSTATS_MIB_CSUMERRORS);
inhdr_error:
__IP_INC_STATS(net, IPSTATS_MIB_INHDRERRORS);
drop:
kfree_skb(skb);
out:
return NET_RX_DROP;
}

直接去内核代码搜索PACKET_OTHERHOST,可以看到对于以太网类型, 有一个函数eth_type_trans正是用来确定数据包类型的: 确认这个包是广播还是组播类型的, 同时返回数据链路层对应的协议类型.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

__be16 eth_type_trans(struct sk_buff *skb, struct net_device *dev)
{
unsigned short _service_access_point;
const unsigned short *sap;
const struct ethhdr *eth;

skb->dev = dev;
skb_reset_mac_header(skb);

eth = (struct ethhdr *)skb->data;
skb_pull_inline(skb, ETH_HLEN);

if (unlikely(is_multicast_ether_addr_64bits(eth->h_dest))) {
if (ether_addr_equal_64bits(eth->h_dest, dev->broadcast))
skb->pkt_type = PACKET_BROADCAST;
else
skb->pkt_type = PACKET_MULTICAST;
}
// MAC地址不一匹配, 则标记该包为其他目标主机的
else if (unlikely(!ether_addr_equal_64bits(eth->h_dest,
dev->dev_addr)))
skb->pkt_type = PACKET_OTHERHOST;

/*
* Some variants of DSA tagging don't have an ethertype field
* at all, so we check here whether one of those tagging
* variants has been configured on the receiving interface,
* and if so, set skb->protocol without looking at the packet.
*/
if (unlikely(netdev_uses_dsa(dev)))
return htons(ETH_P_XDSA);

if (likely(eth_proto_is_802_3(eth->h_proto)))
return eth->h_proto;

/*
* This is a magic hack to spot IPX packets. Older Novell breaks
* the protocol design and runs IPX over 802.3 without an 802.2 LLC
* layer. We look for FFFF which isn't a used 802.2 SSAP/DSAP. This
* won't work for fault tolerant netware but does for the rest.
*/
sap = skb_header_pointer(skb, 0, sizeof(*sap), &_service_access_point);
if (sap && *sap == 0xFFFF)
return htons(ETH_P_802_3);

/*
* Real 802.2 LLC
*/
return htons(ETH_P_802_2);
}
EXPORT_SYMBOL(eth_type_trans);

前面说道, SoC使用的是基于USB的rndis网卡驱动, 直接去usb网卡驱动下面找调用这个函数的地方(drivers/net/usb),会发现在usbnet.c的函数usbnet_skb_return中, 数据包往上层协议栈发送的时候会调用eth_type_trans:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

/* Passes this packet up the stack, updating its accounting.
* Some link protocols batch packets, so their rx_fixup paths
* can return clones as well as just modify the original skb.
*/
void usbnet_skb_return (struct usbnet *dev, struct sk_buff *skb)
{
struct pcpu_sw_netstats *stats64 = this_cpu_ptr(dev->stats64);
unsigned long flags;
int status;
struct timespec64 now;

if (test_bit(EVENT_RX_PAUSED, &dev->flags)) {
dbg_log_string("skb %pK added to pause list", skb);
skb_queue_tail(&dev->rxq_pause, skb);
return;
}

/* only update if unset to allow minidriver rx_fixup override */
if (skb->protocol == 0)
skb->protocol = eth_type_trans (skb, dev->net);

flags = u64_stats_update_begin_irqsave(&stats64->syncp);
stats64->rx_packets++;
dev->net->stats.rx_packets++;
stats64->rx_bytes += skb->len;
dev->net->stats.rx_bytes += skb->len;
u64_stats_update_end_irqrestore(&stats64->syncp, flags);

netif_dbg(dev, rx_status, dev->net, "< rx, len %zu, type 0x%x\n",
skb->len + sizeof (struct ethhdr), skb->protocol);
memset (skb->cb, 0, sizeof (struct skb_data));

if (skb_defer_rx_timestamp(skb))
return;

getnstimeofday64(&now);
dbg_log_string("skb %pK, time %lu.%09lu", skb, now.tv_sec, now.tv_nsec);
status = netif_rx (skb);
if (status != NET_RX_SUCCESS)
netif_dbg(dev, rx_err, dev->net,
"netif_rx status %d\n", status);
}
EXPORT_SYMBOL_GPL(usbnet_skb_return);

至此, 我们总算揭开了TBox数据包被丢弃的谜底, 也把整个问题的来龙去脉理清楚了. 那么, 问题要如何解决?

解决方案

回头看, 问题的表面现象是TBox的ARP cache更新慢了, 但是为什么更新慢了? 总结下有两个方面的原因:

  • SoC侧每次休眠唤醒MAC地址会随机变化, 但是又没有ARP通知到TBox, 所以TBox ARP不会更新
  • TBox不会向SoC侧发送数据包, 所以不会更新ARP cache(内核在发送数据的时候会生成新的cache)

这么来看问题的对策可以有这么几个:

  1. TBox在网卡UP的时候发送ARP包或者ping包给SoC这边, 主动更新ARP cache
  2. SoC把USB网卡的MAC地址修改成固定值, 这样ARP cache始终不会发生变化了
  3. SoC在USB网卡UP时主动发送一个GARP(Gratuitous ARP)给TBox(参考devinet.c函数inetdev_send_gratuitous_arp)(下篇文章就来介绍下GARP)

对局域网来说, USB的rndis网卡修改成固定值看起来更为合理, 而且修改的代码量是最少的, 只需要把rndis_host.c中网卡随机生成的逻辑去掉即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

--- a/drivers/net/usb/rndis_host.c
+++ b/drivers/net/usb/rndis_host.c
@@ -430,10 +430,7 @@ generic_rndis_bind(struct usbnet *dev, struct usb_interface *intf, int flags)
goto halt_fail_and_release;
}

- if (bp[0] & 0x02)
- eth_hw_addr_random(net);
- else
- ether_addr_copy(net->dev_addr, bp);
+ ether_addr_copy(net->dev_addr, bp);

/* set a nonzero filter to enable data transfers */
memset(u.set, 0, sizeof *u.set);

总结

这个问题本身其实并不算困难, 但是由于一开始就忽略了packets donot lie这个原则, 没有认真看tcpdump的数据包, 忽略了MAC地址不匹配重要的事实, 导致问题定位走了弯路. 总的说来, 网络问题首先要抓到数据包, 再基于对TCP/IP协议的理解, 认真梳理linux内核协议栈的流程, 疑难杂症就没有什么可怕的了.

参考文献

原文作者:Jason Wang

更新日期:2021-10-09, 15:50:34

版权声明:本文采用知识共享署名-非商业性使用 4.0 国际许可协议进行许可

CATALOG
  1. 1. 问题现象
  2. 2. 抓包分析
  3. 3. EGAIN错误从何而来
  4. 4. ARP cache才是罪魁祸首
  5. 5. ping回包丢弃之谜
  6. 6. 解决方案
  7. 7. 总结
  8. 8. 参考文献