提到网络协议栈,数据的收发是核心,我们先来看下数据的接收函数 netif_receive_skb()。
先统一介绍下数据收发 。
对于linux内核来说,网络报文由网络设备来进行接收。设备驱动程序从网络设备中读取报文,通过内核提供的网络接口函数,将报文传递到内核中的网络协议栈。报文经过协议栈的处理,或转发、或丢弃、或被传送给某个进程。
网络报文的发送与之相反,进程通过系统调用将数据送入网络协议栈,或者由网络协议栈自己发起报文的发送,然后协议栈通过调用网络接口函数来调度驱动程序,使其将报文传送给网络设备,从而发送出去。
本文讨论的是网络接口层,它是网络设备驱动程序与网络协议栈交互的纽带。见下图中红色部分的netif。
报文的接收
网络报文的接收源自网络设备。网络设备在接收到一个报文之后,通过中断告知CPU。网卡驱动程序需要注册对该中断事件的处理函数(参见《linux中断处理浅析》),以处理接收到的报文。
在中断处理函数中,网络驱动程序有两种方法对报文进行处理(老式的方法,和新式的方法),我们先介绍老式的处理方式。在这种方式下,中断处理函数主要完成以下工作:
分配一个skb结构(该结构用于保存一个报文)。操作设备,将设备收到的数据拷贝到这个skb结构对应的缓冲区中。设置skb的协议类型skb->protocol,该类型表明了网络协议栈的上层协议(下面我们将会看到)。然后调用内核提供的网络接口函数netif_rx;
netif_rx(skb);
netif_rx函数对skb的如时间戳这样的附加信息进行初始化以后,将这个skb结构放入当前CPU的softnet_data结构的input_pkt_queue队列中。netif_rx会根据队列的长度,对设备的拥塞状况进行判断(队列过长则代表报文接收过快,以致于上层来不及处理)。如果设备已陷入拥塞,则收到的报文可能直接被丢弃。
如果一切正常,netif_rx会调用网络接口函数netif_rx_schedule,以触发对接收报文的进一步处理;
netif_rx_schedule(dev);
netif_rx使用softnet_data结构中内嵌的backlog_dev作为dev来调用netif_rx_schedule,后者将其加入到softnet_data结构的poll_list队列中(如果这个dev不在队列中的话),以使其等待被调度。
相比老式的处理方式,新式的处理方式(称为NAPI)在中断处理函数中仅仅是以对应设备的dev结构为参数调用netif_rx_schedule函数即可。
最后netif_rx_schedule函数会触发NET_RX_SOFTIRQ软中断,于是接下来对应的软中断处理函数net_rx_action将被调用;
net_rx_action()
对于当前CPU对应的softnet_data结构的poll_list队列中的所有dev,调用dev->poll方法。该方法是由对应dev的驱动程序实现的,用于接收及处理报文(前面提到的backlog_dev除外)。
net_rx_action每次运行都有一定的限度,并不一定要将所有报文都处理完。在处理完一定数量的报文配额、或处理过程超过一定时间后,net_rx_action便会返回。返回前触发一次NET_RX_SOFTIRQ软中断,等待下一次中断到来的时候继续被调度。
以上过程如图所示:
上面提到的softnet_data结构是用于进行报文收发调度的结构,内核为每个CPU维护一个这样的结构。在报文接收过程中用到了其中的三个成员:
- poll_list,网络设备dev的队列。其中的设备接收到了报文,需要被处理;
- input_pkt_queue,skb报文结构的队列,保存了已接收并需要被处理的报文;
- backlog_dev,一个虚拟的网络设备dev结构;
后两个成员是专门为支持老式的处理方式而设置的,在这种方式下,接收到的skb被放入input_pkt_queue队列,然后backlog_dev被加入poll_list。而最后,自然backlog_dev->poll函数将对input_pkt_queue队列中的skb进行处理。backlog_dev->poll等于process_backlog函数;
process_backlog(backlog_dev, budget);
既然net_rx_action每次运行都有一个配额,它在调用dev->poll时也会传递当前剩余的配额值,即budget。process_backlog会遍历input_pkt_queue队列中的skb,调用netif_receive_skb函数对其进行处理。process_backlog函数有两种结局,一个是配额到或时间到,直接返回;另一个是处理完input_pkt_queue队列中的所有skb,此时需要将backlog_dev从poll_list中删除。
新式的NAPI处理方式所要做的事跟老的处理方式其实是很类似的。在其对应的dev->poll函数中,需要分配skb结构、从设备读取报文、调用netif_receive_skb让网络协议栈的上层来处理报文。
这种方式最大的好处是:在dev->poll函数中,不一定只处理一个报文。具体怎么处理可以由驱动程序灵活控制。比如说,假设现在网络负载非常大,如果网络设备每接收一个报文都通过一次中断来告知内核,这样做效率并不理想。而此时dev->poll可以做一些轮询的工作,如果网络设备已经接收了多个报文,可以一次性都处理了。并且,就算设备此刻所接收到的报文都已经处理完了,驱动程序也可以根据某种方式预判设备在很短的一段时间内还将收到报文,于是依然将自己对应的dev结构留在poll_list中,等待下一次继续被调度。
当dev仍结构留在poll_list中时,设备驱动程序可以关闭设备接收到报文时的中断通知,因为目前处于轮询状态。而当驱动程序认为在将来的一段时间以内无报文可收时,则可以将其dev从poll_list中移除,然后开启设备接收到报文时的中断通知。等待下一次报文接收的中断到来时,这个dev再重新被放入poll_list。
netif_receive_skb(skb);
该函数会将skb提交给抓包程序进行处理、还会触发数据链路层的桥接功能(见《linux网桥浅析》)、然后将报文提交给网络协议栈的上层(网络层)进行处理。
网络层的协议有IP、ARP等等很多种,在这里怎么知道这个skb该提交给哪种协议呢?在报文的数据链路层报头中保存着三个重要信息,发送者和接收者的Mac地址、和上层协议标识。回想一下之前的流程,在skb接收完成之后我们就已经设置了skb->protocol(从报头中得到),上层协议就由它来指定。比如,0x0800代表IP协议、0x0806代表ARP协议,这是由协议规定的。
netif_receive_skb并不是用一个switch-case来匹配skb->protocol,以选择网络层处理函数的。系统中有一个名为ptype_base的hash表,各种网络层的协议在其初始化时都会在这个hash表中注册一个类型为packet_type的表项(以协议类型为key),如下图所示:
netif_receive_skb要做的就是在这个hash表中遍历所有type与skb->protocol匹配的packet_type结构(packet_type结构的dev可用于限定skb->dev,NULL表示不限),然后调用其func回调函数。(可见,一个报文有可能被多种协议所处理。)
至此报文被提交到了网络层,在这里就不继续深入了。
报文的发送
报文的发送是由网络协议栈的上层发起的。网络协议栈上层构造一个需要发送的skb结构后(该skb已经包含了数据链路层的报头),调用dev_queue_xmit函数进行发送;
dev_queue_xmit(skb);
该函数先会处理一些缓冲区重组、计算校验和之类的杂事,然后开始处理报文的发送。
发送报文有两种策略,有队列或无队列。这是由网络设备驱动程序在定义其对应的dev结构时指定的,一般的设备都会使用队列。
dev->qdisc指向一个队列的实例,里面包含了队列本身以及操作队列的方法(enqueue、dequeue、requeue)。这些方法的集合组成了一种队列规则(skb将以某种规则入队、以某种规则出队,并不一定是简单的先进先出),这样的规则可用于流量控制。
网络设备驱动程序可以选择自己的设备使用什么样的队列,或是不使用队列。
对于有队列的设备,dev_queue_xmit调用dev->qdisc->enqueue方法将skb加入队列,然后调用qdisc_run函数。而qdisc_run会调用qdisc_restart来对队列进行处理。
qdisc_restart(dev);
该函数主要的工作就是不断调用dev->qdisc->dequeue方法从队列中取出待发送的报文,然后调用dev->hard_start_xmit方法进行发送。该方法是由设备驱动程序实现的,会直接和网络设备去打交道,将报文发送出去。
如果报文发送失败,qdisc_restart会调用dev->qdisc->requeue方法将skb重新放回队列。同时,还将调用netif_schedule函数将dev加入softnet_data的output_queue队列中(其中的设备都是有报文等待发送的,将在稍后被处理)。然后触发一次NET_TX_SOFTIRQ软中断。于是在下一个中断到来时,对应的软中断处理函数net_tx_action将被调用。
而如果dev->hard_start_xmit方法发送报文成功,则表示报文已经送到了网络设备的发送缓冲区,设备会自动将报文发送出去。并且在报文发送完成时,设备会通过中断通知驱动程序。对应的中断处理函数也会触发NET_TX_SOFTIRQ软中断。此外,已发送完成的skb将被加入softnet_data的completion_queue队列中,等待被释放。
软中断NET_TX_SOFTIRQ被触发,将使得net_tx_action函数被调用。该函数主要做了两件事:
- 从softnet_data的completion_queue队列中取出每一个skb,将其释放;
- 对于softnet_data的output_queue队列中的dev,调用qdisc_run继续尝试发送其qdisc队列中的报文;
对于有队列的设备,其队列主要用于流量控制以及发送失败时的缓冲;对于没有队列的设备(比如lo,环回设备),dev_queue_xmit函数则会直接调用dev->hard_start_xmit进行发送,如果失败报文就会被丢弃。
以上过程如图:
qdisc_restart函数在执行过程中还会关心dev是否被暂停(就如接收报文时要关心网络是否拥塞一样),如果被暂停则结束处理流程并返回。
而dev的暂停与否是由设备驱动程序来设置的,在dev->hard_start_xmit函数中,驱动程序如果发现设备当前的发送缓冲区太小(比如小到无法再容纳一个报文。这表示报文发送过快,以致于设备来不及处理),则会让设备暂停。而当网络设备在完成报文的发送后会产生中断,对应的中断处理程序又可以根据设备当前的发送缓冲区大小,决定是否让设备从暂停中恢复。
而如果网络设备出现问题,无法发送报文了,则可能设备上的发送缓冲区一直处于被占满的状态,导致设备一直被暂停。另一方面,报文发不出去,也就不会有通知发送完成的中断产生,设备也就不会从暂停状态恢复,于是网络就瘫痪了。
为了检测这种情况,驱动程序可以为设备设置一个看门狗定时器。如果发现设备正在暂停状态,并且距离最后一次发送报文已经过去一定的时间,而发送完成的中断还没有收到,则认为该设备出现问题。此时看门狗定时器将触发驱动程序提供的相关函数,将设备复位,以试图让其恢复正常工作。
下面详细看下netif_receive_skb函数:
int netif_receive_skb(struct sk_buff *skb)
{
//略去一些代码
rcu_read_lock();
//第一步:先处理 ptype_all 上所有的 packet_type->func()
//所有包都会调func,对性能影响严重!内核默认没挂任何钩子函数
list_for_each_entry_rcu(ptype, &ptype_all, list) { //遍历ptye_all链表
if (!ptype->dev || ptype->dev == skb->dev) { //上面的paket_type.type 为 ETH_P_ALL
if (pt_prev) //对所有包调用paket_type.func()
ret = deliver_skb(skb, pt_prev, orig_dev); //此函数最终调用paket_type.func()
pt_prev = ptype;
}
}
//第二步:若编译内核时选上BRIDGE,下面会执行网桥模块
//调用函数指针 br_handle_frame_hook(skb), 在动态模块 linux_2_6_24/net/bridge/br.c中
//br_handle_frame_hook = br_handle_frame;
//所以实际函数 br_handle_frame。
//注意:在此网桥模块里初始化 skb->pkt_type 为 PACKET_HOST、PACKET_OTHERHOST
skb = handle_bridge(skb, &pt_prev, &ret, orig_dev);
if (!skb) goto out;
//第三步:编译内核时选上MAC_VLAN模块,下面才会执行
//调用 macvlan_handle_frame_hook(skb), 在动态模块linux_2_6_24/drivers/net/macvlan.c中
//macvlan_handle_frame_hook = macvlan_handle_frame;
//所以实际函数为 macvlan_handle_frame。
//注意:此函数里会初始化 skb->pkt_type 为 PACKET_BROADCAST、PACKET_MULTICAST、PACKET_HOST
skb = handle_macvlan(skb, &pt_prev, &ret, orig_dev);
if (!skb) goto out;
//第四步:最后 type = skb->protocol; &ptype_base[ntohs(type)&15]
//处理ptype_base[ntohs(type)&15]上的所有的 packet_type->func()
//根据第二层不同协议来进入不同的钩子函数,重要的有:ip_rcv() arp_rcv()
type = skb->protocol;
list_for_each_entry_rcu(ptype, &ptype_base[ntohs(type)&15], list) {
if (ptype->type == type && //遍历包type所对应的链表
(!ptype->dev || ptype->dev == skb->dev)) { //调用链表上所有pakcet_type.func()
if (pt_prev)
ret = deliver_skb(skb, pt_prev, orig_dev); //就这里!arp包会调arp_rcv()
pt_prev = ptype; // ip包会调ip_rcv()
}
}
if (pt_prev) {
ret = pt_prev->func(skb, skb->dev, pt_prev, orig_dev);
} else { //下面就是数据包从协议栈返回来了
kfree_skb(skb); //注意这句,若skb没进入socket的接收队列,则在这里被释放
ret = NET_RX_DROP; //若skb进入接收队列,则系统调用取包时skb释放,这里skb引用数减一而已
}
out:
rcu_read_unlock();
return ret;
}
int deliver_skb(struct sk_buff *skb,struct packet_type *pt_prev, struct net_device *orig_dev){
atomic_inc(&skb->users);
return pt_prev->func(skb, skb->dev, pt_prev, orig_dev);//调函数ip_rcv() arp_rcv()等
}
在netif_receive_skb()函数中,可以看出处理的是像ARP、IP这些链路层以上的协议,那么,链路层报头是在哪里去掉的呢?答案是网卡驱动中,在调用netif_receive_skb()前,
skb->protocol = eth_type_trans(skb, bp->dev);
该函数对处理后skb>data跳过以太网报头,由mac_header指示以太网报头:
进入netif_receive_skb()函数
list_for_each_entry_rcu(ptype,&ptype_base[ntohs(type) & PTYPE_HASH_MASK], list)
按照协议类型依次由相应的协议模块进行处理,而所以的协议模块处理都会注册在ptype_base中,实际是链表结构。
net/core/dev.c
static struct list_head ptype_base __read_mostly; /* Taps */
而相应的协议模块是通过dev_add_pack()函数加入的:
void dev_add_pack(struct packet_type *pt)
{
int hash;
spin_lock_bh(&ptype_lock);
if (pt->type == htons(ETH_P_ALL))
list_add_rcu(&pt->list, &ptype_all);
else {
hash = ntohs(pt->type) & PTYPE_HASH_MASK;
list_add_rcu(&pt->list, &ptype_base[hash]);
}
spin_unlock_bh(&ptype_lock);
}
以ARP处理为例
该模块的定义,它会在arp_init()中注册进ptype_base链表中:
static struct packet_type arp_packet_type __read_mostly = {
.type = cpu_to_be16(ETH_P_ARP),
.func = arp_rcv,
};
然后在根据报文的TYPE来在ptype_base中查找相应协议模块进行处理时,实际调用arp_rcv()进行接收
arp_rcv() --> arp_process()
arp = arp_hdr(skb);
……
arp_ptr= (unsigned char *)(arp+1);
sha= arp_ptr;
arp_ptr += dev->addr_len;
memcpy(&sip, arp_ptr, 4);
arp_ptr += 4;
arp_ptr += dev->addr_len;
memcpy(&tip, arp_ptr, 4);
操作后这指针位置:
然后判断是ARP请求报文,这时先查询路由表ip_route_input()
if (arp->ar_op == htons(ARPOP_REQUEST) &&
ip_route_input(skb, tip, sip, 0, dev) == 0)
在ip_route_input()函数中,先在cache中查询是否存在相应的路由表项:
hash = rt_hash(daddr, saddr, iif, rt_genid(net));
缓存的路由项在内核中组织成hash表的形式,因此在查询时,先算出的hash值,再用该项- rt_hash_table[hash].chain即可。这里可以看到,缓存路由项包括了源IP地址、目的IP地址、网卡号。
如果在缓存中没有查到匹配项,或指定不查询cache,则查询路由表ip_route_input_slow();
进入ip_route_input_slow()函数,最终调用fib_lookup()得到查询结果fib_result
if ((err = fib_lookup(net, &fl, &res)) != 0)
如果结果fib_result合法,则需要更新路由缓存,将此次查询结果写入缓存
hash = rt_hash(daddr, saddr, fl.iif, rt_genid(net));
err = rt_intern_hash(hash, rth, NULL, skb, fl.iif);
在查找完路由表后,回到arp_process()函数,如果路由项指向本地,则应由本机接收该报文:
if (addr_type == RTN_LOCAL) {
……
if (!dont_send) {
n = neigh_event_ns(&arp_tbl, sha, &sip, dev);
if (n) { arp_send(ARPOP_REPLY,ETH_P_ARP,sip,dev,tip,sha,dev->dev_addr,sha);
neigh_release(n);
}
}
goto out;
}
首先更新邻居表neigh_event_ns(),然后发送ARP响应 – arp_send。
至此,大致的ARP流程完成。由于ARP部分涉及到路由表以及邻居表,这都是很大的概念,在下一篇中介绍,这里直接略过了。