IP网络层与网络设备之间分组收发原理

来源:互联网 发布:linux top 进程名 编辑:程序博客网 时间:2024/06/15 14:44

Tags: Linux, 计算机网络, 驱动程序

引子

当我们为字符设备或块设备编写驱动程序时,我们实现逻辑设备的接口是文件读写接口——file_operations,这个接口基本上直接面向用户空间程序的。而我们要为网络设备编写驱动则不然了,用户空间程序是通过标准套接口(sockets)系统调用来使用网络功能的,用户空间程序与网络设备之间夹着一层TCP/IP协议栈程序。也就是说,当我们为网络设备编写驱动程序时,实现的设备接口是给TCP/IP协议栈“用”的。

事实上,现代操作系统的实现里,TCP/IP协议栈还不是直接“用”网络设备发送接收数据包的,因为为了实现更好网络性能、控制网络流量等,协议栈的第三层——IP网络层和第二层——网络设备层之间发生很多代码活动(activity)。如果不深入理解这两层之间的代码活动(数据流和控制流),那是很难理解和编写网络设备驱动的。下图概观了这些代码活动:

这些活动包括:

   1. 网卡的硬件中断将收到数据包的入列CPU的收包队列;
   2. 收包软中断(NET_RX_SOFTIRQ)将数据向上层传递或入列网卡的发包队列进行转发;
   3. 用户进程通过系统调用将数据发给协议栈,最后入列网卡的发包队列;
   4. 各种内核活动(softirq,tasklet,timer)将数据入网卡的发包队列;
   5. 发包软中断(NET_TX_SOFTIRQ)直接或间接(经队列调度算法)将数据包出列,通过网卡发走。

 
术语

为了方便描述,有必要统一一下术语。
packet、包与分组

“packet”可以一般地译为数据包,也可以特别地译为网络帧,为了不产生歧义,这里在描述宏观原理时译为[网络分组],简称为分组,特指分组交换网络(packet switching)原理中两个网络节点间传输的数据包格式;在描述微观原理时简单的使用一个字,包。如收包队列,发包过程。

    什么是packet?

    TCP/IP协议规定,在因特网传输的消息(message)必须打包成一个个叫分组(packets)的数据包进行传输,这些分组数据从一个主机到另一个主机,直到它来到它的目标主机为止。分组网络的主要原理是将每一次通信的消息分解为数个大小相同的分组包,这些分组包单独地被发到网络上,经过网络路由到达目标主机后再组装为原来的消息。由于分组的路由线路不是固定的,当网络部分出现故障时,分组仍然可通过可用的线路到达目标主机。

    [网络分组]的长度一般为1K,因而,比较大数据可能需要分解为数千个分组。每个分组都有一个“头”,包括以下一些信息:
    * Source. 源主机IP地址
    * Destination. 目标主机IP地址
    * Length. 分组长度,以字节为单位
    * Number. 分组数,此分组所属的消息的长度
    * Sequence. 分组序号,此分组在所属的消息的序号,目标主机或路由器在传送出错时可根据此序号要求原主机单独重发分组。

收包与发包过程

本文只关注二三层的数据交互,所以[收包过程]与[发包过程]指一层到三层或反方向止。
入列与出列

分别对[CPU的收包队列]和[网络设备的发包队列]的两个操作——enqueue和dequeue的中译。
收包过程

对内核(设备驱动)而言,IP网络分组(packet)的到来是没法预知的,因此网络设备驱动必须使用中断处理函数来接收IP网络分组。每当网卡设备收到一个分组时,它向内核发出中断请求,内核调用相应的中断处理函数,将网卡设备上的分组数据拷入RAM[注]。

注:在具体的实现上,例如PCI以太网卡,驱动程序会与网卡芯片[协作]将收到的分组数据保存在RAM中一个DMAble的缓冲区上,再转拷进协议栈可访问的socket buffer,DMAble缓冲区是在网络设备驱动初始化的分配的,可以理解为逻辑设备的一部分,所以上面的“网卡设备上的分组数据”在这个前提下成立。

