程序的一般运行过程: 把代码加载到内存中,给它一个起始地址,它就会依次取指、翻译、执行每一条指令。平静的生活总是短暂的,程序总需要和外界进行交流,例如响应一个远程网络请求、驱动一个设备工作、显示文本,所以在执行程序之外会有大量的IO工作。
IO请求到达的时间是不固定的,例如一个键盘输入请求取决于你什么时候敲击键盘。什么时候知道有外设请求呢,一种是轮询,它会浪费大量的CPU时间,另外一种就是中断,中断会打断当前CPU执行流并让CPU跳转到预设的中断处理程序地址进行处理。
除了IO之外,现代操作系统为了安全和虚拟化,通常将应用程序和内核放置到不同的特权态中运行,运行在低特权态的应用程序不能直接访问修改硬件资源,必须请求更高特权态中的内核来帮助它完成访问,linux上称为系统调用。虚拟化是内核为每个应用程序提供了虚拟地址空间,真正的物理资源会在TLB类异常中实际分配,linux上称为缺页异常。
在整体的计算机设计中,异常通常有同步和异步两种,异步通常是指的中断,除了中断之外所有的异常都是同步异常。同步异常是由正在运行的指令或指令运行的结果造成的异常,而异步异常则不必由运行的指令产生从而可以在程序运行中的任意时刻发生。异步的基本是中断,而同步异常是由执行程序产生的,所以可以分为TLB类异常(地址类),指令异常(指令不存在,系统调用异常,指令特权态异常,断点指令异常),其他异常(硬件断点,非对齐)。
异常事件会打断当前的执行流,由特权态处理。CPU在流水线阶段检测到例外会暂停当前程序的执行,转移到一个事先规定的例外入口地址来让操作系统处理例外,处理完之后再恢复重新执行当前程序。
现代CPU基本都支持向量中断,根据异常类型跳转到事先规定的例外入口+偏移的位置,但是大部分异常入口和恢复都共享相同的代码,只是根据中断类型分发到不同入口的工作从软件到硬件的转移,所以向量中断的优势可能不会取得巨大性能提升。
异常处理时需要异常上下文,包括:异常发生时的特权态、异常原因、触发异常时的指令、触发异常时的PC、触发异常时的地址。不同类型的异常处理服务可能只需要异常上下文的子集,例如非法指令最关心的就是非法指令的编码与PC值,TLB load异常关心的是触发异常时的地址,而中断处理除了中断号之外并不关心其他,只做保存和恢复上下文。
arm64 下异常类别
同步异常: 系统调用,数据中止,指令中止,栈指针或指令地址没有对齐,未定义指令,调试异常。
异步异常:中断(IRQ),快速中断(FIQ),系统错误
异常向量表
当异常发生的时候,处理器需要执行异常的处理程序。存储异常处理程序的内存位置称为异常向量,通常把所有异常向量存放在一张表中,称为异常向量表。arm64 通过 VBAR_ELn 中存储异常向量表的基址,异常向量表有16个入口,其中4个是异常种类,4个是异常上下文,他们之间是正交关系,发生异常时硬件根据种类自动跳转到异常向量表 + 偏移处。在EL1/EL2/EL3中各有一个VBAR_ELn存储异常向量表。
哪个EL中处理异常?
ARM64提供了接口来配置哪些异常,HCR_EL2/SCR_EL3中分别有EA(External Abort and SError Interrupt routing), FIQ,IRQ控制异常路由。ARM64规定到内核的时候EL需要是EL2或者EL1。
ARM64异常向量表
ARM64比较特殊的有两点,基于这两个,提供了不同的中断入口。
1.兼容armv7之前的32bit模式,不能将armv7的应用全部扔掉,提供兼容模式能够无需修改重编应用就能继续使用,继承之前的生态成果
2.允许EL1/EL2/EL3选择使用当前EL的SP或者共用EL0的SP,EL使用单独的SP好处无需进行栈指针的切换,但是其他架构上基本都是使用的同一个SP,所以软件上可能稍微奇怪一点。
Address | Exception type | Description |
---|---|---|
VBAR_ELn + 0x000 | Synchronous | Current EL with SP0 |
+ 0x080 | IRQ/vIRQ | |
+ 0x100 | FIQ/vFIQ | |
+ 0x180 | SError/vSError | |
+ 0x200 | Synchronous | Current EL with SPx |
+ 0x280 | IRQ/vIRQ | |
+ 0x300 | FIQ/vFIQ | |
+ 0x380 | SError/vSError | |
+ 0x400 | Synchronous | Lower EL using AArch64 |
+ 0x480 | IRQ/vIRQ | |
+ 0x500 | FIQ/vFIQ | |
+ 0x580 | SError/vSError | |
+ 0x600 | Synchronous | Lower EL using AArch32 |
+ 0x680 | IRQ/vIRQ | |
+ 0x700 | FIQ/vFIQ | |
+ 0x780 | SError/vSError |
现在假设所有的异常都到EL1中处理
如果cpu处于EL1,且使用SP0,探测到异常则跳转到0x000-0x180范围的入口。
如果cpu处于EL1,但使用SP1,探测到异常则跳转到0x200-0x380范围的入口。
如果cpu处于EL0时探测到异常,在EL0时可能有两种执行态:32位模式或者64位模式,当处于64位模式时跳转到0x400-0x580范围,处于32位模式时跳转到0x600-0x780入口。
异常向量表设置
ARM64的异常向量表设置,每个异常入口可以有128字节的空间,并且按照128字节对齐,这样可以在异常向量入口处做更多的工作,减少跳转(保持怀疑)。
将异常向量保持对齐,静态编写好放到 “.entry.text” 中,这个主要是可以组织相同功能的代码到同一section中,可以增加代码的局部性,但是在链接阶段,vmlinux.ld.S脚本会将大部分 section 都统一放到 .text 中, 所以在vmlinux中只会看到 .text。
(arch/arm64/kernel/entry.S)
.macro kernel_ventry, el, label, regsize = 64
.align 7
.endm
.pushsection ".entry.text", "ax"
.align 11
ENTRY(vectors)
kernel_ventry 1, sync_invalid // Synchronous EL1t
kernel_ventry 1, irq_invalid // IRQ EL1t
kernel_ventry 1, fiq_invalid // FIQ EL1t
kernel_ventry 1, error_invalid // Error EL1t
kernel_ventry 1, sync // Synchronous EL1h
kernel_ventry 1, irq // IRQ EL1h
kernel_ventry 1, fiq_invalid // FIQ EL1h
kernel_ventry 1, error // Error EL1h
kernel_ventry 0, sync // Synchronous 64-bit EL0
kernel_ventry 0, irq // IRQ 64-bit EL0
kernel_ventry 0, fiq_invalid // FIQ 64-bit EL0
kernel_ventry 0, error // Error 64-bit EL0
kernel_ventry 0, sync_compat, 32 // Synchronous 32-bit EL0
kernel_ventry 0, irq_compat, 32 // IRQ 32-bit EL0
kernel_ventry 0, fiq_invalid_compat, 32 // FIQ 32-bit EL0
kernel_ventry 0, error_compat, 32 // Error 32-bit EL0
END(vectors)
在系统启动过程中,设置vbar_el1的地址为vectors,即异常向量表的起始位置。
__primary_switched: //主核
adr_l x8, vectors // load VBAR_EL1 with virtual
msr vbar_el1, x8
isb
__secondary_switched: //从核
adr_l x5, vectors
msr vbar_el1, x5
ARM64的同步异常和其他架构的异常类型相差不大,只是命名方式上有所不同:数据异常,指令异常,未定义异常,debug异常,栈对齐异常,PC对齐异常,系统调用异常。
现代CPU通常都有MMU部件,当CPU开启MMU功能之后,从CPU发出的地址全是虚拟地址,必须经过MMU转换成物理地址才能经过总线访问内存或者外设。MMU除了进行 VA -> PA 的转换之外,还会有其他的功能:读、写、可执行权限和最低特权态访问权限,所以MMU的异常不仅是包括地址不存在还包括权限异常等,这也是种类占比最多的异常。指令和数据都需要进行地址转换,所以他们都可能触发异常。
arm64规定指令为固定的4字节,而且4字节对齐,这样能够减少非对齐访问,提高流水线效率。当PC不是4字节对齐的时候,尝试取指时会报PC对齐异常检查。同样的,当访问非4字节对齐的数据时,同样会报数据非对齐异常,但是可以通过SCTLR.A 控制是否进行数据对齐检查,默认是不检查的。对于数据非对齐访问 MIPS 架构不支持非对齐访问,但是龙芯默认是支持非对齐访问的,而X86架构支持非对齐访问,都是通过将非对齐访问指令拆分成多条指令执行,结合拼接(或者拆分)指令获取数据,缺点是对性能有影响。
其他异常:
指令解码失败时会报未分配指令异常。
系统调用异常向应用程序提供了一种系统接口,能够主动陷入到高特权态的内核中代理执行它的请求。
栈对齐异常,当根据sp访问数据时,如果sp是非对齐的,则触发栈对齐异常。
debug异常通常的用户是gdb或者类似的应用,一般支持指令断点,数据断点,软件断点等功能。
同步异常的处理结果通常都是可以修复的,这些是系统特意设计的,例如COW为了延迟实际物理内存分配和拷贝将MMU的页表设为只读,实际访问时触发MMU只读异常,这个都是可以修复的。还有一些是无法修复的,异常处理不知道怎么修复,例如执行数据段代码,代码段的页表属性为NX,尝试执行时会触发MMU执行异常,内核确定执行数据段代码不合法,就会杀死进程或者内核。
普通中断使用IRQ,处理中断主要是看中断控制器的状态,最主要的读取中断号并分发到注册的中断处理服务。
FIQ是高优先级的中断,目前还没有使用,2021年Apple提交了对FIQ使用的支持。
中断的处理主要和中断控制器相关,arm64使用自己的gic,当外设中断信号经过gic路由到CPU上并触发,跳转到异常处理后,异常处理主要就是读取gic中的硬件中断号,并根据硬件中断号找到对应的irq及irq action进行处理。在处理外设中断时需要GIC配合做一些事情,对于电平中断,只要设备的中断请求引脚(中断线)保持在预设的触发电平,中断就会一直被请求,所以,为了避免同一中断被重复响应,必须在处理中断前先把mask irq,然后ack irq,以便复位设备的中断请求引脚,响应完成后再unmask irq。
handle_level_irq {
mask(irq); //中断控制器屏蔽中断,防止再次触发
ack(irq); //中断控制器响应中断,很多都是空操作
handle_irq_event(irq); //处理外设的请求
unmask_irq(irq); //中断控制器解除中断屏蔽
}
SError是一种异步外部abort(asynchronous external abort)。发生异常时中断上下文中捕获到信息和真实触发的原因可能已经风马牛不相及了,用这个现场分析永远也查不到真实的原因。外部意味着异常来自于外部存储系统(相较于CPU来说,MMU是内部的),通常,软件不太可能导致这样的问题,通常是硬件触发。
SError比较常见的原因包括:
asynchronous Data Aborts,异步数据异常,数据异常即CPU读写内存数据时产生的异常。 比如:如果误将ROM对应的区域的页表项设置为RW,同时该内存熟悉为write-back cacheable,当尝试写这段区域内存时,数据会先写入cache,而在后面的某个时间点,当其他操作触发相应的脏的cacheline回写内存时,此时内存系统就会返回错误(因为ROM是只读的),从而触发一个异步数据异常。 在Armv8环境中,这就是一个标准的SError。
外部引脚触发。 部分处理器,比如Cortex-A5x,拥有独立的引脚来触发SError,也就是说此时的SError是由SoC设计者自己定义的,比如:ECC和奇偶校验错误就常作为SError来触发。具体应该参考SoC的参考手册来确认SError可能的对应的错误类型。
内核如何处理SError?
Linux内核中,对SError进行了捕获,设置了相应的中断向量,当并未做实际的处理,只是上报异常,并终止内核,因为对于内核来说,SError是致命的,内核自身不知道具体原因,也不知道如何修复。