第五章 系统调用

在任何的现代操作系统中,都提供了一系列的接口用于运行在用户空间的进程和系统交互。这些接口用于完成下面的任务:

  • 用于控制硬件
  • 创建新的进程或者和已有的进程进行交互
  • 请求其他资源的能力

正是因为有了这些接口,以及操作系统不允许程序直接操作它们需要的资源,才使得系统稳定运行。

和内核交互

系统调用在用户进程和硬件之间插入了一个适配层,这个适配层用于满足下面的三个需求:

  1. 给用户空间提供抽象的硬件接口
    • 比如当读写一个文件的时候,应用不需要关心磁盘类型,或者文件所在文件系统的类型
  2. 保证系统安全性和稳定性。内核充当了用户空间和系统资源之间的中间人,所以它可以基于权限,用户以及其他规则来限制来自用户空间的资源访问。
    • 比如,内核会防止应用不正确的使用硬件,窃取其他进程的资源,以及其他对系统有害的操作
  3. 用户空间和其他系统资源之间的通用层,用于给进程提供虚拟化系统。如果系统允许进程不加限制的直接访问系统资源,那么实现多任务和虚拟内存就不太可能了。

在linux系统中,除了异常和陷入(见注释),系统调用是用户空间和内核交互的唯一方法,以及进入到内核的唯一入口。其他接口,比如设备文件和/proc,都是通过系统调用实现的。有趣的是,Linux中的系统调用的数量远比其他系统要少。

注释1: 陷入是一种异常,通常情况下也是一种同步的中断。陷入处理后返回的地址为陷入指令之后的指令对应的地址。陷入通常会导致系统切换到内核态,在某些情况下陷入用户进程监控或者调试。https://en.wikipedia.org/wiki/Trap_(computing)

APIs, POSIX, and the C Library

APIs

应用程序通常由用户空间实现的API来编写,而不是直接通过系统调用,这是因为应用所使用的API和内核实际提供的系统调用之间不需要直接的关联(见注释2)。

API是应用所使用的一系列接口。这些接口通常是:

  • 包含一个系统调用
  • 包含多个系统调用
  • 不包含任何系统调用

同一个API在不同操作系统之间的定义是相同的,但是其内部实现可能因为操作系统不同而有很大的区别。 下面的图展示了POSIX API,C库和系统调用之间的关系。

https://notes.shichao.io/lkd/figure_5.1.png

注释2: 我认为系统调用通常是足够抽象和通用的,因为需要满足各种不同的需求,并且系统调用需要满足单一职责原则。正是因为系统调用的这种特性,所以其对应用层面的开发者不够友好。同时有一些操作需要使用到多个系统调用让直接使用系统调用开发变得困难。所以引入用户空间的API就显得十分重要了,API可以通过封装屏蔽系统调用带来的复杂度,同时可以通过聚合多个系统调用来提供更为丰富的功能。

POSIX

unix系统中最通用的API都是基于POSIX的。POSIX是由一系列的IEEE标准组成,旨在为unix提供一套通用的标准。linux在实现上尽量提供POSIX和SUSv3风格的接口。

c库

linux中的系统调用和大多数unix系统一样,大部分是通过c库提供服务的。

C库实现了unix系统中的重要API,包括:

  • 标准c库
  • 系统调用接口

c库被所有c语言程序使用,这是因为c语言的原生性,可以轻松的嵌入到其他编程语言中。c库还额外提供了多数POSIX API。

从应用程序员的观点来看,系统调用是不需要关注的,他们需要关注的是是API。相反的,内核只关注系统调用,库函数调用哪个系统调用或者应用程序由哪些系统调用组成内核是不关注的。尽管如此,内核还是需要关注一个系统调用的使用价值,并且尽量将系统调用实现的通用简洁。

unix接口定义的哲学是”Provide mechanism, not policy”。换句话说,unix的系统调用提供一个特定的方法,该方法适用于某一特定的抽象场景。内核不关注应用层面如何调用这些方法(系统调用)。

