关于linux:技术干货|浅析Linux如何解析网络帧

37次阅读

共计 11422 个字符,预计需要花费 29 分钟才能阅读完成。

从初学者角度,介绍 Linux 内核如何接管网络帧:从网卡设施实现数据帧的接管开始,到数据帧被传递到网络栈中的第三层完结。重点介绍内核的工作机制,不会深刻过多代码层面的细节,示例代码来自 Linux 2.6。

设施的告诉伎俩

从计算机硬件的角度,一个数据帧从进入网卡到最初被内核解决的整体示意图如下:

当网卡设施实现一个数据帧的接管后,可能将数据帧暂存于设施内存,也可能通过 DMA(Direct memory access) 间接写入到主内存的接管环(rx ring),接着必须告诉操作系统内核对已接管的数据进行解决。上面将探讨几种可能的告诉伎俩。

轮询

轮询(Polling)指的是由内核被动地去查看设施,比方定期读取设施的内存寄存器,判断是否有新的接管帧须要解决。这种形式在设施负载较高时响应效率低,在设施负载低时又占用系统资源,操作系统很少独自采纳,联合其余机制后能力实现较现实的成果。

硬件中断

当接管到新的数据帧等事件产生时,设施将生成一个硬件中断信号。该信号通常由设施发送给中断控制器,由中断控制器分发给 CPU。CPU 承受信号后将从以后执行的工作中被打断,转而执行由设施驱动注册的中断处理程序来解决设施事件。中断处理程序会将数据帧拷贝到内核的输出队列中,并告诉内核做进一步解决。这种技术在低负载时体现良好,因为每一个数据帧都会失去及时响应,但在负载较高时,CPU 会被频繁的中断从而影响到其余工作的执行。

对接管帧的解决通常分为两个局部:首先驱动注册的中断处理程序将帧复制到内核可拜访的输出队列中,而后内核将其传递给相干协定的处理程序直到最初被应用程序生产。第一局部的中断处理程序是在中断上下文中执行的,能够抢占第二局部的执行,这意味着复制接管帧到输出队列的程序比生产数据帧的协定栈程序有更高的优先级。

在高流量负载下,中断处理程序会一直抢占 CPU。结果不言而喻:输出队列最终将被填满,但应该去出队并解决这些帧的程序处于较低优先级没有机会执行。后果新的接管帧因为输出队列已满无奈退出队列,而旧的帧因为没有可用的 CPU 资源不会被解决。这种状况被称为接管活锁(receive-livelock)。

硬件中断的长处是帧的接管和解决之间的提早非常低,但在高负载下会重大影响其余内核或用户程序的执行。大多数网络驱动会应用硬件中断的某种优化版本。

一次解决多个帧

一些设施驱动会采纳一种改进形式,当中断处理程序被执行时,会在指定的窗口工夫或帧数量下限内继续地入队数据帧。因为中断处理程序执行时其余中断将被禁用,因而必须设置正当的执行策略来和其余工作共享 CPU 资源。

该形式还可进一步优化,设施仅通过硬件中断来告诉内核有待解决的接管帧,将入队并解决接管帧的工作交给内核的其余处理程序来执行。这也是 Linux 的新接口 NAPI 的工作形式。

在实践中的组合

不同的告诉机制有其适宜的工作场景:低负载下纯中断模型保障了极低提早,但在高负载下体现蹩脚;计时中断在低负载下可能会引入过高提早并节约 CPU 工夫,但在高负载下对缩小 CPU 占用和解决接管活锁有很大帮忙。在实践中,网络设备往往不依赖某种繁多模型,而是采取组合计划。

以 Linux 2.6 Vortex 设施所注册的中断处理函数 vortex_interrupt(位于 /drivers/net/3c59x.c)为例:

  1. 设施会将多个事件归类为一种中断类型(甚至还能够在发送中断信号前期待一段时间,将多个中断聚合成一个信号发送)。中断触发 vortex_interrupt 的执行并禁用该 CPU 上的中断。
  2. 如果中断是由接管帧事件 RxComplete 引发,处理程序调用其余代码解决设施接管的帧。
  3. vortex_interrupt 在执行期间继续读取设施寄存器,查看设施是否有新的中断信号收回。如果有且中断事件为 RxComplete,处理程序将持续解决接管帧,直到已解决帧的数量达到预设的 work_done 值才完结。而其余类型的中断将被处理程序疏忽。

