一、系统调用过程
1. 用户在进行系统调用时,通过传递一个系统调用编号,来告知内核,它所请求的系统调用,内核通过这个编号进而找到对应的处理系统调用的C函数。这个系统编号,在 x86 架构上,是通过 eax 寄存器传递的。
2. 系统调用的过程跟其他的异常处理流程一样,包含下面几个步骤:
(1) 将当前的寄存器上下文保存在内核 stack 中(这部分处理都在汇编代码中)
(2) 调用对应的C函数去处理系统调用
(3) 从系统调用处理函数返回,恢复之前保存在 stack 中的寄存器,CPU 从内核态切换到用户态
3. 在内核中用于处理系统调用的C函数入口名称是 sys_xxx() ,xxx() 就是对应的系统调用,实际上会有宏在xxx()前面加上一个函数头。 在 Linux 内核的代码中,这样的系统调用函数命名则是通过宏定义 SYSCALL_DEFINEx 来实现的,其中的 x 表示这个系统调用处理函数的输入参数个数。(不同的架构会复写这个宏定义,以实现不同的调用规则,其中 ARM64 的宏定义在 arch/arm64/include/asm/syscall_wrapper.h 文件中)
4. 将系统调用编号与这些实际处理C函数联系起来的是一张系统调用表 sys_call_table 这个表具有 __NR_syscalls 个元素(目前kernel-5.10这个值是440)。表中对应的 n 号元素所存储的就是 n 号系统调用对应的处理函数指针。__NR_syscalls 这个宏只是表示这个表的大小,并不是真正的系统调用个数,如果对应序号的系统调用不存在,那么就会用 sys_ni_syscall 填充,这是一个表示没有实现的系统调用,它直接返回错误码 -ENOSYS。
//arch/arm64/kernel/sys.c #undef __SYSCALL #define __SYSCALL(nr, sym) asmlinkage long __arm64_##sym(const struct pt_regs *); #include <asm/unistd.h> //<1> #undef __SYSCALL #define __SYSCALL(nr, sym) [nr] = __arm64_##sym, typedef long (*syscall_fn_t)(const struct pt_regs *regs); const syscall_fn_t sys_call_table[__NR_syscalls] = { [0 ... __NR_syscalls - 1] = __arm64_sys_ni_syscall, //这个函数是防止没有实现的,直接return -ENOSYS; #include <asm/unistd.h> //<2> };
<asm/unistd.h> 最终使用的是 <uapi/asm-generic/unistd.h> 它里面定义了 NR_xxx 和 相关函数,以 getpriority 系统调用的实现为例:
//include/uapi/asm-generic/unistd.h #define __NR_getpriority 141 __SYSCALL(__NR_getpriority, sys_getpriority)
在位置<1>,展开为:asmlinkage long __arm64_sys_getpriority(const struct pt_regs *);
在位置<2>,展开为:[141] = __arm64_sys_getpriority,
最终 sys_call_table[] 下标为 141 的位置指向的函数为 __arm64_sys_getpriority
二、系统调用的进入和退出
1. 在 x86 的架构上,支持2种方式进入和退出系统调用:
(1) 通过 int $0x80 触发软件中断进入,iret 指令退出
(2) 通过 sysenter 指令进入,sysexit指令退出
2. 在 ARM 架构上,则是通过 svc 指令进入系统调用。
ARM64 架构中,存在4个不同的运行级别,分别为 EL0、EL1、EL2、EL3,这4个级别运行的系统如下图所示:
用户态运行在 EL0 级别,我们讨论的内核则是运行在 EL1 级别。svc 指令通过触发一个同步异常,使得从 EL0 跳转到 EL1 级别,也就是从用户态跳转到了内核态。这个同步异常的处理入口在 arch/arm64/kernel/entry.S
文件中的 el0_sync 它是通过 kernel_ventry 这样一个宏在 ENTRY(vectors) 异常处理向量表中注册的,其实就是汇编中的一个标号。当 svc 指令执行时,CPU 就会切换到 EL1 级别,并且跳转到在异常向量表 vectors 中找到由宏 kernel_ventry 展开所在的地址。kernel_ventry 做了一个简单的溢出检测后,就跳转到真正的异常处理入口 el0_sync 。
/* * EL0 mode handlers. */ .align 6 SYM_CODE_START_LOCAL_NOALIGN(el0_sync) /*宏展开为: ; ; el0_sync: */ kernel_entry 0 mov x0, sp bl el0_sync_handler b ret_to_user SYM_CODE_END(el0_sync) /*宏展开为:.type el0_sync 0 ; .size el0_sync, .-el0_sync*/
在这段汇编指令中, kernel_entry 将寄存器入栈,保存现场。然后将当前的栈指针传递给 x0,作为 el0_sync_handler 的C函数入参。异常处理完成后,则通过 ret_to_user 回到用户态。
由于所有的同步的异常都是这个入口,所以在 el0_sync_handler 中会读取 ESR_EL1 寄存器获取真正触发同步异常的原因,然后进行对应的响应处理。此处,我们是 svc 指令触发的异常,所以调用 el0_svc 进行处理。我们看 do_el0_svc 函数的处理:
asmlinkage void noinstr el0_sync_handler(struct pt_regs *regs) //arch/arm64/kernel/entry-common.c { unsigned long esr = read_sysreg(esr_el1); switch (ESR_ELx_EC(esr)) { //取bit26-bit32 case ESR_ELx_EC_SVC64: //0x15 el0_svc(regs); //系统调用 break; ... default: el0_inv(regs, esr); } } static void noinstr el0_svc(struct pt_regs *regs) { enter_from_user_mode(); do_el0_svc(regs); } void do_el0_svc(struct pt_regs *regs) //arch/arm64/kernel/syscall.c { sve_user_discard(); el0_svc_common(regs, regs->regs[8], __NR_syscalls, sys_call_table); //reg[8]也就是X8寄存器存储的是系统调用号 } static void el0_svc_common(struct pt_regs *regs, int scno, int sc_nr, const syscall_fn_t syscall_table[]) //syscall.c { unsigned long flags = current_thread_info()->flags; regs->orig_x0 = regs->regs[0]; regs->syscallno = scno; cortex_a76_erratum_1463225_svc_handler(); local_daif_restore(DAIF_PROCCTX); if (flags & _TIF_MTE_ASYNC_FAULT) { regs->regs[0] = -ERESTARTNOINTR; return; } if (has_syscall_work(flags)) { if (scno == NO_SYSCALL) regs->regs[0] = -ENOSYS; scno = syscall_trace_enter(regs); if (scno == NO_SYSCALL) goto trace_exit; } /*跳转到对应系统调用编号的处理函数中 */ invoke_syscall(regs, scno, sc_nr, syscall_table); if (!has_syscall_work(flags) && !IS_ENABLED(CONFIG_DEBUG_RSEQ)) { local_daif_mask(); flags = current_thread_info()->flags; if (!has_syscall_work(flags) && !(flags & _TIF_SINGLESTEP)) return; local_daif_restore(DAIF_PROCCTX); } trace_exit: syscall_trace_exit(regs); } static void invoke_syscall(struct pt_regs *regs, unsigned int scno, unsigned int sc_nr, const syscall_fn_t syscall_table[]) //syscall.c { long ret; if (scno < sc_nr) { syscall_fn_t syscall_fn; syscall_fn = syscall_table[array_index_nospec(scno, sc_nr)]; //获取 sys_call_table[] 中的回调函数 ret = __invoke_syscall(regs, syscall_fn); } else { ret = do_ni_syscall(regs, scno); } if (is_compat_task()) ret = lower_32_bits(ret); regs->regs[0] = ret; //将系统调用函数返回值保存在X0寄存器 } static long __invoke_syscall(struct pt_regs *regs, syscall_fn_t syscall_fn) { /* * 调用kernel实现的系统调用函数,对于 getpriority() * 系统调用来说就是 __arm64_sys_getpriority() */ return syscall_fn(regs); }
在结束系统调用的时候,内核需要把 CPU 让给用户,不过在返回前,内核会检查是否需要进行一次 schedule,如果需要,那么这次返回到用户空间的时候,CPU 就会执行另一个进程,而不是触发之前触发系统调用的那个。返回的处理代码在汇编函数 ret_to_user 中:
/* * "slow" syscall return path. */ SYM_CODE_START_LOCAL(ret_to_user) disable_daif gic_prio_kentry_setup tmp=x3 #ifdef CONFIG_TRACE_IRQFLAGS bl trace_hardirqs_off #endif ldr x19, [tsk, #TSK_TI_FLAGS] and x2, x19, #_TIF_WORK_MASK cbnz x2, work_pending finish_ret_to_user: user_enter_irqoff enable_step_tsk x19, x2 #ifdef CONFIG_GCC_PLUGIN_STACKLEAK bl stackleak_erase #endif kernel_exit 0 /* * Ok, we need to do extra processing, enter the slow path. */ work_pending: mov x0, sp // 'regs' mov x1, x19 bl do_notify_resume ldr x19, [tsk, #TSK_TI_FLAGS] // re-check for single-step b finish_ret_to_user SYM_CODE_END(ret_to_user)
首先它会关闭 DAIF(D:进程D状态的 mask,A:exception mask,I:IRQ,F:FIRQ)然后根据 task 的状态,确定是否需要进入 work_pending,也就是代码注释所说的“slow” system call。在 work_pending 中,do_notify_resume 中判断任务切换的标志如果有置位,就进行一次 schedule。最后就是 kernel_exit,这一处的汇编代码比较长,不过这些剩下的事情就是为用户进程做好恢复的准备,然后打开中断之类的。所有的异常处理在返回前都是调用这个宏,此处先略过不提。
asmlinkage void do_notify_resume(struct pt_regs *regs, unsigned long thread_flags) //arch/arm64/kernel/signal.c { do { /* Check valid user FS if needed */ addr_limit_user_check(); //若参数flag表示需要重新调度,就重新调度 if (thread_flags & _TIF_NEED_RESCHED) { /* Unmask Debug and SError for the next task */ local_daif_restore(DAIF_PROCCTX_NOIRQ); schedule(); } else { local_daif_restore(DAIF_PROCCTX); if (thread_flags & _TIF_UPROBE) uprobe_notify_resume(regs); if (thread_flags & _TIF_MTE_ASYNC_FAULT) { clear_thread_flag(TIF_MTE_ASYNC_FAULT); send_sig_fault(SIGSEGV, SEGV_MTEAERR, (void __user *)NULL, current); } if (thread_flags & _TIF_SIGPENDING) do_signal(regs); if (thread_flags & _TIF_NOTIFY_RESUME) { tracehook_notify_resume(regs); rseq_handle_notify_resume(NULL, regs); } if (thread_flags & _TIF_FOREIGN_FPSTATE) fpsimd_restore_current_state(); } local_daif_mask(); thread_flags = READ_ONCE(current_thread_info()->flags); } while (thread_flags & _TIF_WORK_MASK); }
三、系统调用的参数传递
1. 就像C函数一样,系统调用也需要有输入参数。在 X86 架构上,通常函数的参数是通过栈传递。不过由于系统调用,涉及到用户和内核2个栈,为了使参数的处理相对简单一些,系统调用的参数规定通过 CPU 寄存器传递。由于寄存器的数量有限,所以规定系统调用最多传递 6 个参数。如果有多的参数需要传递,那么就通过指针进行传递。
参数传递的实现在内核部分的代码,可以看 SYSCALL_DEFINEx 宏的定义(基于ARM64架构):
//include/linux/syscalls.h #define __MAP0(m,...) #define __MAP1(m,t,a,...) m(t,a) #define __MAP2(m,t,a,...) m(t,a), __MAP1(m,__VA_ARGS__) #define __MAP3(m,t,a,...) m(t,a), __MAP2(m,__VA_ARGS__) #define __MAP4(m,t,a,...) m(t,a), __MAP3(m,__VA_ARGS__) #define __MAP5(m,t,a,...) m(t,a), __MAP4(m,__VA_ARGS__) #define __MAP6(m,t,a,...) m(t,a), __MAP5(m,__VA_ARGS__) #define __MAP(n,...) __MAP##n(__VA_ARGS__) #define __SC_ARGS(t, a) a /* * 若x=2,按上面的宏展开后就是 " regs->regs[0], regs->regs[1] ", 可以使用 gcc -E 进行测试 */ //arch/arm64/include/asm/syscall_wrapper.h #define SC_ARM64_REGS_TO_ARGS(x, ...) \ __MAP(x,__SC_ARGS,,regs->regs[0],,regs->regs[1],,regs->regs[2],,regs->regs[3],,regs->regs[4],,regs->regs[5]) /* * __arm64_sys##name 就是填入到 sys_call_table 中的函数名,svc 同步异常就是跳转到这个入口 * 这个入口函数将CPU寄存器中值作为函数入参传递到下一级子函数中,如此即实现了系统调用的输入 * 参数传递. */ //arch/arm64/include/asm/syscall_wrapper.h #define __SYSCALL_DEFINEx(x, name, ...) \ asmlinkage long __arm64_sys##name(const struct pt_regs *regs); \ ALLOW_ERROR_INJECTION(__arm64_sys##name, ERRNO); \ static long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__)); \ static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__)); \ asmlinkage long __arm64_sys##name(const struct pt_regs *regs) \ { \ return __se_sys##name(SC_ARM64_REGS_TO_ARGS(x,__VA_ARGS__)); \ } \ static long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__)) \ { \ long ret = __do_sys##name(__MAP(x,__SC_CAST,__VA_ARGS__)); \ __MAP(x,__SC_TEST,__VA_ARGS__); \ __PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__)); \ return ret; \ } \ static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__))
注意:系统调用最大只能传入6个参数,使用X0-X5传递参数,在内核中可以全局检索到 SYSCALL_DEFINE6,但是检索不到 SYSCALL_DEFINE7。若是要多于6个参数要传递,就需要传结构体指针,可以参考 sched_setattr() 的实现。
2. 以 int getpriority(int which, id_t who) 为例展示系统调用展开:
//kernel/sys.c SYSCALL_DEFINE2(getpriority, int, which, int, who) { //函数实现 }
上面宏展开:
/* * 就是填入到 sys_call_table 中的函数名,svc 同步异常就是跳转到这个入口 * 这个入口函数将CPU寄存器中值作为函数入参传递到下一级子函数中 */ asmlinkage long __arm64_sys_getpriority(const struct pt_regs *regs); //参数是pt_regs是数组指针 static struct error_injection_entry __used __section("_error_injection_whitelist") _eil_addr___arm64_sys_getpriority = { .addr = (unsigned long)__arm64_sys_getpriority, .etype = EI_ETYPE_ERRNO, };; static long __se_sys_getpriority(__SC_LONG(int,which), __SC_LONG(int,who)); static inline long __do_sys_getpriority(int which, int who); asmlinkage long __arm64_sys_getpriority(const struct pt_regs *regs) { return __se_sys_getpriority(regs->regs[0], regs->regs[1]); //这里将寄存器根据SYSCAL_DEFINEx中的x拆开传递,传参就是X0,X1寄存器 } static long __se_sys_getpriority(__SC_LONG(int,which), __SC_LONG(int,who)) { long ret = __do_sys_getpriority((__force int) which, (__force int) who); __SC_TEST(int,which), __SC_TEST(int,who); return ret; } static inline long __do_sys_getpriority(int which, int who) { //函数实现 }
可见 SYSCALL_DEFINEX(...) {...} 定义的系统调用响应函数就是宏展开部分加函数实现部分的拼接。
3. 没有参数的系统调用宏有点特殊,以 pid_t fork(void) 系统调用为例展开:
//kernel/fork.c SYSCALL_DEFINE0(fork) { 函数实现 }
使用gcc -E 宏展开后:
asmlinkage long __arm64_sys_fork(const struct pt_regs *__unused); static struct error_injection_entry __used __section("_error_injection_whitelist") _eil_addr___arm64_sys_fork = { .addr = (unsigned long)__arm64_sys_fork, .etype = EI_ETYPE_ERRNO, }; asmlinkage long __arm64_sys_fork(const struct pt_regs *__unused) { 函数实现 }
通过以上的宏分析,我们可以看到在 ARM64 架构中,系统调用的参数就是通过 x0~x5 这6个寄存器进行传递的,再加上之前用于传递系统调用编号的 x8 寄存器。
在 X86 架构中,系统调用编号是通过 eax 传递,参数则是由 ebx, ecx, edx, esi, edi, ebp 这6个寄存器实现的。系统调用函数定义的这个宏可以根据不同的架构进行重新定义,如此即可以满足不同架构的系统调用规范要求。
系统调用的参数是用户态传递到内核的,所以对它们都需要进行安全检查。其中非常通用的是对地址的检查,内核通过 access_ok 这个函数进行一个简单的校验,这个函数的定义根据CPU架构不同而不同,下面是 ARM64 的定义:
//arch/arm64/include/asm/uaccess.h #define access_ok(addr, size) __range_ok(addr, size) //__range_ok 是使用汇编实现的函数,就是判断 (u65)addr + (u65)size <= (u65)current->addr_limit + 1
在 ARM64 上,这个函数通过汇编指令实现的,不过看注释就它所做的检查非常地基础,也就是看当前需要访问的空间是否有超过 current->addr_limit 。这个值通常是用户空间的最大地址,可以通过 get_fs 和 set_fs 获取和配置。
系统调用传递的参数有限,很多时候,在内核中处理系统调用的时候,需要访问进程的用户空间地址。内核中有许多用于在内核空间访问用户空间数据的宏,在下面的表格中列出它们。其中,带有双下划线的表示访问前不做地址校验。
Function | Function | Action |
---|---|---|
get_user | __get_user | 从用户空间读取一个整数 |
put_user | __put_user | 写入一个整数到用户空间 |
copy_from_user | __copy_from_user | 从用户空间拷贝一段数据 |
copy_to_user | __copy_to_user | 拷贝一段数据到用户空间 |
strncpy_from_user | __strncpy_from_user | 从用户空间拷贝一个字符串 |
strlen_user | strlen_user | 获取一个用户空间字符串的长度 |
clear_user | __clear_user | 将用户空间的一段空间全部写0 |
如前面所言,access_ok 只是一个非常粗糙的检查,它能确保用户传递的参数没有染指到内核空间。除此以外,传入的参数还是可能会存在错误,如果作为地址的入参并没有在当前这个进程的地址空间中,那么就会触发一个 page fault。下面是内核中产生 page fault 的一些原因:
(1) 内核访问的地址属于进程的地址空间,不过内存页还不存在或者我们对一个只读属性的 page 进行写操作。此时,在 page fault 中会初始化一个新的页框
(2) 内核访问的地址属于进程的地址空间,不过对应的 PTE 还没有建立,此时会新建对应地址的 PTE
(3) 内核函数的 bug,导致出现访问异常,此时会触发 kernel oops
(4) 系统调用传递下来的参数,地址不属于进程的地址空间
前2种情况都是正常的流程,也很好区分,是否属于地址空间,在进程的 VMA 中的进行查找即可知道,PTE 是否建立,查看对应地址的 PTE 是否为空即可。麻烦的是后面2中情况的区分。如果只是系统调用参数导致的错误,那么内核应该只是将这种错误反馈到用户空间即可,不必大惊小怪地进行一次 oops。
为了把这2种情况区分开来,Linux 搞了一张 exception table。内核访问进程的用户空间都是通过前面列出的几个宏进行的,如果是第四种 page fault 的情况,那么引发 page fault的指令地址肯定就是在那几个访问用户空间地址的接口处。这样我们只需要把这些接口中会触发 page fault 的指令登记在这个 exception table 中,出现 page fault 的时候,就去这张表里找,如果能找到,那么就说明是第四种情况。
在 do_page_fault 中,通过函数 search_exception_tables 查找 exception table。而这个 exception table 在编译阶段由编译器将它们存放在了 __ex_table 段,在加载内核的时候,这个段会被加载到内存中。指示这个段的起始地址和结束地址的符号是 __start___ex_table & __stop___ex_table。
在 exception table 中,每个元素由2个整数构成:
struct exception_table_entry { int insn, fixup; };
第一个就是产生异常的指令地址值,而第二个则是 do_page_fault 在匹配到这个地址时,可以跳转继续执行的地址,所有又叫做 fixup 。在 fixup 中,通常会设置好错误码,以便返回给用户空间,并且 fixup 这部分的指令也存放在一个名为 .fixup 的段。下面是 ARM64 架构中 get_user 接口中的对于 exception 的处理:
其中宏 _ASM_EXTABLE 的作用是往 __ex_table 段中添加元素,其中 from 就是异常发生时的指令地址,而 to 就是异常发生后跳转到 fixup 的地址。在 get_user 中,from 对应着标号为 1 的指令所在地址,to 则对应着标号为 3 的指令所在地址。也即是 get_user 中,只有标号为 1 处的指令可能触发 page fault,如果是它触发了异常,那么就跳转到 3 所在位置进行修补。在这里我们看到,它将 -EFAULT 传递给 w0 寄存器,并且将 0 赋值给输入参数 x。这样也就是当 get_user 在访问一个异常地址时,do_page_fault 通过 exception table 将会让它返回一个错误码 -EFAULT,并且读取到的值为0。
在内核中进行系统调用的宏 _syscall0 在最新的内核代码中已经找不到了,这样比较好,毕竟系统调用这个东西就是用户空间与内核空间的一个交互,在内核空间触发进入系统调用流程看起来不太优雅,也没什么必要。不过在最新的代码里找到了这样一个头文件 tools/include/nolibc/nolibc.h ,这个文件比较新,是用于给那些精简到连C运行库都不想用的系统。通过一个头文件,这样程序中真正用到的系统调用才会被编译生成,其他不用的就可以不占用系统的空间了。(我想这个系统都这么扣了,那应该是不是考虑不用 Linux 系统了呢)
四、其它
1. 直接使用系统调用号
#include <stdio.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <string.h> #include <errno.h> #define _GNU_SOURCE #include <unistd.h> #include <sys/syscall.h> void main() { int fd; char r_buf[64] = {0}; fd = open("./tmp.txt", O_RDWR|O_CREAT, S_IRUSR|S_IWUSR); if (fd < 0) { printf("open error, errno=%d: %s\n", errno, strerror(errno)); return; } write(fd, "Hello ", strlen("Hello ")); syscall(SYS_write, fd, "World!", strlen("World!")); //直接使用系统调用号 lseek(fd, 0, SEEK_SET); read(fd, r_buf, sizeof(r_buf)); close(fd); } /* $ ./pp r_buf: Hello World! */