系统调用

系统调用通常通过c库提供的方法访问。

  • 系统调用可以有0个,1个或者多个参数,通常会导致一个或者多个副作用。
    • 尽管几乎所有的系统调用都有副作用(改变系统的状态),但是还有一些系统调用比如getpid(),仅仅从内核获取一些数据
  • 系统调用提供一个long型(用于兼容64位架构)的返回值,表明系统调用成功或者失败:
    • 通常情况下,一个负值代表有错误发生
    • 通常情况下,0表示成功,尽管不都是如此
    • c库将错误码写入到全局的errno变量里,这个错误码可以通过perror()转化为人可读的错误。
  • 系统调用有特定的行为(预定义好的)

系统调用getpid()用于返回当前进程的pid。该方法在内核中的实现十分简单:

SYSCALL_DEFINE0(getpid)
{
    return task_tgid_vnr(current); // returns current->tgid
}

系统调用的定义里没有关于如何实现的细节。内核在实现系统调用的时候必须让系统调用满足特定的需求,但是如何实现内核是不关注的,只要最终的结果正确即可。

SYSCALL_DEFINE0 是一个简单的宏,用于定义没有参数的系统调用。上面例子中的系统调用展开后为下面的代码

asmlinkage long sys_getpid(void);
  • asmlinkage修饰符用于告诉编译器只在栈寻找方法的参数(见注释3)。这个是每个系统调用都需要的修饰符。

这个方法返回了一个long类型的数据。为了兼容32和64位操作系统,系统调用从内核返回到用户空间的时候返回一个long类型的数据。所有系统调用的命名规则是: 如果系统调用为bar(),那么在内核中对应的函数是sys_bar()。

注释3: https://www.quora.com/Linux-Kernel-What-does-asmlinkage-mean-in-the-definition-of-system-calls 例如当系统调用int sethostname(char *name, size_t len)的时候,对应的汇编代码大概为 mov ecx, len ; amount of bytes in name mov ebx, name ; address of name string mov eax, 170 ; syscall number (sys_sethostname) int 0x80 ; x86 call the kernel – sysenter is another entry point 其中int 0x80是一个软中断,用于进入到内核中执行system_call()。该方法会将eax, ebx, ecx等寄存器存放到进程栈上,然后继续调用实际的系统调用。由于参数都已经存放在栈上了,所以需要asmlinkage告诉编译器从栈上取参数

系统调用号

在Linux中,每个系统调用都分配了一个唯一的系统调用号用来索引系统调用。当用户空间进程执行系统调用的时候,系统调用号来指定哪个系统调用应该被执行。进程不通过系统调用的名称来调用系统调用。

  • 一旦系统调用号和系统调用绑定就不可以更改了。否则哪些已经编译好的程序会无法正常使用。
  • 如果一个系统调用被移除了,它对应的系统调用号也不可以回收利用,否则之前编译好的代码在运行的时候可能无法按照预期调用某个系统调用,而会调用一个错误的系统调用。
  • linux提供了一个叫做”note implemented”的系统调用,sys_ni_syscall(),该系统调用除了返回ENOSYS其他事情都不会做,如果遇到了无效的系统调用时该系统调用会作为异常处理。当某个系统调用被移除或者无效的时候,这个方法会作为”plug the hole”(其实就是默认处理器)。

内核有一份列表存储了在系统注册过的系统调用,该表为于sys_call_table,在x86-64系统中定义在arch/x86/kernel/syscall_64.c

所有的系统调用号定义在include/asm-generic/unistd.

系统调用性能

系统调用在linux中执行的速度要快于其他很多系统,因为

  • linux上下文切换的速度很快: 进入内核和退出内核都经过优化,相对简单
  • 系统调用处理器和系统调用本身都很简洁

系统调用处理器

用户空间的应用无法直接执行内核代码。他们无法直接通过简单的方法调用来执行内核中的方法因为内核处于受保护的内存中。否则,系统的安全性和稳定性将不复存在。

