系统调用是应用程序与操作系统内核之间的接口,它决定了应用程序是如何与内核打交道的。无论程序是直接进行系统调用,还是通过运行库,最终还是会达到系统调用这个层面。
系统调用介绍
1、什么是系统调用
在现代的操作系统里,程序运行的时候,本身是没有权利访问多少系统资源的。由于系统有限的资源有可能被多个不同的应用程序同时访问,因此,如果不加以保护,那么各个应用程序难免产生冲突。所以现代操作系统都将可能产生冲突的系统资源给保护起来,阻止应用程序直接访问。这些系统资源包括文件,网络,IO,各种设备等。举个例子,程序员都没有机会擅自去访问硬盘的某个扇区上面的数据,而必须通过文件系统;也不能擅自修改任意文件,所有的这些操作都必须经由操作系统所规定的方式进行,比如我们使用fopen去打开一个没有权限的文件就会发生失败。
没有操作系统的帮助,应用程序的执行可谓寸步难行。为了让应用程序有能力访问系统资源,也为了让程序借助操作系统做一些必须由操作系统支持的行为,每个操作系统都会提供一套接口,以供应用程序使用。这些接口往往通过中断来实现。
2、Linux系统调用
在x86下,系统调用由0x80中断完成,各个通用寄存器用于传递参数,EAX寄存器用于表示系统调用的接口号,比如EAX=1表示进程退出(exit);EAX=2表示进程创建(fork);EAX=3表示读取文件或IO(read);EAX=4表示写文件或IO等,每个系统调用都对应于内核源代码中的一个函数,它们都是以"sys"开头的,比如exit调用对应于内核中的sys_exit含食宿。当系统调用返回时,EAX又作为调用结果的返回值。
Linux内核版本2.6.19总共提供了319个系统调用,我们将其中一部分列在表1中。
表1
EAX | 名字 | C语言定义 | 含义 | 参数 |
1 | exit | void _exit(int status); | 退出进程 | EBX表示退出码(Exit Code) |
2 | fork | pid_t fork(void); | 复制进程 | EBX表示复制参数 |
3 | read | ssize_t read(int fd, void buf, size_t count); | 读文件 | EBX表示文件句柄,ECX表示读取缓冲地址,EDX表示读取大小 |
4 | write | ssize_t write(int fd, void buf, size_t count); | 写文件 | 同sys_read |
5 | open | int open(const char pathname, int flags, mode_t mode); | 打开文件 | EBX表示文件路径,ECX表示打开文件的模式(读,写,追加等),EDX也表示打开文件的模式(文件不存在是否创建) |
6 | close | int close(int fd); | 关闭文件 | EBX表示文件句柄 |
7 | waitpid | pid_t waitpid(pid_t pid, int* status, int options); | 等待进程退出 | EBX表示进程ID,ECX表示指向进程退出码的指针,EDX表示等待模式 |
8 | create | int creat(const char* pathname, mode_t mode); | 创建文件 | EBX表示文件路径,ECX表示创建模式 |
..... |
3、 系统调用的弊端
系统调用完成了应用程序和内核交流的工作,因此理论上只需要系统调用就可以完成一些程序,但是:
理论上,理论总是成立的。
事实上,包括Linux,大部分操作系统的系统调用都有两个特点:
1、使用不便。操作系统提供的系统调用接口往往过于原始,程序员需要了解很多与操作系统相关的细节。如果没进校很好的包装,使用起来不方便。
2、各个操作系统之间系统调用不兼容。Windows系统和Linux系统之间的系统调用就基本上完全不同。
为了解决这个问题,有一个称为"万能法则"---解决计算机的问题可以通过增加层来实现,于是运行库挺身而出,它作为系统调用与程序之间的一个抽象层可以保持着这样的特点:
1、使用简便
2、形式统一。运行库有它的标准,叫做标准库,凡事所有遵循这个标准的运行库理论上都是相互兼容的,不会随着操作系统或编译器的变化而变化。
系统调用原理
1、特权级与中断
现代的CPU常常可以在多种截然不同的特权级别下执行指令,在现代操作系统中,通常也据此有两种特权级别,分别为用户模式和内核模式,也被称为用户态和内核态。由于有多种特权模式的存在,操作系统就可以让不同的代码运行在不同的模式上,以限制它们的权力,提高稳定性和安全性。普通应用程序运行在用户态模式下,诸多操作将受到限制,这些操作包括访问硬件设备,开关中断,改变特权模式等。
系统调用是运行在内核态的,而应用程序基本都是运行在用户态的。用户态的程序如何运行内核态的代码呢?操作系统一般是通过中断来从用户态切换到内核态。什么是中断呢?中断是一个硬件或者软件发出的请求,要求CPU暂停当前的工作转手去处理更加重要的事情。
中断一般具有两个属性,一个称为中断号(从0开始),一个称为中断处理函数(ISR)。不同的中断具有不同的中断号,而同时一个中断处理函数一一对应一个中断号。在内核中,有一个数组称为中断向量表,这个数组的第n项包含了指向第n号中断的中断处理程序的指针。当中断到来时,CPU会暂停当前执行的代码,根据中断的中断号,在中断向量表中找到对应的中断处理程序,并调用它。中断处理程序执行完成之后,CPU会继续执行之前的代码。一个简单的示意图如图2所示。
图2 CPU中断过程
通常意义上,中断有两种类型,一种称为硬中段,这种中断来自于硬件的异常或者其他事件的发生,比如键盘被按下等。另外一种被称为软件中断。软件中断通常是一条指令(i386下是int),带有一个参数记录中断号,使用这条指令用户可以收到触发某个中断并执行其中断处理程序。例如在i386下,int 0x80 这条指令会调用0x80号中断的处理程序。
由于中断号是很有限的,操作系统不会舍得用一个中断号来对应一个系统调用,而更倾向于使用一个或少数几个中断号来对于所有的系统调用,在Linux下面使用int 0x80来触发所有的系统调用。对于同一个中断号,操作系统如何知道是那个系统调用要被调用呢?和中断一样,系统调用都有一个系统调用号,就像身份标识一样来表明是那个系统调用,这个系统调用通常就是系统调用在系统调用表中的位置,例如Linux里fork的系统调用号是2.这个系统调用号在执行int指令前就会被防止在某个固定的寄存器里,对应的中断代码会取得这个系统调用号,并且调用正确的函数。以Linux的int 0x80为例,系统调用是有eax来传入。用户将系统调用好放入eax,然后使用int 0x80调用中断,中断服务程序就可以从eax里取得系统调用号,进而调用对应的函数。
2、基于int的Linux的经典系统调用实现
我们将了解到当应用程序调用系统调用时,程序是如何一步步进入操作系统内核调用相应的函数。图3是以fork为例的Linux系统调用的执行流程。
图3 Linux系统中断流程
接下来我们来了解下这个过程的细节。
1、触发中断
首先当程序在代码里调用一个系统调用时,是以一个函数的形式调用的,例如程序调用fork:
int main()
{
fork();
}
fork函数是一个对系统调用fork的封装,可以用下列红来定义它:
_syscall0(pid_t, fork);
_syscall0是一个宏函数,用于定义一个没有参数的系统调用的封装。它的第一个参数为这个系统调用的返回值类型,这里为pid_t,是一个Linux自定义类型,代表进程的id。_syscall0的第二个参数是系统调用的名称,_syscall0展开之后会形成一个与系统调用名称同名的函数。下面的代码是i386版本的syscall0的定义:
#define _syscall0(type, name)\
type name(void) \
{ \
long __res; \
__asm__ volate ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name)); \
__syscall_return(type, __res); \
}
对于syscall0(pid_t,fork),上面的宏将展开为:
pid_t fork()
{
long __res;
__asm__ volatile("int 0x80"
: "=a" (__res)
: "0" (__NR__fork));
__syscall_return(pid_t,__res);
}
1、首先__asm__是一个gcc的关键字,表示接下来要嵌入汇编代码。volatile关键字告诉GCC对这段代码不进行任何优化。
2、__asm__的第一个参数是一个字符串,代表汇编代码的文本。这里的汇编代码只有一句:int $0x80,这就要调用0x80号中断。
3、“=a”(__res)表示用eax(a表示eax)输出返回数据并存储在__res里。
4、"0"(__NR__##name)表示__NR__##name)为输入,“0”指示由编译器选择和输出相同的寄存器(即eax)来传递参数。
更直观一点,可以把这段汇编代码改写成更为可读的格式:
main-->fork:
pid_t fork(void)
{
long __res;
$eax = __NR__fork
int $0x80
__res = $eax
__syscall_return(pid_t, __res);
}
__NR_fork是一个宏,表示fork系统调用的调用号。
而__syscall_return是另外一个宏,定义如下:
#define __syscall_return(type, res) \
do{ \
if ((unsigned long)(res) >= (unsigned long)(-125)){\
errno = -(res);
res = -1;
}
return (type)(res);
}
这个宏用于检查系统调用的返回值,并把它相应的转换为C语言的errno错误码。在Linux里,系统调用使用返回值传递错误码,如果返回值为负数那么表明调用失败,返回值的绝对值就是错误码。
如果系统调用本身有参数要如何实现呢?下面是x86Linux下的syscall,用于带1个参数的系统调用:
#define __syscall2(type, name, type1, arg1) \
type name(type1, arg1) \
{ \
long __res; \
__asm__ volatile( "int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)arg1) \
__syscall_return(type, __res); \
}
这段代码和_syscall0不同的是,它多了一个“b”((long)arg1)。这一句的意思先把arg1强制转换为龙,然后存放在EBX(b代表EBX)里作为输入。编译器还会生成相应的代码来保护原来的EBX的值不被破坏。
可见,如果系统调用与1个参数,那么参数通过EBX来传入。在x86下Linux支持的系统调用参数至多有6个,分别使用6个寄存器来传递,他们分别是EBX,ECX, EDX, ESI,EDI和EBP。
当用户调用某个系统调用的时候,实际是执行了以上一段汇编代码。CPU执行到int $0x80时,会保存现场以便恢复,接下来会把特权态切换到内核态。然后CPU便会查找中断向量表中的第0x80号元素。
2、切换堆栈
在实际执行中断向量表中的第0x80号元素所对应的函数之前,CPU首先还要进行栈的切换。在Linux中,用户态和内核态使用的是不同的栈,两者各自负责各自的函数调用,互不干扰。但在应用程序调用0x80号中断时,程序的执行流程从用户态切换到内核态,这时程序的当前栈必须也相应地从用户栈切换到内核栈,从中断处理函数中返回时,程序的当前栈还要从内核栈切换回用户栈。
所谓的"当前栈",指的是ESP的值所在的栈空间。如果ESP的值位于用户栈的范围内,那么程序的当前栈就是用户栈,反之亦然。此外,寄存器SS的值还应该指向当前栈所在的页。所以,将当前栈由用户栈切换到内核栈的实际行为就是:
1、保存当前的ESP,SS的值。
2、将ESP,SS的值设置为内核栈的相应值。
反过来,将当前栈由内核栈切换到用户栈的实际行为是:
1、恢复原来ESP,SS的值。
2、用户态的ESP和SS的值保存在哪里呢?答案是在内核栈上。这一行为由i386中的中断指令自动地由硬件完成。
当0x80号中断发生的时候,CPU除了切入内核态之外,还会自动完成下列几件事:
1、找到当前进行的内核栈(每一个进程都有自己的内核栈)。
2、在内核栈中一次压入用户态的寄存器SS,ESP,EFLAGS,CS,EIP。
而当内核从系统调用中返回的时候,需要调用iret指令来回到用户态,iret指令则会从内核栈中弹出寄存器SS,ESP,EFLAGS,CS,EIP的值,使得栈恢复到用户态的状态,这个过程可以用图4来表示。
图4 中断时用户栈和内核栈切换