软中断解决机制

当硬件中断信号达到 CPU 后,须要通过正当的任务调度机制,能力以较低提早解决接管帧,又防止接管活锁和饥饿等资源抢占问题。

一个中断通常会触发以下事件:

  1. 设施产生一个中断并通过硬件告诉内核。
  2. 如果内核没有正在解决另一个中断(即中断没有被禁用),它将收到这个告诉。
  3. 内核禁用本地 CPU 的中断,并执行与收到的中断类型相关联的处理程序。
  4. 内核退出中断处理程序,从新启用本地 CPU 的中断。

CPU 在执行中断号对应处理程序的期间处于中断上下文,中断会被禁用。这意味着 CPU 在解决某个中断期间,它既不会解决其余中断,也不能被其余过程抢占,CPU 资源由该中断处理程序独占。这种设计决定缩小了竞争条件的可能性,但也带来了潜在的性能影响。

显然,中断处理程序该当尽可能快地实现工作。不同的中断事件所须要的解决工作量并不相同,比方当键盘的按键被按下时,触发的中断处理函数只须要将该按键的编码记录下来,而且这种事件的产生频率不会很高;而解决网络设备收到的新数据帧时,须要为 skb 分配内存空间,拷贝接管到的数据,同时实现一些初始化工作比方判断数据所属的网络协议等。

为了尽量减少 CPU 处于中断上下文的工夫,操作系统为中断处理程序引入了上、下半部的概念。

下半部处理程序

即便由中断触发的解决动作须要大量的 CPU 工夫,大部分动作通常是能够期待的。中断能够第一工夫抢占 CPU 执行,因为如果操作系统让硬件期待太长时间,硬件可能会失落数据。这既实用于实时的数据,也实用于在固定大小缓冲区中存储的数据。如果硬件失落了数据,个别没有方法再复原(不思考发送方重传的状况)。另一方面,内核或用户空间的过程被推延执行或抢占时,个别不会有什么损失(对实时性有极高要求的零碎除外,它须要用齐全不同的形式来解决过程和中断)。

鉴于这些思考,古代中断处理程序被分为上半部和下半部。上半局部执行在开释 CPU 资源之前必须实现的工作,如保留接管的数据;下半局部则执行能够在推延到闲暇时实现的工作,如实现接收数据的进一步解决。

你能够认为下半部是一个能够异步执行的特定函数。当一个中断触发时,有些工作并不要求马上实现,咱们能够把这部分工作包装为下半部处理程序延后执行。上、下半部工作模型能够无效缩短 CPU 处于中断上下文(即禁用中断)的工夫:

  1. 设施向 CPU 收回中断信号,告诉它有特定事件产生。
  2. CPU 执行中断相干的上半部处理函数,禁用之后的中断告诉,直到处理程序实现工作:a. 将一些数据保留在内存中,用于内核在之后进一步解决中断事件。b. 设置一个标记位,以确保内核晓得有待解决的中断。c. 在终止之前从新启用本地 CPU 的中断告诉。
  3. 在之后的某个工夫点,当内核没有更紧迫的工作解决时,会查看上半部处理程序设置的标记位,并调用关联的下半局部处理程序。调用之后它会重置这个标记位,进入下一轮解决。

Linux 为下半部解决实现了多种不同的机制:软中断、微工作和工作队列,这些机制同样实用于操作系统中的延时工作。下半部解决机制通常都有以下独特个性:

  • 定义不同的类型,并在类型和具体的解决工作之间建设关联。
  • 调度解决工作的执行。
  • 告诉内核有已调度的工作须要执行。

接下来着重介绍解决网络数据帧用到的软中断机制。

软中断

软中断有以下几种罕用类型

enum{HI_SOFTIRQ=0,  TIMER_SOFTIRQ,  NET_TX_SOFTIRQ,  NET_RX_SOFTIRQ,  BLOCK_SOFTIRQ,  IRQ_POLL_SOFTIRQ,  TASKLET_SOFTIRQ,};

其中 

NET_TX_SOFTIRQ 和 NET_RX_SOFTIRQ 用于解决网络数据的接管和发送。

调度与执行机会

