【计算子系统】系统调用

  计算子系统的诸多功能大多是通过系统调用来实现的,而且对于应用程序员来说,函数调用可能再熟悉不过了,但是对于系统调用这类特殊的函数调用,可能就局限在使用层面,而不会过多地去做深入研究。因此,这篇博文就从系统调用的角度来深入理解计算子系统。

什么是系统调用?

  在计算子系统中,当CPU执行进程时,系统调用是经常发生的一个过程。它是操作系统内核为应用程序提供的一组功能接口(API),通过这组接口应用程序可以实现一系列全局性的系统功能,如创建新的进程(进程是系统全局性的资源,受内核统一调度和管理)、访问文件系统(文件系统也是系统全局性资源,可供多个应用程序共同使用)、访问网络接口设备(网卡是系统全局性资源,同样可被多个应用程序共享)。抛开内部实现功能的核心逻辑,系统调用是一个产生系统权限变化的特殊系统过程。站在上层用户的视角来说,当你打开一个文件、点开一个新的应用窗口或者发送一个网络消息时都会涉及到系统调用。

为什么需要系统调用?

  前期的博文在介绍计算系统时说过,通用计算系统的优点在于可通过软件的部署实现功能的不断扩展。这里就引入一系列问题:

  • 运行在同一个计算系统上的不同应用程序有时需要实现相同的功能,是否需要各自都实现一套代码?
  • 系统性的功能该如何实现?
  • 如果某一个应用程序恶意破坏系统资源状态,该如何做防护?

  对于不同应用程序需要实现相同功能的问题,大家可能都会想到通过函数库的方式对相同功能进行抽取和复用,但这里需要注意一点:不同应用程序即便使用相同的库函数,函数内部所使用进程级全局对象在不同进程间是相互隔离的,并不会相互影响。

  那么对于系统级的全局资源的操作该如何实现?比如两个应用进程都想访问存储设备,如果只是通过函数库的方式实现了对存储设备的访问功能,那么两个应用进程就有可能破环彼此在存储设备上的数据,因为两个进程逻辑上是隔离的,都认为自己是以独占的方式在使用存储设备。正是为了实现对系统全局资源的统一访问和操作,系统工程师们创造一个被所有进程所共享的代码空间和数据空间(这就是被我们被为内核的东西)。内核不仅代码空间被所有进程所共享,而且任意进程修改了数据空间中的数据后,其它进程都可以感知到它的修改。这样所有涉及系统全局资源的操作都可以放到内核中来实现,因此内核是一个涵盖进程、内存、磁盘、网卡等全局资源操作的复杂软件系统。

  内核既然如此重要,而又被所有进程所共同改变,如果有恶意进程刻意破坏内核怎么办?硬件工程师给出了他们的解决方案:将CPU的执行空间划分为不同的等级(比如x86中共分0到3,四个等级),内核被放在最高的等级、应用程序独有的代码和数据被放在比较低的等级(如何linux在x86中将内核放在0级,将应用代码和数据放在3级),高级别的代码可以访问低级别的代码和数据,而低级别的代码不允计访问高级别的代码和数据;同时提供若干特殊指令允许特权级切换到指定的代码位置执行已设定好的代码功能,这些代码功能就是系统调用,是内核为应用程序提供的安全访问系统功能的函数入口。

