1 轮询与中断
外部设备与中央处理器交互一般有两种手段:轮询和中断。
(1)轮询(Polling)
很多I/O设备都有一个状态寄存器,用于描述设备当前的工作状态,每当设备状态发生改变时,设备将修改相应状态寄存器位。通过不断查询设备的状态寄存器,CPU就可以了解设备的状态,从而进行必要的I/O操作。为了节约CPU资源,查询工作往往不是连续的,而是定时进行。
轮询方式具有简单、易实现、易控制等优势,在很多小型系统中有大量应用。对那些实时敏感性不高、具有大量CPU资源的系统来说,轮询方式有很广泛的应用。最典型的用途就是在那些任务比较单一的单片机上,嵌入式系统中也有应用。
轮询的一种典型的实现可能是这样的:while(TRUE){/*…*/ select(,,timeout); /*…*/};当然这里的select()也可以使用poll()替换。
轮询方式主要存在以下不足:
<1>增加系统开销。无论是任务轮询还是定时器轮询都需要消耗对应的系统资源。
<2>无法及时感知设备状态变化。在轮询间隔内的设备状态变化只有在下次轮询时才能被发现,这将无法满足对实时性敏感的应用场合。
<3>浪费CPU资源。无论设备是否发生状态改变,轮询总在进行。在实际情况中,大多数设备的状态改变通常不会那么频繁,轮询空转将白白浪费CPU时间片。
(2)中断(Interrupt)
中断,顾名思义,就是打断正在进行中的工作。中断不需要处理器轮询设备的状态,设备在自己发生状态改变时将主动发送一个信号给处理器(PIC),后者在接收到这一通知信号时,会挂起当前正在执行的任务转而去处理响应外设的中断请求。中断通知机制通过硬件信号异步唤起处理器的注意,解决了外部设备与处理器之间速度不匹配导致的资源浪费问题。
现代设备绝大多数采用中断的方式与处理器进行沟通,因此设备驱动程序必须能够支持设备的中断特性。处理器在中断到达时会根据不同的中断号找到对应设备(IRR),并对中断请求进行响应处理。中断处理例程ISR(Interrupt Service Routine)由设备驱动程序提供,并在设备驱动模块初始化时注册到系统中断向量表中。从设备发出中断信号,到处理器最终调用ISR进行处理,期间会经过很多步骤,这个过程构成了中断处理框架。中断处理框架包括了进入ISR之前的很多进入路径(entry path),例如MIPS下要经历这样几个步骤:设置或屏蔽相关寄存器;进入异常入口点取指;现场保护;异常分类(MIPS下中断也是一种异常)处理;查找中断向量表路由ISR。不同的操作系统对中断处理框架的设计不尽相同,但是要达到的目的是一样的,那就是最终调用用户注册的设备ISR。
(3)中断与轮询的折衷
虽然轮询方式存在空转损耗导致名声不佳,但并非一无是处。中断模型也并非十全十美,其高优先级的VIP待遇和快速响应要求在极端条件下将造成“活锁”效应。有时候需要发挥粗暴中断和温和轮询各自的优势,根据实际应用情景,在两种模式之间切换。手机导航杆卡死情形的处理是个很好的案例。
在过去的一些手机和PDA设备上安装有导航杆,它支持3种动作(顺时针旋转、逆时针旋转和按键),可方便菜单导航。导航杆的三种动作都会向处理器发出中断。系统中通用的目的I/O(GPIO)端口和导航杆连接。中断处理函数的工作就是查看GPIO数据寄存器解析出导航杆运动。假定导航杆由于存在运动部件(如旋轮偶尔被卡住)引起的固有的硬件问题,从而在GPIO端口产生不同于方波的波形。被卡住的旋轮会不停地产生假的中断,并可能使系统冻结。为了解决这个问题,可以捕获波形分析,在卡住的情况下动态地从中断模式切换到轮询模式。如果旋轮恢复正常,再动态地从轮询模式切换到中断模式,软件也恢复正常模式。
在本文的最后,将介绍Linux网络设备驱动模型中的NAPI机制 ,它采用“中断+轮询”的处理方式代替纯中断处理方式,是中断和轮询的完美合体。
2 中断硬件框架
中断的主动通知特性需要硬件设施支持。在数字逻辑电路层面,外部设备和处理器之间有一条专门的中断信号线(Interrupt Line),用于连接外设与CPU的中断引脚(Interrupt Pin)。当外部设备发生状态改变时,可以通过这条信号线向处理器发出一个中断请求(Interrupt Request,IRQ),其中外部设备通常被称作中断源(Interrupt Source)。
处理器一般只有两根左右的中断引脚(例如8259A的INTR和INTA),而管理的外设却很多。为了解决这个问题,现代设备的中断信号线并不是与处理器直接相连,而是与一个称为中断控制器的设备相连接,后者才跟处理器的中断引脚连接。中断控制器一般可以通过处理器进行编程配置,所以常称为可编程中断控制器PIC(Programmable Interrupt Controller)。下图是一个典型的中断硬件连接的系统框架图:
中断连接框图
上图中,PIC的输出中断信号线连接到处理器的INT引脚上,这是处理器专门用来接收中断信号的pin脚。外部设备的中断线连接到PIC的pin引脚上,这是PIC用来接收外设中断的pin脚。比如第一个设备的中断线通过P0连到PIC上。在实际的硬件平台上,PIC有的在CPU外部,比如x86平台的8259中断控制器;有的被封装到CPU的内部,这广泛见于嵌入式领域。一颗SoC芯片内部集成了处理器和各种外部设备的控制器,其中包括PIC。
IRQ相关信息管理的关键点是一个全局数组,每个数组项对应一个IRQ编号,软件中断号irq就是这个数组的索引,irq将一对一或多对一(共享)映射到硬件中断源编号。不同的操作系统相关数据结构的实现和映射策略实现可能有差别。
3 中断向量表
中断向量表其实是处理器内部的概念,因为处理器除了会被外部设备中断外,其内部也可能产生异常等事件,例如在MIPS中,中断只是异常的一种。当这些事件发生时,CPU必须暂停手头上的工作,转而去处理中断或异常,因此处理器需要知道到哪里去获得这些中断或异常的处理函数的目标地址。中断向量表就是用来解决这个问题,其中每一项都是一个中断或异常处理函数的入口地址,具体来说4个字节的函数指针将指向一段汇编微码(intConnectCode)执行跳转。
外部设备的中断常常对应向量表中的某一项,这是通用框架的外部中断处理函数入口,因此在进入通用的中断处理函数之后,系统必须知道正在处理的中断是哪一个设备产生的,而这正是由软件中断号irq定的决。中断向量表的内容是由操作系统在初始化阶段来填写,对于外部中断,操作系统负责实现一个通用的外部中断处理函数,然后把这个函数的入口地址放到中断向量表中的对应位置。用户注册设备驱动ISR,实际上就是挂接到中断向量表中,覆盖某一项的默认处理实现特化。
4 中断路由
很多SoC芯片或设备提供了一个重要的寄存器——IRR(Interrupt Routing Register),例如PCI中的Interrupt Line Register,它用来配置中断源与CPU中断位图的映射关系。所谓CPU中断位图是指CPU中中断相关的控制寄存器(IE(Interrupt Enable flag) 和 IP(Interrupt Pending status))的比特位分布(bitmap),例如MIPS中的C0_SR:IM[7~2] / C0_CAUSE:IP[7~2]对应六路外设中断源。
关于IRR,这里摘录网贴《PCI Interrupt Routing》中的一段阐述:
————————————————————–
PCI interrupt routing consists of figuring out which platform-specific interrupt is asserted when a given PCI interrupt signal is asserted. On x86 machines, this consists of figuring out which input pin on an interrupt controller is asserted when a given PCI interrupt signal is asserted.
————————————————————–
PCI interrupt routing 所描述(figure out)的问题是:PCI作为中断源与PIC(interrupt controller)相连,当PCI有interrupt signal时,PIC哪个输入引脚(input pin)将收到通知(is asserted)。
需要明确的是,在中断硬件连接框图中,左边的中断源与PIC往往是通过硬连线连接的。PCI设备向PIC发出中断,PIC向CPU传递中断,这里需要确定的应该是CPU相关中断控制寄存器IP位图与中断源(PCI设备)的对应关系。这个映射关系是由中介位置的PIC向外提供的IRR寄存器配置的。
《可编程中断控制器8259A》中提到“在80×86系统中,8259A在中断响应周期的第二个总线中期内,从数据总线内向CPU送出8位中断类型码N的值。”当某一路中断源发起中断时,PIC将根据引脚编号编码中断类型号N,CPU收到INTR信号后在稍后适当的时钟周期中从PIC相应端口读取中断类型码,紧接着CPU中断位图将设置相应槽位。例如在MIPS CPU中,外部设备7(Timer)发起中断,C0_CAUSE:IP[7] 将被置位;外部设备6发起中断,C0_CAUSE:IP[6] 将被置位;…。我们可以通过配置IRR寄存器来改变这种映射关系,此即体现了PIC的可编程性。可以将PIC类比为一个路由器,左边各个中断源为接入PHY口的PC,IRR就相当于路由器为各个PC分配IP地址。有的SoC硬连线和位图映射关系已经固定(相当于ARP绑定),并未提供类似的IRR寄存器,此时就得参考硬件datasheet或SDK开发文档。
在MIPS SoC中,外设中断引脚(线)与PIC连接,配置完IRR并配置C0_SR:IM[7~2]使能中断源后,C0_CAUSE:IP[7~2]的相应位将反馈对应设备的中断活跃状态。当有中断发生时,通用中断处理程序将C0_CAUSE:IP[7~2]和C0_SR:IM[7~2]执行逻辑“与”运算,其中为1的IM&IP位对应的设备发出了活跃且使能的中断请求。经过现场保护处理和一系列的进入路径(entry path),如果是共享中断则需要先解复用,最终在中断向量表中查找当初用户为该设备注册的ISR并调用之。
5 中断复用/解复用
在编写设备驱动程序时,理想预期是为每个设备都注册一个IRQ(request_irq),但实际情况往往并不那么理想。8259A芯片最多支持8个中断源,IA-32/8256A最多支持16个中断源,MIPS CPU的C0_SR:IM/C0_CAUSE:IP最多支持6路外设中断源。当SoC芯片上的设备IRQ多于中断槽位时,多个设备只能复用共享同一个中断槽位。例如,PCI中断是典型的复用中断,PCI规范所定义的中断源只包含了ABCD四种,所有的设备都是用这其中之一。
中断复用(Interrupt Multiplexing)/中断共享(Interrupt Sharing)就是几个设备(devA,devB,…)接到了PIC的同一引脚上,当它们有事件发生时,都向这个引脚发出中断信号。那么到底是哪个(些)设备发出的中断通知呢?这就涉及中断解复用。中断解复用(Interrupt Demultiplexing)就是负责识别决断中断信号来自哪个(些)复用设备。
中断发生后,首先需要确定是哪个设备触发的(who trigger),对于共享中断需要解复用,然后回调对应设备注册挂接的ISR。ISR需要确认该设备上发生了哪些事情(what happened),然后对感兴趣的事情进行处理,或仅预处理然后交由底半部继续处理。第一重解复用是系统级别的(which device),第二重解复用是设备级别的(what happed to the device),可以说整个中断处理框架都是围绕中断路由和中断解复用展开分流。中断的解复用总是离不开全局的或局部的使能/状态寄存器,如到处可见的IE和IP寄存器。
(1)全局中断使能/状态寄存器——GIMR/GISR
一般的SoC中会有Global Interrupt Mask Register/Global Interrupt Status Register这两组寄存器,分别简称为GIMR/GISR。GIMR为设备中断使能(IE)寄存器,GISR为设备中断挂起状态(IP)寄存器,类似MIPS CPU中的C0_SR:IM与C0_CAUSE:IP的关系。设备在进行必要的初始化(包括中断挂接)完毕后,启用全局中断(例如MIPS中的C0_SR:IE),然后配置GIMR使能SoC各设备中断,整个SoC系统就可以正常运转了。
(2)设备间中断解复用
不同操作系统的中断处理框架中,对于中断向量表和中断复用/解复用的数据结构实现会有一定的差异。对于普通非共享中断,通过CPU的IP位图和IRQ号可以索引中断向量表直接进入ISR调用。对于中断复用的情况,不同的操作系统处理有所不同,但都是通过GIMR&GISR掩码运算确定具体中断设备的。
在Linux中,对于相同irq号的irq_desc[irq]::irqaction::flags设置为IRQF_SHARED,进而调用irq_desc[irq]::irqaction操作链。在每个irqaction::handler中通过GIMR&GISR运算核对该设备是否发生了中断,如果是则进一步action后返回IRQ_HANDLED;否则返回IRQ_NONE,进行next irqaction。
在VxWorks中,中断共享的多个外设中断源对应一个CPU中断位,但是每个设备都分配了一个中断向量(IRQ vector)并将ISR挂接到中断向量表。在解复用例程(Demux Routine)中通过GIMR&GISR运算核对有效使能中断挂起,从而识别出哪个(些)设备产生了中断,返回中断向量,然后根据中断向量索引中断向量表中的ISR进行处理。
(3)设备内中断解复用
通过直接位图映射或设备间中断解复用识别出了哪个设备发生了中断并进入其ISR,ISR首先需要弄明白这个设备具体发生了什么事情。经常阅读嵌入式SoC芯片datasheet的人可能知道,每个设备本身就有自己的中断使能/状态寄存器,用来区分设备内部发生的具体事件。相对于SoC级别的GIMR/GISR,姑且将设备局部的Device Interrupt MaskRegister/Device Interrupt Status Register简称为DIMR/DISR。当然,在初始化设备时,也需要配置其DIMR,只接收感兴趣或需要处理的设备子事件。在设备ISR中,需要进行类似设备间的解复用处理,通过DIMR&DISR运算,逐位核对哪些bit位处于pending状态,结合datasheet确定该设备发生了什么事件并作出相应处理。
6 中断上下文
当处理器检测到某一中断源对应的中断产生时,它将停止现在的工作,进入中断(异常)入口点取指。在进入ISR之前,通用中断处理框架首先执行现场保护,将当前任务的上下文寄存器组保存一个特定的中断栈(Interrupt Stack)中,然后屏蔽处理器响应外部中断,最后路由中断向量表开始进入C函数ISR调用,例如Linux平台上定义的do_IRQ()。异步中断并不与特定的进程(线程)关联,中断借用被中断的线程栈环境运行自己。此时,软件运行在中断上下文(Interrupt Context)中。为了对粗暴打断当前无辜线程的行为进行补偿,ISR不得不礼貌地执行于受限制的中断上下文中。
通常,处理器在接收到外部的中断信号时,硬件逻辑会自动屏蔽处理器响应外部中断的能力,因此如果操作系统实现的中断处理框架不主动打开中断的话,整个中断处理的流程是在中断关闭的情况下进行的。因为设备中断处理程序是由驱动程序实现的,内核无法保证这些中断处理程序执行时间的长短。如果某一中断处理执行时间过长,则将会导致系统可能很长时间无法接收中断或执行任务调度,这可能使某些外部设备丢失数据或者操作系统响应时间变长。
为了解决中断对系统调度的影响,Linux内核为驱动程序提供的中断处理机制分为两个部分:HARDIRQ顶半部(The top half)和SOFTIRQ底半部(The bottom half)。HARDIRQ顶半部短小精悍(Minimal Fast Handling),它在中断关闭的情况下执行,执行最关键的动作响应硬件交互后,将重大的工作负载丢给底半部,并对外宣称它已经响应了该中断。SOFTIRQ底半部在中断开启的情况下运行,此时外部设备仍可以继续中断处理器,因此驱动程序往往将一些比较耗时的工作延迟到底半部执行。底半部是同步的,因为内核决定了它什么时候会执行中断。
软中断和工作队列常用于执行ISR中非时间关键部分的底半部,其代码一定不能在中断处理程序内调用,而是运行于(软)中断上下文或进程(线程)上下文。
7 中断底半部延期机制
(1)软中断
软中断机制使得内核可以延期执行任务。它们的运作方式与硬件中断类似,但是完全是软件触发实现的,因此称为软中断(Software Interrupt,softirq)。典型的软中断如用于x86体系架构上的系统调用的int 0x80指令,关于系统调用可参考《程序员的自我修养——链接、装载与库》的第12章<系统调用与API>。
软中断是硬件IRQ的软件等价物。软中断只适用于少数场合,只有在一些对性能敏感的中枢子系统(如网络层、SCSI层和内核定时器)中才会使用softirq。
许多软中断不仅可以同时运行,而且相同的软中断还可以在不同的CPU上运行。对并发的唯一限制就是无论何时,在一个CPU上每个软中断都只能有一个实例在运行。同一种类型的软中断的不同实例可以同时在不同的CPU上运行。因此软中断所执行的函数还是必须锁住共享的数据,以避免CPU之间的竞争。
<1>softirq
内核借助软中断来获知异常情况的发生,在do_IRQ()末尾处理所有的待决软中断,因而可以确保软中断能够定期得到处理。
raise_softirq(int nr)用于触发一个软中断,该参数对应CPU提供的软中断源位图(例如MIPS中的C0_CAUSE:IP[1~0]),此时软中断的延期处理将运行于软中断上下文中。如果不在中断上下文中调用raise_softirq,则可调用wakeup_softirqd来唤醒软中断守护进程ksoftirqd,此时软中断的延期处理运行于进程上下文中。当在中断上下文中处理软中断时,处理函数不能进行睡眠,如果睡眠将导致无法唤醒。
<2>tasklet
软中断是将操作推迟到未来时刻执行的最有效方法,但该延期机制处理起来非常复杂。一些对可扩展性和速度要求很高的设备有自身的softirq下半部,有较强的加锁需求,大多数共享一个称之为小片任务(tasklet)的灵活系统。
tasklet的实现是基于软中断(TASKLET_SOFTIRQ和HI_SOFTIRQ),但更容易使用。在任何时刻,每个tasklet都只有一个实例可以等待执行,无需考虑多CPU的支持。
tasklet比softirq更易于使用,因而更适合于设备驱动程序。
<3>其他
内核定时器(timer_list)延期执行工作的机制,也是基于软中断(TIMER_SOFTIRQ)实现。在启用高分辨率定时器时,还需要一个软中断(HRTIMER_SOFTIRQ)。
网络收发包的底半部处理也是基于软中断实现的,它们是NET_RX_SOFTIRQ和NET_TX_SOFTIRQ。
(2) 工作队列(workqueue)
工作队列是中断处理延期执行的第3种方式。对于每个工作队列,内核都会创建一个新的内核守护进程,因此延期任务是在守护进程的上下文中执行的。由于在进程上下文中执行,因此允许睡眠,可以使用互斥体这类可能导致睡眠的函数。
Linux内核创建了一个标准的工作队列,称为events。内核的各个函数中,凡是没有必要创建独立的工作队列者,均可以使用该队列。VxWorks提供了类似的工作队列机制:ISR做完简单的底层操作后,调用netJobAdd()将底半部工作(netJob)排队加入(Add)到网络任务tNetTask的服务队列(netJobRing),tNetTask任务将会调度执行挂载到工作队列上的延期工作。
8 Linux网络设备驱动模型中的NAPI模型
尽管现代绝大多数设备都支持中断特性,但是中断的高优先级和快速响应在极端条件下,可能会带来麻烦。对于一些I/O频繁量大的外设(如net_device),必须及时响应中断,及时递交处理数据包,以防数据积压(如DMA rxoverflow)。但中断处理上下文的切换需要系统开销,在数据量过载中断频率过高时,CPU疲于应付中断而导致上层应用无法得到调度。底半部因频繁被鲁莽中断导致无法完成对数据包的处理(如做NAT),而中断程序依然在不断地往网络子系统的接收队列中灌数据,这将会导致数据队列溢出丢包和传输超时。系统将自陷在中断响应这一环节,产生所谓的“活锁”。中断过度掠夺资源将造成系统响应变慢甚至下半身不遂:底半部消化不良造成吞吐量等性能指标下降。
在Linux旧的网络设备驱动模型中,设备驱动会为其所接收的每个帧都产生一个中断事件(int_events),通过旧函数netif_rx/netif_receive_skb通知内核帧已接收。在高流量负载下,花在处理中断事件的时间会造成资源相当程度的浪费。针对旧的中断处理模式的缺点,一种被称为NAPI(New API)的处理模式被引入到了Linux内核中。
虽然在设备驱动中,轮询方式存在空转损耗导致名声不佳,但并非一无是处。NAPI的设计思想其实是结合了中断与轮询的各自优势,是中断驱动程序所采用的“推”模式和轮询驱动程序所采用的“拉”模式的混合。当有数据包到达时将会触发硬件中断,在中断处理中关闭中断,系统对硬件的掌控将进入轮询模式,直到所有的数据包接收完毕,再重新开启中断,进入下一轮中断轮询周期。显然,在系统对硬件进行轮询期间,硬件可能接收到大量进入的数据包,但是它们不会产生中断。设备关闭中断期间仍然具备接受后续分组的能力,否则轮询也就失去了意义。典型的如以太网芯片中,网络数据包经过PHY到达MAC,此时数据包保存在设备内存中(挂在DMA描述符环上的缓冲区)。每当接收到数据包时,MAC将向CPU发出中断通知,但是如果关闭了中断,DMA通道传输接收数据仍在异步进行中。可以在下一次开启中断时,再收割一轮DMA描述符环。
一个支持NAPI的驱动程序,需要提供poll()函数,它将在内核对当前设备轮询时调用。另外需要提供一个控制多个网络设备轮询公平度的相关权重参数weight,它赋予一个设备进行轮询处理的时间宽度。作为中断和轮询的完美合体,NAPI在高流量负载下的性能比旧方法要出色。从内核的处理的观点来看,NAPI方法有两个优点:(1)减少了CPU的负载(因为中断事件变少了);(2)设备的处理更为公平。
尽管在其他操作系统的网络设备驱动模型中,并无NAPI概念直接对应,但大部分ISR的实现都在秉持或渗透着NAPI理念,所谓英雄所见略同,殊途同归。
参考:
《深入Linux内核架构》
《精通Linux设备驱动程序开发》
《深入Linux设备驱动程序内核机制》
《中断和中断处理》