- 首发微信公号:Rand_cs
越往后,交叉的越多,大多都绕不开 ARMv8 的异常处理,所以必须得先了解了解 ARMv8 的异常处理流程
先说一下术语,从手册中的用词来看,在 x86 平台,一般将异常和中断统称为中断,在 ARM 平台,一般将中断和异常统称为异常
异常的流程,可以分为 3 个阶段,“设备”产生异常信号,中断控制器过滤转发异常,OS 处理异常。设备产生异常的部分我们不讨论,后两个阶段需要仔细说道说道,先来看 ARM 的中断控制器。
GIC(Generic Interrupt Controller)
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 的基本知识
gicv2
中断状态
- inactive:中断处于无效不活跃的状态
- pending:中断处于有效状态,但是 cpu 没有响应该中断,正在 pending 等待被响应中
- active:cpu 正在响应该中断
- active and pending:cpu 正在响应该中断,对应的中断源又发送了一个中断信号
后续代码讲述状态到底是如何转换的
中断类型
- Shared Peripheral Interrupt (SPI) 共享外设中断,中断号为 32~1019,这类中断被共享意思是任何一个 cpu 都可以处理,但最终只会被路由到某一个 cpu。普通的外设中断类型基本都是 SPI
- Private Peripheral Interrupt (PPI) 私有外设中断,中断号为 16~31,一个 CPU 私有外设中断,比如说本地计时器等等,对应着 x86 平台下 lapic 内部中断
- Software-generated interrupt (SGI) 软件中断,也叫做核间中断,是 cpu 向 cpu/cpu组发送中断信号,中断号为 0~15,对应着 x86 平台下的 IPI 中断
- Virtual interrupt,虚拟中断,并不是这个中断是软件模拟的,没有实际物理上的中断信号,而是说一个中断信号发送给了虚拟机
触发方式
edge 边沿触发,level 电平触发
gicv2 架构
gicv2 的总体架构图如上所示,主要分为两部分:
- Distributor,主要作用是收集中断信号,有三个来源分别表示 SGI、PPI、SPI,然后按照一定的规则(寄存器配置)发送给目标 CPU Interface
- Interface,一个 Interface 对应着一个 PE/CPU,Interface 按照一定的规则将中断信号发送给对应的 CPU
gicv2 寄存
Distributor,GICD_xxx 寄存器
有关 Distributor 的寄存器都是以 GICD_xxx 开头
主要寄存器如上所示,具体含义见手册,这里我就不做这个翻译工作了
Interface,GICC_xxx 寄存器
Interface 的寄存器都是以 GICC_xxx 开头:
同样的就不做翻译工作了,重点寄存器我后面都会在代码中提及
gicv3
TODO
ARMv8 异常模型
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 两种执行状态,不同的执行状态下使用的指令集和寄存器都是不同的(是同一个物理硬件,但是使用的寄存器的位数命名等有所不同)
执行状态是可以切换的,但只有在系统重置或者异常级别更改的时候才能更改执行状态。在异常级别更改的时候更改执行状态也有两条规则:
- 从较低的异常级别切换到较高的异常级别时,执行状态只能不变或者切换为 AArch64
- 从较高的异常级别切换到较低的异常级别时,执行状态只能不变或者切换为 AArch32
这两条规则就是说 64 位层次可以托管 32 位层次,反之则不行,比如说 64 位的内核上可以运行 32 位程序,而 32 位的内核只能运行 32 位的应用程序。一图以蔽之:
异常类型
前面说过在 gic 的角度上来看,中断可以分为 SGI、SPI、PPI。现在从 CPU 角度来看,异常有哪些类型,主要分为两大类:Synchronous exceptions 和 Saynchronous exceptions,就是同步异常和异步异常。就是 x86 平台对异常和中断的分类,两个平台习惯叫法不同而已。
同步异常
同步异常有以下几个特点:
- 异常是由于指令执行造成的,比如执行 ld 指令,对目标地址做地址转换的时候发生缺页,导致缺页异常
- 异常处理后的返回地址与导致异常的指令有体系结构的关系,就是说不同的异常,其返回地址可能是不同的,可能是异常指令的地址,也可能是异常指令后面一条指令的地址
- 异常是精确的,对于芯片在异常部分的设计,有个很重要的概念就是精确异常,它指的是该指令之前进入流水线的所有指令都必须正常运行完毕,而该指令及之后进入流水线的指令都必须从流水线中清除,不影响任何处理器的状态,就好像什么事情都没有发生一样。这样的好处是可以准确找到异常处理后的返回地址
同步异常又分为以下几种情况:
- invalid instructions,指令无效的原因很多,包括未定义的指令,当前异常级别不允许的指令
- trap exceptions,通过设置某些控制寄存器拦截某些指令,比如 HCR 等寄存器就可以拦截某些指令执行,让其 trap 到 EL2
- memory access,MMU 执行检查的时候可能会产生一些异常,比如说写入一个只读地址
- Exception-generating instructions,这指的是 svc、hvc、smc 指令,就是系统调用指令,它们的关系,获得服务如下所示:
- Debug exceptions,有一些调试专用寄存器,可以设置这些寄存器,然后触发某些条件来触发异常。比如说硬件断点异常,可以设置某个地址到断点寄存器,执行到该地址表示的指令的时候就会触发一个异常,然后转移到断点处理程序。(我们平时打断点是软件断点不是硬件断点)
异步异常
异步异常是在 CPU 外部产生的,所以与当前的指令流不同步。异步异常与当前正在执行的指令没有直接关联,通常是来自处理器外部的系统事件,异步异常通常又被称为中断,异步异常有以下几种情况:
- 常见的 gic 中断控制发过来的中断,分为 irq 和 fiq,通过上面 gic 的架构图可以看出,它们都有实际的信号线连接着 cpu,比较紧急需要快速响应的中断通过 fiq 发送给 cpu
- SError,系统错误,比如访存的时候总线通信上遇到某些错误
- 虚拟中断,virq、vfiq、vserror,可以直接注入虚拟机,从上面的 gic 架构图来看,也是有实际的信号线连接着 cpu,这部分后文详细讨论
寄存器
这部分再过一遍异常流程涉及的一些寄存器
通用寄存器
31 个 64bit 通用寄存器 x0~x31,x29 是 fp 保存上一个栈帧底部地址,x30 保存返回地址,minos 中 x18 存放当前线程地址
特殊寄存器
- zero register,全 0 值
- PC,保存下一条指令地址
- PSTATE,processor state,标志着当前 CPU 的状态
- 4 个不同异常等级的 SP_ELx
- 在其他任何异常级别执行时,可以将处理器配置为使用SP_EL0或配置为对应该异常级别的堆栈指针SP_ELx
- 软件可以在目标异常级别执行的时候通过更新PSTATE.SP来指向SP_EL0的堆栈指针
- 3 个不同异常等级的 SPSR_ELx
- saved processor state register ,保存执行异常前的 PSTATE 状态值
- 3 个不同异常等级的 ELR_RLx,exception link register ,保存异常返回地址
- ESRx,Exception Syndrome Register异常综合表征寄存器,简单来说就是存放异常原因
- VBAR_ELx,Vector Base Address Register,存放异常向量表的基地址
- FAR_ELx,存放出故障的虚拟地址
- HCR_ELx,Hypervisor Configuration Register,最显著的作用就是可以配置此寄存器使得某些异常被 trap 到 hypervisor
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 需要完成的部分是保存最基本的现场,它会做以下的事情:
- 将 PSTATE 的内容保存到 SPSR_ELx(ELx 指的是在异常级别 ELx 处理异常)
- 将 PC 保存到 ELR_ELx
- 对于同步异常和 SError,将异常原因写入 ESR_ELx
- 对于与地址相关的异常,将出错的地址写入 FAR_ELx
异常处理完成后,基本就是上述的逆操作,总体如下图所示:
上述描述有一个小问题,异常处理通常都是在更高级别或者同级别处理(EL0 不能处理异常),那这个更高级别指的是哪个级别,有多高?
如果是只实现了 EL0 和 EL1 两个级别的机器,那么就只能在 EL1 级别处理中断。如果 4 个级别都实现了,那么也有一些寄存器来配置异常的路由情况。比如说配置 HCR 寄存器可以将一些异常路由到 EL2 的 hypervisor,执行 EL2 的异常处理程序来处理异常,配置 SCR 寄存器可以将异常路由到 secure monitor。所以这个具体在哪一个级别处理异常都是可以配置的,通常有 hypervisor,就会配置 HCR,让异常路由到 EL2,EL2 可以自己处理,也可以再注入到虚拟机,让虚拟机的内核处理。
异常向量表
还记得初次见这张异常向量表的时候,当时是在奔叔的 Linux 书籍里面,那是一脸的懵逼,这里来详细解释一下。
首先回顾一下,异常入口确定方式
中断向量(Interrupt Vector)是计算机系统中用于处理中断的一种技术。它是一个包含各种中断处理程序入口地址的表格。当发生中断时,中断控制器通过查找中断向量表找到对应的中断处理程序地址,然后跳转到该地址执行相应的处理。
- 向量方式:CPU 根据硬件中断号直接获取相应的中断服务程序的地址,然后跳去执行
- 查询方式:CPU 跳去一个特定的地址,此地址一般是一个通用的中断处理程序,由它来查询中断源(一般是中断控制器中的某个寄存器),然后根据不同的中断源执行不同的中断处理程序
EL1~EL3 都有一个 VBAR_ELx 寄存器,里面存放着异常向量表的基地址,都有一个像上面的异常向量表。向量可以分为两大类,四种:
- Exception from Lower EL,从低特权级来的异常
- 低特权级的执行状态为 AArch64
- 低特权级的执行状态为 AArch32
- Exception from the current EL,异常就来自当前特权级
- 当前选择了使用 SP_EL0 处理异常,就是处理异常使用 EL0 的栈空间
- 当前选择了使用 SP_ELx 处理异常,异常处理使用当前特权级的栈空间
SP_EL0
大部分应该还是挺好理解的,就是为啥有个 SP_EL0,正常情况下,就是处于哪个异常等级,就是用哪个等级的 SP。但是 ARM 提供了一种机制可以在 ELx 使用 SP_EL0:
- PSTATE.SPSel = 0,那么使用 SP_EL0
- PSTATE.SPSel = 1,使用 SP_ELx
什么情况下会在 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,等到异常返回时,都会恢复
异常处理流程
现代的异常处理基本都包括上述两个阶段:
- 首先跳转到异常向量记录的地址,进行第一级的处理,这个阶段主要保存现场,就是将一系列寄存器保存到高特权级栈中
- 然后跳转到实际的异常处理程序处理异常,比如说键盘中断就跳转到键盘中断 handler。但一般来说这个后续的处理也不是一个函数,一个 handler 就搞定了,比如在 Linux 里面还分了中断上下半部来处理。但总的来说这个第二阶段就是执行 handler 来处理中断
异常返回
处理完成之后,执行上述的逆操作,将保存在高特权级栈里面的信息恢复,之后通常紧接着一条 eret 指令,实现异常返回,异常返回就是将保存在 SPSR_ELx 寄存器中的状态字恢复到 PSTATE,将保存在 ELR 的返回地址恢复到 PC
- 首发微信公号:Rand_cs