系统中断——硬件的事件系统

32 分钟阅读

中断是什么

CPU 在实现功能时,总是需要与其它的硬件进行协作的。读取用户输入、提供数据输出, 都需要 CPU 与其它硬件进行交互。

从简单的统筹策略出发,想要提升运算效率,CPU 给外围硬件下达任务信号后,就不应该等待 硬件返回任务结果后才回到主要负载的处理中。而是应该立即进行其它还不需要任务结果的 负载处理,等到目标硬件的主控完成任务后,CPU 去取回任务结果即可。

中断便是给硬件向 CPU 发送任务完成信号的机制。

从根本的意思上来说,中断指的是不按照系统常规的执行顺序、不经过系统的任务调度,直接 发起的函数调用。CPU 会在进行中断处理前,保存当前执行环境,跳转到中断指定的函数, 完成目标函数的执行后再恢复中断处理前的执行环境。

所谓异常(Exception)就是 CPU 主动发起的中断,在进程中出现无法解码的指令时、出现 除以 0 问题时、发起系统调用时……CPU 都不得不停止原本的进程执行,改换到针对异常的 处理操作上。

因为都是打断常规执行顺序的机制,异常与中断两词常常被作为同义词来使用的。 但是本文将使用中断一词来专指由 CPU 外围的设备触发的执行序变化。

中断的硬件支持

此处介绍一种中断的实现,该实现方式并不是最前沿的,但是已足以用于理解中断的作用方式。

在单片机上因为外围的硬件很少,中断的处理可能非常的简单。

  flowchart TD
    init_entry[单片机启动]
    --> init_pin_mode[将指定引脚设定为输入模式]
    --> init_interrupt[开启该引脚上的中断监听]
    --> init_handler[将某一个函数绑定到该引脚上]
    --> loop_entry[单片机进行负载处理循环]
    --> state_check{监听的引脚上是否出现中断}


    state_check cond_1@-->|是| handle_interrupt[绑到引用上的函数被执行]
    state_check cond_2@-->|否| run_instruction
    handle_interrupt cond_3@--> run_instruction

    run_instruction[执行一条指令]
    run_instruction loop@--> state_check

    cond_1@{ animate: true }
    cond_2@{ animate: true }
    cond_3@{ animate: true }
    loop@{ animate: true }

开启中断的引脚上触发中断的方式通常有:输入维持在低电平、电平从高变低、电平从低变高。 被绑定到引脚的函数,叫作中断处理函数(interrupt handler)。

这款单片机上所有引脚都支持用于触发中断

键盘就可以使用这一机制来制作——按键按下时电流导通,直接触发中断通知主控,主控通过 USB 向 电脑发送按键码。

CPU 同样也可以提供用于感知中断的引脚。在 CPU 的执行循环中通常有 4 个状态, 指令读取、指令解码、执行指令、检查中断信号。如果中断信号在感知中断的引脚上出现, 在下一个时钟周期里,CPU 就会进入到中断处理流程了。

中断引脚以两种形式出现:可屏蔽中断、不可屏蔽中断。

两者在触发方式上也有很大的区别:

  • 不可屏蔽中断的引脚上,中断信号是电平的变化,比如电平从高变为低。 变化过程每发生一次,就会触发一次中断处理函数的执行。
  • 可屏蔽中断引脚上的电平处于低电位时,处理器认为收到中断信号。如果引脚上的电位 维持在低水平不变,则处理器认为中断信号持续存在,会持续调用中断处理函数。 在中断处理完成后,CPU 必须主动将该引脚上的电平恢复到不触发中断的电位。

读者对于两种中断触发是否有疑问,为什么会需要两种不同的中断?为什么两种中断的触发 方式不同?一次中断事件,只触发一次中断处理已经足够解决问题,为何要允许可屏蔽中断 信号重复触发中断?

中断的屏蔽

处理器中有一个特殊的寄存器,该寄存器的值器设定为 0 时,不论可屏蔽中断引脚 上的输入数据是什么,处理器均不会认为有可屏蔽中断出现;对于不可屏蔽中断则没有这种 控制寄存器。

正是是否可屏蔽这一点,使得两个引脚上中断信号需要采用不同的触发方式。

乍一看,不可屏蔽中断的触发方式,要比可屏蔽中断的触发方式使用更简单,为什么 不惜降低易用性,也要提供可屏蔽中断的支持?

举一个例子来说,假设中断函数在执行时需要对全局变量进行读写,若在中断处理函数执行 中途同一中断被再次触发,处理器就会在前一次读写还没完成时,就重新开始一次处理函数 的执行。等到第二重处理完成,回到第一次的处理函数环境时,最初的变量读写就已经被扰乱 了。

