ARM 中断机制

原创: w568w

注意

这是对 trap 相关机制的详细介绍,感兴趣的同学可以阅读了解。但不要求掌握,因为这不是 Lab2 的一部分,和评分也没有关系,也不要求在报告中体现。

1. 中断是什么

各位在先前的计算机系统类课程上多多少少应该接触过中断的概念。

中断(Interrupt)是指处理器接收到来自硬件或软件的信号,提示发生了某个事件,应该被注意,转而去执行相应的处理程序的过程。

中断可以粗略分为两种:

  1. 异步中断指由外围硬件(相对于中央处理器和内存)发出的中断信号,它们和处理器的执行周期没有关系(也就是说,可以在两个指令周期之间到来),因此称为异步。例如以下场景:

    1. 网卡接收到了一帧数据包,需要通知 CPU 去处理;

    2. 磁盘控制器(在本实验中是 SD 卡控制器)完成了一次读写操作;

    3. 系统时钟要通知 CPU 一个时间片已经过去了。

  2. 同步中断指由处理器中的软件发出的中断信号,它们通常是因为执行了特定的指令或程序而触发,例如:

    1. 较低的异常等级下执行了系统调用(Syscall)命令;

    2. 执行了一个发生错误的指令(例如除零,内存访存失败等);

    3. 执行了一个在指令集中找不到定义的指令(例如在 ARMv7-M 中执行了一个 ARMv8-A 指令)。

所有的中断都是因为:处理器需要暂时停下来,去处理一些(更紧迫的)事情,然后再回来继续执行。

注意

请将「中断」和「上下文切换」区分:后者是指因为处于(内核态中的)进程主动调用 sched(RUNNABLE) 或者 sched(SLEEP),自愿放弃 CPU 使用权,故调度器决定切换到其他进程(同为内核态)上继续执行的过程。

而中断可以发生在任何时候,包括用户态和内核态,是由外部事件和系统调用触发的,一般不是进程或者内核主动发起的(系统调用除外)。

我们的实验目标设备为 Raspberry Pi Compute Module 3 (2024秋助教注:从2024年秋学期起,我们改用 virt 作为目标设备),使用博通 BCM2837 集成处理器,含有四核 ARM Cortex A53 处理单元(ARMv8-A 架构)。因此,接下来的内容将以 Cortex A53 和 ARMv8-A 架构为例进行讲解。

资源

BCM2837 除了处理单元以外,和 BCM2836 的配置完全相同,因此可参考 BCM2836 的技术手册,了解系统时钟、中断控制器、外设访存和 GPU 的详细信息:https://datasheets.raspberrypi.com/bcm2836/bcm2836-peripherals.pdf

你知道吗?

你是否想过,为什么内核程序的异常处理往往是 「暂停现场-处理-恢复现场」 的过程,而我们编写普通用户代码时往往是 「抛出-处理」 的过程(例如各个编程语言中的 try...catch...try...except)?

主要原因是:普通程序的异常是「自产自销」的,而内核程序的异常往往是「外来的」(不管是其他硬件还是用户进程),起到的是「兜底」的作用,不能随意改变异常处的程序流。

其实,学术界早已开始探索在普通编程语言中引入「暂停现场-处理-恢复现场」的机制,有个专门术语来指代之:代数效应(Algebraic Effects)。对编译理论感兴趣的同学可以自行了解。

2. 中断向量表

对于各种异常情况,我们需要给处理器分别指定一个处理程序,而这个处理程序的地址需要被存放在某个固定的位置,这个位置就是中断向量表(Interrupt Vector Table)

提示

可以类比 x86 中的保护模式下的中断描述符表(Interrupt Descriptor Table)。

ARMv8-A 的中断向量表在每个异常等级下各有一个,表的地址储存在 VBAR_ELx(Vector Based Address Register, ELx)寄存器中,其中 x 为异常等级。例如,VBAR_EL1 寄存器储存了 EL1 级别下发生中断时使用的向量表的地址。

