目录
一、异常向量表
二、保存现场
三、interrupt handling
四、异常返回
内核版本:linux-4.9.217
异常向量表----异常发生后cpu如何跳转到正确的异常处理入口
保存现场----进入异常入口后如何保存现场
中断处理----识别了异常,现在要跳转到真正的处理函数中处理中断
异常返回----恢复现场返回用户态
这一节要分析内容为异常发生后cpu如何跳转到正确的异常处理入口
异常发生时,PC指针会自动跳转到正确的异常向量入口进行异常处理,这是如何实现的呢?这得从异常向量表说起。
不同类型的异常有不同的异常处理例程,这些异常处理例程通常设计有简单的入口统一放在一段内存中,称为异常向量表,而每个异常处理例程入口称为异常向量。
在aarch64架构的异常向量表中有16个异常向量,每个异常向量有128个字节,其存放的主要内容是异常处理入口的代码。
异常向量表的地址在系统初始化阶段存放到VBAR_ELn(VBAR_EL3, VBAR_EL2 and VBAR_EL1)寄存器中,即不同的异常级别都有一份单独的异常向量表。当异常发生时PC指针通过VBAR_ELn寄存器找到异常向量表基址,然后通过如下几个条件找到准确的异常向量偏移:
1 异常类型 (SError, FIQ, IRQ, or Synchronous);
2 如果异常发生前的EL与异常级别相同,则向量偏移还和系统当前堆栈指针sp使用的情况(SP_EL0 or SP_ELn)有关;
3 如果异常发生前的EL比异常后的EL级别低, 则向量便宜和异常前的系统的执行状态(aarch64 or aarch32)有关.
地址偏移 | 异常类型 | 描述 |
VBAR_ELn + 0x000 | Synchronous | 异常EL与异常前的EL相同, 且使用SP_EL0
|
+0x080 | IRQ/vIRQ | |
+0x100 | FIQ/vFIQ | |
+0x180 | SError/vSError | |
+0x200 | Synchronous | 异常EL与异常前的EL相同,且使用SP_ELn |
+0x280 | IRQ/vIRQ | |
+0x300 | FIQ/vFIQ | |
+0x380 | SError/vSError | |
+0x400 | Synchronous | 异常前的EL比异常EL低,异常前系统模式为aarch64 |
+0x480 | IRQ/vIRQ | |
+0x500 | FIQ/vFIQ | |
+0x580 | SError/vSError | |
+0x600 | Synchronous | 异常前的EL比异常EL低,异常前系统模式为aarch32 |
+0x680 | FIQ/vFIQ | |
+0x700 | FIQ/vFIQ | |
+0x780 | SError/vSError |
图1 具体偏移
参考:https://developer.arm.com/docs/100933/0100/aarch64-exception-vector-table
例如, 当前cpu在EL0级别以aarch64运行一个应用程序, 这时cpu收到一个IRQ信号进入IRQ异常,此时PE进入到EL1。此时,异常入口就是VBAR_EL1 + 0x480,cpu就跳转到这里。
而在linux内核中异常向量表放在arch/arm64/kernel/entry.S文件中,不同类型的异常在这个表中有不同的异常处理程序入口填充的各个项:
/*
* Exception vectors.
*/
.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_invalid // 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_invalid // Error 64-bit EL0
#ifdef CONFIG_COMPAT
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_invalid_compat, 32 // Error 32-bit EL0
#else
kernel_ventry 0, sync_invalid, 32 // Synchronous 32-bit EL0
kernel_ventry 0, irq_invalid, 32 // IRQ 32-bit EL0
kernel_ventry 0, fiq_invalid, 32 // FIQ 32-bit EL0
kernel_ventry 0, error_invalid, 32 // Error 32-bit EL0
#endif
END(vectors)
对于前面讲到的EL0级别发生的中断异常,其异常向量偏移在VBAR_EL1 + 0x480的地址处,在linux中的内核代码即为:
kernel_ventry 0, irq // IRQ 64-bit EL0
上面分析Linux内核遇到EL0级别的中断异常发生时,cpu会自动跳转到对应的异常向量入口
kernel_ventry 0, irq
这个kernel_ventry一个宏,也定义在arch/arm64/kernel/entry.S文件中,展开后如下:
.macro kernel_ventry, el, label, regsize = 64
.align 7
#ifdef CONFIG_UNMAP_KERNEL_AT_EL0
......
#endif
sub sp, sp, #S_FRAME_SIZE
b el\()\el\()_\label
.endm
ARM64_UNMAP_KERNEL_AT_EL0时安全相关的,我们这个场景先不关注。这样精简后就是三行代码:
(1) "mov x30, xzr" 将x30寄存器置为0;
(2) "sub sp, sp, #S_FRAME_SIZE" 扩展堆栈S_FRAME_SIZE字节大小,为后续寄存器入栈做准备;
(3) “b el\()\el\()_\label”跳转到“el\()\el\()_\label”处执行; 将入参"el"和"lable"展开后就是el0_irq,即跳转到el0_irq处执行。
el0_irq才是irq异常处理的主要例程,这其中包括了保存现场、异常处理和现场恢复与返回,主要逻辑如下(去掉了一些调试或者我们无需关注的小部分代码):
el0_irq:
kernel_entry 0
el0_irq_naked:
......
irq_handler
......
b ret_to_user
ENDPROC(el0_irq)
保存现场就是通过宏kernel_entry来实现,这个宏带有两个参数,参数el表示异常等级,例如我们这里irq到来前系统运行在EL0,则参数为0,即"kernel_entry 0";另一个是regsize表示寄存器的size,如果不传此参数则默认是64。
保存现场主要保存哪些内容呢?主要有通用寄存器、cpu状态寄存器pstate、堆栈sp和pc指针。这一系列的内容在linux内核中使用struct pt_regs这个数据结构来管理,这个数据结构定义在arch/arm64/include/asm/ptrace.h文件中:
struct pt_regs {
union {
struct user_pt_regs user_regs;
struct {
u64 regs[31];
u64 sp;
u64 pc;
u64 pstate;
};
};
u64 orig_x0;
u64 syscallno;
u64 orig_addr_limit;
u64 unused; // maintain 16 byte alignment
};
现场的内容保存到哪里呢?保存到堆栈中。前面我们已经有了解到kernel_ventry宏会将在sp中预留S_FRAME_SIZE大小的空间,这个预留出的S_FRAME_SIZE大小就用于保存现场。至于S_FRAME_SIZE,它是一个定义在arch/arm64/kernel/asm-offsets.c的宏,其值大小为sizeof(struct pt_regs),也就是说在堆栈中预留了一个struct pt_regs结构大小的空间。
好了,回答了保留什么,保留在哪里的问题,下面就来看看"kernel_entry 0"的详细情况。和前面一样我把这里不涉及到的代码都暂时省略。
.macro kernel_entry, el, regsize = 64
...... //regsie == 32的情况先忽略
stp x0, x1, [sp, #16 * 0]
...... //x0~x29入栈
.if \el == 0
mrs x21, sp_el0
mov tsk, sp
and tsk, tsk, #~(THREAD_SIZE - 1) // Ensure MDSCR_EL1.SS is clear,
ldr x19, [tsk, #TI_FLAGS] // since we can unmask debug
disable_step_tsk x19, x20 // exceptions when scheduling.
...... //CONFIG_ARM64_SSBD的情况先忽略
1:
mov x29, xzr // fp pointed to user-space
.else
...... //el1的情况先忽略
.endif /* \el == 0 */
mrs x22, elr_el1
mrs x23, spsr_el1
stp lr, x21, [sp, #S_LR]
stp x22, x23, [sp, #S_PC]
/*
* Set syscallno to -1 by default (overridden later if real syscall).
*/
.if \el == 0
mvn x21, xzr
str x21, [sp, #S_SYSCALLNO]
.endif
/*
* Set sp_el0 to current thread_info.
*/
.if \el == 0
msr sp_el0, tsk
.endif
.endm
上面这段代码就是kernel_entry,0,64的基本流程,下面归纳为如下几个步骤:
【1】将x0~x29保存到sp[0, 8*29]的位置,即pt_reg.regs[0]~pt_reg.regs[29];
【2】然后将当前任务内核堆栈栈顶放到tsk,tsk是一个宏,对于aarch64而言一般是x28寄存器。
这里要对这个步骤进行简单的讲解。从用户态进入到IRQ异常后,堆栈指针切换到当前任务的内核堆栈,即current->stack,而任务内核堆栈大小用THREAD_SIZE表示,在aarch64中一般为16kb。
mov tsk, sp /* 将sp指针值放到tsk */
and tsk, tsk, #~(THREAD_SIZE - 1)
【3】将lr寄存器保存到sp[8*30]的位置, 即pt_reg.regs[30];sp_el0保存到sp[8*31]位置,即pt_reg.regs[31],它保存的异常发生前用户态堆栈指针的地址;
【4】将ELR_EL0和SPSR_EL0放到sp + 8*32的位置,即pt_reg.pc和pt_reg.psate。ELR_EL0寄存器的内容是异常处理完毕返回到用户态时的地址,SPSR_EL0保存了异常发生前的PE状态,二者都用于异常返回。
【5】将sp + 8*35位置清0,即pt_reg.syscallno清0;
【6】sp_el0设置为前面tsk的地址。
第二章中我们已经了解到"el0_irq"首先用"kernel_entry 0"进行了现场保存,接下来就是具体的处理例程----中断处理,由宏irq_handler来实现,这个宏很简单:
.macro irq_handler
ldr_l x1, handle_arch_irq
mov x0, sp
irq_stack_entry
blr x1
irq_stack_exit
.endm
真正中断处理函数为handle_arch_irq,在arm架构它只是一个函数指针,在gic初始化时linux调用set_handle_irq(gic_handle_irq)函数将handle_arch_irq设置为真正的中断处理函数gic_handle_irq()。
函数gic_handle_irq()带一个参数struct pt_regs *,这段汇编将sp指针作为入参放到x0中,然后通过"blr x1"跳转到handle_arch_irq执行真正的中断处理。这里将sp作为gic_handle_irq()的入参,是因为在进入异常时堆栈指针先预留了一个struct pt_regs大小的位置,并且将现场信息填充到了这段pt_regs内存中。
另外还需要注意,在"blr x1"前后被"irq_stack_entry"和"irq_stack_exit"所环绕;irq_stack_entry用以将当前堆栈切换到中断栈,即sp指向irq_stack这段内存区间,前提是当前堆栈指针栈顶与tsk栈顶相同;而irq_stack_exit刚好相反,在中断处理完后将堆栈指针从irq_stack切换回原来的堆栈。
异常处理完毕后执行"b ret_to_user"进行异常处理的收尾工作,我们还是结合代码来分析
ret_to_user:
disable_irq // disable interrupts
ldr x1, [tsk, #TI_FLAGS]
and x2, x1, #_TIF_WORK_MASK
cbnz x2, work_pending
finish_ret_to_user:
enable_step_tsk x1, x2
kernel_exit 0
ENDPROC(ret_to_user)
【1】关中断
【2】检查当前任务的thread_info.flags & _TIF_WORK_MASK,以判断当前任务是否有"pengding work",如果有则调用work_pending处理。peding flags包括:
(1)_TIF_NEED_RESCHED,表示current设置了抢占调度标志,在异常退出前可提供给高优先级任务一个抢占的机会;
(2)_TIF_SIGPENDING,有信号挂起,在返回前调用do_signal()进行信号处理;
(3)_TIF_NOTIFY_RESUME,tracehook_notify_resume()处理pengding work;
(4)_TIF_FOREIGN_FPSTATE,任务调试。
【3】kernel_exit 0恢复现场,异常返回。
与前面的kernel_entry保存现场相对应,异常处理完毕后,linux内核调用kernel_exit el宏来恢复现场,并返回到异常发生前的状态。宏kernel_exit带有一个参数el,表示异常的级别。我们这里只关注el0的情况,其他无关的代码我们也省略。
.macro kernel_exit, el
.if \el != 0
......
.endif
ldp x21, x22, [sp, #S_PC] // load ELR, SPSR
.if \el == 0
......
ldr x23, [sp, #S_SP] // load return stack pointer
msr sp_el0, x23
tst x22, #PSR_MODE32_BIT // native task?
b.eq 3f
......
3:
......
5:
.endif
msr elr_el1, x21 // set up the return data
msr spsr_el1, x22
ldp x0, x1, [sp, #16 * 0]
...... //从堆栈恢复x0~x29
ldr lr, [sp, #S_LR]
add sp, sp, #S_FRAME_SIZE // restore sp
.if \el == 0
alternative_insn eret, nop, ARM64_UNMAP_KERNEL_AT_EL0
#ifdef CONFIG_UNMAP_KERNEL_AT_EL0
......
#endif
.else
eret
.endif
.endm
(1) 从堆栈中恢复x0~x29、lr、sp_el0、ELR和SPSR等寄存器;
其中ELR是异常返回后执行的下一条指令;SPSR保存了cpu进入异常前的状态寄存器内容。当执行eret指令时硬件自动将ELR加载到pc指针,SPSR自动恢复到cpu状态寄存器;
(2) 恢复堆栈。
在第二章我们讨论过,异常处理初期会执行"sub sp, sp, #S_FRAME_SIZE"指令在堆栈中预留出pt_regs结构大小的内存用于保护现场;现在现场信息已经恢复,执行"add sp, sp, #S_FRAME_SIZE"指令将堆栈sp恢复到原来的位置。
(3) 异常返回。
通过执行eret指令从异常返回。返回后pc指针由ELR寄存器自动恢复,返回后的cpu状态也由SPSR恢复。这样异常处理完毕后又回到了异常发生前风平浪静状态。