每当网络设备接管一个帧后,会发送硬件中断告诉内核调用中断处理程序,处理程序通过以下函数在本地 CPU 上触发软中断的调度:

  1. __raise_softirq_irqoff:在一个专门的 bitmap(位图)构造中设置与软中断类型对应的比特位,当后续对该比特位的查看后果为真时,调用与软中断关联的处理程序。每个 CPU 应用一个独自的 bitmap。
  2. raise_softirq_irqoff:外部包装了 __raise_softirq_irqoff 函数。如果此函数不是从中断上下文中调用,且抢占未被禁用,将会额定调度一个 ksoftirqd 线程。
  3. raise_softirq: 外部包装了 raise_softirq_irqoff,但执行时会禁用 CPU 中断。

在特定的机会,内核会查看每个 CPU 独有的 bitmap 判断是否有已调度的软中断期待执行,如果有将会调用 do_softirq 解决软中断。内核解决软中断的机会如下:

1.  do_IRQ

每当内核收到一个硬件中断的 IRQ 告诉时,会调用 do_IRQ 来执行中断对应的处理程序。中断处理程序中可能会调度新的软中断,因而在 do_IRQ 完结时解决软中断是一个很天然的设计,也能够无效的升高提早。此外,内核的时钟中断还保障了两次软中断解决机会之间的最大工夫距离。

大部分架构的内核会在退出中断上下文步骤 irq_exit() 中调用 do_softirq:

unsigned int __irq_entry do_IRQ(struct pt_regs *regs){......  exit_idle();  irq_enter();
  // handle irq with registered handler
  irq_exit();
  set_irq_regs(old_regs);  return 1;}

在 irq_exit() 中,如果内核曾经退出中断上下文且有待执行的软中断,将调用 invoke_softirq():

void irq_exit(void){account_system_vtime(current);  trace_hardirq_exit();  sub_preempt_count(IRQ_EXIT_OFFSET);  if (!in_interrupt() && local_softirq_pending())    invoke_softirq();
  rcu_irq_exit();
  preempt_enable_no_resched();}

invoke_softirq 是对 do_softirq 的简略封装:

static inline void invoke_softirq(void){if (!force_irqthreads)    do_softirq();  else    wakeup_softirqd();}
  1.  从中断和异样事件(包含零碎调用)返回时,这部分解决逻辑间接写入了汇编代码。
  2.  调用 local_bh_enable 开启软中断时,将执行待处理的软中断。
  3. 每个处理器有一个软中断线程 ksoftirqd_CPUn,该线程执行时也会解决软中断。

软中断执行时 CPU 中断是开启的,软中断能够被新的中断挂起。但如果软中断的一个实例曾经在一个 CPU 上运行或挂起,内核将禁止该软中断类型的新申请在 CPU 上运行,这样能够大幅缩小软中断所需的并发锁。

软中断的解决

当执行软中断的机会达成,内核会执行 do_softirq 函数。

do_softirq 首先会将待执行的软中断保留一份正本。在 do_softirq 运行时,同一个软中断类型有可能被调度屡次:运行软中断处理程序时能够被硬件中断抢占,解决中断时期间能够从新设置 cpu 的待处理软中断 bitmap,也就是说,在执行一个待处理的软中断期间,这个软中断可能会被从新调度。出于这个起因,do_softirq 会首先禁用中断,将待处理软中断的 bitmap 保留一份正本到局部变量 pending 中,而后将本地 CPU 的软中断 bitmap 中对应的位重置为 0,随后从新开启中断。最初,基于正本 pending 顺次查看每一位是否为 1,如果是则依据软中断类型调用对应的处理程序:

do {if (pending & 1) {unsigned int vec_nr = h - softirq_vec;      int prev_count = preempt_count();
      kstat_incr_softirqs_this_cpu(vec_nr);
      trace_softirq_entry(vec_nr);      h->action(h);      trace_softirq_exit(vec_nr);      if (unlikely(prev_count != preempt_count())) {printk(KERN_ERR "huh, entered softirq %u %s %p"               "with preempt_count %08x,"               "exited with %08x?\n", vec_nr,               softirq_to_name[vec_nr], h->action,               prev_count, preempt_count());        preempt_count() = prev_count;}
      rcu_bh_qs(cpu);    }    h++;    pending >>= 1;  } while (pending);

