实现原理

用户态 –> 中断(Linux 下中断号0x80) –> 内核态

中断

中断就是一个硬件或软件请求,要求 CPU 暂停当前的工作,去处理更重要的事情。比如,在 x86 机器上可以通过 int 指令进行软件中断,而在磁盘完成读写操作后会向 CPU 发起硬件中断。

中断号

中断有两个重要的属性,中断号和中断处理程序。中断号用来标识不同的中断,不同的中断具有不同的中断处理程序。再操作系统内核中维护着一个中断向量表(Interrupt Vector Table),这个数组存储了所有中断处理程序的地址,而中断号就是相应中断再中断向量表中的偏移量。

Linux 下系统调用的实现

前文已经提到了 Linux 下的系统调用是通过 0x80 实现的,但是我们知道操作系统嗯有多个系统调用(Linux 下有 319 个系统调用),而对于同一个中断号是如何处理多个不同的系统调用的呢?最简单的方式是对于不同的系统调用采用不同的中断号,但是中断号明显是一种稀缺资源,Linux 显然不会这么做;还有一个问题就是系统调用是需要提供参数,并且具有返回值的,这些参数又是怎么传递的?也就是说,对于系统调用我们要搞清楚两点:

  1. 系统调用的函数名称转换。
  2. 系统调用的参数传递。

首先看第一个问题。实际上,Linux 中处理系统调用的方式与中断类似。每个系统调用都有相应的系统调用号作为唯一的标识,内核维护一张系统调用表,表中的元素是系统调用函数的起始地址,而系统调用号就是系统调用在调用表的偏移量。在进行系统调用时只要指定对应的系统调用号,就可以明确的要调用哪个系统调用,这就完成了系统调用的函数名称的转换。举例来说,Linux 中 fork 的调用号是2(具体定义,在我的计算机上是在 /usr/include/asm/unistd_32.h,可以通过 find / -name unistd_32.h -print 查找)。

#ifndef _ASM_X86_UNISTD_32_H
#define _ASM_X86_UNISTD_32_H 1

#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_close 6
#define __NR_waitpid 7
#define __NR_creat 8
#define __NR_link 9
#define __NR_unlink 10
#define __NR_execve 11
#define __NR_chdir 12
#define __NR_time 13
#define __NR_mknod 14
#define __NR_chmod 15

Linux中是通过寄存器 %eax 传递系统调用号,所以具体调用 fork 的过程是:将 2 存入 %eax 中,然后进行系统调用,伪代码:

mov     eax, 2
int     0x80

对于参数传递,Linux 是通过寄存器完成的。Linux 最多允许向系统调用传递 6 个参数,分别依次由 %ebx,%ecx,%edx,%esi,%edi和 %ebp 这个 6 个寄存器完成。比如,调用 exit(1),伪代码是:

mov    eax, 1
mov    ebx, 1
int    0x80

因为 exit 需要一个参数 1,所以这里只需要使用 ebx。

栈切换

Linux 中,在用户态和内核态运行的进程使用的栈是不同的,分别叫做用户栈和内核栈,两者各自负责相应特权级别状态下的函数调用。当进行系统调用时,进程不仅要从用户态切换到内核态,同时也要完成栈切换,这样处于内核态的系统调用才能在内核栈上完成调用。系统调用返回时,还要切换回用户栈,继续完成用户态下的函数调用。 寄存器 %esp(栈指针,指向栈顶)所在的内存空间叫做当前栈,比如 %esp 在用户空间则当前栈就是用户栈,否则是内核栈。

栈切换主要就是 %esp 在用户空间和内核空间间的来回赋值。

在 Linux 中,每个进程都有一个私有的内核栈,当从用户栈切换到内核栈时,需完成保存 %esp 以及相关寄存器的值(%ebx,%ecx…)并将 %esp 设置成内核栈的相应值。而从内核栈切换会用户栈时,需要恢复用户栈的 %esp 及相关寄存器的值以及保存内核栈的信息。一个问题就是用户栈的 %esp 和寄存器的值保存到什么地方,以便于恢复呢?答案就是内核栈,在调用 int 指令执行系统调用后会把用户栈的 %esp 的值及相关寄存器压入内核栈中,系统调用通过 iret 指令返回,在返回之前会从内核栈弹出用户栈的 %esp 和寄存器的状态,然后进行恢复。

相信大家一定听过说,系统调用很耗时,要尽量少用。通过上面描述系统调用的实现原理,大家也应该知道这其中的原因了。第一,系统调用通过中断实现,需要完成栈切换。第二,使用寄存器传参,这需要额外的保存和恢复的过程。

参考

系统调用的实现原理