目前版本的内核(的网络子系统)实现了两种[收包]算法。其中一种已经存在内核中很长一段时间了,我们称其为传统算法。传统算法的大致原理是,网络设备的分组的异步收与发统一使用中断方式,一次中断一个分组;CPU和网络设备之间使用[等待队列]缓解二者速度上的差异,并作相应的拥塞管理——网络设备的启停,防止队列溢出。

由于传统算法跟不上网络设备的发展,在高速网卡的应用场景里存在不足,主要的不足是过多的中断产生,让CPU不断重得做一件无效的事——掉包;于是内核开发者重新设计了新算法,新API——NAPI。NAPI引入最古老的设备访问方式——轮询(polling),也就是CPU通过定期检测设备状态来与设备通信。轮询在网络流量负载重的情况下是高效的,但在网络流量很少时,浪费CPU资源,于是NAPI结合轮询和中断的优点,自动地按网络流量选择适当的通信方式。由于传统比较容易理解,并且还有很多网络设备速度并不是很高,还在用旧算法,我们这里只讨论传统算法,NAPI的更多内容请参考文尾的文献。

下图大略展示了网络分组到达网卡后从驱动程序传到网络层的情况。从图可见,收包任务可划分两个部分:

第一,将分组入列收包队列的上半部——硬中断处理;

第二,将分组出列收包队列的下半部——软中断处理。

Figure 6-3. 内核收包发包路径图

由于收包的上半部是在中断上下文内完成,因此中断处理函数必须只执行收包的关键操作,以免因为执行一些不重要的操作影响系统正常行为[注]。

注:因为中断可打断系统上任何正在运行的进程,如果中断处理时间过长甚至被阻塞,那么被打断的进程(对用户而言)表现为性能低下,反应缓慢,甚至没有任何反应。另外中断处理进会关闭系统中断,如果中断处理时间过长可能丢失一些不可重现的中断信号。
硬中断处理

在收包的硬中断处理里,有三个短小的函数完成以下任务:

1. net_interrupt函数是驱动注册的中断处理入口函数,它判断中断信号是不是真的由收包事件产生的(因为中断信号有可能是错误处理中断,或者发包完成中断),如果是,调用net_rx;

2. net_rx函数是网卡特定相关的,它首先创建一个新socket buffer(dev_alloc_skb()),然后将网卡设备上的分组数据拷入这个socket buffer。接着驱动开始调用内核的API(eth_type_trans())分析分组头部,用以判断分组使用的网络层协议类型,例如IP。最后把创建好的socket buffer(地址)传给netif_rx;

3. netif_rx是网络设备与网络层协议之间的通路,与上面两个函数不同, netif_rx是独立于网络设备驱动的函数(实现在net/core/dev.c),它的主要任务是做一些记录和统计,例如记录分组的时间截更新收包总数;然后判断当前CPU的收包队列是否已满,决定是将分组入列[收包队列],还是丢掉;如果分组入列,入列后向内核发出软中断—— NET_RX_SOFTIRQ,然后退出中断上下文。
softnet_data

为了让系统有更灵活的性能,内核用[等待队列]来管理系统的网络分组的接收和发送。内核定义了全局数组来实现[等待队列]——名为 softnet_data,元素类型为softnet_data。为了进一步提升在多处理器系统的性能,内核为每个CPU单独创建专用的[等待队列],实现并行处理分组的收发。没必要对等待队列进行显式的并发管理,因为每个CPU只会对自己的[等待队列]操作,不会影响其它CPU。
softnet_data定义大略如下:

<netdevice.h>
struct softnet_data
{
    ...
    struct sk_buff_head    input_pkt_queue;
    struct net_device      *output_queue;
    struct sk_buff         *completion_queue;
...
}

