Linux 中断子系统(二)ARM64 的异常处理过程
原图
异常级别
通常 ARM64 的进程执行在 EL0 级别,内核执行在 EL1 级别。
虚拟机是现在流行的虚拟化技术,在计算机上创建一个虚拟机,虚拟机里可以运行一个操作系统。常用的开源虚拟机管理软件是 QEMU,QEMU 支持基于内核的虚拟机 KVM,KVM 的特点是直接在处理器上执行客户机的操作系统,所以虚拟机的执行速度很快。
ARM64 架构的安全扩展定义了两种安全状态,正常世界和安全世界,两个世界只能通过异常级别 3 的安全监视器切换。
异常类型
中断
在 ARM 处理器中,FIQ(Fast Interruptre Quest)的优先级要高于 IRQ(Interrupt ReQuest)。在芯片内部,分别由 IRQ 和 FIQ 两根中断线连接到中断控制器再连接到处理器内部,发送中断信号给处理器。
中止
中止(abort)主要有指令中止(instruction abort)和数据中止(data abort)两种,通常是因为访问外部存储单元时发生了错误,处理器内部的 MMU(Memory Management Unit)能捕获这些错误并且报告给处理器。指令中止是指当处理器尝试执行某条指令时发生了错误,而数据中止是指使用加载或存储指令读写外部存储单元时发生了错误。
复位
复位(reset)操作是优先级最高的一种异常处理,复位操作包括上电复位和手动复位两种。
软件产生的异常
ARMv8 加构提供了 3 种软件产生的异常。发生这些异常通常是因为软件想尝试进入高的异常等级。
- SVC 指令:允许用户模式下的程序请求操作系统服务
- HVC 指令:允许客户机(guestOS)请求主机服务
- SMC 指令:允许普通世界(normal world)中的程序请求安全监控服务
同步异常和异步异常
ARMv8 架构把异常分成同步异常和异步异常两种。同步异常是指处理器需要等待异常处理的结果,然后再继续执行后面的指令,比如数据中止发生时我们知道发生数据异常的地址,因而可以在异常处理函数中修复这个地址。常见的同步异常如下。
- 尝试访问异常等级不恰当的寄存器
- 尝试执行没有定义(UNDEFINED)的指令
- 使用没有对齐的 SP 或执行没有对齐的 PC 指令
- 软件产生的异常,比如执行系统调用(SVC)、HVC 或 SMC 指令
- 因地址翻译或权限等导致的数据异常或指令异常
- 调试导致的异常,比如断点异常、观察点异常、软件单步异常等
中断发生时,处理器正在处理的指令和中断是完全没有关系的,它们之间没有依赖关系因此,指令异常和数据异常称为同步异常,而中断称为异步异常。常见的异步异常包括物理中断和虚拟中断。
- 物理中断分为系统错误、IRQ、FIQ。
- 虚拟中断分为 vSError、 vIRQ、vFIQ。
异常的发生和退出
当异常发生时,CPU 会自动做如下一些事情。
- 将处理器状态寄存器 PSTATE 保存到对应目标异常等级的 SPSR_ELx 中
- 将返回地址保存在对应目标异常等级的 ELRELx 中
- 把 PSTATE 寄存器里的 DAIF 域都设置为 1,这相当于把调试异常、系统错误 (SError)、IRQ 以及 FIQ 都关闭了。具体异常原因需要查看 ESR_ELx
- 设置栈指针,指向对应目标异常等级下的栈,自动切换 SP 到 SP_ELx
- CPU 处理器会从异常发生现场的异常等级切换到对应目标异常等级,然后跳转到异常向量表并执行
上述是 ARMv8 处理器检测到异常发生后自动做的事情。操作系统需要做的事情是从中断向量表开始,根据异常发生的类型,跳转到合适的异常向量表。异常向量表中的每项会保存一个异常处理的跳转函数,然后跳转到恰当的异常处理函数并处理异常。
当操作系统的异常处理完成后,执行一条 eret 指令即可从异常返回。这条指令会自动完成如下工作
- 从 ELR_ELx 中恢复 PC 指针
- 从 SPSR_ELx 恢复处理器的状态
异常向量表
ARMv7 架构的异常向量表比较简单,每个表项占用 4 字节,并且每个表项里存放了一条跳转指令。但是 ARMv8 架构的异常向量表发生了变化。每一个表项需要 128 字节,这样可以存放 32 条指令。注意,ARMv8 指令集支持 64 位指令集,但是每一条指令的位宽是 32 位宽而不是 64 位宽。
在下表中,异常向量表存放的基地址可以通过 VBAR (Vector Base Address Register)来设置。VBAR 是异常向量表的基地址寄存器。见《ARM Architecture Reference Manual,ARMv8,for ARMv8-A architecture profile v8.4》的 D.1.10 节。
异常向量表在 Linux 中的实现(扩展)
汇编宏定义
要理解下面这段代码需要先简单了解一下一些汇编语法,下面做简单介绍: 首先是。macro 命令 .macro 和 .endm 之间允许您定义生成汇编输出的宏。例如,如下这段宏定义:
1 | .macro sum from=0, to=5 |
根据该定义,“SUM 0,5”等效于以下程序集输入:
1 | .long 0 |
.macro comm 开始定义一个名为 comm 的宏,它不带参数。
1 | .macro plus1 p, p1 |
任一语句都开始定义一个名为 plus1 的宏,它带有两个参数;在宏定义中,写“”或“”来计算参数。
1 | .macro reserve_str p1=0 p2 |
以两个参数开始一个名为 reserve_str 的宏的定义。 第一个参数有一个默认值,但第二个没有。 定义完成后,您可以将宏调用为 'reserve_str a,b'('' 计算为 a,'' 计算为 b),或为 'reserve_str ,b'(使用 ' ' 计算为默认值,在本例中为 '0','' 计算为 b)。
1 | .macro m p1:req, p2=0, p3:vararg |
以至少三个参数开始一个名为 m 的宏的定义。 第一个参数必须始终指定一个值,但第二个参数不是,而是具有默认值。 第三个形式将被分配在调用时指定的所有剩余参数。
调用宏时,可以按位置或关键字指定参数值。 例如,“sum 9,17”等价于“sum to=17, from=9”。
请注意,由于每个 macargs 都可以是与目标架构允许的任何其他标识符完全相同的标识符,因此如果目标在某些字符出现在特殊位置时对其制作了特殊含义,则可能会偶尔出现问题。 例如,如果冒号 (:) 通常被允许作为符号名称的一部分,但架构特定代码在作为符号的最后一个字符(以表示标签)出现时将其特殊化,则宏参数替换 代码将无法知道这一点并将整个构造(包括冒号)视为标识符,并仅检查此标识符是否受参数替换。 例如这个宏定义:
1 | .macro label l |
可能无法按预期工作。 调用“label foo”可能不会创建一个名为“foo”的标签,而只是将文本“:”插入到汇编源代码中,可能会产生关于无法识别的标识符的错误。
类似的问题可能会出现在操作码名称(以及标识符名称)中通常允许使用的句点字符 (‘.’) 中。 因此,例如构造一个宏以根据基本名称和长度说明符构建操作码,如下所示:
1 | .macro opcode base length |
并将其作为“opcode store l”调用不会创建“store.l”指令,而是在汇编器尝试解释文本“.”时产生某种错误。 有几种可能的方法可以解决这个问题:
- Insert white space
如果可以使用空格字符,那么这是最简单的解决方案。例如:
1 | .macro label l |
- Use ‘()’
字符串“()”可用于将宏参数的结尾与以下文本分开。例如:
1 | .macro opcode base length |
- Use the alternate macro syntax mode
在替代宏语法模式中,与号字符 (‘&’) 可用作分隔符。例如:
1 | .altmacro |
正确识别伪操作的字符串参数的这个问题也适用于 .irp 和 .irpc 中使用的标识符。
.endm 标记宏定义的结尾。
源码
下面看下 Linux 中 arm64 的异常向量表,在 arch/arm64/kernel/entry.S 文件中有:
1 | /* |
上述异常向量表的定义和前面那张图是一致的,其中 kernel_ventry 是一个宏,他的实现如下:
1 | .macro kernel_ventry, el:req, ht:req, regsize:req, label:req |
.macro kernel_ventry, el:req, ht:req, regsize:req, label:req 定义 kernel_ventry 宏有 4 个参数,其中:req 代表这个参数必须提供。撇开各种宏定义我们看下里面其实主要就是如下三行代码,我们一一分析:
- .align 7
表示把一条指令的地址对其到 2 的 7 次方,即 128 字节。
- sub sp, sp, #PT_REGS_SIZE
sp 指针减去 PT_REGS_SIZE,PT_REGS_SIZE 的定义在 arch/arm64/kernel/asm_offset.c 文件中,如下:
1 | DEFINE(PT_REGS_SIZE, sizeof(struct pt_regs)); |
pt_regs 定义如下:
1 | struct pt_regs { |
该结构定义了异常期间寄存器在堆栈上的存储方式。 请注意 sizeof(struct pt_regs) 必须是 16 的倍数(用于堆栈对齐)。
- b el()()
以 kernel_ventry 1, t, 64, sync 为例将宏展开得到:el1t_64_sync, 下面我们去看看该标号的实现,如下:
1 | .macro entry_handler el:req, ht:req, regsize:req, label:req |
将 entry_handler 1, t, 64, sync 宏展开,得到如下:
1 | el1t_64_sync: |
kernel_entry 宏定义如下:
1 | .macro kernel_entry, el, regsize = 64 |
这里不做详细分析了,除了对一些寄存器做基础处理,保存基本和特殊寄存器值外最重要的就是 bl el()()()_handler,同样以上面第一个宏定义为例,展开为 el1t_64_sync_handler,这些函数的定义和实现在 linux/arch/arm64/include/asm/exception.h 和 linux/arch/arm64/kernel/entry-common.c 中。
在上面的一系列函数中,其实就是一个中断向量表,及其里面的函数实现,当外部中断发生时,跳转到具体标处运行代码。
参考文献
https://sourceware.org/binutils/docs/as/Macro.html#Macro
https://sourceware.org/binutils/docs/as/
《奔跑吧 Linux 内核》
《Linux 内核深度解析》
《Linux 设备驱动开发》