前几天同事做了一个报告讲述中断处理在虚拟化环境中所面临的一些问题。通过这个报告让我想起了中断技术的方方面面,所以想在此畅谈一下中断技术的前世今生。
做计算机/电子技术的人对中断技术再了解不过了,我记得在大学微机原理和单片机技术课程中对中断技术进行了深入的剖析。我是学电子技术的,在很多基于处理器的设计中,大量的采用了中断技术。记得在51单片机中,需要自己去定义中断向量表,然后将中断处理函数放置到中断向量表中就可以了,这个思路和X86是一样的。在功能单一的51控制器中,由于没有多任务操作系统的支撑,所以,中断处理函数就是一个普通的函数,没有中断上半部和下半部的设计。如果系统采用前后台程序架构,往往会设置一个定时器中断进行任务调度,中断服务程序通过flag对任务进行调度处理。这是在51这样一个系统对中断非常多的应用。在单片机系统设计的时候需要考虑电平中断和脉冲中断的模式,这两种模式是可配的。脉冲中断会导致一些中断事件的丢失,所以很多设计中都会考虑采用电平中断。
在ARM处理器中,中断技术发生了很大的变化。使我感触最深的是ARM处理器将中断分成三大类,非向量中断、向量中断和快速中断。这种对中断的分类处理使我深深的体会到了ARM是一个强实时应用的处理器。评价一个系统是否具备实时性,最主要一个衡量标准是对中断处理的响应能力。在CPU发生中断的时候,CPU通常会做哪些操作?处理器最主要的事情是清理流水线,切换处理器模式,并且保存主要寄存器中的内容,然后将PC指针指向中断服务程序。为了达到实时性,这个过程最好由处理器硬件全部完成,这也就是ARM处理器中的快速中断。ARM中支持的快速中断数量是有限的,绝大部分是向量中断,向量中断是具有优先级的,也是可屏蔽的。当一个向量中断发生之后,ARM处理器会自动将对应的中断服务程序地址加载到特定的“中断寄存器”中,然后程序跳转到中断向量表中向量中断的位置,中断向量表中该位置会放置一条地址加载指令,将“中断寄存器”中的值加载到PC,从而实现中断服务程序的跳转。在ARM处理器中,一旦发生中断,处理器会进入中断模式,用户程序可以给该模式设置自己的堆栈,从而实现与用户程序堆栈的分离。在很多ARM开发工具中,如果需要实现中断服务程序,首先需要采用汇编指令实现寄存器的入栈、出栈操作,然后再跳转到C代码的服务程序中。ARM处理器对中断的精细分类是我体会比较深的地方。
在X86平台上,大家应该都比较熟悉,因为我们教科书上都是以8086中断处理机制为原型进行介绍的。在当代处理器的实模式下,中断处理机制还保留原来的方法。该思路比较简单,所有的中断服务程序地址都需要存储在中断向量表中,中断向量表中每项长度为4字节。一旦发生中断,处理器会加载中断向量表中的地址,从而实现中断服务程序的调用。在硬件上,可编程中断控制器是8259A,级联8259A可以实现对中断的扩展,该芯片亦可以实现对中断优先级的控制。在保护模式下,中断向量表进行了优化,演变成了IDT(Interrupt Description Table)。因为在保护模式下,X86处理器进行分段式寻址,IDT中不能存储实际的物理地址,需要存储段索引号,本质的区别就这点,所以,我们也可以把IDT看成是一个中断向量表。
当多核处理器发展之后,一个问题出现了,如果系统发生中断,那么具体将这个中断请求提交给哪个处理器内核呢?8259A芯片已经不再适合于多核处理器的应用,因此,Intel针对这种情况引入了APIC。在每个处理器内核中都会有一个本地APIC(Local APIC),主要职责和原来的8259A类似。在一个处理器内部有一个总管APIC,我们称之为IO APIC,因为他的主要职责是负责中断请求的分发。所有的Local APIC会通过BUS和IO APIC互联在一起。当系统的一个外设向处理器提交一个中断请求,这个请求会首先达到IO APIC。IO APIC可以通过软件进行配置,告诉处理器如何分发中断请求。IO APIC通过设置将该中断请求提交给特定的Local APIC,然后该Local APIC触发对应处理器内核产生中断请求。该处理器内核接收到中断请求之后,加载IDT中的地址,从而实现中断服务程序的调用。
在多核处理器中,通过设置IO APIC可以进行中断平衡的处理,但是这种中断平衡处理不一定能够满足用户的需求。所以,在Linux内核中通过软件的手段再次对中断平衡进行处理。针对不同的应用中断平衡处理的需求是不一样的,对于有些应用,中断固定在某一个处理器内核上比较合理,这样可以减少上下文切换引入的开销。对于有些应用,中断分布到不同的处理器内核上比较占优势,所以,需要针对应用配置中断平衡方式。
目前在很多的应用中,面临IO中断太多的问题,导致CPU处理中断过程中的上下文切换开销巨大,从而导致系统性能下降。面对这个问题,从硬件上或者软件上都有相应的解决方案。从硬件上来,在网卡级别对中断进行聚合,当聚合到一定程度之后再对处理器进行中断请求处理。这种方式可以大大减少中断数量,处理器也可以对多个中断进行了批量处理,但是引入了中断处理的延迟。对于有些关键业务,显然这种方式是不允许的。从软件上,可以将采用查询的方式替代中断方式,从而也可以实现对中断请求的批量处理。这两种方式也是在存储业界比较常用的方式。
话说IDT中存储了中断服务程序所在段的索引值,但是一个中断真的发生之后,Linux会做哪些操作呢?仔细阅读Linux代码之后,我们发现Linux对中断处理进行了管理,这种管理带来了更加清晰的程序架构,但是,如果这种架构被用到强实时的应用中,那就不是一个很好的解决方案了。在Linux的IDT中很多存储的地址都是同一个,它们都指向一个公共的中断处理函数,这个公共的中断处理器函数是common_interrupt,在这个公共的中断处理函数中会调用do_IRQ。在do_IRQ函数中通过中断向量号实现对不同中断向量的区分。在PCI系统中,中断是可以共享的,也就是多个PCI设备可以共享一个中断向量,因此,Linux需要管理一个中断向量所属的多个中断服务程序。为了达到此目的,Linux设计了irq_desc[]数组和irq_action对象的这样一种中断管理方式。当我们需要给一个PCI设备编写驱动程序的时候,我们只需要向irq_desc[]数组中注册我们的中断服务程序就可以了,而不需要修改IDT表格。另外,对于这种共享的PCI中断,在中断服务程序中,必须要读取自己设备的中断状态寄存器,从而判断是否自己设备发生了中断,从而实现对本中断请求的认领。
在Linux上中断服务程序的编写不是那么随意的,不像在51控制器上实现的那么随意。Linux将中断服务程序分成上半部和下半部两大块。其实所有的大型操作系统都会进行这种切分,主要考虑是加快中断请求的处理。因此,在上半部中需要将很关键的任务都完成,而将那些不是很关键的应用放到下半部中去做。在Linux中下半部采用软中断的方式来实现,软中断是一种软件实现的模式,在do_IRQ完成之后,会触发调用软中断继续下半部的工作。此时,软中断仍然工作在中断上下文,所以,我们在设计下半部的时候,需要注意不能睡眠。当软中断工作繁重,在中断上下文调用太多之后,Linux系统会自动触发一个softirq内核线程去处理软中断任务。所以,如果需要在软中断处理函数中睡眠,那么必须保证在softirq内核线程上下文中进行处理,而不能在中断上下文中进行处理。
在PCI-X总线引入的时候,除了在性能上得到一定程度的提升之外,一个很重要的技术是引入了消息中断MSI。前面提到过,PCI系统采用的是共享中断的方式,显然,这种方式对中断处理的效率很低。所有驱动程序都要去查询自己的状态寄存器,从而判断自己的板卡是否发生了中断?引入MSI机制之后,这种共享中断的方式得到了优化,提高了PCI中断响应能力。MSI的关键技术点是在PCI设备的配置空间中设置了消息中断所需要的信息,例如目的寄存器地址,中断向量。如果PCI设备需要向处理器发送中断时,只需要往配置的目的寄存器地址中写入中断向量即可。这种思路十分灵活,可以让不同厂商具有不同实现。例如,对于PowerPC处理器,可以将北桥中的一个寄存器作为目的寄存器地址,当PCI设备中断时,往这个地址中写入数据,从而触发一个CPU中断,处理器发生中断之后读取这个目的寄存器中的值,从而得到具体的中断向量,然后调用执行对应的中断服务程序。相比而言这种处理方式在原有基础上有了很大进步,但是效率不是最高的。Intel X86处理器同样支持上述中断模式,IO APIC提供了一个目的寄存器地址,通过往这个目的寄存器写数据,从而实现MSI中断的触发。此外,X86对MSI中断进行了进一步优化,通过FSB interrupt message实现对处理器的中断。PCI设备可以往一个特定的寄存器(FSB Interrupt存储器空间中)发送一个消息,MCH截获到消息之后,将此转换成一个FSB Interrupt message,触发处理器中断,并且通过这个message告知了处理器中断向量号,从而可以实现对应中断服务程序的直接调用。这种方式是效率最高的。
中断技术看似简单,其实不然,涉及到的东西还是很多的,值得慢慢品味。