其中列出本文讨论到的三个成员:

   1. 收包队列,input_pkt_queue,类型为sk_buff_head,从名字可知,input_pkt_queue指向由sk_buff_head构造的链表,逻辑语义就是[分组]的数据库;
   2. 设备列表,output_queue,被调度的等待发包的设备列表;
   3. 完成列表,completion_queue,发送完成的分组列表,用于缓冲区延迟处理。

软中断处理

系统退出收包的硬中断处理后返回到原来被中断的进程继续执行,片刻后内核调度进程(schedule() in kernel/sched.c) 被时钟中断唤醒,调度进程在执行进程调度前,先检测软中断信号[Q]。发现有软中断则执行软中断处理总入口——do_softirq()。

Q:内核有多处检测软中断信号的操作,进程调度前只是其中之一。如每次硬中断退出前,irq_exit( )会检测。研究到此,在软中断功能的实现上出现了多处理解冲突或理解不了。第一,软中断处理函数属于哪个内核线程;多处软中断检测都是直接执行 do_softirq(),调度线程和硬中断处理函数不可能会是软中断处理函数的父线程,这个怎么理解;第二,我在进程调度前的代码里没有找到软中断检测;

do_softirq会逐个检查软中断——假设只有NET_RX_SOFTIRQ,并调用它们的软中断处理函数——net_rx_action()。

net_rx_action是一个中立的通用函数,它不偏向特定网络设备和网络层协议,只负责管理收包队列。net_rx_action主要是通过一个无条件循环(for(;;){…})将[当前CPU的收包队列]上的分组一个接一个地转给网络层协议,直到队列为空。为了防止DOS( denial-of-service)攻击,net_rx_action的无条件循环在以下两条件满足时,必须退出:

    * 第一,执行时间超过一滴答(one tick or 10 ms);
    * 第二,此次连续收包数达到了队列的最大容量(budget =net_dev_max_backlog),不管队列是否为空。

它的流程大略如下:

~/Pictures/net_rx_action_codeflow.jpg

net_rx_action先做一些准备工作,然后收包任务转给process_backlog,process_backlog反复执行以下任务(为了简化讨论,假设process_backlog循环执行直到没有分组包需要处理为止,并且处理过程中没有中断):

   1. __skb_dequeue将分组数据(socket buffer)从收包队列中出列;
   2. 调用netif_receive_skb分析分组数据的网络层类型,然后调用网络层协议的收包接口——deliver_skb 。
   3. deliver_skb根据网络层协议类型转而调用相应的收包接口,如IP协议的ip_rcv

值得注意的是,net_rx_action是内核中网络代码的入口点之一,net_rx_action有两个出口点:

    * 第一,将分组向上传,最后来到socket’s wait queue;
    * 第二,在IP层被发现是转发包,转入发包队列。

更详细的net_rx_action描述和更多ip_rcv以后的代码控制请参考文献。
发包过程

由上可知,收包过程由两个主要内核活动完成的,这两个活动的上下文是断开的。发包过程的内核活动比较收包多一些,并且有在单一线程上下文内完成的。因此可以将发包线路(活动)分为两种:

第一,直接发包:在单一线程上下文上的直接发包;单一线程亦分为两种情况,从收包过程中转发过来的收包软中断上下文,和由上层传下来的系统调用上下文;

第二,软中断发包:分开两个线程的————[入列线程]和[软中断线程],有点像收包过程上半部和下半部。软中断发包统一在软中断上下文。
发包队列

无论是直接发包还是软中断发包,分组包必须先入列网络设备的发包队列[注]。

注:有一种例外,就是设备的入列接口(dev->enqueue == NULL)没有实现,在这种情况下,分组会直接发走,例如loopback设备。