用户空间的程序通知内核他们想执行系统调用,并且将系统切换到内核态,这时候系统调用可以在内核态执行。这种机制是通过软中断完成的: 产生一个异常,然后系统切换到内核态,执行异常处理。在这种情况下对应的异常处理就是系统调用处理器。

在x86系统上,定义好的软中断号是128,通过指令int 0x80触发。它会将系统切换到内核态,同时执行异常表中的128,对应的是系统调用处理器。系统调用处理器对应的方法是ssystem_call()。这个方法的实现和体系结构密切相关,在x86-64系统上它的实现位于entry_64.s中。(arch/x86/kernel/entry_64.S)。

最近,x86处理器添加了一个叫做sysenter的特性,相比于使用int中断指令该特性提供了一个更快的陷入内核的方法。内核很快添加了对该特性的支持。不管系统调用处理器是如何被调用的,用户态需要通过异常或者陷入进入到内核。

指向正确的系统调用

linux系统调用的更多细节参见Interfacing with Linux

只进入到内核空间是不够的: 系统调用号必须也传入到内核中。

在x86系统中,系统调用号通过寄存器eax传入到内核中:

  • 在陷入内核之前,用户空间将系统调用好放到eax中用于调用对应的系统调用
  • 系统调用处理器会从eax读取系统调用号

方法system_call()通过将系统调用号和NR_syscalls进行比较来检查其合法性。如果大于或者等于NR_syscalls,方法会返回-ENOSYS。否则对应的系统调用会被调用:arch/x86/kernel/entry_64.S#L487

call *sys_call_table(,%rax,8)

因为系统调用表中的每一个项都是64bits的,内核将系统调用号乘以8,然后将得到的值作为索引在系统调用表中查找对应的系统调用。在x86-32位系统上,代码类似,不过8被替换成了4。

https://notes.shichao.io/lkd/figure_5.2.png

参数传递

除了系统调用号之外,多数的系统调用还需要一个或者多个参数传递到内核。在陷入的时候,用户空间必须将对应的参数传递到内核中。最早的传递方法和传递系统调用号类似: 参数存放到寄存中。在x86-32系统上,这些寄存器包括ebx, ecx, edx, esi和edi,依次存放前五个参数。如果有6个或者更多参数,一个寄存器用来存放一个指针,该指针指向存放在用户空间的所有参数所在的地址。

返回到用户空间的值同样也存放在寄存器中。在x86中,返回值存放在eax寄存器。

系统调用的实现

系统调用的实现不需要关注系统调用处理器的实际行为(意思应该是两者实现上解耦)。因此,在Linux添加一个新的系统调用是十分简单的。最复杂的工作集中在设计和实现系统调用;在内核中注册已经实现好的系统调用是很简单的。

实现系统调用

实现系统调用的第一步是设计系统调用的目标,系统调用必须只包含一个目标(单一职责原则)。多语义(通过一个参数控制做不同的事情)的系统调用在linux上不被鼓励的。ioctl()是一个例子用来告诉我们什么不应该做。

考虑系统调用的参数,返回值和异常码。系统调用必须拥有尽可能少数量的参数,同时必须是清晰的简洁的。系统调用的语义和行为是很重要的;它们不能改变,因为已经存在的应用强依赖系统调用现有的语义。许多系统调用提供flag参数用于向前兼容。这个标志不是用来在一个系统调用中提供多语义,而是在不破坏向后兼容性的前提下用来开启新的功能。

设计一个系统调用需要着眼于未来。系统调用的作用必须保持稳定,但是使用它的地方可能发生改变。记住unix的哲学: “Provide mechanism, not policy.”。

当你编写一个系统调用的时候,你需要从未来的角度考虑到可移植性和鲁棒性。unix系统中的系统调用已经经历了时间的考验;他们中的大多数在30年前已经出现,并且在今天看来依然十分适用。

参数校验

系统调用必须仔细的验证它们的参数,确保它们的合法性,这样才能保证系统的安全和稳定。