如何实现系统调用?

  下面我们将深入系统内部,从寄存器和指令过程的层次来理解整体系统调用过程。

  以文件访问为例,首先从应用层开始,为实现对文件的读取操作,一个C语言应用程序通常是这样的:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
    int fd = -1;
    char buff[1024] = {0};
    
    fd = open("XXX", O_RDWR); //执行打开文件的系统调用
    ...
    read(fd, buff, 1024); //执行读取文件的系统调用
    ...
    
    return 0;
}

  C语言应用程序中的系统调用最终会由glibc库实现,在glibc库中这些系统调用是用汇编语言完成的(原因是涉及特殊的特权级切换指令的调用)。当然,我们也可以直接通过汇编指令来实现系统调用:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
    int fd = -1;
    char buff[1024] = {0};

    //调用open系统调用,其系统调用号为2,第一个参数放在rdi寄存器中,代表打开的文件名
    //第二个参数放在rsi寄存器中,代表文件打开方式(这里以读写方式打开文件)

    asm("mov %2, %%rax;
         syscall;"
        :"=a"(fd)
        :"D"(FILENAME), "S"(O_RDWR)
    );

    ...

    //调用read系统调用,其系统调用号为0,第一个参数rdi代表之前打开的文件句柄号
    //第二个参数rsi代表数据存储内存起始地址,第三个参数rdx代表读取的最大长度

    asm("mov %0, %%rax;
         syscall;"
        :"=a"
        :"D"(fd), "S"(buff), "d"(1024)
    );
    ...

    return 0;
}

  所有系统调用通过特权切换后都将跳转到相同的函数地址,因此为区别不同的系统调用功能,内核将所有的系统调用功能实现函数组成一个数组,并通过数组下标来索引具体的系统调用功能实现函数,这个下标就是系统调用号,如open系统调用的调用号为2,read系统调用的调用号为0。具体代码如下:

arch/x86/syscalls/syscall_64.tbl:

#
# 64-bit system call numbers and entry vectors
#
# The format is:
# <number> <abi> <name> <entry point>
#
# The abi is "common", "64" or "x32" for this file.
#
0	common	read			sys_read
1	common	write			sys_write
2	common	open			sys_open
3	common	close			sys_close
...

  有的读者可能对C语言内嵌汇编的语法不太熟悉(可参考有关内嵌AT&T汇编语法的学习资料),这里再简要介绍下前面系统调用的指令过程:

  • 首先,将希望调用的系统调用功能的系统调用号放入rax寄存器中;
  • 接着,通过给寄存器赋值来进行参数传递,最多可传递6个参数,依次为rdi、rsi、rdx、r10、r8、r9;
  • 最后,执行syscall指令进行特权级切换和执行跳转;

  syscall指令是x86_64架构下引入的轻量级特权切换指令(相对于x86_32架构下的int 0x80指令),其主要功功能是:(1)将当前函数执行地址(rip寄存器的值)保存到rcx中;(2)将当前标志寄存器rflag的值保存到r11寄存器中;(3)通过修改rip跳转到MSR_LSTAR寄存器指向的内核函数入口;(4)根据MSR_SYSCALL_MASK寄存器修改rflag寄存器。可见相比x86_32架构,syscall指令执行的动作要少得多,因此它的执行速度更快。

  至此我们终于要涉足内核了,那么系统调用入口函数是什么?答案就在内核启动过程中系统调用的初始化流程中:

arch/x86/kernel/cpu/common.c:

void syscall_init(void)
{
    /*
     * LSTAR and STAR live in a bit strange symbiosis.
     * They both write to the same internal register. STAR allows to
     * set CS/DS but only a 32bit target. LSTAR sets the 64bit rip.
     */
    wrmsrl(MSR_STAR,  ((u64)__USER32_CS)<<48  | ((u64)__KERNEL_CS)<<32);
    wrmsrl(MSR_LSTAR, system_call);
    wrmsrl(MSR_CSTAR, ignore_sysret);
    
    ...

    /* Flags to clear on syscall */
    wrmsrl(MSR_SYSCALL_MASK,
        X86_EFLAGS_TF|X86_EFLAGS_DF|X86_EFLAGS_IF|
        X86_EFLAGS_IOPL|X86_EFLAGS_AC|X86_EFLAGS_NT);
}

  这段代码说明在linux-3.10系统中,执行syscall指令后,当前进程会切换到内核态并将开始执行system_call函数;同时rflag标志寄存器的中断标志位(X86_EFLAGS_IF)会清零,这使得当前CPU不会响应普通中断,即不会被普通中断打断执行逻辑(但会被不可屏蔽中断NMI打断)。下面我们就来看system_call函数的实现,该函数在linux-3.10/arch/x86/kernel/entry_64.S中,这是一段底层代码,因此是用汇编语言编写的,在正式分析函数功能前,建议大家先看看函数的注释:

arch/x86/kernel/entry_64.S:

