x86_64系统调用过程

x86_64系统调用过程

本文所述Linux内核版本为v6.4.0

一、概述

在x86_64架构下,系统调用会经历以下过程:

  1. 将系统调用号存入rax寄存器,参数依次存入rdirsirdxr10r8r9寄存器,第7个及之后的参数会通过栈传递。
  2. 执行syscall指令,该指令会保存syscall指令下一条指令的地址,然后将权限从用户态转换到内核态,并将pc设置为entry_SYSCALL_64程序的入口地址。
  3. 执行entry_SYSCALL_64程序,内核会保存用户态的上下文,包括寄存器和堆栈指针,然后调用do_syscall_64函数来完成系统调用功能。
  4. 系统调用处理函数执行完毕后,内核将返回值放入rax寄存器,然后内核恢复之前保存的用户态上下文,包括寄存器和堆栈指针。
  5. 内核执行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)。

三、段选择符

段选择符结构如下:

image-20240607154222921
  • Index:所对应的段描述符处于GDTLDT中的索引。

  • TI:表示对应段描述符保存在GDT中还是LDT中,0表示全局描述符表GDT,1表示局部描述符表LDT

  • RPL:当该段选择符装入cs寄存器时,设置CPU当前的特权级CPL的值为RPL,也就是cs寄存器中的RPL就是CPL

CPL值为0,表示CPU当前特权级别为Ring0(内核态),值为3,表示表示CPU当前特权级别为Ring3(用户态)。

四、段描述符

GDT全局段描述符表中的每个条目都有一个这样的复杂的结构:

image-20240607003309591

  • BASE :段首地址的线性地址。

  • LIMIT :该段最后一个地址的偏移量。

  • MORE:包括段的各种标志(如类型、特权级别等),结构如下:

image-20240607003958353

  • DPL:表示访问这个段CPU要求的最小优先级(保存在cs寄存器的CPL特权级)。当DPL为0时,只有CPL为0才能访问,DPL为3时,CPL为0为3都可以访问这个段。

五、SYSCALL指令

syscall指令主要做了三个工作:

  • rip寄存器内容保存到rcx寄存器。
  • MSR_LSTAR寄存器中的系统调用处理程序入口地址存入rip寄存器。
  • MSR_STAR 寄存器的 [47:32] 存入 csss段选择寄存器。

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指令将内核代码段选择符的值存入了 csss段选择寄存器,当前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机制中,内核态空间的内存和用户态空间的内存的隔离进一步得到了增强。

image-20240606003335605
  • 内核态中的页表包括用户空间内存的页表和内核空间内存的页表。
  • 用户态的页表只包括用户空间内存的页表以及必要的内核空间内存的页表,如用于处理系统调用、中断等信息的内存。
posted @ 2024-06-07 21:54  道成空  阅读(251)  评论(0编辑  收藏  举报