minos 2.1 中断虚拟化——ARMv8 异常处理

  • 首发公号: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 平台对异常和中断的分类,两个平台习惯叫法不同而已。

同步异常

同步异常有以下几个特点:

  1. 异常是由于指令执行造成的,比如执行 ld 指令,对目标地址做地址转换的时候发生缺页,导致缺页异常
  2. 异常处理后的返回地址与导致异常的指令有体系结构的关系,就是说不同的异常,其返回地址可能是不同的,可能是异常指令的地址,也可能是异常指令后面一条指令的地址
  3. 异常是精确的,对于芯片在异常部分的设计,有个很重要的概念就是精确异常,它指的是该指令之前进入流水线的所有指令都必须正常运行完毕,而该指令及之后进入流水线的指令都必须从流水线中清除,不影响任何处理器的状态,就好像什么事情都没有发生一样。这样的好处是可以准确找到异常处理后的返回地址

同步异常又分为以下几种情况:

  1. invalid instructions,指令无效的原因很多,包括未定义的指令,当前异常级别不允许的指令
  2. trap exceptions,通过设置某些控制寄存器拦截某些指令,比如 HCR 等寄存器就可以拦截某些指令执行,让其 trap 到 EL2
  3. memory access,MMU 执行检查的时候可能会产生一些异常,比如说写入一个只读地址
  4. Exception-generating instructions,这指的是 svc、hvc、smc 指令,就是系统调用指令,它们的关系,获得服务如下所示:

  1. Debug exceptions,有一些调试专用寄存器,可以设置这些寄存器,然后触发某些条件来触发异常。比如说硬件断点异常,可以设置某个地址到断点寄存器,执行到该地址表示的指令的时候就会触发一个异常,然后转移到断点处理程序。(我们平时打断点是软件断点不是硬件断点)

异步异常

异步异常是在 CPU 外部产生的,所以与当前的指令流不同步。异步异常与当前正在执行的指令没有直接关联,通常是来自处理器外部的系统事件,异步异常通常又被称为中断,异步异常有以下几种情况:

  1. 常见的 gic 中断控制发过来的中断,分为 irq 和 fiq,通过上面 gic 的架构图可以看出,它们都有实际的信号线连接着 cpu,比较紧急需要快速响应的中断通过 fiq 发送给 cpu
  2. SError,系统错误,比如访存的时候总线通信上遇到某些错误
  3. 虚拟中断,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 需要完成的部分是保存最基本的现场,它会做以下的事情:

  1. 将 PSTATE 的内容保存到 SPSR_ELx(ELx 指的是在异常级别 ELx 处理异常)
  2. 将 PC 保存到 ELR_ELx
  3. 对于同步异常和 SError,将异常原因写入 ESR_ELx
  4. 对于与地址相关的异常,将出错的地址写入 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

未经允许不得转载:大白鲨游戏网 » minos 2.1 中断虚拟化——ARMv8 异常处理