处理器至少需要提供在中断处理过程中,防止正在进行的中断被重复触发的机制。有时出于对 实时性的高要求,也有可能在部分代码执行时屏蔽中断处理。

正是因为可屏蔽中断允许处理器等到方便的时候再进行处理,才必须使用可持续的状态来表 示有中断发生——这样处理器才能在解除中断屏蔽后还能发现中断的存在。

只有处理器能确定中断处理完成的时间,所以中断信号的移除需要处理器主动进行。

不可屏蔽中断相比之下,处理器地位更加被动,响应具有相当的强制性,灵活度更低。 所以一般用于传递对整个系统重要程度很高的信息。比如用于监听断电信息,在发生意外断 电时可以立刻切换到备用电源并保存、清理工作状态关机。

接收来自多个硬件的中断

在一块主板上给处理器提供扩展功能的硬件很多,其数量和种类的可变幅度之大,仅仅使用 处理器上的两个引脚必定不足以直接接收来自硬件的中断通知。

这时就可以加入专门的控制芯片用于管理外围设备的中断信息。在这样的芯片上,有很多个 引脚可以用于监控中断的发生。这些引脚基本上和处理器的两个中断引脚是一样的。 控制芯片又提供了一条引脚用于直接连接处理器的可屏蔽中断引脚,一旦控制器检测到中断, 就利用这条直连引脚上的电位通告处理器中断发生。

同时,芯片提供两个寄存器,处理器可以通过内存管理单元的映射,使用内存地址来访问 它们。两个寄存器的功能分别如下:

  1. 用于提供中断所在引脚信息。这个寄存器上的数值其中一个位用于表示该芯片上是否有 中断发生,当多个控制芯片并联到处理器时,处理器可以用这一位来 识别出有中断发生的中断控制芯片。其它的各位则分别对应不同的引脚,当某一个引脚上有未 处理的中断时,其对应的数据位就是 1。
  2. 用于设定需要监听的中断引脚。该寄存器上的数值,各位对应一个引脚, 一个设置为 1 的位就指示其对应的引脚上允许触发中断。

在处理器完成控制芯片上中断信号的处理后,就可以发出消息到控制芯片,消除中断发生标 记里的一个活跃位。

通常,中断处理器还包含有对接收到的中断请求进行优先级排序,将最紧急的中断优先传给 处理器的功能。

中断的软件支持

中断发生之后的跳转是由硬件实现,如何跳转、跳转到何处在硬件设计时就已经确定了, 软件不参与也不能干涉。

向量中断(vectored interrupt)是常用的一种跳转形式,处理器会记录一个向量表的基地址值, 中断发生时处理器使用执行状态码作为索引,从向量表中查询出处理函数的地址,并进行调用。

或者处理器在中断发生后总是跳转到固定内存地址,执行不限指令长度的函数,同时额外使 用寄存器记录诸如中断发生原因这样的环境数据。在处理函数内再根据环境数据进行中断的 区分处理。

针对不同架构的中断跳转实现,操作系统层面需要对硬件进行不同的初始化操作。

接下来以 Linux 内核在 ARM64 架构下的中断处理介绍中断的软件实现。 读者可以在 https://github.com/torvalds/linux 找到内核的源代码。

ARM64 的中断模型

ARM64 中使用异常等级(exception level,EL)0 到 3 表示不同的执行权限。

很多时候,系统的开发者对 EL 进行如下的分配方式:

  • EL0:用户应用
  • EL1:操作系统
  • EL2:虚拟机监视器
  • EL3:固件或安全监控组件

等级越大,处理器在硬件层面对指令的限制就越少,比如 EL0 是没有资格处理中断的, 发生中断时,处理器就会提升到至少 EL1 的运行等级。至于在不同类型的中断后 EL 具体提升到哪个 等级,读者在本文中不用了解,一概当成在 EL1 进行中断处理即可。

每个等级都有专属的中断向量表基地址寄存器,记做 vbar_elxx 是各个等级的编号)。 中断发生后提升到的 EL 决定了将要使用 1、2、3 哪一个中断向量表来查询处理向量。

处理器会按以下几个指标确定使用中断向量表中的哪个元素进行中断处理(列表中的内容不能 完全理解也不会影响后续内容的阅读):

  • 开始中断处理之后需要使用的栈指针是 EL0 的栈指针还是中断处理所在 EL 的栈指针;
  • 中断处理指令是 32 位模式进行还是 64 位模式;
  • 中断是异常、中断请求(IRQ)、快速中断请求(FIQ)、系统错误(SError)中的哪一种。

