linux系统调用过程剖析

前言

linux中用户空间程序调用内核功能的唯一方式就是系统调用,内核中实现了一种跨平台的通用框架和实现方式,使得系统调用接口一致并且高效。系统调用和普通的函数调用有一些不同,系统调用函数位于内核中,需要从ring 3切换到ring 0,而且系统调用函数是通过系统调用号来使用而普通函数是通过地址使用。

普通的系统调用

我们以read系统调用为例来看系统调用是怎么实现的,read系统调用实现位于fs/read_write.c中,它的原型中通过SYSCALL_DEFINE3()宏来包裹的:

    SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
    {
    	struct fd f = fdget_pos(fd);
    	ssize_t ret = -EBADF;
    	/* ... */

SYSCALL_DEFINEn()宏是一种标准的系统调用定义方式,其中n表示参数的个数,它是在include/linux/syscalls.h中声明的,这个宏展开就有两个输出:

    SYSCALL_METADATA(_read, 3, unsigned int, fd, char __user *, buf, size_t, count)
    __SYSCALL_DEFINEx(3, _read, unsigned int, fd, char __user *, buf, size_t, count)
    {
    	struct fd f = fdget_pos(fd);
    	ssize_t ret = -EBADF;
    	/* ... */

SYSCALL_METADATA()是为tracing目的而设计的一个宏,只有在CONFIG_FTRACE_SYSCALLS配置的时候才会展开,它会收集系统调用的信息,和系统调用参数是完全一样的。
__SYSCALL_DEFINEx()完成系统调用的功能,一旦宏展开,代码就像下面这样:

    asmlinkage long sys_read(unsigned int fd, char __user * buf, size_t count)
    	__attribute__((alias(__stringify(SyS_read))));

    static inline long SYSC_read(unsigned int fd, char __user * buf, size_t count);
    asmlinkage long SyS_read(long int fd, long int buf, long int count);

    asmlinkage long SyS_read(long int fd, long int buf, long int count)
    {
    	long ret = SYSC_read((unsigned int) fd, (char __user *) buf, (size_t) count);
    	asmlinkage_protect(3, ret, fd, buf, count);
    	return ret;
    }

    static inline long SYSC_read(unsigned int fd, char __user * buf, size_t count)
    {
    	struct fd f = fdget_pos(fd);
    	ssize_t ret = -EBADF;
    	/* ... */

我们发现实际的实现是SYSC_read(),但是它是静态类型,不能在这个文件外访问,而基于它封装的SyS_read()可以在外部使用,并且有个别名sys_read()。仔细看这些别名,我们发现他们的参数类型上有一些不同,sys_read()的第二个参数是char __user *类型,而SyS_read()只是一个long类型整数,因为使用long型的版本可以在一些64位内核系统上使得在兼容32位应用时还可以正确的被转换。

我们注意到SyS_read()使用了asmlinkage进行修饰,调用的时候使用asmlinkage_protect()进行修饰。使用asmlinkage修饰时它会从栈上获取参数而不是寄存器上,而asmlinkage_protect()可以防止编译器重用栈上的这块区域,否则修改了保存寄存器的栈区域,当系统调用结束的时候从栈上弹出寄存器内容的时候就会出现问题。
sys_read()的声明在include/linux/syscalls.h中,这样可以允许其他的内核代码直接调用系统调用,不过内核中直接进行系统调用一般是不太常见,也不太受欢迎。

系统调用表

而用户空间使用系统调用是怎样到达sys_read()的呢?很多平台的实现中共享了大部分代码。在include/uapi/asm-generic/unistd.h文件中包含了引用sys_read的入口。

    #define __NR_read 63
    __SYSCALL(__NR_read, sys_read)

这个宏定义了read的系统调用号__NR_read(63),使用__SYSCALL()宏将调用号和sys_read()关联起来。__SYSCALL()都是平台自己实现的,例如arm64使用asm-generic/unistd.h头文件通过一张表来实现这种映射。

现在我们先来看x86_64平台,它并没有使用表的形式而是定义了它自己的arch/x86/syscalls/syscall_64.tbl文件,在其中有一项sys_read相关的:

    0	common	read			sys_read

它表明在x86_64上有一个系统调用号0(不是63)对应着read,它是由sys_read来实现的。编译时syscalltbl.sh脚本根据arch/x86/syscalls/syscall_64.tbl生成一个头文件arch/x86/include/generated/asm/syscalls_64.h,其中会使用__SYSCALL_COMMON()宏来包裹sys_read。这个头文件就用来填充系统调用表sys_call_table,它是关键的数据结构,以系统调用号为下标,对应的值为sys_name(),这样就可以系统调用号找到内核实现。

现在我们来看一下用户空间程序进行系统调用时会发生什么,这是平台相关的,仍然以x86_64平台为例看一下是如何实现的。

系统调用整图
我们看到这张表,最开始表中所有的指针都指向sys_ni_syscall,经过GCC将宏展开后,表就填充成这样

    asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
    	[0 ... __NR_syscall_max] = &sys_ni_syscall,
    	[0] = sys_read,
    	[1] = sys_write,
    	/*... */
    };

在64位平台下,arch/x86/kernel/entry_64.S中的system_call代码将会使用这张表。它通过RAX寄存器来作为系统调用数组表的下标来访问相应的实现。在这段代码中早期通过SAVE_ARGS宏来将所有的寄存器全部入栈保存起来,也就是通过asmlinkage访问。
在内核启动的时候syscall_init()会设置系统调用发生时触发system_call

    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);
    	/* ... */

wrmsrl指令设置特殊模式寄存器,将system_call的地址写到MSR_LSTAR(0xc0000082)上,这个模式寄存器专门用来处理系统调用指令。
系统调用标准的ABI中要求用户程序设置系统调用号到RAX,前三个参数依次放到RDI,RSI,RDX,然后发起SYSCALL指令,这条指令会引起处理器陷入到ring 0并且调用MSR_LSTAR寄存器中指向的代码,就是system_call。在system_call中会将寄存器都保存到内核栈上,并且根据RAX中的值作为sys_call_table的下标去访问sys_read,它只是SYSC_read()的简单封装,并且使用了asmlinkage修饰,从栈上获取参数。

以上就是一个普通系统调用的整体过程,使用宏的方式生成了系统调用表,这样添加系统调用时会非常的简洁方便,不过当我们细究的时候会带来一点麻烦,可以使用--save-temps参数保存中间文件。

多样的系统调用指令

系统调用前后有三种实现方式,int80,syscall,sysenter,他们的不同之处在于系统调用指令触发后陷入内核并且到系统调用入口中间的过程,之后的系统调用实现就是完全一样的了。而对于在64位内核中兼容32位应用时也没有特别之处,主要是中间参数转换的时候有些系统调用需要提供兼容版本的系统调用,可以看后面execve系统调用的兼容处理。

x86_32 SYSENTER

x86_32上系统调用非常像x86_64系统,它的系统调用信息位于arch/x86/syscalls/syscall_32.tbl,sys_read项信息是下面这样,read的系统调用号是3.

    3	i386	read			sys_read

这个文件经过处理会生成arch/x86/include/generated/asm/syscalls_32.h文件,read对应着__SYSCALL_I386(3, sys_read, sys_read),填充系统调用表sys_call_table.
arch/x86/kernel/entry_32.S ia32_sysenter_target会访问这个表sys_call_table,它也会先保存寄存器,和x86_64中不同,不再是RDI/RSI/RDX/R10/R8/R9这些寄存器而是他们对应的32bit寄存器EBX/ECX/EDX/ESI/EDI/EBP
在系统初始化时也会将ia32_sysenter_target的地址设置到特殊模式寄存器MSR_IA32_SYSENTER_EIP(0x176)中,它会处理32位下的SYSENTER指令。

x86_32中的ABI规范要求系统调用号存放到EAX寄存器中,前三个参数放到EBX, ECX, EDX中,之后调用SYSENTER指令就可以触发系统调用陷入到ring 0中,然后调用MSR_IA32_SYSENTER_EIP特殊模式寄存器,之后EAX中的值作为sys_call_table的下标跳转到具体的系统调用。

x86_32 INT 0x80

arch/x86/kernel/entry_32.S中的system_call作为系统调用入口点,之后访问sys_call_table,EAX中的值作为sys_call_table的下标跳转到具体的系统调用。系统初始化时候通过trap_init()设置系统调用的触发。

    #ifdef CONFIG_X86_32
    	set_system_trap_gate(SYSCALL_VECTOR, &system_call);
    	set_bit(SYSCALL_VECTOR, used_vectors);
    #endif

它不再通过wrmsr指令设置MSR_IA32_SYSENTER_EIP而是设置idt(interrupt descriptor table)中,这也是非常古老的系统调用方式,他的效率太差,目前默认的实现不再是它。和sysenter指令一样,它们都是使用system_call处理,只是进来的方式不同,不再赘述。

x86_64上进行32位系统调用

当硬件和内核是64位,但是运行的是32位程序,系统调用的处理是怎样的?
当它使用SYSENTER发起系统调用时,内核中设置MSR_IA32_SYSENTER_EIP寄存器指向ia32_sysenter_target,但是它的实现方式是不一样的,它位于arch/x86/entry/entry_32.S中,同样的他也会保存32位的寄存器,不过使用的系统调用表名称为ia32_sys_call_table,里面是32位系统版本的对应关系,例如read位于下标为3的位置。
当它使用INT 0x80发起系统调用时,在trap_init中设置了idt,指向ia32_syscall,它也是使用ia32_sys_call_table。

execve系统调用

execve和read比较像,但是有一些不同,当CONFIG_COMPAT配置的时候,也就是支持32位系统调用的时候,它会有两个不同的入口,分别给64位和32位系统调用使用。

    SYSCALL_DEFINE3(execve,
		    const char __user *, filename,
    		    const char __user *const __user *, argv,
    		    const char __user *const __user *, envp)
    {
    	    return do_execve(getname(filename), argv, envp);
    }
    #ifdef CONFIG_COMPAT
    asmlinkage long compat_sys_execve(const char __user * filename,
    	    const compat_uptr_t __user * argv,
    	    const compat_uptr_t __user * envp)
    {
    	    return compat_do_execve(getname(filename), argv, envp);
    }
    #endif

他们最终的实现都会汇聚到do_execve_common中完成实际的工作,sys_execve() → do_execve() → do_execve_common()compat_sys_execve() → compat_do_execve() → do_execve_common()。中间他们都会创建一个user_arg_ptr结构的对象,它里面包含系统调用的参数,额外带有兼容性标志来表明它是来32位的还是64位的。

read系统调用中不需要区分32位还是64位的,它的参数指针到值的转换,在用户空间时是32位地址,到内核中首先会强转成long型,此时高位填充0变成了64位长度,再转换成指,中间没有任何的损失,所以它不需要提供兼容版本的实现。但是execve需要区分,它的第二个和第三个参数是指向指针数组的指针,当取数组中的指针时,我们想要获取的是32位地址,所以必须提供兼容版本来分别取参的过程。

兼容x86时的execve
为了支持两个版本的execve系统调用,在x86_64的系统调用表中有两个关于execve的信息:

    0   common  read            sys_read
   59  64  execve          sys_execve/ptregs
    ...
    520 x32 execve          compat_sys_execve/ptregs

除了59对应的stub_execve还有一个520对应的stub_x32_execve。而read实现不需要区分兼容版本,所以系统调用表中可以共享处理函数sys_read.
兼容性系统调用入口除了保存RDI RSI RDX等寄存器并且还会保存额外的寄存器R12-R15, RBX, RBP到内核栈上,它可能会在系统调用返回时需要设置新的地址到指令寄存器或者设置新的用户栈地址,与它类似功能的clone,fork,vfork。

在x86_32中,系统调用表中只有一项execve,直接使用兼容版本的实现;同时系统调用号也是不一样的,在32位上execve的系统调用号是11,而在x86_64位上兼容32位时系统调用号是520。

    11  i386    execve          sys_execve          compat_sys_execve

vdso

一些系统调用就只是读取内核中的信息,而完整的一次系统调用代价比较大,所以设计了vDSO(Virtual Dynamically-linked Shared Object)来加速一些只读系统调用,它将一些页以只读的方式映射到用户空间并且这个页组织成了ELF动态库格式,可以直接在程序执行时进行动态链接。

当我们运行ldd查看ELF动态可执行文件时,它依赖linux-vdso.so.1或者linux-gate.so.1,但是找不到这个文件的位置,在运行时我们通过查看/proc/xxx/maps时还可以看到有一块区域映射了vdso。更早以前是通过vsyscall来做这项工作,不过从安全性上考虑已经取消了。
我们以gettimeofday为例,首先vsyscall_gtod_data会被导出到一个特殊节中.vvar_vsyscall_gtod_data,链接脚本会将.vvar_vsyscall_gtod_data合并到__vvar_page节中,在系统启动的时候setup_arch()会调用map_vsyscall()来为__vvar_page创建一个固定映射。

vdso版本的gettimeofday()主要实现是__vdso_gettimeofday(),它被标记位不能trace,这样可以防止编译器添加函数剖析。为了能够生成一个ELF动态库的页,vdso.lds.S和vdso-layout.lds.S,gettimeofday(),__vdso_gettimeofday()都会放入这个页内。

为了能够用户空间程序能够访问vDSO页,在进程启动的时候setup_additional_pages()会首先根据vdso_addr()选择一个随机地址来映射vDSO页,之后通过辅助变量AT_SYSINFO_EHDR导出地址到用户空间,用户空间程序就可以使用这个页。

vDSO机制的另外一个重要用途是在32位程序运行时来选择合适的系统调用方式,在启动的时候,内核决策哪种x86_32系统调用方式最好,并且把对应的封装实现放到__kernel_vsyscall函数中。用户空间程序调用这个封装函数就能使用最快的方式来进行系统调用。

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页