系统调用是应用程序与操作系统内核之间的接口,它决定了程序如何与内核打交道的。无论程序是直接进行系统调用,还是通过运行库,最终还是会到达系统调用这个层面上。x86系统下,Linux系统使用0x80号中断作为系统调用的入口。EAX寄存器用于表示系统调用的接口号,比如EAX=1表示退出进程,EAX=2表示创建进程,EAX=3表示读取文件,EAX=4表示写文件等。每一个系统调用都对应于内核代码中的一个函数,它们都是以“sys_”开头的,当系统调用返回时,EAX又作为调用结果的返回值。
fork函数是一个对系统调用fork的封装,可以用下列宏来定义它:
包括Linux,大部分操作系统的系统调用都有两个特点:使用不便、各个操作系统之间系统调用不兼容。为了解决这些问题,运行库挺身而出,它作为系统调用与程序之间的抽象层可以保持这样的特点:
- 使用简便
- 形式统一;运行库有它的标准,凡是所有遵循这个标准的运行库理论上都是相互兼容的,与操作系统和编译库无关
CPU常常可以在多种截然不同的特权级别下执行指令,在现代操作系统中,通常也据此有两种特权级别,分别为用户模式和内核模式,也被称为用户态和内核态。系统调用是运行在内核态,而应用程序基本都是运行在用户态。操作系统一般是通过中断来从用户态切换到内核态;中断是一个硬件或软件发出的请求,要求CPU暂停当前的工作转手去处理更加重要的事情。
中断一般具有两个属性,一个称为中断号,一个称为中断处理程序。不同的中断具有不同的中断号,而同时一个中断处理程序一一对应一个中断号。在内核中,有个数组称为中断向量表,这个数组的第n项包含了指向第n号中断的中断处理程序的指针。当中断到来时,CPU会暂停当前执行的代码,根据中断的中断号,在中断向量表中找到对应的中断处理程序,并调用它。中断处理程序执行完成之后,CPU会继续执行之前的代码。
通常意义上,中断有两种类型,一种称为硬件中断,这种中断来自硬件的异常或其他事件的发生。另一种称为软件中断,软件中断通常是一条指令,带有一个参数记录中断号,使用这条指令用户可以手动触发某个中断并执行其中断处理程序。由于中断号是很有限的,操作系统不会舍得用一个中断号来对应一个系统调用,Linux使用int 0x80来触发所有的系统调用。和中断一样,系统调用都有一个系统调用号,通常就是系统调用在系统调用表中的位置。linux系统中,系统调用号通常由eax传入,用户将系统调用号放入eax中,然后使用int 0x80调用中断,中断服务程序就可以从eax里取得系统调用号,进而调用对应的函数。
下图是以fork为例的Linux系统调用的执行流程:
![](https://img-my.csdn.net/uploads/201304/12/1365729608_9225.jpg)
fork函数是一个对系统调用fork的封装,可以用下列宏来定义它:
- _syscall0(pid_t, fork);
_syscall0是一个宏函数,用于定义一个没有参数的系统调用的封装。它的第一个参数为这个系统调用的返回值类型,这里为pid_t,是一个Linux自定义类型,代表进程的id。 _syscall0的第二个参数是系统调用的名称,_syscall展开之后会形成一个与系统调用名称同名的函数。该宏定义展开之后如下:
// fork
pid_t fork(void)
{
long __res;
$eax = __NR_fork
__res = $eax
__syscall_return(pid_t, __res);
}
__NR_fork是一个宏,表示fork系统调用的调用号,对于x86体系结构,该宏的定义可以在Linux/include/asm-x86/unistd_32.h里找到;而__syscall_turn是另一个宏,这个宏用于检查系统调用的返回值,并把它转换为C语言的error错误码。在Linux里,系统调用使用返回值传递错误码,如果返回值为负数,那么表明该调用失败,返回值的绝对值就是错误码。而在C语言里,大多数函数都以返回-1表示调用失败,而将出错信息存储在一个名为errno的全局变量里。__syscall_return就负责将系统调用的返回信息存储在errno中。
如果系统调用本身有参数,那么参数通过ebx来传入。x86下Linux支持的系统调用参数至多有6个,分别使用6个寄存器来传递,它们分别是EBX、ECX、EDX、ESI、EDI 和 EBP。
当用户调用某个系统调用的时候,实际是执行了一段汇编代码。CPU执行到int $0x80时,会保存现场以便恢复,接着会将特权状态切换到内核态。然后CPU便会查找中断向量表中的第0x80号元素。
在实际执行中断向量表中0x80号元素之前,CPU首先还要进行栈的切换。在Linux中,用户态和内核态使用的是不同的栈,两者各自负责各自的函数调用,互不干扰。但在应用程序进行系统调用时,程序的执行流程从用户态切换到内核态,这是程序的当前栈必须也相应地从用户栈切换到内核栈。从中断处理函数中返回时,程序的当前栈还要从内核栈切换回用户栈。
所谓的“当前栈”,指的是ESP所在的栈空间。如果ESP的值位于用户栈的范围内,那么程序的当前栈就是用户栈;此外,寄存器SS的值还应该指向当前栈所在的页。所以,将当前栈有用户栈切换为内核栈的实际行为就是:
- 保存当前ESP、SS的值
- 将ESP、SS的值设置为内核栈的相应值
- 找到当前进程的内核栈
- 在内核栈中依次压入用户态的寄存器SS、ESP、EFLAGS、CS、EIP。
这样,系统调用fork通过int 0x80调用system_call,根据当前eax的值在系统调用表中选择合适的函数继续执行。我们看下system_call片段:
ENTRY(system_call)
...
SAVE_ALL
...
cmpl $(nr_syscalls), x
jae syscall_badsys
......
在这里一开始使用宏SAVE_ALL将各种寄存器压入栈中,以免它们的值被后续执行的代码所覆盖。然后接下来使用cmpl指令比较eax和nr_syscalls(比最大的有效系统调用号大一的值),因此,如果不在有效系统调用号的范围内,就会跳转到syscall_badsys执行。如果系统调用号是有效的,则:
确定系统调用号有效并且保存寄存器之后,接下来要执行的就是调用 *sys_call_table(0,x,4)来查找中断服务程序并执行。执行结束只有使用宏RESTORE_REGS来恢复之前被SAVE_ALL保存的寄存器。最后通过iret从中断处理程序中返回。
system_call:
call *sys_call_table(0, x, 4)
......
RESTORE_REGS
......
iret