一个最主要的检查点是用户提供指针的校验。通过指针定位到用户空间之前,系统必须保证:

  • 指针指向了用户空间的内存区域。进程不能欺骗系统读取内核的数据。
  • 指针指向了进程的地址空间中的内存区域。进程不能欺骗系统读取其他进程的数据。
  • 进程不能够绕过内存权限控制。如果需要读取内存,这些内存必须是被标记可读的。如果需要写入内存,这些内存必须是被标记可写的。如果是运行代码,那么内存必须标记为可运行的。

内核提供了两个方法用来满足上面的校验,并且可以将内存从用户空间拷贝到内核空间或者从内核空间拷贝到用户空间。注意内核代码不能盲目的从用户空间获取数据。必须采用下面提供的两个方法中的一个。

  • copy_to_user()用来写入用户空间。这方法有三个参数:
    • 第一个参数是进程地址空间中的写入目标地址
    • 第二个参数是写入数据在内核空间的源地址
    • 第三个参数是写入数据的大小
  • copy_from_user()用来从用户空间读取数据。和copy_to_user()类似。函数从第二个参数所指向的地址读取数据然后拷贝到第一个参数指向的地址,拷贝的大小为第三个参数。

这两个方法都会返回没有成功读取的字节数。如果成功,则返回0;在失败的时候通常会返回-EFAULT

下面的例子silly_copy()使用了上面的两个方法copy_from_user()copy_to_user()。它从第一个参数对应的地址拷贝数据到第二个参数对应的地址。这个方法虽然可以正常执行,但是却不是最优的,因为将数据拷贝到内核是没有意义的。虽然如此,它仍能帮助我们理解上面的两个方法:

/*
* silly_copy - pointless syscall that copies the len bytes from
* ‘src’ to ‘dst’ using the kernel as an intermediary in the copy.
* Intended as an example of copying to and from the kernel.
*/
SYSCALL_DEFINE3(silly_copy,
                unsigned long *, src,
                unsigned long *, dst,
                unsigned long len)
{
    unsigned long buf;

    /* copy src, which is in the user’s address space, into buf */
    if (copy_from_user(&buf, src, len))
        return -EFAULT;

    /* copy buf into dst, which is in the user’s address space */
    if (copy_to_user(dst, &buf, len))
        return -EFAULT;

    /* return amount of data copied */
    return len;
}

这两个方法都可能会阻塞。比如,当请求的业不在物理内存而是被交换到磁盘的时候,阻塞有可能发生。在这种情况下,进程会阻塞直到页错误处理将对应的内存从磁盘读取到物理内存。

最后的可能检查是权限检查。在老版本的linux系统中,调用系统调用需要使用suser()获取root权限。这个方法检查当前用户是否是root用户;现在这个机制已经被移除了,并且添加了更好的机制。

新系统引入了机制用来检查特定资源的权限。capable()接受一个权限检查标志,如果调用者有权限则返回非0否则返回0。比如,capable(CAP_SYS_NICE)检查调用者是否有权限修改别的进程的nice值。默认情况下,超级用户拥有所有权限,而非超级用户什么权限都没有。

下面的例子是系统调用reboot()。可以看到它的第一步是保证调用者有CAP_SYS_REBOOT权限。如果这个判断条件被移除了,任何进程都可以重启系统了。 kernel/sys.c#L368

