Linux内核Arm64用户态

文摘   2024-12-12 10:11   新加坡  

点击上方蓝字 江湖评谈设为关注/星标




前言

大部分人玩的都是X86/x64那一套,移动端玩的倒是Arm,不过偏向于应用层级。本篇深入Arm64内核层级,看下Linuxkernel是如何调用Arm64的用户态。

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`vectors

Qemu-ASM在EA被调用的上一条指令:

----------------IN0xffff8000800121d8:  OBJD-T: fe7b40f9ff4305911f2003d51f2003d5e0039fd6

其代码如下:

(lldb) di -s 0xffff8000800121d8 -bvmlinux`ret_to_user:    0xffff8000800121d8 <+264>: 0xf9407bfe   ldr    x30, [sp, #0xf0]    0xffff8000800121dc <+268>: 0x910543ff   add    sp, sp, #0x150    0xffff8000800121e0 <+272>: 0xd503201f   nop        0xffff8000800121e4 <+276>: 0xd503201f   nop        0xffff8000800121e8 <+280>: 0xd69f03e0   eret       0xffff8000800121ec <+284>: 0xd503379f   dsb    nsh    0xffff8000800121f0 <+288>: 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, sync
4.用户态的调用
内核态第一次调用EA的时候,报了异常,进入向量异常处理选项,调用了向量异常处理。在向量异常处理函数里面,保存了内核态的帧,栈,以及特殊寄存器之后。通过一个linux内核非常常用的变量*regs保存的PC寄存器值(保存的即EA),跳转到PC寄存器值,进行用户态运行。向量异常处理程序调用了函数el0t_64_sync_handler:
(lldb) nProcess 1 stopped* thread #1, stop reason = step over    frame #00xffff80008109e130 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)) {   729 		case ESR_ELx_EC_SVC64:
此时内核变量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的状态:
(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`vectors
我们看到内核态第一次调用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 <+264>: 0xf9407bfe   ldr    x30, [sp, #0xf0]    0xffff8000800121dc <+268>: 0x910543ff   add    sp, sp, #0x150    0xffff8000800121e0 <+272>: 0xd503201f   nop        0xffff8000800121e4 <+276>: 0xd503201f   nop        0xffff8000800121e8 <+280>: 0xd69f03e0   eret 

那么总体来说,Arm64用户态的调用有以下步骤:1.通过内核态的调度,2.调度填充相关的特权寄存器ELR_EL1,且把EA赋给ELR_EL1 3.通过指令ERET返回到ELR_EL1(EA),切换内核态到用户态环境,4.EA不能在内核态运行,调用向量异常选项,5.调用向量异常处理程序。6.调用EA。

可以看到,Arm64跟X64的是有大大的不同的。

以上就是本篇内容了,欢迎关注,点赞。

往期精彩回顾

Arm32-Linux子系统的互相Notify

.Net JIT支持的Risc-V/La/Arm


关注公众号↑↑↑:江湖评谈 

江湖评谈
记录,分享,自由。
 最新文章