x86_64系统调用过程
本文所述Linux内核版本为v6.4.0
一、概述
在x86_64架构下,系统调用会经历以下过程:
- 将系统调用号存入
rax
寄存器,参数依次存入rdi
、rsi
、rdx
、r10
、r8
、r9
寄存器,第7个及之后的参数会通过栈传递。 - 执行
syscall
指令,该指令会保存syscall
指令下一条指令的地址,然后将权限从用户态转换到内核态,并将rip
设置为entry_SYSCALL_64
程序的入口地址。 - 执行
entry_SYSCALL_64
程序,内核会保存用户态的上下文,包括寄存器和堆栈指针,然后调用do_syscall_64
函数来完成系统调用功能。 - 系统调用处理函数执行完毕后,内核将返回值放入
rax
寄存器,然后内核恢复之前保存的用户态上下文,包括寄存器和堆栈指针。 - 内核执行
sysret
指令,将控制权返回给用户态程序。
二、MSR寄存器
从80486之后的x86架构CPU,内部增加了一组新的寄存器,统称为MSR
寄存器(Model Specific Registers),这些寄存器不像上面列出的寄存器是固定的,这些寄存器可能随着不同的版本有所变化,主要用来支持一些新的功能。
随着x86CPU不断更新换代,MSR
寄存器变的越来越多,但与此同时,有一部分MSR
寄存器随着版本迭代,慢慢固化下来,成为了变化中那部分不变的。
在早期的x86架构CPU上,系统调用依赖于软中断实现,如Linux中的int 80
。软中断是一个比较慢的操作,因为执行软中断就需要内存查表,通过IDTR
定位到IDT
,再取出函数地址进行执行。
而系统调用是一个频繁触发的动作,如此这般势必对性能有所影响。在进入奔腾时代后,就使用几个特定的MSR
寄存器,分别存储了执行系统调用时内核系统调用入口函数所需要的参数,不再需要内存查表。快速系统调用还提供了专门的CPU指令sysenter
/sysexit
用来发起系统调用和退出系统调用(在64位上,这一对指令升级为syscall
/sysret
)。
三、段选择符
段选择符结构如下:
-
Index:所对应的段描述符处于
GDT
或LDT
中的索引。 -
TI:表示对应段描述符保存在
GDT
中还是LDT
中,0表示全局描述符表GDT
,1表示局部描述符表LDT
。 -
RPL:当该段选择符装入
cs
寄存器时,设置CPU当前的特权级CPL
的值为RPL
,也就是cs
寄存器中的RPL
就是CPL
。
CPL
值为0,表示CPU当前特权级别为Ring0
(内核态),值为3,表示表示CPU当前特权级别为Ring3
(用户态)。
四、段描述符
GDT
全局段描述符表中的每个条目都有一个这样的复杂的结构:
-
BASE :段首地址的线性地址。
-
LIMIT :该段最后一个地址的偏移量。
- MORE:包括段的各种标志(如类型、特权级别等),结构如下:
- DPL:表示访问这个段CPU要求的最小优先级(保存在cs寄存器的CPL特权级)。当
DPL
为0时,只有CPL
为0才能访问,DPL
为3时,CPL
为0为3都可以访问这个段。
五、SYSCALL指令
syscall
指令主要做了三个工作:
- 将
rip
寄存器内容保存到rcx
寄存器。 - 将
MSR_LSTAR
寄存器中的系统调用处理程序入口地址存入rip
寄存器。 - 将
MSR_STAR
寄存器的[47:32]
存入cs
和ss
段选择寄存器。
MSR
寄存器初始化核心代码为:
// MSR_STAR的[63:48]存入用户代码段选择符,[47:32]存入内核代码段选择符
// wrmsr函数第一个参数表示要写入的MSR编号,第二个参数表示要写入低32位的值,第三个参数表示要写入高32位的值
wrmsr(MSR_STAR, 0, (__USER32_CS << 16) | __KERNEL_CS);
// 使用系统调用处理程序entry_SYSCALL_64地址填充MSR_LSTAR寄存器
wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);
cs
代码段寄存器指向包含程序指令的段,在cs
寄存器中RPL
用于表示当前CPU的特权级CPL
。
CPL
为0是最高权限(内核态使用),CPL
为3是用户态使用。
-
__USER32_CS
是用户代码段选择符的值,低两位为0b11
。 -
__KERNEL_CS
是内核代码段选择符的值,低两位为0b00
。
由于syscall
指令将内核代码段选择符的值存入了 cs
和 ss
段选择寄存器,当前CPU特权级别从Ring3
变为Ring0
,即由用户态转变为了内核态。
接下来就是进入entry_SYSCALL_64
处理流程。
六、entry_SYSCALL_64
在arch/x86/entry/entry_64.S
中的entry_SYSCALL_64
程序源码如下:
SYM_CODE_START(entry_SYSCALL_64)
UNWIND_HINT_ENTRY
ENDBR
/* 交换gs寄存器的值 */
swapgs
/* tss.sp2 is scratch space. */
/* 将当前的栈指针保存到tss中的sp2字段 */
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
/* 使用%rsp作为临时寄存器来切换到内核态页表(KPTI内核页表隔离) */
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
/* 构建用户态寄存器上下文(struct pt_regs) */
/* 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. */
/* 将当前内核栈指针作为参数,相当于传递了一个用户态的pt_regs */
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
/* 函数执行系统调用功能,并将返回值存入rax寄存器 */
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.
*/
/* do_syscall_64执行过程中产生异常或其他特殊情况,会跳转到慢退出路径 */
ALTERNATIVE "", "jmp swapgs_restore_regs_and_return_to_usermode", \
X86_FEATURE_XENPV
movq RCX(%rsp), %rcx
movq RIP(%rsp), %r11
cmpq %rcx, %r11 /* SYSRET requires RCX == RIP */
jne swapgs_restore_regs_and_return_to_usermode
/*
* On Intel CPUs, SYSRET with non-canonical RCX/RIP will #GP
* in kernel space. This essentially lets the user take over
* the kernel, since userspace controls RSP.
*
* If width of "canonical tail" ever becomes variable, this will need
* to be updated to remain correct on both old and new CPUs.
*
* Change top bits to match most significant bit (47th or 56th bit
* depending on paging mode) in the address.
*/
#ifdef CONFIG_X86_5LEVEL
ALTERNATIVE "shl $(64 - 48), %rcx; sar $(64 - 48), %rcx", \
"shl $(64 - 57), %rcx; sar $(64 - 57), %rcx", X86_FEATURE_LA57
#else
shl $(64 - (__VIRTUAL_MASK_SHIFT+1)), %rcx
sar $(64 - (__VIRTUAL_MASK_SHIFT+1)), %rcx
#endif
/* If this changed %rcx, it was not canonical */
cmpq %rcx, %r11
jne swapgs_restore_regs_and_return_to_usermode
cmpq $__USER_CS, CS(%rsp) /* CS must match SYSRET */
jne swapgs_restore_regs_and_return_to_usermode
movq R11(%rsp), %r11
cmpq %r11, EFLAGS(%rsp) /* R11 == RFLAGS */
jne swapgs_restore_regs_and_return_to_usermode
/*
* SYSCALL clears RF when it saves RFLAGS in R11 and SYSRET cannot
* restore RF properly. If the slowpath sets it for whatever reason, we
* need to restore it correctly.
*
* SYSRET can restore TF, but unlike IRET, restoring TF results in a
* trap from userspace immediately after SYSRET. This would cause an
* infinite loop whenever #DB happens with register state that satisfies
* the opportunistic SYSRET conditions. For example, single-stepping
* this user code:
*
* movq $stuck_here, %rcx
* pushfq
* popq %r11
* stuck_here:
*
* would never get past 'stuck_here'.
*/
testq $(X86_EFLAGS_RF|X86_EFLAGS_TF), %r11
jnz swapgs_restore_regs_and_return_to_usermode
/* nothing to check for RSP */
cmpq $__USER_DS, SS(%rsp) /* SS must match SYSRET */
jne swapgs_restore_regs_and_return_to_usermode
/*
* We win! This label is here just for ease of understanding
* perf profiles. Nothing jumps here.
*/
/* 若通过所有检查,使用sysret来返回用户态 */
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
/* 切换回用户态,Ring0 -> Ring3 */
sysretq
SYM_INNER_LABEL(entry_SYSRETQ_end, SYM_L_GLOBAL)
ANNOTATE_NOENDBR
/* 正常返回情况不会被执行 */
int3
SYM_CODE_END(entry_SYSCALL_64)
七、内核页表隔离KPTI
内核页表隔离(Kernel page-table isolation,缩写KPTI,也简称PTI,旧称KAISER)是Linux内核中的一种强化技术,旨在更好地隔离用户空间与内核空间的内存来提高安全性,缓解现代x86CPU中的“熔断(Meltdown)”硬件安全缺陷。
在 KPTI机制中,内核态空间的内存和用户态空间的内存的隔离进一步得到了增强。
- 内核态中的页表包括用户空间内存的页表和内核空间内存的页表。
- 用户态的页表只包括用户空间内存的页表以及必要的内核空间内存的页表,如用于处理系统调用、中断等信息的内存。