每个表中含有 16 个地址,分别对应 16 种异常情况的处理程序的地址。

具体向量表可参考仓库首页提供的 ARM Cortex-A Series Programmer’s Guide for ARMv8-A 的 10.4 AArch64 exception table 一节。

之所以叫「向量」表,一个原因是这 16 种情况实际上是以下两个维度的排列组合:发生的位置中断的原因

发生的位置可分为:

  1. el1_sp0:在当前 EL 级别下发生的,但发生时使用的是 EL0 的 SP(想一想,为什么可能出现这样的情况?);

  2. el1_spx:在当前 EL 级别下发生的,且发生时使用的是当前 EL 的 SP;

  3. el0_aarch64:在较低级别下发生的,且发生时是 AArch64 环境;

  4. el0_aarch32:在较低级别下发生的,且发生时是 AArch32 环境。

发生的原因可分为:

  1. Synchronous:同步中断;

  2. IRQ/vIRQ:外部中断(Interrupt Request);

  3. FIQ/vFIQ:快速外部中断(Fast Interrupt Request);

  4. SError/vSError:系统错误(System Error)。

你知道吗?

后三种中断原因都属于异步中断。有关它们的区别以及 v 前缀的含义,同样可以参考 ARM Cortex-A Series Programmer’s Guide for ARMv8-A Chapter 10 的引言部分。

不过,在本实验中,我们通常只研究 Synchronous 和 IRQ 两种情况,这对应于第一节中的同步中断和异步中断。

我们的向量表定义在 src/aarch64/exception_vector.S 下。

3. 当中断发生了…

让我们跟随一个流程来看看中断发生后,处理器会做些什么。

我们不妨假设一个场景:在 EL1 级别(也就是本实验中内核所处的级别)下,发生了一个同步中断。比方说,我们试图访问了一个根本不存在的内存地址。

  1. 中断发生,处理器暂停当前的指令执行,将 PC(Program Counter)寄存器的值保存到 ELR_EL1(Exception Link Register, EL1)寄存器中,以便能够回到原来的位置继续执行;将 PSTATE(Program State)寄存器的值保存到 SPSR_EL1(Saved Program Status Register, EL1)寄存器中,以便能够恢复到原来的处理器状态;将当前的 PSTATE 做修改,以便在处理中断过程中不会再触发中断(这是通过将 PSTATE 中的 DAIF 位设置为特定值实现的);

  2. 处理器查询 VBAR_EL1 寄存器,得到中断向量表,从中选取 el1_spx + Synchronous 对应的地址(也就是 trap_entry),开始跳转到此处执行我们的中断处理程序;

  3. 中断处理程序从 ESR(Exception Syndrome Register)寄存器中了解中断的具体原因,发现原因为 ESR_EC_DABORT_EL1,即 EL1 级别下的数据访问异常(Data Abort)。

  4. 于是,处理程序着手处理此事,比如打印一些信息到串口输出;

  5. 中断处理程序在处理异常完毕后,执行 eretException Return)指令。处理器将 ELR_EL1 寄存器的值写入 PC 寄存器,将 SPSR_EL1 寄存器的值写入 PSTATE 寄存器,将 PSTATE 寄存器的值恢复为原来的值,处理器完全恢复到原来的状态,继续执行。

资源

有关 ESR 寄存器的具体信息,可参考 Arm® Architecture Reference Manual 的 D7.2.28 一节。我们鼓励你根据手册的说明对不同的异常类型做不同的处理。

看起来很完整?其实,除了处理中断之外,我们还反复提到了必须「恢复到原来的状态」,这是保证中断不会影响到原来的程序执行的关键。然而,处理器做的两项储存工作远远不够,我们需要知道……

4. 怎么储存和恢复现场?

4.1. 保存什么?

让我们思考一下处理器的状态由哪些东西决定。或者说,只要保证哪些东西不变,处理器接下来的运算、状态、执行过程等就绝对不会变

其实问题的答案就是:寄存器