发包线程从调用dev_queue_xmit(skb)开始发包。参数skb是socket buffer对象,skb->dev指向了非常重要信息——net_device。这个信息是在IP协议层由路由算法算得的,它决定分组入列哪个网络设备的发包队列。确定入列目标后[注1],dev_queue_xmit调用dev->qdisc->enqueue()将分组入列[注 2]。

    * 注1:dev_queue_xmit还会对分组进行链路层协议的处理,如分片,这里不详述,请参考文献。
    * 注2:发包队列可能以简单的FIFO实现,也可能由多个队列实现复杂的队列算法。这个队列算法名叫Queuing Discipline,是设备相关的(dev->qdisc),这里不详述,请参考文献。

直接发包

分组入列后,发包线程调用qdisc_run(dev)启动队列发送。qdisc_run主要做一件简单的事,重复调用 qdisc_restart()将队列上的分组逐个发走。qdisc_restart(dev)的主要任务是将分组从发包队列出列(dev->qdisc->dequeue()),然后调用网络设备的发包接口(dev->hard_start_xmit())将其发走。

    网络设备的并发控制

    由于网卡是慢速的硬件设备,并且网络设备(及其发包队列)是公共资源,因而需要对其作并发控制。例如,qdisc_restart正式调用 dev->hard_start_xmit前必须取得(acquire)发送锁(dev->xmit_lock),如果发送接口已经锁上,还判断是当前CPU还是其它CPU锁上的,并且相应的处理。关于qdisc_restart(dev)内的并发控制这里不详述,详见文献。

qdisc_run(dev)的具体执行大概如下:

   1. 检查发包队列是否停止,如果是则立即退出;发包线程通过读取[网络设备的流控制(Flow Control)API]——netif_queue_stopped(dev)确认发包队列的状态;
   2. 调用 qdisc_restart( ) ,转而执行以下子步骤:
         1. 调用网络设备的队列算法中的出列函数,如果队列为空,返回0;
         2. 检查内核是否实现了帧嗅探策略(packet sniffing policy),然则调用dev_queue_xmit_nit()向其传一份分组拷贝;
         3. 调用网络设备的发包接口(dev->hard_start_xmit())
         4. 如果hard_start_xmit发送失败,分组重新入列,执行设备调度(netif_schedule(dev)),向内核发出发包软中断——NET_TX_SOFTIRQ;返回-1;
   3. 如果队列为空(出列操作返回空指针),或者hard_start_xmit发送失败,立即退出;否则返回第一步发送下一个分组。

根据实际发包情况,qdisc_restart向qdisc_run返回三种值:

    * = 0: 队列为空,发包完成;
    * > 0: 队列上仍有分组,但是队列算法(dev->qdisc)为了流量控制(Traffic Control)决定暂时停止发送;
    * < 0: 队列上仍有分组,网络设备因空间不足暂不能接收更多的分组。

无论返回什么值,发送线程到此结束,分组要么直接发走,要么整个任务被重新调度,等待软中断处理。
设备调度

发包线程将分组出列后,会因为各种原因不能立即发送,设备不能立即发送的原因有:

   1. 设备被独占,锁上了
   2. 设备缓冲区满了,放不下一个分组
   3. 分组发送流量管制,重要性高的分组先发送
   4. 控制发送的频率,在指定的时间点发送

那么,发包线程只能将分组重新入列,并执行设备调度操作:_ _netif_schedule 。

_ _netif_schedule 主要完以下两个任务:

   1. 将发包队列不为空的设备入列CPU的output_queue;
   2. 发出发包软中断——NET_TX_SOFTIRQ;

由于调度一个被停掉的设备是无意义的,所以_ _netif_schedule被抽象包装为两个高级接口:

1. 网络层方向的接口 netif_schedule

实现如下,很直观,只对活动的设备进行调度(_ _LINK_STATE_XOFF标志)

static inline void netif_schedule(struct net_device *dev)
{
    if (!test_bit(_ _LINK_STATE_XOFF, &dev->state))
        _ _netif_schedule(dev);
}

2. 网络设备层方向的接口 netif_wake_queue

此接口的典型应用是设备的发包完成中断处理函数,逻辑语义是,已经发完一个包了,唤醒发包队列。实现如下,也很直观,先启用,后调度,注意,此接口功能相当于netif_start_queue 加 netif_schedule。