SYSCALL_DEFINE4(reboot, int, magic1, int, magic2, unsigned int, cmd,
        void __user *, arg)
{
    char buffer[256];
    int ret = 0;

    /* We only trust the superuser with rebooting the system. */
    if (!capable(CAP_SYS_BOOT))
        return -EPERM;

    /* For safety, we require "magic" arguments. */
    if (magic1 != LINUX_REBOOT_MAGIC1 ||
        (magic2 != LINUX_REBOOT_MAGIC2 &&
                    magic2 != LINUX_REBOOT_MAGIC2A &&
            magic2 != LINUX_REBOOT_MAGIC2B &&
                    magic2 != LINUX_REBOOT_MAGIC2C))
        return -EINVAL;

    /* Instead of trying to make the power_off code look like
     * halt when pm_power_off is not set do it the easy way.
     */
    if ((cmd == LINUX_REBOOT_CMD_POWER_OFF) && !pm_power_off)
        cmd = LINUX_REBOOT_CMD_HALT;

    mutex_lock(&reboot_mutex);
    switch (cmd) {
    case LINUX_REBOOT_CMD_RESTART:
        kernel_restart(NULL);
        break;

    case LINUX_REBOOT_CMD_CAD_ON:
        C_A_D = 1;
        break;

    case LINUX_REBOOT_CMD_CAD_OFF:
        C_A_D = 0;
        break;

    case LINUX_REBOOT_CMD_HALT:
        kernel_halt();
        do_exit(0);
        panic("cannot halt");

    case LINUX_REBOOT_CMD_POWER_OFF:
        kernel_power_off();
        do_exit(0);
        break;

    case LINUX_REBOOT_CMD_RESTART2:
        if (strncpy_from_user(&buffer[0], arg, sizeof(buffer) - 1) < 0) {
            ret = -EFAULT;
            break;
        }
        buffer[sizeof(buffer) - 1] = '\0';

        kernel_restart(buffer);
        break;

#ifdef CONFIG_KEXEC
    case LINUX_REBOOT_CMD_KEXEC:
        ret = kernel_kexec();
        break;
#endif

#ifdef CONFIG_HIBERNATION
    case LINUX_REBOOT_CMD_SW_SUSPEND:
        ret = hibernate();
        break;
#endif

    default:
        ret = -EINVAL;
        break;
    }
    mutex_unlock(&reboot_mutex);
    return ret;
}

系统调用上下文

在执行系统调用的时候,内核处于进程的上下文中。”当前指针”指向当前触发系统调用的进程。

在进程的上下文中,内核可以睡眠可以被抢占。下面的两个观点是很重要的:

  • 可以睡眠代表系统调用可以使用内核的大部分功能。
    • 这个功能大大的简化了系统调用的开发难度
    • 中断处理是不能够睡眠的,这大大的限制了中断处理所能够做的事情,相比之下系统调用可以在进程空间做很多事情。
  • 系统调用是可以被强占的。
    • 新的任务可能会执行同样的系统调用,所以系统调用必须要是可重入的。

当系统调用返回的时候,进程切换回用户空间继续执行刚才的代码。

绑定系统调用

注册一个已经编写好的系统调用的步骤是琐碎的:

  • 在系统调用表的结尾添加一个新的项。在支持系统调用的每一个体系结构上都需要执行这个操作。系统调用表的index从0开始,每个index表示一个系统调用号。
  • 在每个支持系统调用的体系结构中,系统调用号都存放在<asm/unistd.h>中。
  • 将系统调用编译到内核镜像中(而不是编译成一个模块)。只需要简单的将系统调用放到kernel/目录下的关联文件中,比如sys.csys.c中存放了各种各样的系统调用。

以一个虚构的系统调用foo()为例。我们希望将sys_foo()添加到系统调用表中。对于多数的体系结构而言,系统调用表位于entry.S中,该文件和下面的内容类似:

ENTRY(sys_call_table)
.long sys_restart_syscall /* 0 */
.long sys_exit
.long sys_fork
.long sys_read
.long sys_write
.long sys_open /* 5 */
...
.long sys_eventfd2
.long sys_epoll_create1
.long sys_dup3 /* 330 */
.long sys_pipe2
.long sys_inotify_init1
.long sys_preadv
.long sys_pwritev
.long sys_rt_tgsigqueueinfo /* 335 */
.long sys_perf_event_open
.long sys_recvmmsg
.long sys_foo