/*
 * System call entry. Up to 6 arguments in registers are supported.
 *
 * SYSCALL does not save anything on the stack and does not change the
 * stack pointer.  However, it does mask the flags register for us, so
 * CLD and CLAC are not needed.
 */

/*
 * Register setup:
 * rax  system call number
 * rdi  arg0
 * rcx  return address for syscall/sysret, C arg3
 * rsi  arg1
 * rdx  arg2
 * r10  arg3 	(--> moved to rcx for C)
 * r8   arg4
 * r9   arg5
 * r11  eflags for syscall/sysret, temporary for C
 * r12-r15,rbp,rbx saved by C code, not touched.
 *
 * Interrupts are off on entry.
 * Only called from user space.
 *
 * XXX	if we had a free scratch register we could save the RSP into the stack frame
 *      and report it properly in ps. Unfortunately we haven't.
 *
 * When user can change the frames always force IRET. That is because
 * it deals with uncanonical addresses better. SYSRET has trouble
 * with them due to bugs in both AMD and Intel CPUs.
 */

ENTRY(system_call)
    CFI_STARTPROC	simple
    CFI_SIGNAL_FRAME
    CFI_DEF_CFA	rsp,KERNEL_STACK_OFFSET
    CFI_REGISTER	rip,rcx
    /*CFI_REGISTER	rflags,r11*/
    SWAPGS_UNSAFE_STACK

  从这里的注释中我们可以看出如下要点:

  • syscall指令不会在栈在保存中作何信息,也不会切换栈指针,即修改rsp寄存器;因此,熟悉x86_32架构下int 0x80系统调用原理的同学注意了,这里是不同的;
  • 系统调用最多传递6个参数,依次放在rdi、rsi、rdx、r10、r8、r9寄存器中;这里有一个扩展的知识点,在x86_64下普通C语言函数也可以通过寄存器传参数的,前6个参数的顺序是rdi、rsi、rdx、rcx、r8、r9寄存器,好奇的读者可能会问那系统调用的传参为何不跟普通C函数保持一致呢?回顾一下syscall指令的执行过程,大家可能就会发现此时rcx已经存放了系统调用结束后的返回地址,因此不能用来传参了;
  • 进入system_call函数的时候,CPU是不响应外部中断的;

  system_call函数开始的几个CFI开头的宏和函数追踪相关,通过展开后是空的,因此不作关心。SWAPGS_UNSAFE_STACK用来切换gs寄存器,我们继续往下分析:

arch/x86/kernel/entry_64.S:

GLOBAL(system_call_after_swapgs)

    movq	%rsp,PER_CPU_VAR(old_rsp)
    movq	PER_CPU_VAR(kernel_stack),%rsp
/*
 * No need to follow this irqs off/on section - it's straight
 * and short:
 */
    ENABLE_INTERRUPTS(CLBR_NONE)
    SAVE_ARGS 8,0
    movq  %rax,ORIG_RAX-ARGOFFSET(%rsp)
    movq  %rcx,RIP-ARGOFFSET(%rsp)
    CFI_REL_OFFSET rip,RIP-ARGOFFSET
    testl $_TIF_WORK_SYSCALL_ENTRY,TI_flags+THREAD_INFO(%rsp,RIP-ARGOFFSET)
    jnz tracesys
system_call_fastpath:
#if __SYSCALL_MASK == ~0
    cmpq $__NR_syscall_max,%rax
#else
    andl $__SYSCALL_MASK,%eax
    cmpl $__NR_syscall_max,%eax
#endif
    ja badsys
    movq %r10,%rcx
    call *sys_call_table(,%rax,8)  # XXX:	 rip relative
    movq %rax,RAX-ARGOFFSET(%rsp)

  GLOBAL定义了一个全局函数system_call_after_swapgs,随后两条mov指令是在关中断的前提下执行的,因此不会被打断,它们的指令过程是:先将当前rsp寄存器(指向用户态栈空间)保存到内核中per cpu变量old_rsp中(即每个CPU访问不同的old_rsp变量),接着将当前CPU的kernel_stack值(指向当前进程内核栈且预留了ss、rsp、rflags、cs、rip的40个字节的空间)赋给rsp寄存器,即完成了栈的切换。将栈指针切换到内核态之后,即完成了基本执行环境的准备,随后通过ENABLE_INTERRUPT宏(本质为执行sti指令)打开当前CPU的中断,后续的执行过程中CPU又可以响应外部中断了。接着SAVE_ARGS宏开始保存rdi到r11寄存器的值到栈中,其实现在linux-3.10/arch/x86/include/asm/calling.h中:

