前言摘要:大部分人玩的都是X86/x64那一套,移动端玩的倒是Arm,不过偏向于应用层级。本篇深入Arm64内核层级,看下Linuxkernel是如何调用Arm64的用户态。
大部分人玩的都是X86/x64那一套,移动端玩的倒是Arm,不过偏向于应用层级。本篇深入Arm64内核层级,看下Linuxkernel是如何调用Arm64的用户态。
1.前置概念
Arm64要运行用户态的程序,必须要内核调用才行。任何程序都是如此,不过大部分编译器对内核方面进行了深度隐藏,应用层级的程序基本上无感进行了调用。
先看下用户态的入口,注意linux下面用户态的入口不是main函数,而是在elf二进制文件里面的EntryPoint Addres(后面简称:EA)项,可以通过如下命令查看:
# readelf -h helloworld //hELloworld为可执行二进制文件ELF Header: Magic: 7f 45 4c 46 02 01 01 03 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - GNU ABI Version: 0 Type: EXEC (Executable file) Machine: AArch64 Version: 0x1 Entry point address: 0x400680可以看到以上EA是:0x400680,这个数值才是内核态最先调用的用户态地址。那么内核态如何调用的EA的呢?
Arm64的linux内核有一个调度(__schedule)功能,有一个类似于队列(Queue)的变量。队列用来存储一些需要被内核调度的功能/进程/函数/用户模块/用户入口等等。当我们运行可执行的elf二进制文件的时候,linux用户态会通过一些既定的函数进入内核态,把elf二进制文件的入口EA存储到内核的队列变量里面,当内核进行队列扫描的时候,扫描到了用户态EA,就会进行调度,运行EA。EA进而运行用户态的main函数,从而运行整个应用层级的程序。以上是大致的概念,但实际上的操作会充满荆棘。
2.异常的引出
上面的调度功能是在vmlinux(完整的linux内核映像),它有一个函数__schedule,这个函数会对内核队列变量存储的内容进行调度,当需要调度用户态的时候,它会通过ret_from_fork函数调用ret_to_user函数,ret_to_user如下://inux-6.11.7/arch/arm64/kernel/entry.S:612SYM_CODE_START_LOCAL(ret_to_user) ldr x19, [tsk, #TSK_TI_FLAGS] // re-check for single-step enable_step_tsk x19, x2#ifdef CONFIG_GCC_PLUGIN_STACKLEAK bl stackleak_erase_on_task_stack#endif kernel_exit 0SYM_CODE_END(ret_to_user)ret_to_user函数通过指令ERET负责从内核模式(EL1)返回到用户模式(EL0)。注意这里的EL,ERET,他们都是特权寄存器/指令。EL在内核态它的模式为ELR_EL1,在低级的用户态它则为ELR_EL0。内核态的ELR_EL1保存了异常处理返回后的地址(也即是被调度填充的EA地址),当ret_to_user通过ERET返回的地址即是ELR_EL1(也即是当一个程序发生异常,系统处理完成这个异常之后,需要返回的地址就保存在了ELR_EL1)。但是这个地方需要注意,ELR_EL1是被调度填充的值EA,而非某个地方异常填充的值。这点可以通过硬软断点检测,除了软件断点硬件读写断点无法断下来。(lldb) watc s exp -s 4 -w read_write -- 0x400680(lldb) b 0x400680通过代码验证下上面的说法。当EA第一次被调用的时候,ELR_EL1存储了EA地址。通过ERET跳转到EA。下面EA第一次被调用的状态:(lldb) cProcess 1 resumingProcess 1 stopped* thread #1, stop reason = breakpoint 1.1 frame #0: 0x0000000000400680error: memory read failed for 0x400600(lldb) re r pc ELR_EL1 VBAR pc = 0x0000000000400680 ELR_EL1 = 0x0000000000400680 VBAR = 0xffff800080010800 vmlinux`vectorsIN: 0xffff8000800121d8: OBJD-T: fe7b40f9ff4305911f2003d51f2003d5e0039fd6其代码如下:
(lldb) di -s 0xffff8000800121d8 -bvmlinux`ret_to_user: 0xffff8000800121d8 : 0xf9407bfe ldr x30, [sp, #0xf0] 0xffff8000800121dc : 0x910543ff add sp, sp, #0x150 0xffff8000800121e0 : 0xd503201f nop 0xffff8000800121e4 : 0xd503201f nop 0xffff8000800121e8 : 0xd69f03e0 eret 0xffff8000800121ec : 0xd503379f dsb nsh 0xffff8000800121f0 : 0xd5033fdf isb 0xffff8000800121f4: 0xd503201f nop我们看到上一条指令刚好运行到了eret处,而这条指令处在ret_to_user函数里。因EA是用户态的地址,在内核是无法被运行的,所以导致了一个Arm64里面的向量异常。
这里需要注意ERET同时会恢复 SP_EL0(用户模式堆栈指针)、ELR_EL1(用户模式程序计数器)、SPSR_EL1(用户模式程序状态寄存器)等寄存器的值。这些寄存器在发生异常或系统调用时被保存到内核栈中。用以修改内核态模式为用户态模式,为运行EA做准备。上面说了,这个地方的SP_EL0,ELR_EL1,SPSR_EL1寄存器值是被调度时候填充的,而非异常引起的填充。
3.内核态异常类别
linuxkernel的arm64向量异常选项分为如下几种:
//linux-6.11.7/arch/arm64/kernel/entry.S:520SYM_CODE_START(vectors) kernel_ventry 1, t, 64, sync // Synchronous EL1t kernel_ventry 1, t, 64, IRQ // IRQ EL1t kernel_ventry 1, t, 64, FIQ // FIQ EL1t kernel_ventry 1, t, 64, error // Error EL1t kernel_ventry 1, h, 64, sync // Synchronous EL1h kernel_ventry 1, h, 64, irq // IRQ EL1h kernel_ventry 1, h, 64, fiq // FIQ EL1h kernel_ventry 1, h, 64, error // Error EL1h kernel_ventry 0, t, 64, sync // Synchronous 64-bit EL0 kernel_ventry 0, t, 64, irq // IRQ 64-bit EL0 kernel_ventry 0, t, 64, fiq // FIQ 64-bit EL0 kernel_ventry 0, t, 64, error // Error 64-bit EL0 kernel_ventry 0, t, 32, sync // Synchronous 32-bit EL0 kernel_ventry 0, t, 32, irq // IRQ 32-bit EL0 kernel_ventry 0, t, 32, fiq // FIQ 32-bit EL0 kernel_ventry 0, t, 32, error // Error 32-bit EL0SYM_CODE_END(vectors)这每一项的偏移是0x80大小,比如Synchronous EL1t为0偏移,则IRQ EL1t的偏移是0x80。kernel_ventry标记所有项都是一样的。1表示内核态,0表示用户态。t表示64位地址,h表示32位地址。64表示指令集的宽度。后面是向量异常选项的类别,如下:Sync:同步异常(如未定义指令、预取中止、数据中止等)。IRQ:中断请求。FIQ:快速中断请求。Error:系统错误(通常是硬件错误或不可屏蔽中断)。0x400680这个地址是用户态的,在内核态未被定义,所以当前的异常是Sync。当前是Arm64的指令集,所以它应该标记为t,64位宽度指令集地址。又因为0x400680是用户态,用户态的表示是0。
综合以上特征,内核第一次通过ret_to_user函数调用0x400680地址的时候,调用的向量异常选项如下:
kernel_ventry 0, t, 64, sync // Synchronous 64-bit EL0异常选项需要调用向量异常处理程序,我们看下Arm64异常处理程序:
entry_handler 1, t, 64, syncentry_handler 1, t, 64, irqentry_handler 1, t, 64, fiqentry_handler 1, t, 64, errorentry_handler 1, h, 64, syncentry_handler 1, h, 64, irqentry_handler 1, h, 64, fiqentry_handler 1, h, 64, errorentry_handler 0, t, 64, sync //这个地方时异常选项的处理程序entry_handler 0, t, 64, irqentry_handler 0, t, 64, fiqentry_handler 0, t, 64, errorentry_handler 0, t, 32, syncentry_handler 0, t, 32, irqentry_handler 0, t, 32, fiqentry_handler 0, t, 32, error内核第一次通过ret_to_user函数调用0x400680地址异常处理程序如下:
entry_handler 0, t, 64, sync4.用户态的调用
内核态第一次调用EA的时候,报了异常,进入向量异常处理选项,调用了向量异常处理。在向量异常处理函数里面,保存了内核态的帧,栈,以及特殊寄存器之后。通过一个linux内核非常常用的变量*regs保存的PC寄存器值(保存的即EA),跳转到PC寄存器值,进行用户态运行。向量异常处理程序调用了函数el0t_64_sync_handler:
(lldb) nProcess 1 stopped* thread #1, stop reason = step over frame #0: 0xffff80008109e130 vmlinux`el0t_64_sync_handler(regs=0xffff800082b9beb0) at entry-common.c:726:22 723 724 asmlinkage void noinstr el0t_64_sync_handler(struct pt_regs *regs) 725 {-> 726 unsigned long esr = read_sysreg(esr_el1); 727 728 switch (ESR_ELx_EC(esr)) {此时内核变量regs里面的PC如下,刚好是EA。
(lldb) p/x regs->pc(u64) $1 = 0x0000000000400680看下第二次EA的状态:
(lldb) cProcess 1 resumingProcess 1 stopped* thread #2, stop reason = breakpoint 3.1 frame #0: 0x0000000000400680-> 0x400680: nop 0x400684: mov x29, #0x0 0x400688: mov x30, #0x0 0x40068c: mov x5, x0(lldb) re r pc ELR_EL1 VBAR pc = 0x0000000000400680 ELR_EL1 = 0x0000000000400680 VBAR = 0xffff800080010800 vmlinux`vectors这里的PC和ELR_EL1都是EA的地址,说明当前运行在了用户态EA入口处,且异常的返回地址也是EA。这里有个VBAR,它是异常选项的基地址。内核态第一次调用EA异常,就是通过VBAR的基地址找到异常选项,然后通过异常选项找到异常处理程序,进行正确的调用EA。
SYM_CODE_START(vectors) kernel_ventry 1, t, 64, sync //这里就是VBAR的基地址 //中间省略便于观看 kernel_ventry 0, t, 64, sync //这里即是内核调用用户态的向量异常选项 //省略便于观看SYM_CODE_END(vectors)下面看下内核态第一次调用EA的状态:
我们看到内核态第一次调用EA和第二次调用EA的状态完全一样。为什么会这样?
内核态第一次调用EA,需要向量异常选项找到向量异常处理程序再次调用EA。第一次EA的状态PC为EA地址,通过内核态调度填充。然后ELR_EL1也为EA,内核态不识别用户态地址,所以导致了向量异常选项被调用。然后根据VBAR基地址以及同步异常(sync)的偏移调用异常处理程序,为第二次调用EA做准备。这期间无论是ELR_EL11还是VBAR都没有被改变,而PC被改了之后又改了回来(因为需要再次调用EA),所以它们两次的状态完全一样。
第二次调用EA,因为ERET指令已经切换好了用户态模式,所以可以顺利地调用了。
这里需要注意的是,其一用户态的调用是被内核态调度填充的特权寄存器导致的,而非某个地方异常填充特权寄存器。其二Qemu-asm可以向上推导,其三以下Register,以及指令ERET也需注意:(lldb) re r ELR_EL1 ESR_EL1 VBAR PC ELR_EL1 = 0x0000000000400680 ESR_EL1 = 0x0000000096000044 VBAR = 0xffff800080010800 vmlinux`vectors pc = 0x0000000000400680(lldb) di -s 0xffff8000800121d8 -bvmlinux`ret_to_user: 0xffff8000800121d8 : 0xf9407bfe ldr x30, [sp, #0xf0] 0xffff8000800121dc : 0x910543ff add sp, sp, #0x150 0xffff8000800121e0 : 0xd503201f nop 0xffff8000800121e4 : 0xd503201f nop 0xffff8000800121e8 : 0xd69f03e0 eret那么总体来说,Arm64用户态的调用有以下步骤:1.通过内核态的调度,2. 调度填充相关的特权寄存器ELR_EL1,且把EA赋给ELR_EL1 ,3.通过指令ERET返回到ELR_EL1(EA),切换内核态到用户态环境,4.EA不能在内核态运行,调用向量异常选项,5.调用向量异常处理程序。6.调用EA。
可以看到,Arm64跟X64的是有大大的不同的。
来源:opendotnet