期待中的软中断调用秩序取决于位图中标记位的地位以及扫描这些标记的方向(由低位到高位),并不是以先进先出的形式执行的。

当所有的处理程序执行结束后,do_softirq 再次禁用中断,并从新查看 CPU 的待处理中断 bitmap,如果发现又有新的待处理软中断,则再次创立一份正本从新执行上述流程。这种解决流程最多会反复执行 MAX_SOFTIRQ_RESTART 次(通常值为 10),以防止有限抢占 CPU 资源。

当解决轮次达到 MAX_SOFTIRQ_RESTART 阈值时,do_ softirq 必须完结执行,如果此时仍然有未执行的软中断,将唤醒 ksoftirqd 线程来解决。然而 do_softirq 在内核中的调用频率很高,实际上后续调用的 do_softirq 可能会在 ksoftirqd 线程被调度之前就解决完了这些软中断。

ksoftirqd 内核线程

每个 CPU 都有一个内核线程 ksoftirqd(通常依据 CPU 序号命名为 ksoftirqd_CPUn), 当上文形容的机制无奈解决完所有的软中断时,该 CPU 位于后盾的 ksoftirqd 线程被唤醒,并承当起在取得调度后尽可能多的解决待执行软中断的职责。

ksoftirqd 关联的工作函数 run_ksoftirqd 如下:

static int run_ksoftirqd(void * __bind_cpu){set_current_state(TASK_INTERRUPTIBLE);
  while (!kthread_should_stop()) {preempt_disable();    if (!local_softirq_pending()) {preempt_enable_no_resched();      schedule();      preempt_disable();    }
    __set_current_state(TASK_RUNNING);
    while (local_softirq_pending()) {/* Preempt disable stops cpu going offline.         If already offline, we'll be on wrong CPU:         don't process */      if (cpu_is_offline((long)__bind_cpu))        goto wait_to_die;      local_irq_disable();      if (local_softirq_pending())        __do_softirq();      local_irq_enable();      preempt_enable_no_resched();      cond_resched();      preempt_disable();      rcu_note_context_switch((long)__bind_cpu);    }    preempt_enable();    set_current_state(TASK_INTERRUPTIBLE);  }  __set_current_state(TASK_RUNNING);  return 0;
wait_to_die:  preempt_enable();  /* Wait for kthread_stop */  set_current_state(TASK_INTERRUPTIBLE);  while (!kthread_should_stop()) {schedule();    set_current_state(TASK_INTERRUPTIBLE);  }  __set_current_state(TASK_RUNNING);  return 0;}

ksoftirqd 做的事件和 do_softirq 基本相同,其次要逻辑是通过 while 循环不断的调用 __do_softirq(该函数也是 do_softirq 的外围逻辑),只有达到以下两种条件时才会进行:

  1. 没有待处理的软中断时,此时 ksoftirqd 会调用 schedule() 触发调度被动让出 CPU 资源。
  2. 该线程执行结束被调配的工夫分片,被要求让出 CPU 资源期待下一次调度。

ksoftirqd 线程设置的调度优先级很低,同样能够防止软中断较多时抢占过多的 CPU 资源。

网络帧的接管

在 do_softirq 中,内核通过执行 h->action(h); 调用该软中断类型所注册的处理程序,本文仅关注与接管网络帧相干的软中断处理程序。

Linux 的网络系统次要应用以下两种软中断类型:

  • NET_RX_SOFTIRQ 用于解决接管(入站)网络数据
  • NET_TX_SOFTIRQ 用于解决发送(出栈)网络数据

NET_RX_SOFTIRQ 软中断处理程序接管网络帧的整体流程示意如下:

在理解具体的软中断处理程序之前,咱们还须要联合 Linux 的具体实现从新回顾上文介绍过的告诉解决机制。

Linux New API (NAPI)

网卡设施每接管到一个二层的网络帧后,应用硬件中断来向 CPU 发出信号,告诉其有新的帧须要解决。收到中断的 CPU 会执行 do_IRQ 函数,调用与硬件中断号关联的处理程序。处理程序通常是由设施驱动程序在初始化时注册的一个函数,这个中断处理程序将在禁用中断模式下执行,使得 CPU 临时进行接管中断信号。中断处理程序会执行一些必要的即时工作,并将其余任务调度到下半部中提早执行。在 Linux 中,具体来说中断处理程序会做这些事件:

  1. 将网络帧复制到 sk_buff 数据结构中。
  2. 初始化一些 sk_buff 的参数,供下层的网络栈应用。特地是 skb->protocol,它标识了下层的协定处理程序。
  3. 更新其余的设施专用参数。
  4. 通过调度软中断 NET_RX_SOFTIRQ 来告诉内核进一步解决接管帧。