中断向量表如此细分为 16 个向量元素。

与很多架构不同,ARM64 的中断向量的元素是指令块,而不是跳转地址。每个元素固定为 128 字节,最多包含 32 条指令。这 32 条指令用于进行寄存器的现场保存,并进一步跳转到 更加复杂的处理函数。

中断向量表初始化

在 Linux 中,应用层使用 EL0 运行,内核以 EL1 运行。中断相关的 Linux 的启动流程如下:

  flowchart TD
    load[内核加载到内存]
    --> init_jump[CPU 执行 <code>.head.text<code> 内存段的指令]
    --> init_vbar[
        __primary_switched 函数
        进行中断向量表初始化
    ]
    --> init_pic[
        start_kernel 函数
        初始化中断控制芯片
    ]
    --> irq_bridging[将架构特定的中断处理流程衔接到 Linux 提供的通用框架]
    --> start_init_process[
        创建用户空间的 <code>init</code> 进程
        系统启动完成
    ]

.head.text 的内容是 CPU 上电之后首先执行的指令,这一内存段中的内容来自 arch/arm64/kernel/head.S 文件。这一阶段里,处理器还处于需要初始化的状态,内核的代码都是直接使用汇编来编写的。

下面是 head.S 里直接与中断设定相关的一段代码:

// arch/arm64/kernel/head.S

SYM_FUNC_START_LOCAL(__primary_switched)
    // ...
	adr_l	x8, vectors			// 获取标签 `vector` 所在的地址值
	msr	vbar_el1, x8			// 将标签地址作为 EL1 的中断向量表基地址
    // ...
SYM_FUNC_END(__primary_switched)

其中的 vectors 标签定义在 arch/arm64/kernel/entry.S

// arch/arm64/kernel/entry.S

SYM_CODE_START(vectors)
	kernel_ventry	1, t, 64, sync		// Synchronous EL1t
	kernel_ventry	1, t, 64, irq		// IRQ EL1t
	kernel_ventry	1, t, 64, fiq		// FIQ EL1t
	kernel_ventry	1, t, 64, error		// Error EL1t

	kernel_ventry	1, h, 64, sync		// Synchronous EL1h
	kernel_ventry	1, h, 64, irq		// IRQ EL1h
	kernel_ventry	1, h, 64, fiq		// FIQ EL1h
	kernel_ventry	1, h, 64, error		// Error EL1h

	kernel_ventry	0, t, 64, sync		// Synchronous 64-bit EL0
	kernel_ventry	0, t, 64, irq		// IRQ 64-bit EL0
	kernel_ventry	0, t, 64, fiq		// FIQ 64-bit EL0
	kernel_ventry	0, t, 64, error		// Error 64-bit EL0

	kernel_ventry	0, t, 32, sync		// Synchronous 32-bit EL0
	kernel_ventry	0, t, 32, irq		// IRQ 32-bit EL0
	kernel_ventry	0, t, 32, fiq		// FIQ 32-bit EL0
	kernel_ventry	0, t, 32, error		// Error 32-bit EL0
SYM_CODE_END(vectors)

kernel_ventry 是一个会展开指令块的宏,其定义同样也在 entry.S 文件中, 指令块主要内容是保存寄存器环境以及进行向处理函数的跳转。

kernel_ventry 1, h, 64, irq 是内核模式下中断请求的处理向量,会跳转到 el1h_64_irq 这一汇编函数; 而 el1h_64_irq 在进行一些准备工作后,会进一步调用 el1h_64_irq_handler C 函数。

至此汇编部分的中断初始化结束,C 语言开始对硬件进行操作。

中断控制芯片初始化

el1h_64_irq_handler 函数定义在 arch/arm64/kernel/entry-common.c 文件中, 其核心功能是通过调用 handle_arch_irq 来进行中断处理。

handle_arch_irq 是一个函数指针全局变量,该变量在中断控制器驱动初始化流程中被赋值。

head.S 指令执行的最后,会调用 start_kernel 开始使用 C 语言进行硬件的初始化。 驱动相关的初始化在这里进行:

start_kernel()                            // init/main.c
   init_IRQ()                            // arch/arm64/kernel/irq.c
     irqchip_init()                      // drivers/irqchip/irqchip.c
       of_irq_init()                     // drivers/of/irq.c

拿 AMR64 的通用中断控制芯片规格 GICv3 来举例, 其对应的驱动文件是 drivers/irqchip/irq-gic-v3.c。其中设定 handle_arch_irq 的 一行是:

// 可以看做是 handle_arch_irq = gic_handle_irq;
set_handle_irq(gic_handle_irq);

gic_handle_irq 这一函数里,最终实际进行中断处理的是 generic_handle_domain_irq 函数调用,这是底层不同的控制芯片接入到 Linux 设计的通用 IRQ 处理流程的接口。

Linux 的通用 IRQ 处理

  flowchart TD
    irq_init[中断初始化]
    --> driver_init[
        硬件驱动初始化
        各自进行中断事件的监听注册
    ]
    --> irq[中断控制器收到中断]
    --> cpu_jump[处理器跳转到中断处理函数]
    --> pic_msg[向中断控制器查询中断类型]
    --> desc_look_up[
        根据中断类型获取处理函数链表
        依序对各个函数进行调用
    ]
    --> call_handler[
        各个处理函数使用传入的中断信息与设备 ID 判断是否要参与该中断的处理
    ]
    --> eoi_msg[
        处理完成,CPU 向中断控制器发送 EOI(End of Interrupt) 信号
    ]

在 Linux 中,进行中断处理的能力是由各个硬件的驱动提供的。驱动通过 request_irq 函数进行处理函数的注册。

// include/linux/interrupt.h
int request_irq(unsigned int irq,
                irq_handler_t handler,
                unsigned long flags,
                const char *name,
                void *dev)

在中断处理的通用流程中,不同类型、不同来源的中断是通过一个整数 ID 来识别的, 驱动首先通过设备结点获取目标设备能够提供的中断,再使用这一中断的 ID 配上一个用于 处理该中断的函数发起中断处理的注册。

在注册处理函数时,系统创建对应该 ID 的 irq_desc 结构体,将处理函数包裹在 irqaction 结构体中,添加到 irq_descaction 链表中; 如果已经有其它的驱动触发过 irq_desc 的创建,那新注册的处理函数就会直接被记录到 action 链表中。

各种中断根据开始中断处理前的准备工作、中断处理结束后的清理工作的不同, 大致可以分成以下几个种类:

  • level,依靠电平维持在某个状态来触发的中断;
  • edge,通过电平变化来触发的中断;
  • fasteoi,支持 Fast EOI 的中断;
  • simple,不需要特殊中断硬件支持的类型;
  • percpu
  • edge_eoi

每个 irq_desc 结构的 handle_irq 字段都是指向处理流程函数的函数指针。 中断处理通常会以如下形式出现:

struct irq_desc *desc;
desc->handle_irq = handle;    // handle 是某处进行过实现的处理流程函数
// ...
desc->handle_irq(desc);

所谓处理流程函数,就是包含事前准备、执行处理 action、事后清理这些工作逻辑的函数。 内核代码中提供了与前述分类列表中各个类型对应的流程函数实现。

如果某款中断控制器中提供了新的中断类型,用户也可以自己实现一个流程函数, 并在驱动中提供将 irq_deschandle_irq 字段绑定到新流程函数的支持。

下面是内核中提供的对应 level 类型中断的流程函数 handle_level_irq 的实现:

// kernel/irq/chip.c

void handle_level_irq(struct irq_desc *desc)
{
	guard(raw_spinlock)(&desc->lock);
	mask_ack_irq(desc);              // 屏蔽中断,并将表示中断出现的电平信号清除
                                     // 防止重复触发中断处理

	if (!irq_can_handle(desc))
		return;

	kstat_incr_irqs_this_cpu(desc);  // 增加中断处理计数
	handle_irq_event(desc);          // 进行 action 链表中函数的调用

	cond_unmask_irq(desc);           // 解除中断的屏蔽
}

减少中断被屏蔽的时间

从上文中的 handle_level_irq 流程函数实现中可以看到,中断处理开始时会首先对该 ID 的中断进行屏蔽。

所有类型的中断都是这样处理的,尽管其它的中断还可以发生并被处理,但是同一中断不能 在被处理的过程中再触发新的处理。

中断处理函数必须快,一方面是为了减少因为屏蔽中断而产生的中断处理延迟;另一方面, 处理中断并不是处理器最主要的任务,运行用户进程才是。中断处理函数一旦开始执行,就 不可延后、不可阻塞。

因此,中断处理函数需要更加仔细地设计。

其实在很多的中断处理中,有一部分行为虽然是由中断触发、会执行需要特权的逻辑,但是 与硬件的读写完全没有关系。

拿键盘输入举例子,电脑收到输入码之后,按键输入需要传递给窗口管理进程,窗口管理 进程再根据输入的按键码是不是全局快捷键、当前活跃的窗口是哪一个,来进行按键码的下 一步分发。

