当计算机的CPU需要在执行任务的同时响应外部事件时,中断是一种重要的机制。中断是异步事件的一种形式,这是指发起事件与处理事件之间的时间间隔没有固定的模式,而是在不同的时间点发生的。
在计算机系统中,中断可分为软件中断和硬件中断两类。在软件中断中,中断是由CPU执行的一条特殊指令造成的,它用于暂停CPU的正常执行流程,然后转而执行一个内部事件或处理程序。硬件中断则是由硬件设备发出的,它与处理器无关,并向CPU发出中断请求信号。
中断的作用主要有以下几个方面:
响应外部事件:当外部事件发生时,中断能够及时响应并引导CPU执行事件处理程序,保证事件能够得到及时处理。
提高系统效率:CPU无需主动地去轮询外部设备是否有数据需要处理,中断机制可以使CPU在处理器时间的同时响应必要的事件处理。
处理复杂任务:某些外部事件处理需要一段复杂的代码,中断处理程序可以协助CPU快速完成这些复杂任务。
进行设备驱动:硬件设备的驱动需要中断的支持,来达到与CPU通讯和协调工作的目的。
Linux操作系统具有广泛的应用,它的中断处理机制是实时、高效和可扩展的。Linux中断处理机制采用了事件驱动模型,对中断按照优先级进行排队,以确保最优先处理高优先级事件,同时保证低优先级事件不会被阻塞。下面从中断请求、中断处理和中断完成三个方面介绍Linux中断处理机制。
在Linux中,中断被认为是分离的、独立的事件,因此中断的处理必须是实时的,使用优先级表进行调度和排序。Linux内核实现了基于可插拔中断体系结构的抽象机制,从而可以在处理器和设备机制之间提供更好的耦合性和深入性。这种体系结构允许中断请求线共享,提高了系统的可扩展性和可靠性,提升了系统的响应速度和性能表现。
当一个硬件设备需要处理器的处理时,它将通过硬件 Intline 请求一个中断。中断请求将被系统中断控制器传递,并被处理器接收。 Linux 支持多个中断请求,通常使用 PCI 设备接口来实现。当控制器接收到中断请求后,它会将请求传递给 Linux 中的 IRQ 子系统,于是就有了一个中断。
中断处理是指系统响应中断事件的过程,它通常包含以下几个步骤:
当中断处理完成后,处理器会向中断控制器发送完成中断的信号。中断控制器将中断信号发送回硬件设备。在中断完成的过程中,主要包含以下几个步骤:
中断状态恢复:在中断处理过程中,处理器会保存中断处理程序执行前的所有中断状态,包括进程的上下文、寄存器和状态字等。因此,在中断处理程序执行结束后,处理器需要通过对这些状态进行恢复,来重新运行之前被中断的进程。
设备控制器响应:当设备控制器接收到中断完成信号后,将在下一个可用的时钟周期中恢复设备操作,以继续处理新的输入或输出请求。此时,处理器将会准备新的请求或服务,以响应下一次中断请求。
中断完成是中断处理机制的最后一个阶段,通过该阶段可以使得设备控制器继续其后续的工作,同时为下一次中断请求做好准备。
我们以gpio中断位例子讲解一下arm64中断的完整流程,先看看cpu到gpio的硬件上是怎么样的一个关系:
看到上面这个图片我们可以看到中断触发的流程:外部设备(比如按键)→GPIO控制器→GIC→CPU。
当触发流程走到cpu后,cpu会跳转到中断异常向量表,执行后面的操作:读取GIC信息,了解到是GPIO触发的中断,再读取GPIO控制器的信息,了解到是某一个GPIO的外设导致的,执行这个外设的中断处理函数。
看到上面这个简单的过程,感觉还是很不清晰。
arm64中断初始化,cpu接受到中断后为什么会自动跳转到异常向量表呢?
这是因为linux初始化的时候,会把异常向量表vectors的地址写入到vbar_el1寄存器中,我们linux内核触发了中断,则会自动跳转到vbar_el1这个这个地址上运行。具体初始化可以查看linux内核启动分析(一)。
我们再看看异常向量表是怎么样的,linux的异常向量表代码在arm64\kernel\entry.S:
SYM_CODE_START(vectors)
kernel_ventry 1, sync_invalid // Synchronous EL1t
kernel_ventry 1, irq_invalid // IRQ EL1t
kernel_ventry 1, fiq_invalid // FIQ EL1t
kernel_ventry 1, error_invalid // Error EL1t
kernel_ventry 1, sync // Synchronous EL1h
kernel_ventry 1, irq // IRQ EL1h
kernel_ventry 1, fiq_invalid // FIQ EL1h
kernel_ventry 1, error // Error EL1h
kernel_ventry 0, sync // Synchronous 64-bit EL0
kernel_ventry 0, irq // IRQ 64-bit EL0
kernel_ventry 0, fiq_invalid // FIQ 64-bit EL0
kernel_ventry 0, error // Error 64-bit EL0
#ifdef CONFIG_COMPAT
kernel_ventry 0, sync_compat, 32 // Synchronous 32-bit EL0
kernel_ventry 0, irq_compat, 32 // IRQ 32-bit EL0
kernel_ventry 0, fiq_invalid_compat, 32 // FIQ 32-bit EL0
kernel_ventry 0, error_compat, 32 // Error 32-bit EL0
#else
kernel_ventry 0, sync_invalid, 32 // Synchronous 32-bit EL0
kernel_ventry 0, irq_invalid, 32 // IRQ 32-bit EL0
kernel_ventry 0, fiq_invalid, 32 // FIQ 32-bit EL0
kernel_ventry 0, error_invalid, 32 // Error 32-bit EL0
#endif
SYM_CODE_END(vectors)
其实,在 ARM64 体系结构中,异常分为同步异常和异步异常。
同步异常是试图执行指令时生成的异常,或是作为指令的执行结果生成的异常。同步异常包括如下。
异步异常不是由正在执行的指令生成的,和正在执行的指令没有关联。异步异常包括以下。
当异常发生的时候,处理器需要执行异常的处理程序。存储异常处理程序的内存位置称为异常向量,通常把所有异常向量存放在一张表中,称为异常向量表。对于 ARM64 处理器的异常级别 1、2 和 3,每个异常级别都有自己的异常向量表,异常向量表的起始虚拟地址存放在寄存器 VBAR_ELn(向量基准地址寄存器,Vector Based Address Register)中。每个异常向量表有 16 项,分为 4 组,每组 4项,每项的长度是 128 字节(可以存放32 条指令)中。
我们看到有4组,每一组的4项都是分别表示发生了同步异常,irq,firq和系统错误。这4组的区别就是,第一组表示异常发生在EL0,处理异常的特权等级也是EL0。第二组表示异常发生在ELn(n可以为1,2,3),处理异常的特权等级也是ELn(n可以为1,2,3),但是这里是linux内核,所以我们的特权为EL1,我们可以理解为异常发生在EL1,处理异常的特权等级也是EL1。这两组的共同点是异常的发生和处理在同一个特权级别,不需要进行特区那级别的切换;而后面两组则是异常的发生和处理不在同一个特权级别,需要进行特区那级别的切换。在linux这里就是说,第三组和第四组表示异常发生在EL0,但是异常处理却在EL1,而他们的区别就是,第三组表示异常发生在64位环境下,第四组表示异常发生在32位环境下。
我们现在知道这4组,16项的含义了,我们看fiq都是invalid的,是因为linux不支持fiq,所以没有对fiq异常进行处理。我们也看到第一组都是invalid的,是因为EL0发生了异常,不会在EL0处理,会陷入EL1处理,所以第一组也不需要实现。
第二组第一项kernel_ventry 1, sync ,在这里就是b el1_sync ,也就是跳转到el1_sync函数了。到这里,我们把每一项支持的异常向量跳转函数写成表格就是:
异常向量 | 跳转的函数 |
---|---|
kernel_ventry 1, sync | el1_sync |
kernel_ventry 1, irq | el1_irq |
kernel_ventry 1, error | el1_error |
kernel_ventry 0, sync | el0_sync |
kernel_ventry 0, irq | el0_irq |
kernel_ventry 0, error | el0_error |
kernel_ventry 0, sync_compat, 32 | el0_sync_compat |
kernel_ventry 0, irq_compat, 32 | el0_irq_compat |
kernel_ventry 0, error_compat, 32 | el0_error_compat |
也就是说linux在用户态触发中断会执行el0_irq,在内核态触发中断会执行el1_irq,这两个的代码看下面:
SYM_CODE_START_LOCAL_NOALIGN(el0_irq)
kernel_entry 0
el0_irq_naked:
el0_interrupt_handler handle_arch_irq
b ret_to_user
SYM_CODE_END(el0_irq)
SYM_CODE_START_LOCAL_NOALIGN(el1_irq)
kernel_entry 1
el1_interrupt_handler handle_arch_irq
kernel_exit 1
SYM_CODE_END(el1_irq)
我么可以看到他们都是执行函数handle_arch_irq函数,最后返回各自的状态。handle_arch_irq在下面的gic控制器的初始化过程中会设置的。
硬件中断号是中断控制器自己定义的中断号,每一个中断控制器都有0到n不同数量的中断号,用于识别连接到中断控制器的不同的硬件设备发生的中断事件。比如,GIC这个中断控制器,0-15是SGI中断,16-31是PPI中断,32-1023是SPI中断,我们的设备到是连接到SPI中断上的,甚至GPIO中断控制器也是连接到SPI上面的。GPIO中断控制器自己也有这0-n的中断。所以硬件中断号是某一个中断控制器上的硬件标识。
软件中断号,有时候也叫虚拟中断号,是由内核定义的中断号,用于执行内核函数或服务。软件中断号是预定的,由系统中的中断控制器管理。硬件中断号的产生都会分配一个对应的软件中断号。所以软件中断号硬件无关,仅仅是被CPU用来标识一个外设中断,便于系统管理所有的中断。
这样,CPU和终中断控制器在标识中断上就有了一些不同的概念,但是,对于驱动工程师而言,我们和CPU视角是一样的,我们只希望得到一个软件中断号,而不关系具体是那个interrupt controller上的那个硬件中断号。这样一个好处是在中断相关的硬件发生变化的时候,驱动软件不需要修改。因此,linux内核中的中断子系统需要提供一个将硬件中断号映射到软件中断号上来的机制。