arch/x86/include/asm/calling.h:

#define R15		  0
#define R14		  8
#define R13		 16
#define R12		 24
#define RBP		 32
#define RBX		 40

/* arguments: interrupts/non tracing syscalls only save up to here: */
#define R11		 48
#define R10		 56
#define R9		 64
#define R8		 72
#define RAX		 80
#define RCX		 88
#define RDX		 96
#define RSI		104
#define RDI		112
#define ORIG_RAX	120       /* + error_code */
/* end of arguments */

/* cpu exception frame or undefined in case of fast syscall: */
#define RIP		128
#define CS		136
#define EFLAGS		144
#define RSP		152
#define SS		160

#define ARGOFFSET	R11
#define SWFRAME		ORIG_RAX

.macro SAVE_ARGS addskip=0, save_rcx=1, save_r891011=1
    subq  $9*8+\addskip, %rsp
    CFI_ADJUST_CFA_OFFSET	9*8+\addskip
    movq_cfi rdi, 8*8
    movq_cfi rsi, 7*8
    movq_cfi rdx, 6*8

.if \save_rcx
    movq_cfi rcx, 5*8
.endif

    movq_cfi rax, 4*8

.if \save_r891011
    movq_cfi r8,  3*8
    movq_cfi r9,  2*8
    movq_cfi r10, 1*8
    movq_cfi r11, 0*8
.endif

.endm

  回到system_call主逻辑,保存完rdi到r11的寄存器和rax寄存器后,紧随的mov指令把rcx(前面讲过多次,此时rcx保存的是用户态返回地址)也保存到栈中。随后的一段逻辑用来判断是否需要对系统调用进行追踪及rax中的系统调用号是否超过设定的最大值__NR_syscall_max,如果无须追踪且系统调用号没有超过最大值,则通过call指令调用sys_call_table数组中由系统调用号索引的具体处理函数(如系统调用号为0,则调用sys_read函数)。当然,在调用sys_函数前需要将系统调用的第4个参数从r10中复制到rcx中,以确保sys_函数能获取正确的参数。当sys_函数调用完成后,rax将保存其返回值,这里也会将返回值压入栈中。到这步系统调用核心功能已经完成,但是在返回到用户空间前,内核还会做一些常规性的事务,如检查信号、检查当前进程是否需要被调度等,最后才是从栈中恢复之前保存的诸多寄存器、切换栈指针到用户态、通过sysret指令返回到用户空间(syscall的下一条指令)继续执行。

arch/x86/kernel/entry_64.S:

/*
* Syscall return path ending with SYSRET (fast path)
* Has incomplete stack frame and undefined top of stack.
*/
ret_from_sys_call:
    movl $_TIF_ALLWORK_MASK,%edi
    /* edi:	flagmask */
sysret_check:
    LOCKDEP_SYS_EXIT
    DISABLE_INTERRUPTS(CLBR_NONE)
    TRACE_IRQS_OFF
    movl TI_flags+THREAD_INFO(%rsp,RIP-ARGOFFSET),%edx
    andl %edi,%edx
    jnz  sysret_careful
    CFI_REMEMBER_STATE
/*
 * sysretq will re-enable interrupts:
 */
    TRACE_IRQS_ON
    movq RIP-ARGOFFSET(%rsp),%rcx
    CFI_REGISTER	rip,rcx
    RESTORE_ARGS 1,-ARG_SKIP,0
    /*CFI_REGISTER	rflags,r11*/
    movq	PER_CPU_VAR(old_rsp), %rsp
    USERGS_SYSRET64

  至此,linux-3.10内核在x86_64上完整系统调用过程中已分析完毕,过程中分析的部分内容涉及C函数编译和汇编,建议结合网上现有的一些资料来加深理解。enjoy hacking the system~


转载请注明:吴斌的博客 » 【计算子系统】系统调用