static inline void netif_wake_queue(struct net_device *dev)
{
    ...
    if (test_and_clear_bit(_ _LINK_STATE_XOFF, &dev->state))
        _ _netif_schedule(dev);
}

软中断发包

最后的一项重要内核活动是发包软中断,net_tx_action是软中断处理函数。如期望的功能,net_tx_action主要任务是取得当前 CPU的调度设备列表上的设备,将设备的发包队列上的分组发走。另外,由于设备驱动的发包操作是在中断上下文内完成的,为了让硬件发包操作耗时更短,发包完成后的缓冲区清理工作亦被延迟到net_tx_action来完成。

我们先看看缓冲区清理任务。设备驱动成功将一个分组发走后会调用dev_kfree_skb_irq将分组(的地址)添加到当前CPU的完成队列(softnet_data->completion_queue)。由于缓冲区清理很耗时,所以这里只是使用dev_kfree_skb_irq 将分组的地址保存起来,等软中断稍后处理,而不是直接调用dev_kfree_skb进行清理。

由于net_tx_action工作在硬件中断上下文以外,而设备驱动会随时中断系统,将新分组添加到completion_queue,因此,net_tx_action必须在访问softnet_data时关闭中断。为了让关闭中断时间尽量的短,net_tx_action只是简单的将 completion_queue保存到本地,然后置NULL。这样,net_tx_action可以调用_ _kfree_skb逐个清理缓冲区的同时,设备驱动可以将新分组添加completion_queue。

if (sd->completion_queue) {
    struct sk_buff *clist;

    local_irq_disable( );
    clist = sd->completion_queue;
    sd->completion_queue = NULL;
    local_irq_enable( );

    while (clist != NULL) {
        struct sk_buff *skb = clist;
        clist = clist->next;
        BUG_TRAP(!atomic_read(&skb->users));
        _ _kfree_skb(skb);
    }
}

我们再看发包任务。与清理缓冲区类似,操作被隔离出硬中断,设备列表(output_queue)保存到本地处理。值得注意的是,在调用 qdisc_run(dev)发包前,必须取得发包队列锁(dev->queue_lock),如果没能取得队列锁(有其它CPU正发这个设备上的分组,将其锁上),设备再被调度(netif_schedule)。

if (sd->output_queue) {
    struct net_device *head;

    local_irq_disable( );
    head = sd->output_queue;
    sd->output_queue = NULL;
    local_irq_enable( );

    while (head) {
        struct net_device *dev = head;
        head = head->next_sched;
        smp_mb_ _before_clear_bit( );
        clear_bit(_ _LINK_STATE_SCHED, &dev->state);
        if (spin_trylock(&dev->queue_lock)) {
            qdisc_run(dev);
            spin_unlock(&dev->queue_lock);
        } else {
        netif_schedule(dev);
        }
    }
}

以上代码有一点值得注意,就是设备列表是从头部开始按顺序处理的,而调度函数netif_schedule也是将最后调度的保存在头部,那么设备列表是按照先进后出(LIFO)的规则处理,在某些情况下可能会不公平。

小结
回应文首,字符设备或块设备驱动设计的重心是对硬件一方的接口的理解,因为它们上层的逻辑接口语义基本固定;而从网络设备收发分组的原理里,我们隐隐约约发现在IP层与设备之间还存在着一层机构,协调着分组的收发。这层机构叫什么呢?姑且叫[网络设备驱动子系统]好了。网络设备驱动设计必须掌握硬件和协议栈两个方向的实现原理。

参考

    * 1.《Understanding Linux Network Internals》10.4. Notifying the Kernel of Frame Reception: NAPI and netif_rx
    * 2.《UTLK 2nd》18.4 Receiving Packets from the Network Card
    * 3.《The LInux networking Architecture》6.2 Processes on the Data-Link Layer
0 0
原创粉丝点击