咱们上文介绍过轮询和中断告诉机制(包含几种改进版本),它们有不同的优缺点,实用不同的工作场景。Linux 在 Linux v2.6 引入了一种混合了轮询和中断的 NAPI 机制来告诉并解决新的接管帧。NAPI 在高负载场景下有良好体现,还能显著的节俭 CPU 资源。本文将重点介绍 NAPI 机制。

当设施驱动反对 NAPI 时,设施在接管到网络帧后仍然应用中断告诉内核,但内核在开始解决中断后将禁用来自该设施的中断,并继续地通过轮询形式从设施的输出缓冲区提取接管帧进行解决,直到缓冲区为空时,完结处理程序的执行并从新启用该设施的中断告诉。NAPI 联合了轮询和中断的长处:

  1. 闲暇状态下,内核既不须要浪费资源去做轮询,也能在设施接管到新的网络帧后立即失去告诉。
  2. 内核被告诉在设施缓冲区有待解决的数据之后,不须要再浪费资源去解决中断,简略通过轮询去解决这些数据即可。

对内核来说,NAPI 无效缩小了高负载下须要解决的中断数量,因而升高了 CPU 占用,此外通过轮询地形式去拜访设施,也可能缩小设施之间的争抢。

内核通过以下数据结构来实现 NAPI:

  1. poll:用于从设施的入站队列中出队网络帧的虚构函数,每个设施都会有一个独自的入站队列。
  2. poll_list: 一个保护处于轮询中状态设施的链表。多个设施能够共用同一个中断信号,因而内核须要轮询多个设施。退出到列表之后来自该设施的中断将被禁用。
  3. quota 和 weight:内核通过这两个值来管制每次从设施中出队数据的数量,quota 数量越小象征不同设施的数据帧更有机会失去偏心的解决机会,但内核会破费更多的工夫在设施之前切换,反之仍然。

当设施发送中断信号且被接管之后,内核执行该设施驱动注册的中断处理程序。中断处理程序将调用  napi_schedule 来调度轮询程序的执行。在 napi_schedule 中,如果发送中断的设施未在 CPU 的 poll_list 中,内核将其退出到 poll_list,并通过 __raise_softirq_irqoff 触发 NET_RX_SOFTIRQ 软中断的调度。其次要逻辑位于 ____napi_schedule 中:

/* Called with irq disabled */static inline void ____napi_schedule(struct softnet_data *sd,             struct napi_struct *napi){list_add_tail(&napi->poll_list, &sd->poll_list);  __raise_softirq_irqoff(NET_RX_SOFTIRQ);}

输出队列

每个 CPU 都有一个寄存接管网络帧的输出队列 input_pkt_queue,这个队列位于 softnet_data 构造中:

struct softnet_data {  struct Qdisc    *output_queue;  struct Qdisc    **output_queue_tailp;  struct list_head  poll_list;  struct sk_buff    *completion_queue;  struct sk_buff_head process_queue;
  /* stats */  unsigned int    processed;  unsigned int    time_squeeze;  unsigned int    cpu_collision;  unsigned int    received_rps;
  unsigned    dropped;  struct sk_buff_head input_pkt_queue;  struct napi_struct  backlog;};

但并不是所有的网卡设施驱动都会应用这个输出队列,对于应用 NAPI 机制的网卡,每个设施都有一个独自的输出队列。这个输出队列可能位于设施内存,也可能是主内存中的接管环。

接管帧软中断处理程序

NET_RX_SOFTIRQ 的处理程序是 net_rx_action。其局部代码如下:

