系统调用介绍
什么是系统调用
为了让应用程序有能力访问系统资源,也为了让程序借助操作系统做一些由操作系统支持的行为,每个操作系统都会提供一套接口,以供应用程序使用。系统调用涵盖的功能很广,有程序运行所必需的支持,例如创建/退出进程和线程,进程内存管理,也有对系统资源的访问,例如文件,网络,进程间通信,硬件设备的访问,也可能有对图形界面的操作支持。
Linux系统调用
下面让我们来看看Linux系统调用的定义,有一个比较直观的概念。在x86下,系统调用由0x80中断完成,各个通用寄存器用于传递参数,EAX寄存器用于表示系统调用的接口号,比如EAX=1表示退出进程(exit);EAX=2表示创建进程(fork);EAX=3表示读取文件或IO(read);EAX=4表示写文件或IO(write)等,每个系统调用都对应内核源代码中的一个函数,它们都是以"sys_"开头,比如exit调用对应内核中的sys_exit函数。当系统调用返回时,EAX又作为调用结果的返回值。
EAX | 名字 | C语言定义 | 含义 | 参数 |
1 | exit | void _exit(int status) | 退出进程 | EBX表示退出码 |
3 | read | ssize_t read(int fd, void* buf, size_t count) | 读文件 | EBX表示文件句柄,ECX表示读取缓冲区地址,EDX表示读取大小 |
系统调用的弊端
系统调用完成了应用程序和内核交流的工作,因此理论上只需要系统调用就可以完成一些程序,但是:
理论上,理论总是成立的。
事实上,包括Linux,大部分操作系统的系统调用都有两个特点:
1、使用不便。操作系统提供的系统调用接口往往过于原始,程序员需要了解很多与操作系统相关的细节。如果没有进行很好的包装,使用起来不方便。
2、各个操作系统之间系统调用不兼容。
系统调用原理
特权级与中断
现在的CPU常常可以在多种截然不同的特权级别下执行指令,在现代操作系统中,通常也据此有两种特权级别,分别为用户模式和内核模式,也被称为用户态和内核态。由于有多种特权模式的存在,操作系统就可以让不同的代码运行在不同的模式下,以限制它们的权利,提高稳定性和安全性。普通应用程序运行在用户态的模式下,诸多操作将受到限制,这些操作包括访问硬件设备,开关中断,改变特权模式等。
一般来说,运行在高特权级的代码将自己降至低特权级是允许的,但反过来低特权级的代码将自己提升至高特权级则不是轻易就能进行的。在将低特权级的环境转为高特权级时,必须使用一种较为受控和安全的形式,以防止低特权模式的代码破坏高特权模式代码的执行。
系统调用是运行在内核态的,而应用程序基本都是运行在用户态的。用户态的程序如何运行内核态的代码呢?操作系统一般是通过中断来从用户态切换到内核态。什么是中断呢?中断是一个硬件或软件发出的请求,要求CPU暂停当前的工作转手去处理更加重要的事情。
中断一般具有两个属性,一个称为中断号(从0开始),一个称为中断处理程序。不同的中断具有不同的中断号,而同时一个中断处理程序一一对应一个中断号。在内核中,有一个数组称为中断向量表,这个数组的第n项包含了第n号中断的中断处理程序的指针。当中断到来时,CPU会暂停当前执行的代码,根据中断号,在中断向量表中找到对应的中断处理程序,并调用它。中断处理程序执行完成之后,CPU会继续执行之前的代码。一个简单的示意图如图1所示。
图1 CPU中断过程
通常意义上,中断有两种类型,一种称为硬件中断,这种中断来自硬件的异常或其他事件的发生,如电源掉电,键盘被按下。另一种称为软件中断,软件中断通常是一条指令,带有一个参数记录中断号,使用这条指令用户可以手动触发某个中断并执行其中断处理程序。例如在i386下,int 0x80这条指令会调用第0x80号中断的处理程序。
由于中断号是很有限的,操作系统不会舍得用一个中断号来对应一个系统调用,而更倾向于一个或少数几个终端号来对应所有的系统调用。对于同一个中断号,操作系统如何知道是哪一个系统调用被调用呢?和中断一样,系统调用都有一个系统调用号,就像身份标识一样来表明是那个系统调用,这个系统调用号通常就是系统调用在系统调用表中的位置,例如Linux里的fork系统调用号是2。这个系统调用号在执行int指令前会被放置在某个固定的寄存器里,对应的中断代码会取得这个系统调用号,并且调用正确的函数。以Linux的int 0x80为例,系统调用号是由eax来传入。用户将系统调用号放入eax,然后使用int 0x80调用中断,中断服务程序就可以从eax里取得系统调用号,进而调用对应的函数。
基于int的Linux的经典系统调用实现
图2是以fork为例的Linux系统调用的执行流程。
图2 Linux系统中断流程
触发中断
首先当程序在代码里调用一个系统调用时,是以一个函数的形式调用的,例如程序调用fork:
int main(){ fork();}
fork函数是一个对系统调用fork的封装,可以用下列宏来定义它:
_syscall0(pid_t, fork);
_syscall0是一个宏函数,用于定义一个没有参数的系统调用的封装。它的第一个参数为这个系统调用的返回值类型,这里为pit_t,是一个Linux自定义类型,代表进程的id。_syscall0的第二个参数是系统调用的名称,_syscall0展开之后会形成一个与系统调用名称同名的函数。下面的代码是i386版本的syscall0定义:
#define _syscall0(type, name )
type name(void)
{
long __res;
__asm__ volatile("int $0x80"
: "=a" (__res)
: "0" (__NR__##name));
__syscall_return(type, __res);
对应syscall0(pid_t, fork),上面的宏将展开为:
pid_t fork(void)
{
long __res;
__asm__ volatile("int $0x80"
: "=a" (__res)
: "0" (__NR_fork));
__syscall_return(pid_t, __res);
}
首先__asm__是一个gcc的关键字,表示接下来要嵌入汇编代码。volatile
关键字告诉gcc对这段代码不进行任何优化。
__asm__的第一个参数是一个字符串,代表汇代码的文本。这里的汇编代码只有一句: int $0x80,这就要调用0x80号中断
"=a"(__res)表示用eax(a表示eax)输出返回数据并存储在__res里。
"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);
}
如果系统调用本身由参数要如何实现呢?下面是x86Linux下的syscall1,用于带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强制转换为long,然后存放在EBX(b代表EBX)里作为输入。编译器还会生产相应的代码来保护原理的EBX的值不被破坏。
可见,如果系统调用由一个参数,那么参数通过EBX来传入。x86下Linux支持的系统调用参数至多由6个,分别使用6个寄存器来传递,它们分别是EBX,ECX,EDX,ESI,EDI和EBP。
当用户调用某个系统调用的时候,实际是执行了以上一段汇编代码。CPU执行到int $0x80时,会保存现场以便恢复,接着会将特权状态切换到内核态。然后CPU便会查找中断向量表中的第0x80号元素。
切换堆栈
在实际执行中断向量表中的第0x80号元素所对应的函数之前,CPU首先还要进行栈的切换。在Linux中,用户态和内核态使用的是不同的栈,两者各自负责各自的函数调用,互不干扰。但在应用程序调用0x80号中断时,程序的执行流程从用户态切换到内核态,这时程序的当前栈必须也相应的从用户栈切换到内核栈。从中断处理函数返回时,程序的当前栈还要从内核栈切换回用户栈。
所谓的"当前栈",指的是ESP的值所在的栈空间。如果ESP的值位于用户栈的范围内,那么程序的当前栈就是用户栈,反之亦然。此外,寄存器SS的值还应该指向当前栈所在的页。所以,将当前栈由用户栈切换为内核栈的实际行为就是:
1、保存当前的ESP,SS的值。
2、将ESP,SS的值设置为内核栈的相应值。
反过来,将当前栈由内核栈切换为用户栈的实际行为则是:
1、恢复原来ESP,SS的值。
2、将用户态的ESP,SS的值保存在内核栈上。
当0x80号中断发生的时候,CPU除了切入内核态之外,还会自动完成下列几件事:
1、找到当前进程的内核栈(每一个进程都有自己的内核栈)。
2、在内核栈中依次压入用户态的寄存器SS,ESP,EFLAGS,CS,EIP。
而当内核从系统调用中返回的时候,需要调用iret指令来回倒用户态,iret指令则会从内核栈里弹出寄存器SS,ESP,EFLAGS,CS,EIP的值,使得栈恢复到用户态的状态。这个过程可以用图3来表示。
图3 中断时用户栈和内核栈切换