系统调用角度看用户栈与内核栈切换

语言: CN / TW / HK

上次分析了Linux进程用户栈与内核栈: Linux内核基础-进程用户栈与内核栈 ,当发生系统调用、产生异常,外设发生中断等事件时,会发生用户栈和内核栈之间的切换, 本文从系统调用角度分析用户栈与内核栈的切换。

系统调用的演变

x86 的系统调用经历了 int / iret 到 sysenter / sysexit 再到 syscall / sysret 实现方式的转变, 关于具体的演化和区别、系统调用的其他细节等将在以后的系统调用专栏里分析 。本文从系统调用最原始的int 0x80开始分析 用户栈与内核栈的切换, 重点看系统调用过程用户栈与内核栈切换的过程中的一些细节。

系统调用-分析从用户栈切换内核栈

内核SYSCALL 入口代码在entry_64.S中:

//arch/x86/entry/entry_64.S
ENTRY(entry_SYSCALL_64)
UNWIND_HINT_EMPTY
/* Interrupts are off on entry. */
swapgs
// 将用户栈偏移保存到 per-cpu 变量 rsp_scratch 中
movq %rsp, PER_CPU_VAR(rsp_scratch)
// 切换到进程内核栈
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp

/* 在栈中倒序构建 struct pt_regs */
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(rsp_scratch) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip */
GLOBAL(entry_SYSCALL_64_after_hwframe)
//rax 保存着系统调用号
pushq %rax /* pt_regs->orig_ax */

PUSH_AND_CLEAR_REGS rax
=$-ENOSYS

TRACE_IRQS_OFF

/* 保存参数到寄存器,调用do_syscall_64函数 */
movq %rax, %rdi
movq %rsp, %rsi
call do_syscall_64 /* returns with IRQs disabled */

上面的汇编指令中先将当前用户栈(用户空间栈顶)记录在CPU独占变量区域里(PER_CPU变量),如下所示:

movq %rsp, PER_CPU_VAR(rsp_scratch)

然后将CPU独占区域里记录的内核栈顶放入rsp/esp寄存器

movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp

就是这么简洁, 上面两句汇编:就 将用户栈顶保存在了当前CPU的rsp_scratch这样一个PER_CPU变量里,完成了用户栈的保存 ,然后 将当前内核栈的地址存放到当前栈指针寄存器中 那么此时栈寄存器指向的就是内核栈的栈顶, 由此优雅、完美地 完成了用户栈到内核栈的切换!

接下来所有的压栈操作 都是在内核栈 里操作 了,依次将用户空间寄存器压栈,此时也是往内核栈push的,具体可以参考上文: Linux内核基础-进程用户栈与内核栈内核使用struct pt_regs初始化内核栈,也就是通过push保存寄存器的值 (将用户栈信息:用户调用的系统调用号、参数、代码段地址、数据段地址等以struct pt_regs形式压入栈) ,形成一个pt_regs结构 ,如下图(源于上篇文章中的分析):

在栈中顺序固定且倒序压栈 (在x86_64中,内核栈rbx rbp r12 r13 r14 r15不是必须保存的项(为了访问不越界相应空间必须保留),根据需要保存,linux后续版本采取都保存方式) ,其中rax保存系统调用号

//rax 保存着系统调用号
pushq %rax /* pt_regs->orig_ax */

接下来:call do_syscall_64,进入do_syscall_64函数:

__visible void do_syscall_64(struct pt_regs *regs)
{
struct thread_info *ti = current_thread_info();
unsigned long nr = regs->orig_ax;

enter_from_user_mode();
local_irq_enable();

if (READ_ONCE(ti->flags) & _TIF_WORK_SYSCALL_ENTRY)
nr = syscall_trace_enter(regs);

/*
* NB: Native and x32 syscalls are dispatched from the same
* table. The only functional difference is the x32 bit in
* regs->orig_ax, which changes the behavior of some syscalls.
*/

if (likely((nr & __SYSCALL_MASK) < NR_syscalls)) {
regs->ax = sys_call_table[nr & __SYSCALL_MASK](
regs->di, regs->si, regs->dx,
regs->r10, regs->r8, regs->r9);
}

syscall_return_slowpath(regs);
}

上述函数的主逻辑很简单:

1、通过之前保存下来的pt_regs(往内核栈中格式化压入的),获取用户传入的系统调用号nr,系统调用号保存在了regs->orig_ax:

 unsigned long nr = regs->orig_ax;

2、通过系统调用号nr,执行对应的回调函数,sys_call_table是函数指针数组,不同nr对应不同系统调用对应的函数。其中regx->di、regx->si、regs->dx、regs->r10、regs->r8、regs->r9分别是之前保存到内核栈(以struct pt_regs格式化)保存到pt_regs中的,对应着用户传入该系统调用的参数1~6:

 if (likely((nr & __SYSCALL_MASK) < NR_syscalls)) {
regs->ax = sys_call_table[nr & __SYSCALL_MASK](
regs->di, regs->si, regs->dx,
regs->r10, regs->r8, regs->r9);
}

以上就完成了用户调用系统调用,并从用户栈切换到内核栈,并执行到系统调用号对应函数的过程。 具体的系统调用相关细节将在以后系统调用相关文章中分析。

系统调用-分析从内核栈切换用户栈

上面分析到了执行系统调用对应的函数,如下所示, 并将返回值保存在regs->ax中了

regs->ax = sys_call_table[nr & __SYSCALL_MASK](
regs->di, regs->si, regs->dx,
regs->r10, regs->r8, regs->r9);

函数执行到 do_syscall_64->syscall_return_slowpath(regs),开始为返回用户态做准备.

并最终回到 系统调用  内核SYSCALL 入口:ENTRY(entry_SYSCALL_64)->return_from_SYSCALL_64,继续完成系统调用返回工作,并切换用户栈与内核栈,使用struct pt_regs恢复用户态寄存器值。

总之

用户栈——>内核栈 cpu保存用户当前堆栈信息保存到 内核的栈 中(恢复时用到),然后将cpu指向内核堆栈,去执行内核代码。完成用用户栈到内核栈转换。

内核栈——>用户栈 再切换到内核堆栈前,将用户堆栈信息压入到内核栈中,内核函数执行完回退栈帧,会将用户的堆栈信息POP出栈,然后cpu堆栈寄存器就知道怎么回去了,返回的用户程序中断的地方继续执行。

END