5.1. ARM64 Linux系统调用
5.1.1 用户层:用hello world演示系统调用
从何说起?从最简单的hello world实例说起!
root@u20:~# cat hello.c
#include <stdio.h>
int main()
{
printf("Hello World!\n");
return 0;
}
root@u20:~# gcc -g -o hello hello.c
一个最简单的hello world程序,会涉及多少系统调用?使用strace -c来统计一下涉及了哪些syscall,如下图:

接下来使用gdb来找出系统调用write对应的汇编指令:
(1)通过(gdb) catch syscall write定义一个断点。
(2)执行(gdb) run,走到write断点。
(3)通过(gdb) disassemble打印出当前上下文的汇编代码。

上述汇编代码片段是 __GI___libc_write 函数的一部分,这是 GNU C 库(glibc)中实现的 write 系统调用的包装函数。这个函数的作用是准备参数并通过系统调用write来与内核交互,以完成实际的数据写入操作。在hello world代码中,所谓数据写入对应的就是printf操作。
其中的关键代码片段如下:
0x0000fffff7f08c7c <+44>: mov x8, #0x40 // #64
0x0000fffff7f08c80 <+48>: svc #0x0
在发起 SVC 指令之前,应用程序需要将系统调用号以及任何必要的参数放置在特定的寄存器中。对于 AArch64 来说:系统调用号通常放在 x8 寄存器。参数依次放在 x0 到 x7 寄存器中(最多 6 个参数通过寄存器传递,更多参数可以通过内存传递)。
在当前的例子中,x8 被设置为 0x40,这是 write 系统调用的编号,对应的十进制是64。
当程序在用户层(EL0)执行 SVC 指令时,它会触发一个同步异常(即软件中断),导致处理器进入更高的特权级别(通常是 EL1,即操作系统运行的级别)。那么Linux内核如何处理这个EL0 同步异常呢?在下一章节揭晓。
5.1.2 内核层:ARM64 Linux系统调用的流程
参考之前分析的《2.2 ARM64异常处理》,对于用户层(EL0)触发的同步异常,其对应的异常向量表表项是“kernel_ventry 0,sync”,最终跳转到el0_sync。
第一步,从el0_sync跳转到el0_svc。
同步异常有很多种,需要根据同步异常的类别,跳转到不同的处理程序。对应syscall,最终跳转到el0_svc。

第653行,mrs 指令将系统寄存器esr_el1的内容复制到通用寄存器x25。ESR_EL1(Exception Syndrome Register at Exception Level 1)是 ARMv8-A 架构中的一个系统寄存器,用于提供关于异常的信息。当处理器进入异常处理模式时,ESR_EL1 寄存器会被自动填充有关引起异常的详细信息。
第654行,lsr 指令执行逻辑右移操作。这里,x25 的值被右移 ESR_ELx_EC_SHIFT(26)位,以提取出异常类(exception class),存入x24。ESR_EL1 是一个 32 位寄存器,其各个字段提供了不同类型的异常信息。其中 ESR_EL1 [31:26]表示异常的类别(exception class),这是识别异常类型的关键部分。
第655行~656行,对x24 中的异常类与 ESR_ELx_EC_SVC64(0x15) 常量进行比较,检查是否为 64 位状态下的 SVC(Supervisor Call)异常。如果异常确实是 64 位状态下的 SVC,则跳转到 el0_svc 处理程序。
第二步,从el0_svc跳转到c函数el0_svc_handler
el0_svc非常简单,跳转到c函数el0_svc_handler。

第三步,从el0_svc_handler调用el0_svc_common

-
第一个入参regs,是struct pt_regs *regs,在处理系统调用和异常时,它用于保存处理器寄存器的状态,当用户空间程序进入内核模式(例如通过系统调用或异常)时,这些寄存器的值会被保存到 pt_regs 结构体中。
-
第二个入参scno的值,是从x8读出的系统调用号;
-
第三个入参为宏定义__NR_syscalls(294),它表示系统调用表的大小,即支持的最大系统调用编号加一,定义在include/uapi/asm-generic/unistd.h。
-
第四个入参是函数指针数组sys_call_table,也称为系统调用表,后面用到的时候再展开。它定义在arch/arm64/kernel/sys.c。
第四步,el0_svc_common调用invoke_syscall
el0_svc_common把自身的四个入参直接传递给invoke_syscall。