处理器帮我们完成了 PSTATE 和 PC 的保存和恢复,但是,这两个寄存器只是众多处理器状态的一部分。我们还需要保存和恢复其他的寄存器,比如通用寄存器、浮点寄存器、其他必要的系统寄存器等等:

  1. 通用寄存器:x0 ~ x30

  2. 浮点寄存器:q0 ~ q31

  3. 栈寄存器:sp

另外,为了防止我们不小心把 ELR_EL1SPSR_EL1 的值覆盖掉,我们也需要保存这两个寄存器的值。

4.2. 保存在哪里?

下个问题是,我们应该把这些寄存器的值保存在哪里?

显然,我们不可能再用寄存器来保存寄存器的值。我们需要一个地方,能够在中断发生时保存这些寄存器的值,而在中断处理完毕后恢复这些寄存器的值。

这个地方,当然就是内存

写入内存时,我们必须找到一个合适的位置。而这个位置,只能是:因为它直接由目前的 SP 寄存器指向。而且栈的顶部往前的空间,本就是用于存放函数栈帧的,我们当然可以利用这个空余的空间来存放我们的环境。

这还有一个好处:当我们的中断处理程序结束时,栈指针 SP 会自动回到中断发生时的位置,我们不需要再手动恢复 SP 的值(想想为什么?),就可以直接从栈中取出我们先前保存的寄存器的值。

换句话说,我们每个中断的处理逻辑如下:

trap_entry:
// ... 挨个保存寄存器到栈中

bl trap_handler // 实际的中断处理程序

// ... 从栈中挨个恢复寄存器

eret

整个过程非常优美,仅仅是往/从栈中推/弹寄存器的值而已。如果观察 sp 的移动,很像是栈顶向前「陷入」了中断中,然后又向后「浮出」了中断,回到了原来的位置。这就是我们称中断过程为下陷(Trap)的原因之一。

而且,只要我们将保存寄存器后的栈顶的位置作为参数传递给实际的中断处理程序(怎么传递?思考一下 ARM C 的调用惯例),我们就可以在中断处理程序中用一个内存布局相同的结构(例如 C 中的 struct)读取原先寄存器的值(为什么?思考一下推完所有寄存器后的内存布局),甚至修改恢复后的寄存器的值。

5. 和切换上下文的关系?

上面的状态保存和恢复过程,其实和我们讲到的「切换上下文」非常相似。不妨比较一下:

  • 中断时,我们想回到原来的程序,所以:

    • 保存寄存器到栈中;

    • 调用中断处理程序;

    • 从栈中恢复寄存器;

    • 返回到原来的程序。

  • 切换上下文时,我们想去往新的程序,所以:

    • 保存寄存器到栈中;

    • 从参数中读取新的栈位置;

    • 从(新的)栈中恢复寄存器;

    • 返回到新的程序。

在本实验中,中断上下文结构体被称为 UserContext(然而更好的名字是 TrapContext,后面的实验会看到,它确实和用户态有一定的关系,但不必然代指用户态的上下文)。

不过当然,切换上下文的过程和「中断」没有关系,虽然使用 UserContext 的保存和恢复逻辑当然可以,但我们需要注意几个本质的区别:

  1. 没有 bl trap_handler 的过程,因为切换时没有什么「中断处理程序」需要执行;

  2. 不是用 eret 指令,它仅可用于从中断处理程序返回;我们用 ret 命令(因为 eret 会跳转到 ELR,而 ret 会跳转到 x30(或者叫 lr,即 link register));

  3. 很多寄存器的值不需要保存,比如 x8 ~ x18,因为它们是被调用者保存的寄存器,而我们在把 swtch 切换函数当作普通的 C 函数来执行时,这些寄存器的值已经会被编译器生成的指令保存在栈帧中了;elrspsr 也不需要保存,因为我们不关心它们的值。

在本实验中,切换上下文使用的结构体被称为 KernelContext(相应地,KernelContext 也许可以被称为 SwitchContext)。

Last updated