越往后,交叉的越多,大多都绕不开 ARMv8 的异常处理,所以必须得先了解了解 ARMv8 的异常处理流程
先说一下术语,从手册中的用词来看,在 x86 平台,一般将异常和中断统称为中断,在 ARM 平台,一般将中断和异常统称为异常
异常的流程,可以分为 3 个阶段,“设备”产生异常信号,中断控制器过滤转发异常,OS 处理异常。设备产生异常的部分我们不讨论,后两个阶段需要仔细说道说道,先来看 ARM 的中断控制器。
ARM 使用的中断控制叫做 Generic Interrupt Controller,中断控制器主要作用就是转发设备的中断信号,因为外设可能很多,中断信号也很多,不可能每个外设都连一根线与 cpu 相连,所以要有中断控制器来作为中转站
gic 发展到现在有 4 个版本:
上述是手册给出的每个 gic 版本特性,以及对应的 ARM 芯片型号,目前手机端应该用的是 GICv3 居多,qemu 默认使用的似乎是 GICv2。
gicv1 只支持 8 个 PE,PE 是 arm 架构下定义的抽象机器,processing element,可以简单理解为最多支持 8 个 cpu。最多支持 1020 个中断,支持 2 种安全状态,arm 架构一直有个 安全模式,这个我们暂且不提
从 gicv2 开始便在硬件级别支持了虚拟化,有了这个扩展,可以向虚拟机注入中断,也就是向虚拟机发送中断信号
从 gicv3 开始,gic 的架构有了较大的变化,具体的见手册,从 gicv4 开始支持直接投递中断,具体含义后面详细说明,这里只是过过眼,本篇讲述 gicv2 的基本知识
后续代码讲述状态到底是如何转换的
edge 边沿触发,level 电平触发
gicv2 的总体架构图如上所示,主要分为两部分:
有关 Distributor 的寄存器都是以 GICD_xxx 开头
主要寄存器如上所示,具体含义见手册,这里我就不做这个翻译工作了
Interface 的寄存器都是以 GICC_xxx 开头:
同样的就不做翻译工作了,重点寄存器我后面都会在代码中提及
TODO
NOTE,以下大部分内容来自 ARM 手册 https://developer.arm.com/documentation/102412/0103,感兴趣的建议直接看原手册,更详尽。
ARMv8 相较于 ARMv7,在整体架构上有了较大的变化,ARMv8 实现了 4 个异常等级 EL0~EL3,EL0 运行用户态程序,EL1 运行 Guest OS,EL2 运行 hypervisor,EL3 运行 secure monitor 程序。
EL0、EL1 是 ARM 芯片必须实现的异常级别,EL2、EL3 异常级别是可选的
另外还有个安全世界,EL1、EL2 可以通过 smc 指令切换到安全世界,安全世界也运行了一个 OS,叫做 TrustOS,TrustOS 之上也运行了一些应用,通常称作为安全应用,比如手机中常见的支付操作,与安全强相关的操作都会调用到安全世界。可以这样简单理解,安全世界提供了一系列安全功能,需要使用 smc 系统调用来调用这些安全服务。安全世界的话题就此打住,后面有时间写个 TEE 系列(经典有时间)
ARMv8 支持 AArch32 和 AArch64 两种执行状态,不同的执行状态下使用的指令集和寄存器都是不同的(是同一个物理硬件,但是使用的寄存器的位数命名等有所不同)
执行状态是可以切换的,但只有在系统重置或者异常级别更改的时候才能更改执行状态。在异常级别更改的时候更改执行状态也有两条规则:
这两条规则就是说 64 位层次可以托管 32 位层次,反之则不行,比如说 64 位的内核上可以运行 32 位程序,而 32 位的内核只能运行 32 位的应用程序。一图以蔽之:
前面说过在 gic 的角度上来看,中断可以分为 SGI、SPI、PPI。现在从 CPU 角度来看,异常有哪些类型,主要分为两大类:Synchronous exceptions 和 Saynchronous exceptions,就是同步异常和异步异常。就是 x86 平台对异常和中断的分类,两个平台习惯叫法不同而已。
同步异常有以下几个特点:
同步异常又分为以下几种情况:
异步异常是在 CPU 外部产生的,所以与当前的指令流不同步。异步异常与当前正在执行的指令没有直接关联,通常是来自处理器外部的系统事件,异步异常通常又被称为中断,异步异常有以下几种情况:
这部分再过一遍异常流程涉及的一些寄存器
31 个 64bit 通用寄存器 x0~x31,x29 是 fp 保存上一个栈帧底部地址,x30 保存返回地址,minos 中 x18 存放当前线程地址
Register | Name | Description |
---|---|---|
Exception Link Register | ELR_ELx | Holds the address of the instruction which caused the exception |
Exception Syndrome Register | ESR_ELx | Includes information about the reasons for the exception |
Fault Address Register | FAR_ELx | Holds the virtual faulting address |
Hypervisor Configuration Register | HCR_ELx | Controls virtualization settings and trapping of exceptions to EL2 |
Secure Configuration Register | SCR_ELx | Controls Secure state and trapping of exceptions to EL3 |
System Control Register | SCTLR_ELx | Controls standard memory, system facilities, and provides status information for implemented functions |
Saved Program Status Register | SPSR_ELx | Holds the saved processor state when an exception is taken to this ELx |
Vector Base Address Register | VBAR_ELx | Holds the exception base address for any exception that is taken to ELx |
异常处理是硬件和软件一起完成的,对于硬件 CPU 需要完成的部分是保存最基本的现场,它会做以下的事情:
异常处理完成后,基本就是上述的逆操作,总体如下图所示:
上述描述有一个小问题,异常处理通常都是在更高级别或者同级别处理(EL0 不能处理异常),那这个更高级别指的是哪个级别,有多高?
如果是只实现了 EL0 和 EL1 两个级别的机器,那么就只能在 EL1 级别处理中断。如果 4 个级别都实现了,那么也有一些寄存器来配置异常的路由情况。比如说配置 HCR 寄存器可以将一些异常路由到 EL2 的 hypervisor,执行 EL2 的异常处理程序来处理异常,配置 SCR 寄存器可以将异常路由到 secure monitor。所以这个具体在哪一个级别处理异常都是可以配置的,通常有 hypervisor,就会配置 HCR,让异常路由到 EL2,EL2 可以自己处理,也可以再注入到虚拟机,让虚拟机的内核处理。
还记得初次见这张异常向量表的时候,当时是在奔叔的 Linux 书籍里面,那是一脸的懵逼,这里来详细解释一下。
首先回顾一下,异常入口确定方式
中断向量(Interrupt Vector)是计算机系统中用于处理中断的一种技术。它是一个包含各种中断处理程序入口地址的表格。当发生中断时,中断控制器通过查找中断向量表找到对应的中断处理程序地址,然后跳转到该地址执行相应的处理。
EL1~EL3 都有一个 VBAR_ELx 寄存器,里面存放着异常向量表的基地址,都有一个像上面的异常向量表。向量可以分为两大类,四种:
大部分应该还是挺好理解的,就是为啥有个 SP_EL0,正常情况下,就是处于哪个异常等级,就是用哪个等级的 SP。但是 ARM 提供了一种机制可以在 ELx 使用 SP_EL0:
什么情况下会在 ELx 上使用 SP_EL0 呢?
一般来说 EL0 用户态的栈比较大,在内核栈可能溢出的情况下,那么我们就可以使用 EL0 栈。对于栈溢出的情况,通常都要使用另外一个栈来处理。在信号处理中,对于栈溢出通常需要使用 sigaltstack 系统调用来设置另外一个栈来处理信号(原来的栈满了,当然不能执行函数处理信号了)
但是 Linux 内核现在都没使用这个特性,不过既然 ARM 提供了这个特性,那么可以用它来存放一些内核重要数据,比如说 Linux 内核常见的 get_current() 获取当前线程结构体指针:
static __always_inline struct task_struct *get_current(void)
{
unsigned long sp_el0;
asm ("mrs %0, sp_el0" : "=r" (sp_el0));
return (struct task_struct *)sp_el0;
}
#define current get_current()
可以看出,在内核态(EL1) 的时候,SP_EL0 里面存放的是当前线程结构体指针。这个 SP_EL0 在用户态的时候指向的是用户栈,那什么时候变成 task_struct 指针的,又是什么时候恢复的呢?可以猜到,多半是进入内核态以及返回用户态的时候做的这些操作,查找代码验证下:
// linux-6.6.23
// 进入内核态时:
.if \el == 0
clear_gp_regs
mrs x21, sp_el0
ldr_this_cpu tsk, __entry_task, x20
msr sp_el0, tsk
原先的 SP_EL0 和其他的通用寄存器都会保存到 SP_EL1,等到异常返回时,都会恢复
现代的异常处理基本都包括上述两个阶段:
处理完成之后,执行上述的逆操作,将保存在高特权级栈里面的信息恢复,之后通常紧接着一条 eret 指令,实现异常返回,异常返回就是将保存在 SPSR_ELx 寄存器中的状态字恢复到 PSTATE,将保存在 ELR 的返回地址恢复到 PC