中断「interrupt」
中断使交互式计算的实现成为可能。如果在任务执行期间发生了中断,操作系统将响应处理中断请求,然后继续执行上一个任务或新任务。借助中断,内核能够实现多任务处理「multitasking」。其实在计算机中有很多场景会发生中断,比如从网络设备发送和接收数据包「packet」、通过鼠标或触摸屏等输入设备接收用户输入内容。本文首先从硬件角度了解了什么是中断,然后介绍了从中断硬件抽象出来的Linux通用中断处理层「generic interrupt handling layer」,最后分析了中断子系统的初始化。
- Target Platform: Rock960c
- ARCH: arm64
- Linux Kernel: linux-4.19.27
中断概念
什么是中断?
中断就是硬件或软件生成的信号「signal」和事件「event」。一旦中断发生,处理器会立即中止现在的执行并响应中断请求。硬件中断就是从设备发送给处理器的物理信号「physical signal」。一旦设备满足特定条件,它就会通知处理器并要求立刻处理。软件中断是由运行在处理器上的软件生成的信号,且它多发生在特殊情况,比如执行系统调用「system call」、陷阱「trap」指令或系统提供的其他中断生成指令。
中断控制器「interrupt controller」
处理器能不能接受和处理很多设备同时发送的中断?实际上这是可能的,正是由于中断控制器的存在,使多个设备产生的中断复用处理器的一条中断线。中断控制器仲裁并按顺序转递设备发出的中断给处理器。在多核系统「multicore system」中,每个核心都可以接收中断,因此中断控制器要与每个核心的控制接口相连接。
中断控制器的功能
中断控制器有如下主要功能:
- 中断复用和路由「interrupt multiplexing and routing」
- 中断优先处理「interrupt prioritizing」
- 中断屏蔽「interrupt masking」
除了以上主要功能外,中断控制器目前引入越来越多的其他功能,比如支持多核处理器「multicore processor」、支持唤醒事件「wakeup event」以及生成软件中断等。随着各个体系架构的改进,中断控制器所需的功能也在不断变化。例如,ARM定义了通用中断控制器「Generic Interrupt Controller,GIC」,随着ARM体系架构的演进,GIC的版本也在不断迭代,目前已经迭代到GICv3/v4。比如,最新的GICV3/v4支持了ARMv8架构的安全扩展[security extension]和虚拟化扩展[virtualization extension],其能够分配安全和非安全状态【secure/non-secure state】的中断,以及能够触发产生虚拟中断并发送给虚拟机【virtual machine, VM】
多中断控制器的结构
一个简单的系统仅需使用一个中断控制器,但有些系统却需使用多个中断控制器。比如,在有些系统中GPIO控制器具有中断功能,通常以级联方式将它与主中断控制器相连,并被配置成从中断控制器。如图2所示,给出了两种关于多个中断控制器的配置示例。
Linux中断子系统
尽管中断控制器硬件差异很大,但通常都可以对中断控制器进行编程配置,使其能够按照设置进行操作。为了使Linux支持多种硬件系统,因此内核从中断控制器和中断信号的物理特性抽象出一个通用中断处理层「generic interrupt handling layer 」。当设备驱动调用诸如请求「requesting」、取消「canceling」、启用「enabling」和禁止「disabling」等中断操作函数时,配置数据会通过通用中断处理层被传递给中断控制器驱动的处理函数。正是由于这个抽象层的存在,不管体系架构和平台如何变化,设备驱动都能以相似的方式处理中断。相关代码都位于<kernel / irq>目录下。
irq_chip: 描述中断控制器的结构体
结构体struct irq_chip用于描述和管理硬件中断芯片(控制器),称之为中断芯片描述符,其大部分字段都是由中断控制器驱动程序负责初始化的函数指针。在初始化后,内部中断处理函数「函数前面带有两个下划线__」就能直接调用它们。
1 | <include/linux/irq.h> |
如表1所示,将详细说明结构体的部分主要字段。
irq_domain:中断域「interrupt domain」
结构体struct irq_domain用于统一管理Linux内核采用的中断编号。在中断初始化过程中,Linux内核会从特定整数区间中选取一个整数作为一个中断源「interrupt source」的编号,并且使每个中断源都有一个独一无二且不重复的编号。如果系统仅存有一个中断控制器,那么能简单地给每个引脚对应的中断源分配一个编号。不过,如前所述,在有些情况下系统可能配置了多个中断控制器,所以中断源的编号方式将会有点复杂。因此内核需要一种机制去管理硬件中断编号「hwirq」与Linux中断编号「irq」之间的映射,以便使多个中断控制器之间不会出现重复相同的irq。正因如此,内核抽象出了中断域概念,由它实现处理中断号的分配和管理。
irq_desc:中断描述符「interrupt descriptor」
结构体struct irq_desc用于描述和管理中断,其大多数字段由中断处理核心部分「interrupt core」所使用。如果没有选用CONFIG_SPARSE_IRQ配置选项,中断描述符和irq之间的关联映射是通过数组,根据irq能够直接索引到对应的中断描述符。否则,通过基数数「radix tree」构建两者之间的关联映射,依据irq遍历搜索基数树才能寻找到对应的中断描述符。不管是否启用这个配置,两种情况都是通过irq_to_desc函数获取到对应的中断描述符。在ARM64的内核配置中,为了能够动态分配和管理中断描述符,默认启用了CONFIG_SPARSE_IRQ选项。
中断处理函数「interrupter handler」
本小节首先介绍了内核代码在请求中断时如何完成中断处理函数的注册以及中断管脚的配置。然后分析了在CPU接收到物理中断信号后中断处理函数的调用流程。最后说明了各个中断容许和禁止函数、以及中断子系统初始化函数。
中断请求
所谓的中断请求其实就是启用中断号对应的中断管脚以及注册中断触发后执行的中断处理函数。一个中断管脚「interrupt line」就是一条传递中断电信号的媒介,其与中断控制器相连并且拥有一个硬件中断号「hwirq」作为标识。一旦启用的中断管脚上触发了中断,就会调用中断号对应的中断处理函数。
1 | <include/linux/interrupt.h> |
request_irq()用于注册给定的中断处理函数,它直接把给定参数全传递给request_threaded_irq()。但是除了这些参数,还为request_threaded_irq()的irq_handler_t thread_fn参数赋值NULL,因此request_irq()仅注册中断处理函数而不会创建中断线程「interrupt thread」。关于中断线程「thread_fn」的更多细节,请参考中断延迟处理。
在请求中断时传递给request_irq函数的参数如表4所示:
可以通过读取/proc/interrupts
查看系统中已注册的所有中断,如图4所示。
request_threaded_irq函数:注册中断处理函数
在请求中断时调用的request_threaded_irq()函数不仅能够注册一个在中断上下文「interrupt context」执行的中断处理程序「intrrupt handler」,还可以注册一个在线程环境运行的中断处理函数「thread_fn」。除了thread_fn参数(中断线程调用执行的函数)外,其他参数都是与request_irq()的参数相同。下面代码将分析介绍该函数的具体细节。
kernel/irq/manage.c的request_threaded_irq()
1 | int request_threaded_irq(unsigned int irq, irq_handler_t handler, |
- 21~24行:对传递的中断标识「irqflags」进行合理性检查和验证。如果请求多个设备共享的中断并且没有指定设备标识符「device ID」,或不支持suspend的设备指定了IRQF_COND_SUSPEND标志,也就是说这两种情况都将导致请求中断失败。
- 26行:根据Linux中断号「irq」获取对应的中断描述符。
- 37行:如果没有指定在中断上下文执行的中断处理函数「参数handler」,将为其指定默认的中断处理函数。下面67~70行给出的默认中断处理函数「irq_default_primary_handler」总是返回IRQ_WAKE_THREAD。该数值表示中断处理程序「handler」需要唤醒中断处理线程去继续处理中断。注意,中断处理函数的返回值如下所示:
- IRQ_NONE:中断未能正确处理或者与设备不相关
- IRQ_HANDLED:成功处理了设备产生的中断
- IRQ_WAKE_THREAD:要求中断处理程序唤醒中断处理线程
- 44行:根据传入的参数为中断设置struct irqaction实例
- 56行:将struct irqaction实例添入irq_desc对象的action链表
中断流处理函数「interrupt flow handler」的调用流程
尽管中断控制器千差万别,但采用的中断流处理函数(比如handle_level_irq()、handle_percpu_irq()、handle_fasteoi_irq()、handle_edge_irq()等等)却大同小异。正如我们所知道的,基于ARMv8架构实现的AArch64核心在捕获到中断控制器触发的中断时会生成一个异步中断异常,该异常会使CPU暂停当前执行来响应处理中断。在中断异常发生后,CPU会执行函数指针handle_arch_irq所指向的一个回调函数,其是在中断控制器驱动初始化时调用set_handle_irq()设定的。
更具体地说,使用GIC的ARM64内核在初始化时会调用set_handle_irq()使函数指针handle_arch_irq指向gic_handle_irq()函数,因此在发生中断时就会调用执行gic_handle_irq()函数,然后其调用执行通用中断处理层的generic_handle_irq()函数,接着调用执行函数指针desc->handle_irq所指向的一个回调函数。而且desc->handle_irq指向的回调函数也是在由中断控制器驱动初始化时根据中断类型负责设定的。比如在ARM64内核中,对于单个CPU响应的中断,为desc->handle_irq所设定的回调函数都是handle_percpu_devid_irq(),对于多CPU响应的中断,为desc->handle_irq所设定的回调函数都是handle_fasteoi_irq()。
接下来看一下handle_fasteoi_irq()函数,其处理常见设备触发的中断。
kernel/irq/chip.c
1 | void handle_fasteoi_irq(struct irq_desc *desc) |
kernel/irq/handle.c
1 | irqreturn_t handle_irq_event(struct irq_desc *desc) |
中断动作「irqaction」的处理
下面代码展示了__handle_irq_event_percpu()函数如何调用执行传递给request_irq()的中断处理函数。
1 | irqreturn_t __handle_irq_event_percpu(struct irq_desc *desc, unsigned int *flags) |
- 9行:遍历中断描述符「irq_desc」的中断动作「irqaction」链表,它们是由request_irq()创建的struct irqaction实例,一个实例描述了中断处理活动中的一个动作,主要为了封装注册的中断处理函数,隐藏handle_fn和thread_fn的差异,从而简化中断处理函数的调用执行逻辑,并使中断处理函数与通用中断处理层解耦。
- 13行:调用每个action绑定的中断处理函数,正如前面中断请求小节所述,中断处理函数一定会返回三个返回值中的一个。
- 21行:返回值IRQ_WAKE_THREAD表示需要唤醒中断处理线程去执行中断处理函数「thread_fn」。
- 34行:返回值IRQ_HANDLED表示中断处理函数已正常处理。在这种情况下,仅返回action的标识给调用者,利用它往熵池添加中断随机种子。
IPI「Inter Processor Interrupts」:处理器间的通信
在SMP系统中,IPI用于传递核间事件「inter-core event」。为了使用硬件体系架构相关的特殊功能,要借助中断控制器提供的功能使IPI从一个核传递给另外其他核。因此,在中断控制器驱动初始化时将调用set_smp_cross_call()函数去设定触发IPI的回调函数。IPI不仅需要能够被一个核或多个核捕获,而且还需要能够被中断处理函数辨识出。例如,在使用GIC中断控制器的系统中,小于等于15的硬中断号「hwirq」都属于软件生成中断「SGI,Software Generated Interrupt」。如果gic_handle_irq()函数处理的中断属于SGI,那么它将调用handle_IPI()函数而不是handle_domain_irq()函数。
ARM64内核使用的IPI种类如下所示。内核调用smp_cross_call()函数向目标CPU发送特定IPI,该函数需要传入IPI编号和参数cpumask,其用于记录要接收IPI的CPU。
容许「enabling」与禁止「disabling」中断
目前存在两种用于容许/禁止中断的方法。第一种禁止中断的方法是禁止当前核心「core」上的所有中断,从而不可能再发生中断异常。另一种方法是屏蔽特定中断管脚「interrupt line」。同样地,容许中断的方法也是这种情况。
容许和禁止当前核心的中断
如果禁止了核心的中断,则中断进入挂起状态以及中断处理程序不会执行。即使中断产生了也不会触发抢占「preemption 」。换句话说,禁止本地中断「local interrupt」也能保护当前核心上的执行环境「context」不受影响。但是在多核系统中,仅禁止本地中断并不能完全保护执行环境「context」不改变。由于其他核心仍然可以接收中断,如果在其他核心上执行的中断处理程序与当前核心上执行的代码共享数据,那么将可能破坏共享数据的同步。为此,在屏蔽本地中断时还需使用自旋锁「spinlock」。接下来看看如何禁止当前核心(本地)的中断。
1 | <include/linux/irqflags.h> |
由于会使用处理器相关的汇编指令,所以每种体系架构的具体实现都不相同。在ARM64内核使用如下的代码。
arch/arm64/include/asm/irqflags.h的arch_local_irq_disable()和arch_local_irq_enable()
1 | static inline void arch_local_irq_enable(void) |
- 4行:将2写入DAIF清除寄存器「DAIF clear register」就能屏蔽「diable」本地中断。
- 13行:将2写入DAIF置位寄存器「DAIF set register」就能容许「enable」本地中断。
在ARMv8体系架构中,能够利用汇编指令仅操作PSTATE寄存器的DAIF字段。写入寄存器的值也仅根据DAIF计算得出,如果IRQ位被掩蔽「mask」,那么当前核心不再接收中断,从而使中断被挂起。反之,如果IRQ位被去除掩蔽「unmask」,那么当前核心能继续接收中断。如图6所示说明了PSTATE的DAIF位操作。
启用和屏蔽特定中断管脚「interrupt line」
如果必要的话,驱动程序可以先屏蔽已激活的中断管脚,然后再启用那个中断管脚。然而如果中断管脚被多个设备共享,那么它就不可以被屏蔽。对中断禁止函数的调用本质上就是调用中断控制器驱动提供的回调函数。容许和屏蔽中断管脚函数都允许多次被调用,但它们只能被成对地调用。也就是说,只要调用了中断管脚屏蔽函数,就必须调用中断管脚容许函数,以致于实际的中断管脚任然是激活的。具体细节参考下面的代码。
disable_irq():屏蔽某个中断管脚
目前内核存在两种中断管脚屏蔽函数。第一种是disable_irq()函数,它屏蔽特定中断管脚,并一直等待相关的待处理中断处理程序执行完毕才返回。第二种是disable_irq_nosync()函数,它在屏蔽特定中断管脚后立马返回。
kernel/irq/manage.c的disable_irq()和disable_irq_nosync()
1 | void disable_irq(unsigned int irq) |
- 3行:__disable_irq_nosync()屏蔽特定的中断管脚,并检查它是否无错误地完成操作。
- 4行:一直等待对应中断处理程序执行结束,很可能它正在其他CPU上运行。如果直接调用disable_irq()的驱动程序与对应中断处理程序共享相同数据资源,那很有可能发生死锁。比如驱动在调用disable_irq()之前获得了资源的自旋锁,并一直等待中断处理程序执行完毕,但中断处理程序在执行过程中也需要获得该资源的自旋锁,因此这样导致死锁。
- 20行:通用中断处理函数实质上是对中断硬件相关函数的封装,__disable_irq()最终会调用中断控制器驱动初始化的irq_disable或irq_mask回调函数完成具体的中断管脚屏蔽操作。
- 9~10行:检查中断描述符对象的depth成员是否等于0,若是,irq_disable()将调用中断控制器驱动指定的中断屏蔽函数。depth是一个计数器,每调用一次__disable_irq()将它自增1。
- 27行:disable_irq_nosync()不会等待中断处理程序执行完成,它屏蔽中断管脚后直接返回。
enable_irq():启用某个中断管脚
目前内核仅有一种启用中断管脚的函数。
kernel/irq/manage.c的enable_irq()和__enable_irq()
1 | void enable_irq(unsigned int irq) |
- 4行:在发出中断控制器操作命令之前,先要获得总线锁「bus lock」,在操作命令执行完后,还要释放总线锁,从而保证命令能在总线上同步执行「bus-level synchronization」。
- 12行:调用内部使用的中断容许函数__enable_irq(),其最终会使用中断硬件相关的回调函数去启用中断管脚。
- 19行:查看irq_desc对象的depth成员,从而选择不同的代码处理路径
- 20行:如果depth等于0,则不能启用中断管脚并输出警告消息
- 25行:如果depth等于1,则调用中断控制器驱动初始化的启用中断管脚的回调函数, 并将depth减1。
- 41行:除此之外,将记录disable_irq()调用次数的depth自减1,借助它能实现中断管脚的重复屏蔽或启用,从而追踪disable_irq()和enable_irq()两者是否成对调用。
中断子系统的初始化
中断子系统是内核的重要组成部分,在内核启动阶段必定会初始化中断子系统。这里将介绍的初始化函数会直接被start_kernel()调用。下面将分析代码具体完成了哪些初始化。
early_irq_init():初始化中断描述符
在中断未初始化之前,首先为中断设置默认CPU亲和性「affinity 」,除此之外,还为中断硬件分配必要的中断描述符「irq_desc」实例。
kernel/irq/irqdesc.c的early_irq_init()
1 | int __init early_irq_init(void) |
- 6行:初始化struct cpumask类型的实例irq_default_affinity,其实就是分配它所用的内存空间并全部置位,结构体struct cpumask包含一个具有NR_CPUS位的位图。如果命令行参数irqaffinity指定了允许中断的CPU列表,那么irq_default_affinity位图内这些CPU对应位将被设置成1(置位)。不过,如果irq_default_affinity位图内任何位都没有被设置,也即所有位都是0(复位),则将所有CPU对应位都设置成1。
- 9行:获得NR_IRQS_LEGACY宏的值,该宏记录着每种体系架构「arch」必须提前预处理的中断数量,比如在ARM64内核中该宏的值等于0。
- 19~20行:如果initcnt大于Linux内核配置的中断数量「全局变量nr_irqs = NR_IRQS宏」,将nr_irqs更新为预处理的中断数量。
- 23行:分配与initcnt个中断描述符「struct irq_desc」实例
- 24行:allocated_irqs位图内中断号对应位被设置成1
- 25行:如果启用了CONFIG_SPARSE_IRQ选项,则将分配的中断描述符「struct irq_desc」实例插入基数树「radix tree」。
- 27行:执行每种体系架构定义的早期中断初始化函数,比如在ARM64内核,该函数是一个不做任何事情的空函数。
init_IRQ():初始化中断硬件
该函数搜索寻找设备树「device tree」的中断控制器节点和初始化系统所用的中断控制器。
arch/arm64/kernel/irq.c的init_IRQ()
1 | void __init init_IRQ(void) |
4行:将设备树的所有节点与__irqchip_of_table表进行比对,从而找到匹配的中断控制器节点和对应的struct of_device_id实例。一旦发现了匹配的中断控制器节点,就会立马调用初始化函数设置中断控制器,这些都由of_irq_init()负责完成,同时它在执行初始化时还必须为handle_arch_irq指定回调函数。
of_irq_init():初始化中断控制器
该函数搜索设备树的所有中断控制器节点,并根据中断控制器的层次关系依次执行中断控制器的初始化函数。__irqchip_of_table是由struct of_device_id实例组成的一个匹配比对表,这些实例是在设备驱动中声明定义的。一旦搜索发现与__irqchip_of_table的实例相匹配的中断控制器节点,就将它添入链表「intc_desc_list」的末尾。每个添入链表的中断控制器节点都设置了回调函数,因此后面能够调用它初始化中断硬件。接下来分析代码是如何执行回调函数的。
drivers/of/irq.c的of_irq_init()[1/2]
1 | void __init of_irq_init(const struct of_device_id *matches) |
- 8~9行:初始化管理中断控制器的两个链表,其中intc_desc_list用于连接struct of_intc_desc实例,每一个实例记录着一个中断控制器的信息,intc_parent_list用于连接已初始化的中断控制器对象「struct of_intc_desc实例」,那为什么这个链表的名字要包含parent,我估计原因是从上到下优先初始化父中断控制器。
- 11行:遍历设备树的每一个节点,并通过与matchs表比对获得匹配的节点和对应的struct of_device_id实例(也叫匹配对象)
- 12~14行:使用of_property_read_bool()在匹配的节点内搜寻给定属性,of_device_is_available()判断匹配的节点是否可用。如果未发现“interrupt-controller”属性或节点不可用,则跳过当前匹配的节点并继续搜寻下一个节点。
- 24行:分配一个struct of_intc_desc实例,用于保存中断控制器的初始化信息
- 30行:将匹配对象「struct of_device_id实例」的data成员用作回调函数,用于初始化匹配节点所描述的中断控制器。对于ARM64内核,若在设备树中创建了interrupt-controller节点,则在搜寻匹配后回调函数通常会设定为gic_of_init()。不管怎么样,后面的代码定会调用执行设定的回调函数。
- 31行:到目前为止,已经通过遍历设备树发现了匹配的GIC节点,因此让desc->dev指向这个中断控制器节点。
- 32~34行:行使desc->interrupt_parent指向当前节点的父节点,若指向的父节点与当前节点相同,则意味着当前节点没有父节点,因此重新设置desc->interrupt_parent为NULL。
- 35行:将一个中断控制器对象「struct of_intc_desc实例」添加到intc_desc_list链表的末尾。至此就完成了在intc_desc_list链表注册入中断控制器对象,接下来看看如何处理注册的对象。
drivers/of/irq.c的of_irq_init()[2/2]
1 | ...... |
- 3行:检查intc_desc_list链表,直到所有的中断控制器对象都处理完才退出循环。被处理的对象将从链表中移除,若检查到链表为空,意味着所有注册的对象已经出来完。
- 9行:从intc_desc_list链表头开始遍历每个对象。除了遍历链表,该宏会提前保存指向下一个对象的指针以防数据结构的破坏,也就是说即使在遍历时删除了当前处理对象,也能安全地继续进行遍历。
- 12~13行:检查判断当前节点的父节点,如果它是父节点「parent」的孩子,将调用回调函数初始化对应的中断控制器,否则继续检查下一个节点。在第一轮外循环「while层循环」,parent为NULL,所以首先搜寻初始化的是根中断控制器。在第二轮外循环,parent变成上一轮发现的中断控制器,所以会搜寻初始化它的孩子。
- 15~22行:在执行初始化回调函数前将从intc_desc_list链表移除中断控制器对象「struct of_intc_desc实例」。对于ARM64内核,将执行的初始化回调函数是gic_of_init(),它既为__smp_cross_call设定gic_raise_softirq()回调函数,又为handle_arch_irq指定gic_handle_irq()回调函数。但是,具体的初始化回调函数依赖于使用的体系架构和设备树配置的中断硬件。
- 23行:如果初始化执行失败,则释放当前中断控制器对象并继续处理intc_desc_list链表中的下一个对象。
- 34行:将当前已经初始化的中断控制器对象添加到intc_parent_list链表的末尾,因此后面的代码能用它指向的中断控制器节点更新父节点「parent」,从而能够继续初始化作为它孩子的中断控制器对象。
- 38~39行:如果intc_parent_list链表非空,则该函数将返回第一个已初始化的中断控制器对象。若为空,就是说不存在已初始化的父中断控制器对象,因此该函数返回NULL,意味着中断子系统初始化出现致命错误。
- 44~45行:从intc_parent_list链表删除这个中断控制器对象,然后用它指向的节点更新父节点「parent」以便进行下一轮循环。
- 49~57行:释放intc_parent_list和intc_desc_list中所有的中断控制器对象。至此,该函数完成了中断控制器及相关中断的初始化。