尽管这一流程有相当长的调用链,但是真正涉及到操作系统与硬件交互的部分,只不过是 操作系统将 USB 或蓝牙模块收到的键盘输入码写入到内存罢了。

Linux 内核提供了将中断处理函数与一个线程结合使用的机制。

// kernel/irq/manage.c
int request_threaded_irq(unsigned int irq,
                         irq_handler_t handler,
                         irq_handler_t thread_fn,
                         unsigned long flags,
                         const char *name,
                         void *dev);

驱动在注册中断处理时,额外提供一个用于表示由中断触发、可延迟、不紧急的任务函数。 执行顺序上,额外的任务函数会在中断处理函数调用完成后的某一不确定时间被调用。

内核会使用 kthread_create 为额外任务创建线程,并将该线程同处理函数一起被记录在 irqaction 结构体中。

// include/linux/interrupt.h
struct irqaction {
    // ...
	irq_handler_t		handler;
	irq_handler_t		thread_fn;
	struct task_struct	*thread;
    // ...
}

中断处理函数通过返回值告知内核是否还有后续步骤需要执行。

// include/linux/irqreturn.h
/**
 * enum irqreturn - irqreturn type values
 * @IRQ_NONE:		interrupt was not from this device or was not handled
 * @IRQ_HANDLED:	interrupt was handled by this device
 * @IRQ_WAKE_THREAD:	handler requests to wake the handler thread
 */
enum irqreturn {
	IRQ_NONE		= (0 << 0),
	IRQ_HANDLED		= (1 << 0),
	IRQ_WAKE_THREAD		= (1 << 1),
};

如果返回值等于 IRQ_WAKE_THREAD,内核就会将与该中断处理绑定的线程加入到待执行队列中。

// kernel/irq/handle.c

irqreturn_t __handle_irq_event_percpu(struct irq_desc *desc)
{
    // ...
    irqreturn_t res;
    // ...
        res = action->handler(irq, action->dev_id);
    // ...
    switch (res) {
    case IRQ_WAKE_THREAD:
        // ...
        __irq_wake_thread(desc, action);
        break;
        // ...
    }
    // ...
}

被唤醒的线程化中断任务就可以使用一般的线程调度机制介入,更均衡地分配处理器的运算 负载。

总结

在 Linux 里,中断的实现被分成了 3 个层级。

  • 驱动使用的高层 API
  • 中断处理的流程函数
  • 与中断控制芯片交互的接口封装

硬件驱动的编写者注意力主要在设备会产生什么样的中断、中断发生之后的工作流程应该 如何分配到中断处理函数与线程任务中。

到了中断控制芯片的驱动层面,就需要了解中断处理器是如何表示各个引脚上的中断的、对外 使用什么寄存器提供中断信息。

衔接一般硬件驱动程序与中断控制器的流程控制函数则相对来说变化较少。

再下探一层就到了处理器架构的层面,内核的编写都需要了解自己选用的处理器使用什么 样的权限控制设计、使用什么样的中断跳转机制。

本文虽然忽略了很多实现细节,但是仍希望这样从整体上叙述中断处理的形式,能够成为 读者进一步进行中断处理实践的起点。

参考

  1. Javier Lobato Perez. "Interrupt Handling in Linux", https://www.baeldung.com/linux/interrupt-handling, 2025.03.19.
  2. ankurt. "Linux Kernel Interrupts and Handlers - Top and Bottom Halves", https://linuxburps.blogspot.com/2013/09/linux-kernel-interrupts-and-handlers.html, 2013.09.15.
  3. ankurt. "Linux kernel interrupt handling – A code walk", https://linuxburps.blogspot.com/2013/10/linux-interrupt-handling.html, 2013.10.01.
  4. Ben Eater. "Hardware interrupts", https://www.youtube.com/watch?v=DlEa8kd7n3Q, 2020.08.01.
  5. BitLemon. "How Interrupts Work in Modern Computers", https://www.youtube.com/watch?v=G7bqvpAw7HE, 2025.08.11.
  6. David A. Patterson, John L. Hennessy. Computer Organization and Design: The Hardware/Software Interface RISC-V Edition. Elsevier Inc. 2018
  7. 作者不明. "Learn the architecture - AArch64 Exception Model", https://developer.arm.com/documentation/102412/0103/Privilege-and-Exception-levels/Exception-levels.
  8. Thomas Gleixner, Ingo Molnar. "Linux generic IRQ handling", https://www.kernel.org/doc/html/v6.18/core-api/genericirq.html.
Last Update 2025-11-15