尽管没有明确的指出该系统调用对应的系统调用号,但是我们还是可以很容易的看出对应的系统调用号是338。

  • 对于每个你想支持的体系结构,系统调用都需要被添加到对应的系统调用表中。
  • 每个体系结构中该系统调用对应的系统调用号可以不同,因为系统调用号是体系结构特有的部分。
  • 通常情况下,你应该在每个体系结构中都支持该系统调用。
  • 根据约定需要每隔五个项添加一个数字,方便于查找系统对应的系统调用号是多少。

接下来系统调用号被添加到了<asm/unistd.h>中,像下面这样:

/*
* This file contains the system call numbers.
*/
#define __NR_restart_syscall 0
#define __NR_exit 1
#define __NR_fork 2
#define __NR_read 3
#define __NR_write 4
#define __NR_open 5
...
#define __NR_signalfd4 327
#define __NR_eventfd2 328
#define __NR_epoll_create1 329
#define __NR_dup3 330
#define __NR_pipe2 331
#define __NR_inotify_init1 332
#define __NR_preadv 333
#define __NR_pwritev 334
#define __NR_rt_tgsigqueueinfo 335
#define __NR_perf_event_open 336
#define __NR_recvmmsg 337
#define __NR_foo

最后就是系统调用foo()的实现了。因为系统调用必须被编译进内核镜像中,所以在这个例子中我们将其定义到kernel/sys.c中。你应该将系统调用放到关联性最强的地方;例如如果系统调用和调度相关,它们应该被定义在kernel/sched.c中中。

#include <asm/page.h>

/*
* sys_foo – everyone’s favorite system call.
*
* Returns the size of the per-process kernel stack.
*/
asmlinkage long sys_foo(void)
{
    return THREAD_SIZE;
}

启动内核,用户空间就可以调用foo()这个系统调用了。

在用户空间调用系统调用

C库提供了对系统调用的支持。应用程序可以引入标准头,然后链接到c库来使用你的系统调用。

linux提供了一系列的宏来访问系统调用。这些宏用于设置寄存器内容,然后调用陷入指令。这些宏命名为_syscalln(),n位于0到6之间。这个n用于表示传入到系统调用的参数数量,因为宏需要知道有几个参数需要被设置到寄存器上。

例如,系统调用open(),有如下的定义:

long open(const char *filename, int flags, int mode)

在没有显示的库支持的情况下,系统调用宏可以这样使用系统调用:

#define __NR_open 5
_syscall3(long, open, const char *, filename, int, flags, int, mode)

应用程序可以简单的调用open()方法。

一个宏有2+2*n个参数:

  • 第一个参数对应系统调用的返回值类型
  • 第二个参数对应系统调用的名称
  • 其他的则是各个参数的类型和名称,需要注意的这些参数的顺序和系统调用严格一致

__NR_open(arch/x86/include/asm/unistd_64.h#L19)定义在```<asm/unistd.h>中。对应的是系统调用号。

通过汇编内联的方式,_syscall3可以扩展到c函数中。这段汇编主要用于将系统调用号和参数放到对应的寄存器中,并且调用陷入指令进入到内核中。如果需要调用系统调用open(),只需要将上面的宏放到应用的对应位置即可。

比如我们要调用之前定义的系统调用foo(),我们需要这么做:

#define __NR_foo 283
__syscall0(long, foo)

int main ()
{
    long stack_size;
    stack_size = foo ();
    printf ("The kernel stack size is %ld\n", stack_size);
    return 0;
}

为什么不要自己实现系统调用

不鼓励添加自行添加新的系统调用,如果非要添加你需要格外小心谨慎。

下面是将一个新接口实现为系统调用的支持和反对观点:

支持:

  • 系统调用易于实现并且使用简单
  • 在Linux上系统调用的性能不错

反对:

  • 你需要官方分配一个系统调用号给你
  • 一旦系统调用进入到稳定的内核版本中,它就不可以再改变了。否则会似的很多应用无法执行。
  • 系统调用无法被脚本调用,并且不能在文件系统中直接调用
  • 因为你需要一个进程调度号,掌控和使用一个非主流的内核分支是十分困难的
  • 对于简单的信息交换,系统调用太重了

总结