在系统调用中了解到了系统调用执行的流程。在背诵八股文的过程当中,一直提到过说系统调用的开销大。那么这里就详细了解一下系统调用的开销就是在哪里了。
从glibc到陷入内核
还是以glibc库的open函数为入口。前面章节介绍过的这里不再赘述,直接跳过下面的部分
// sysdeps/unix/sysv/linux/sysdep.h
#undef INLINE_SYSCALL
#define INLINE_SYSCALL(name, nr, args...) \
({ \
long int sc_ret = INTERNAL_SYSCALL (name, nr, args); \
__glibc_unlikely (INTERNAL_SYSCALL_ERROR_P (sc_ret)) \
? SYSCALL_ERROR_LABEL (INTERNAL_SYSCALL_ERRNO (sc_ret)) \
: sc_ret; \
})在这里进行系统调用,并处理系统调用的返回结果。如果发生错误则将errno设置为对应的值,并返回-1。
在对应架构的实现进行展开,例如x86,就按照参数个数展开为了不同的宏
// sysdeps/unix/sysv/linux/x86_64/sysdep.h
#undef INTERNAL_SYSCALL
#define INTERNAL_SYSCALL(name, nr, args...) \
internal_syscall##nr (SYS_ify (name), args)会经过SYS_ify将传入的服务名转为系统服务号,通过加上__NR_前缀来解决,在linux的头文件当arch/x86/include/generated/uapi/asm/unistd_64.h中设置了对应的值。
这是由于不同的参数个数会使用不同的数量的寄存器来传递,因此这样展开,目前最多支持6个参数。逻辑是一样的,那就直接看6个参数的处理方式.
// sysdeps/unix/sysv/linux/x86_64/sysdep.h
#undef internal_syscall6
#define internal_syscall6(number, arg1, arg2, arg3, arg4, arg5, arg6) \
({ \
unsigned long int resultvar; \
TYPEFY (arg6, __arg6) = ARGIFY (arg6); \
TYPEFY (arg5, __arg5) = ARGIFY (arg5); \
TYPEFY (arg4, __arg4) = ARGIFY (arg4); \
TYPEFY (arg3, __arg3) = ARGIFY (arg3); \
TYPEFY (arg2, __arg2) = ARGIFY (arg2); \
TYPEFY (arg1, __arg1) = ARGIFY (arg1); \
register TYPEFY (arg6, _a6) asm ("r9") = __arg6; \
register TYPEFY (arg5, _a5) asm ("r8") = __arg5; \
register TYPEFY (arg4, _a4) asm ("r10") = __arg4; \
register TYPEFY (arg3, _a3) asm ("rdx") = __arg3; \
register TYPEFY (arg2, _a2) asm ("rsi") = __arg2; \
register TYPEFY (arg1, _a1) asm ("rdi") = __arg1; \
asm volatile ( \
"syscall\n\t" \
: "=a" (resultvar) \
: "0" (number), "r" (_a1), "r" (_a2), "r" (_a3), "r" (_a4), \
"r" (_a5), "r" (_a6) \
: "memory", REGISTERS_CLOBBERED_BY_SYSCALL); \
(long int) resultvar; \
})- 首先定义了resutlval调用结果,并作为函数返回值
TYPEFY (arg6, __arg6) = ARGIFY (arg6);:这里使用了两个特殊的宏,分别用来处理参数的类型和值register TYPEFY (arg6, _a6) asm ("r9") = __arg6;:分配寄存器,将参数的值放入到分配的寄存器当中。- 接下来是一段内联汇编内联汇编,执行syscall指定跳转到指定的位置。一行一行进行说明
- 汇编指令:syscall即x86-64的指令
- 输出操作数:=a表示输出在rax寄存器当中,(resultval)表示将rax寄存器的值存到变量resultval当中
- 输入操作数:“0”(number)表示将number的值放入rax寄存器当中。“r”(_a1)表示使用通用寄存器来存放_a1,其余同理。
- 破坏性操作数:
"memory":告知编译器该汇编块可能会修改内存状态,不要对内存操作进行优化。这通常意味着汇编指令会读写内存中的数据。REGISTERS_CLOBBERED_BY_SYSCALL:表示系统调用可能会修改的寄存器的列表
syscall指令陷入内核处理
现在cpu跳转到了syscall指定的入口(通常存储在 LSTAR MSR 寄存器中),也就是arch/x86/entry/entry_64.S的entry_SYSCALL_64代码段。此时已经在寄存器中设置好了这次调用的系统服务号以及参数。
下面是一段比较长的汇编代码,需要好好梳理一下。也可以参考kernel entry中的说明
SYM_CODE_START(entry_SYSCALL_64)
// 提供给调试器和栈展开器信息,表示是一个函数的入口
UNWIND_HINT_ENTRY
// Intel的一条指令。用来确保只有合法的间接分支才能跳转到含有此指令的位置,防止控制流劫持攻击
ENDBR
swapgs
/* tss.sp2 is scratch space. */
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
movq PER_CPU_VAR(pcpu_hot + X86_top_of_stack), %rsp
SYM_INNER_LABEL(entry_SYSCALL_64_safe_stack, SYM_L_GLOBAL)
ANNOTATE_NOENDBR
/* Construct struct pt_regs on stack */
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp2) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip */
SYM_INNER_LABEL(entry_SYSCALL_64_after_hwframe, SYM_L_GLOBAL)
pushq %rax /* pt_regs->orig_ax */
PUSH_AND_CLEAR_REGS rax=$-ENOSYS
/* IRQs are off. */
movq %rsp, %rdi
/* Sign extend the lower 32bit as syscall numbers are treated as int */
movslq %eax, %rsi
/* clobbers %rax, make sure it is after saving the syscall nr */
IBRS_ENTER
UNTRAIN_RET
CLEAR_BRANCH_HISTORY
call do_syscall_64 /* returns with IRQs disabled */
/*
* Try to use SYSRET instead of IRET if we're returning to
* a completely clean 64-bit userspace context. If we're not,
* go to the slow exit path.
* In the Xen PV case we must use iret anyway.
*/
ALTERNATIVE "testb %al, %al; jz swapgs_restore_regs_and_return_to_usermode", \
"jmp swapgs_restore_regs_and_return_to_usermode", X86_FEATURE_XENPV
/*
* We win! This label is here just for ease of understanding
* perf profiles. Nothing jumps here.
*/
syscall_return_via_sysret:
IBRS_EXIT
POP_REGS pop_rdi=0
/*
* Now all regs are restored except RSP and RDI.
* Save old stack pointer and switch to trampoline stack.
*/
movq %rsp, %rdi
movq PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp
UNWIND_HINT_END_OF_STACK
pushq RSP-RDI(%rdi) /* RSP */
pushq (%rdi) /* RDI */
/*
* We are on the trampoline stack. All regs except RDI are live.
* We can do future final exit work right here.
*/
STACKLEAK_ERASE_NOCLOBBER
SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi
popq %rdi
popq %rsp
SYM_INNER_LABEL(entry_SYSRETQ_unsafe_stack, SYM_L_GLOBAL)
ANNOTATE_NOENDBR
swapgs
CLEAR_CPU_BUFFERS
sysretq
SYM_INNER_LABEL(entry_SYSRETQ_end, SYM_L_GLOBAL)
ANNOTATE_NOENDBR
int3
SYM_CODE_END(entry_SYSCALL_64)swapgs切换GS段寄存器
这条指令用于切换GS段寄存器。这里需要先了解一下GS寄存器。
在32bit模式下,CPU提供了6个段,并且支持段限制来加强地址空间保护。在64bit下,CS/SS/DS/ES的值都设置为0,忽略掉了来使用全部的64bit地址空间。但是FS和GS两个段寄存器还是仍然在使用的。
其中FS寄存器用户Thread Local Storage(TLS),通常由运行时或者线程库进行管理,使用__thread声明的变量是每个线程私有的,所以每个线程都有一个自己的FS值。线程访问私有变量的时候也不会复杂的地址便宜计算,每个线程切换的时候FS也会随之切换,直接用偏移量访问即可。
至于GS寄存器,每个cpu都有自己特定的数据,如堆栈、调度器等,切换到内核态需要访问与当前CPU相关的内核数据,就是通过GS寄存器来记录的。使用swapgs来完成gs寄存器的切换。
至于被切换的值会保存在MSR 寄存器(Model-Specific Registers)当中。
切换内核栈
在gs寄存器切换之后,就可以访问到当前cpu的内核数据了。 然后从用户栈切换到内核栈。
// 将当前栈指针 `%rsp` 保存到 `TSS_sp2`
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
// 切换到内核的页表,确保后续内核操作使用内核地址空间。将CR3寄存器指向内核的页目录表
SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
// 切换栈指针到内核栈顶
movq PER_CPU_VAR(pcpu_hot + X86_top_of_stack), %rsp可以看到这里的切换操作是切换了rsp栈顶的值。至于rbp,这个可能在后续调用具体的系统服务函数实现的时候切换,这里只是陷入内核的处理,并不是需要关注的函数栈帧。
保护用户空间寄存器状态
也是构建pt_regs结构体的过程,寄存器的值就保存在这里结构体中。
SYM_INNER_LABEL(entry_SYSCALL_64_safe_stack, SYM_L_GLOBAL)
ANNOTATE_NOENDBR
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp2) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip */
SYM_INNER_LABEL(entry_SYSCALL_64_after_hwframe, SYM_L_GLOBAL)
pushq %rax /* pt_regs->orig_ax */
这里保存的都是一些比较特殊的寄存器,下面还会保存和清空通用寄存器,通过PUSH_AND_CLEAR_REGS指令来完成,以确保后续的内核代码不会误用用户态的寄存器值。,还将 rax 设置为 -ENOSYS,作为默认的返回值表示系统调用无效。
PUSH_AND_CLEAR_REGS rax=$-ENOSYS设置参数并执行系统调用
/* IRQs are off. */
// 将栈顶赋值给rdi,现在这个栈顶的值就是保存寄存器的pt_regs结构体
movq %rsp, %rdi
/* Sign extend the lower 32bit as syscall numbers are treated as int */
// 将系统调用号扩展为64位赋值给rsi
movslq %eax, %rsi
/* clobbers %rax, make sure it is after saving the syscall nr */
// 下面三条指令用于 Spectre 和 Meltdown 缓解措施,保护内核免受投机执行攻击。
IBRS_ENTER
UNTRAIN_RET
CLEAR_BRANCH_HISTORY
// 执行实际的系统调用
call do_syscall_64 /* returns with IRQs disabled */do_syscall_64函数的定义在arch/x86/entry/common.c当中,追踪下面就会发现使用系统服务号在系统服务表中找到对应的系统服务入口,并完成执行。·
系统调用返回路径选择
/*
* Try to use SYSRET instead of IRET if we're returning to
* a completely clean 64-bit userspace context. If we're not,
* go to the slow exit path.
* In the Xen PV case we must use iret anyway.
*/
ALTERNATIVE "testb %al, %al; jz swapgs_restore_regs_and_return_to_usermode", \
"jmp swapgs_restore_regs_and_return_to_usermode", X86_FEATURE_XENPV
/*
* We win! This label is here just for ease of understanding
* perf profiles. Nothing jumps here.
*/
syscall_return_via_sysret:从注释中可以发现,对于纯64bit用户上下文,希望通过SYSRET指令来返回系统调用。而不是IRET指令,不过对于Xen环境的必须使用iret指令。
所以在系统调用之后的返回多了一个路径的选择判断,iret返回走swapgs_restore_regs_and_return_to_usermode,否则可以走sysret快速退出路径,也就是syscall_return_via_sysret标签后面的内容。
sysret系统调用返回
这个标签路径也说明了是通过sysret返回用户态。
syscall_return_via_sysret:
// 退出IBRS模式
IBRS_EXIT
// 恢复通用寄存器的值,与前面的保存相呼应
POP_REGS pop_rdi=0
// 恢复用户态的GS寄存器
swapgs
// 清除 CPU 缓存和缓冲区,进一步减小缓存投机执行攻击风险。
CLEAR_CPU_BUFFERS
// 通过 SYSRET 指令从内核模式快速返回用户态
sysretq
开销在哪里
- 模式切换。在一个系统调用中涉及到用户态到内核态,以及内核态到用户态的切换。每次切换都会涉及寄存器状态的保存和恢复,会占用cpu周期。
- 上下文切换。例如gs的切换,切换之后还需要加载对应cpu的内核数据结构,还有栈的切换。每个进程和内核线程都有自己的栈。上下文切换的开销是巨大的,还可能导致缓存失效,进一步增加开销。
- 地址空间切换。在上面的内核切换小节中,还有一个页表的切换。用户态和内核态有各自的地址空间,所以在切换的时候就需要刷新页表。这回影响内存访问速度以及进一步缓存失效的问题。
- 调度干扰。系统调用导致进程阻塞(I/O),调度器会调度其他进程运行,会引起更多的上下文切换。
- 陷阱指令的开销。如syscall指令会触发特殊的CPU行为,包括特权级别的检查(从ring3到ring0)、陷阱向量的查找和跳转等。这些陷阱指令需要执行多步操作,甚至涉及到处理器内部的多次微指令执行,导致较高的执行开销。
总结下来就是寄存器保存和恢复、进程栈和用户栈的保存与恢复、CPU内核数据的加载、页表的切换。以及这些切换引起的缓存失效问题,导致内存访问变慢,可能的调度引起的进一步切换。还有就是指令自身的开销。