第45行,检查给定的系统调用号是否在有效的范围内(小于 sc_nr)。sc_nr传入的值为__NR_syscalls。即如果不在有效范围内,则会跳到第50行调用 do_ni_syscall 处理未知或不支持的系统调用。
第46~48行,使用 array_index_nospec 函数来确保索引的安全性,防止越界访问。然后从 syscall_table 中获取系统调用号所对应的处理函数指针,保存到syscall_fn。最后,通过__invoke_syscall执行syscall_fn(regs)。
以上文中hello world程序使用的系统调用write为例,参考下图的调用堆栈,其对应的syscall_fn就是__arm64_sys_write。__arm64_sys_write是如何定义的?又是如何插入sys_call_table的呢?
5.1.3 内核层:ARM64 Linux系统调用函数的定义
以上文中hello world程序使用的系统调用write为例,参考下图的调用堆栈,其对应的syscall_fn就是__arm64_sys_write。__arm64_sys_write是如何定义的?又是如何插入sys_call_table的呢?

- 第一问:__arm64_sys_write是如何定义的?
是在fs/read_write.c中使用SYSCALL_DEFINE3宏来进行定义的,即上图中的第608行。

如上图所示,SYSCALL_DEFINE3宏,定义在include/linx/syscalls.h,数字3代表所定义的函数有3个入参。SYSCALL_DEFINE3依赖于其它宏定义,关键依赖关系是:SYSCALL_DEFINE3->SYSCALL_DEFINEx->__SYSCALL_DEFINEx。其中最后一个__SYSCALL_DEFINEx宏,如果沿着图中的箭头去展开,那么write的系统调用函数是第239行定义的sys_write,并不是上图实际调用堆栈显示的__arm64_sys_wrirte。
为什么?
同样是在include/linx/syscalls.h中更靠前的地方,如果定义了CONFIG_ARCH_HAS_SYSCALL_WRAPPER,则包含asm/syscall_wrapper.h头文件。

asm/syscall_wrapper.h头文件会提前定义__SYSCALL_DEFINEx,如果下图第47行。针对write的系统调用函数进行展开,就是__arm64_sys_write。这个宏定义很复杂,实际定义了3个函数,调用关系是__arm64_sys_write->__se_sys_write->__do_sys_write,与上面的调用堆栈完全匹配。

- 第二问:__arm64_sys_write是如何插入sys_call_table的呢?
回到sys_call_table定义的地方arch/arm64/kernel/sys.c。

第69行,定义了函数指针数组sys_call_table,且数组的大小为__NR_syscalls。
第70行,把所有的数组元素都初始化为__arm64_sys_ni_syscall函数,这个函数是一个通用的系统调用处理函数,当系统调用号无效或未实现时,它会被调用。
第71行,包含了一个头文件<asm/unistd.h>,它会使用第67行定义的__SYSCALL宏来初始化数组的元素! 头文件包含是非常复杂的,展开过程如下:
arch/arm64/kernel/sys.c
->#include <asm/unistd.h>即arch/arm64/include/asm/unistd.h
->#include <uapi/asm/unistd.h>即arch/arm64/include/uapi/asm/unistd.h
->#include <asm-generic/unistd.h>即include/asm-generic/unistd.h
->#include <uapi/asm-generic/unistd.h>即include/uapi/asm-generic/unistd.h

第205行,定义了write系统调用号为64。同时可以看到,所有系统调用号都是在include/uapi/asm-generic/unistd.h文件中定义的。
第206行,展开后为”[64] = __arm64_sys_write, “,注意最后有个逗号,因为这是嵌入在sys_call_table数组的定义中的。
总结一下,本文以系统调用write为例,追踪系统调用发生的顺序,找到系统调用表sys_call_table中的__arm64_sys_write。__arm64_sys_write是如何定义的?又是如何插入sys_call_table的呢?通过解答上述两个问题,彻底搞清楚了arm64系统调用的原理。
arm64系统调用的相关定义汇总如下:
-
系统调用号
include/uapi/asm-generic/unistd.h: #define __NR_write 64 -
系统调用表
arch/arm64/kernel/sys.c: const syscall_fn_t sys_call_table[__NR_syscalls] = { [0 ... __NR_syscalls - 1] = __arm64_sys_ni_syscall, #include <asm/unistd.h> }; -
系统调用函数
fs/read_write.c: SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count) -
系统调用号和系统调用函数插入系统调用表
include/uapi/asm-generic/unistd.h: -
系统调用号和系统调用函数插入系统调用表
include/uapi/asm-generic/unistd.h: __SYSCALL(__NR_write, sys_write)
1235

被折叠的 条评论
为什么被折叠?