static void net_rx_action(struct softirq_action *h){struct softnet_data *sd = &__get_cpu_var(softnet_data);  unsigned long time_limit = jiffies + 2;  int budget = netdev_budget;
  local_irq_disable();
  while (!list_empty(&sd->poll_list)) {    struct napi_struct *n;    int work, weight;
    // If softirq window is exhuasted or has run for 2 jiffies then exit    if (unlikely(budget <= 0 || time_after(jiffies, time_limit)))      goto softnet_break;
    // Acces current first entry in poll_list    n = list_first_entry(&sd->poll_list, struct napi_struct, poll_list);
    weight = n->weight;    work = 0;    if (test_bit(NAPI_STATE_SCHED, &n->state)) {work = n->poll(n, weight);      trace_napi_poll(n);    }    budget -= work;
    // remove device or move device to tail    if (unlikely(work == weight)) {if (unlikely(napi_disable_pending(n))) {local_irq_enable();        napi_complete(n);        local_irq_disable();} else        list_move_tail(&n->poll_list, &sd->poll_list);    }  }out:  net_rps_action_and_irq_enable(sd);  return;
// schedule again before exitsoftnet_break:  sd->time_squeeze++;  __raise_softirq_irqoff(NET_RX_SOFTIRQ);  goto out;}

当 net_rx_action 被调度执行后:

  1. 从头开始遍历 poll_list 链表中的设施,调用设施的 poll 虚构函数解决入站队列中的数据帧。咱们将在下一节介绍该虚构函数。
  2. poll 被调用时所解决的数据帧数量达到最大阈值后,即便该设施的入站队列还未被清空,也会将该设施挪动到 poll_list 的尾部,转而去解决 poll_list 中的下一个设施。
  3. 如果设施的入站队列被清空,调用 napi_complete 将设施移出 poll_list 并开启该设施的中断告诉。
  4. 始终执行该流程直到 poll_list 被清空,或者 net_rx_action 执行完了足够的工夫片(为了不过多占用 CPU 资源),这种状况退出前 net_rx_action 会从新调度本人的下一次执行。

Poll 虚构函数

在设施驱动的初始化过程中,设施会将 dev->poll 指向由驱动提供的自定义函数,因而不同驱动会应用不同的 poll 函数。咱们将介绍由 Linux 提供的默认 poll 函数 process_backlog,它的工作形式与大多数驱动的 poll 函数类似,其次要的区别在于,process_backlog 工作时不会禁用中断,因为非 NAPI 设施应用一个共享的输出队列,因而从输出队列中出栈数据帧时须要长期禁用中断以实现加锁;而 NAPI 设施应用独自的入站队列,且退出 poll_list 的设施会被独自禁用中断,因而在 poll 时不须要思考加锁的问题。

process_backlog 执行时,首先计算出该设施的 quota。而后进入上面的循环流程:

  1. 禁用中断,从该 CPU 关联的输出队列中出栈数据帧,而后从新启用中断。
  2. 如果出栈时发现输出队列已空,则将该设施移出 poll_list,并完结执行。
  3. 如果输出队列不为空,调用 netif_receive_skb(skb) 解决被出栈的数据帧,咱们将在下一节介绍该函数。
  4. 查看以下条件,如果未满足条件则跳转到步骤 1 持续循环:
  • 如果已出栈的数据帧数量达到该设施的 quota 值,完结执行。
  • 如果已执行完了足够的 CPU 工夫片,完结执行。

解决接管帧

netif_receive_skb 是 poll 虚构函数用于解决接管帧的工具函数,它次要调用了 __netif_receive_skb(skb); 对数据帧顺次进行一系列解决工作:

  1. 解决数据帧的 bond 性能。Linux 可能将一组设施聚合成一个 bond 设施,数据帧在进入三层解决之前,会在此将其接管设施 skb->dev 更改为 bond 中的主设施。
  2. 传递一份数据帧正本给已注册的各个协定的嗅探程序。
  3. 解决一些须要在二层实现的性能,包含桥接。如果数据帧不须要桥接,持续向下执行。
  4. 传递一份数据帧正本给 skb->protocol 对应的且已注册的三层协定处理程序。至此数据帧进入内核网络栈的更下层。

如果没有找到对应的协定处理程序或者未被桥接等性能生产,数据帧将被内核抛弃。

通常来说,三层协定处理程序会对数据帧作如下解决:

  • 将它们传递给网络协议栈中更下层的协定如 TCP, UDP, ICMP,最初传递给利用过程。
  • 在 netfilter 等数据帧解决框架中被抛弃。
  • 如果数据帧的目的地不是本地主机,将被转发到其余机器。

正文完
 0