2.6 System Call

[Home]  [Top]  [Previous]


2.6.1 Overview

操作系统的重要任务之一就是为应用程序提供服务,而提供服务的最直接手段就是允许应用程序能够调用操作系统的相关服务代码。作为程序员,我们知道,一个程序如果想调用一段代码,它必须知道这段代码的入口地址。对于DOS这种非保护模式操作系统,如果我们知道其内核一段代码的入口地址,我们的应用程序只需要执行一条CALL指令,就可以执行这段内核代码,以获取服务。

对于这一点,操作系统的设计原则有两种:即对应用程序总是信任的,还是总是不信任的。如果操作系统对于应用程序总是信任的,那就意味着操作系统认为应用程序总是按照它们之间的约定或协议在做事情,应用程序永远不会违反这些协议,无论是有意的还是无意的;但很明显,即使一个程序员总是在善意的写程序,他也无法保证其程序没有错误,无论其水平有多高,经验有多丰富;而对于恶意的程序,这种操作系统几乎不设防,一个水平不用太高的程序员就可以很轻松的写一个程序让系统崩溃。这正是DOS时代病毒肆虐的原因。另外,或许对于一个单任务的操作系统来说,当问题程序运行引起系统崩溃时,只会影响当前存在问题的任务。但对于多任务操作系统来说,一个问题程序引起其它健康程序无法运行,无疑是一种非常不公平的结果。

所以,今天运行在PC机以上平台(包括工作站,小型机,大型机)的操作系统对应用程序几乎都是采取不信任原则,也就是说,操作系统假定运行在其上的任何应用程序都有可能是不安全的,这种类型的操作系统非常保守,尽其所能的对内核其其它应用程序给予保护,任何不遵守协议的应用程序都无法正常运行,却有不会影响操作系统自身的安全,以及其它正常应用程序的运行。一个安全的社会总需要完善的法制来保障,这就是生活。

在具有保护模式的操作系统中,操作系统内核运行在高特权级别(内核态),而用户应用程序运行在低特权级别(用户态)。应用程序无法直接调用任何操作系统的代码,即使应用程序非常清楚这些代码的入口地址,但由于硬件给予的保护,应用程序对于这些入口地址的调用,会引起异常。操作系统会捕获到这个异常,并在这个异常的处理中将这个进行非法调用的应用程序杀掉。

但操作系统必须向应用程序提供服务,否则,应用程序几乎无法作任何有价值的事情。既然直接调用的方法会导致系统的不安全,那么只能进行间接调用,那就是中断。

即使对于非保护模式的DOS来说,它也是通过中断的方法向应用程序提供服务,这就是DOS程序员熟悉的INT 21H。事实上,通过中断的方法,应用程序无需知道相应的操作系统服务例程的入口地址。因为中断服务程序知道它们,而这一切都是由操作系统设定和维护的。应用程序只需要设定好相应的寄存器,然后执行一条INT指令就可以对这些操作系统服务例程进行调用,并通过寄存器获取执行的结果。

对于保护机制的操作系统来说,中断机制本身也是受保护的,在IBM PC上,Intel规定多达255个中断号,但只有授权给应用程序保护等级的中断号才是可以被应用程序调用的,对于未被授权的中断号,如果应用程序进行调用,同样会引起保护异常,而导致自己被操作系统杀死。比如,Linux仅仅给应用程序授权了4个中断号——3,4,5,以及80h。前三个中断号为了提供给应用程序调试(单步跟踪)的手段,而80h正是我们本节讨论的系统调用(system call)的中断号。

应用程序在执行用户态代码时,被称为运行在用户态;在通过系统调用执行内核服务代码时,被称为运行在内核态——这是一个从应用程序角度来观察的结果。如果你从Client/Server的观点来看,当应用程序作为Client执行一个系统调用时,那么相当于向Server(内核)发起一个请求,当系统调用返回结果时,相当于内核给应用程序一个应答。尤其是基于消息机制的操作系统(比如Minix),这样的逻辑更加直观。

作为前一种观点的结果,系统调用很自然的被称做应用程序的陷阱(Trap),在某些硬件系统上,有专门的陷阱指令,尽管机制仍然是中断的机制。当应用程序进行系统调用时,就像从用户态通过一个陷阱掉进内核态里一样,很多操作系统文献都这样比喻,很有趣! 


2.6.2 Mechanism

前面我们已经讨论,系统调用是通过中断机制实现的,并且一个操作系统的所有系统调用都通过一个中断号来实现。如下的一些例子都是Unix系统的系统调用函数:

从这些例子可以看出,它们有3个特点:

DOS程序员都知道如何通过INT 21H来进行DOS功能调用,应用程序只需要将功能号装入AX寄存器,将参数(如果存在的话)按照需要装入BX, CX, DX, SI, DI寄存器,然后调用INT 21H指令。等中断服务程序完成后,AX寄存器用来存放返回值(如果有的话),其它寄存器用来存放执行结果(如果存在的话)。依赖于Intel 80x86芯片的通用寄存器只有(E)AX, (E)BX,(E)CX,(E)DX,(E)SI,(E)DI等6个寄存器,其中(E)AX要用来存放功能号,使用这种手段最多只能允许有5个参数。

保护模式的操作系统也可以使用相同的手段来进行系统调用的处理。我们以Linux为例,在进行系统调用时,它使用EAX存放系统调用功能号,在内核中每一个功能号对应一个系统调用功能,比如0对应exit,1对应fork,2对应read,如果对应的系统调用功能有参数,那么应用程序需要按照此功能要求的参数个数分别设置EBX, ECX, EDX, ESI,EDI,基于同样原因,这种方法所允许的参数也最多为5个。为了能够定义6个参数的系统调用功能函数,Linux使用了%ebp,但%ebp对于函数来说是一个非常重要的寄存器,所以使用前必须进行压栈保存。由于这些寄存器都是32-bit宽度的,所以要求参数也都应该为32-bit类型的;然后应用程序执行指令INT 80h。下面是Linux 2.4相关代码。

#define __NR_exit    1 /* exit 的系统调用号 */
#define __NR_fork    2 /* fork 的系统调用号 */
#define __NR_read    3  /* read 的系统调用号 */
#define __NR_write   4  /* write 的系统调用号 */
#define __NR_open    5  /* open 的系统调用号 */
#define __NR_close   6  /* close 的系统调用号 */

/*这里为了节省篇幅,只列出了几个系统调用功能号*/

#define __syscall_return(type, res) \
do { \
 if ((unsigned long)(res) >= (unsigned long)(-125)) { \
  errno = -(res); \
  res = -1; \
 } \
 return (type) (res); \
} while (0)

/* 没有参数的系统调用使用此Macro进行定义 */
#define _syscall0(type,name) \
type name(void) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
 : "=a" (__res) \
 : "0" (__NR_##name)); \
__syscall_return(type,__res); \
}

/* 具有1个参数的系统调用使用此Macro进行定义 */

#define _syscall1(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); \
}

/* 具有2个参数的系统调用使用此Macro进行定义 */

#define _syscall2(type,name,type1,arg1,type2,arg2) \
type name(type1 arg1,type2 arg2) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
 : "=a" (__res) \
 : "0" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2))); \
__syscall_return(type,__res); \
}

/* 具有3个参数的系统调用使用此Macro进行定义 */

#define _syscall3(type,name,type1,arg1,type2,arg2,type3,arg3) \
type name(type1 arg1,type2 arg2,type3 arg3) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
 : "=a" (__res) \
 : "0" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2)), \
    "d" ((long)(arg3))); \
__syscall_return(type,__res); \
}

/* 具有4个参数的系统调用使用此Macro进行定义 */

#define _syscall4(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4) \
type name (type1 arg1, type2 arg2, type3 arg3, type4 arg4) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
 : "=a" (__res) \
 : "0" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2)), \
   "d" ((long)(arg3)),"S" ((long)(arg4))); \
__syscall_return(type,__res); \
}

/* 具有5个参数的系统调用使用此Macro进行定义 */

#define _syscall5(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4, \
   type5,arg5) \
type name (type1 arg1,type2 arg2,type3 arg3,type4 arg4,type5 arg5) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
 : "=a" (__res) \
 : "0" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2)), \
   "d" ((long)(arg3)),"S" ((long)(arg4)),"D" ((long)(arg5))); \
__syscall_return(type,__res); \
}

/* 具有6个参数的系统调用使用此Macro进行定义 */

#define _syscall6(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4, \
   type5,arg5,type6,arg6) \
type name (type1 arg1,type2 arg2,type3 arg3,type4 arg4,type5 arg5,type6 arg6) \
{ \
long __res; \
__asm__ volatile ("push %%ebp ; movl %%eax,%%ebp ; movl %1,%%eax ; int $0x80 ; pop %%ebp" \
 : "=a" (__res) \
 : "i" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2)), \
   "d" ((long)(arg3)),"S" ((long)(arg4)),"D" ((long)(arg5)), \
   "0" ((long)(arg6))); \
__syscall_return(type,__res); \
}

/* 下面是几个通过上述Macro进行定义的系统调用的例子 */

static inline _syscall0(pid_t,setsid)
static inline _syscall1(int,dup,int,fd)
static inline _syscall3(int,write,int,fd,const char *,buf,off_t,count)
static inline _syscall3(int,read,int,fd,char *,buf,off_t,count)
static inline _syscall3(off_t,lseek,int,fd,off_t,offset,int,count)

Linux捕获到这个中断后,将执行其对应的中断服务程序(system_call),system_call首先将必要的寄存器(包括这6个通用寄存器)push到栈中,然后以%eax中的值作为索引到其所维护的系统调用功能函数入口表(sys_call_table)中查找并调用相应函数。等系统调用完成后,Linux内核将返回值装入EAX寄存器(如果不是void的话),但不用其它寄存器来返回执行结果。下面是Linux 2.4相关代码。

/* 这里定义的是Stack中各个寄存器距离栈顶的字节数 */

EBX = 0x00
ECX = 0x04
EDX = 0x08
ESI = 0x0C
EDI = 0x10
EBP = 0x14
EAX = 0x18
DS  = 0x1C
ES  = 0x20
ORIG_EAX = 0x24
EIP = 0x28
CS = 0x2C
EFLAGS = 0x30
OLDESP = 0x34
OLDSS = 0x38

/* 此Macro用来保存和设置所有相关寄存器*/

#define SAVE_ALL \
 cld; \
 pushl %es; \
 pushl %ds; \
 pushl %eax; \
 pushl %ebp; \
 pushl %edi; \
 pushl %esi; \
 pushl %edx; \
 pushl %ecx; \
 pushl %ebx; \
 movl $(__KERNEL_DS),%edx; \
 movl %edx,%ds; \
 movl %edx,%es;

/* 为了便于理解,system_call中几条与此关键机制无关的指令被删去 */

ENTRY(system_call)
 pushl %eax   # save orig_eax
 SAVE_ALL
 cmpl $(NR_syscalls),%eax # %eax中存放的系统调用号必须为一个存在的号
 jae badsys                # 否则返回-ENOSYS错误
 call *SYMBOL_NAME(sys_call_table)(,%eax,4) # 以%eax为索引,调用相应系统调用函数
 movl %eax,EAX(%esp)     # save the return value

badsys:
 movl $-ENOSYS,EAX(%esp)
 jmp ret_from_sys_call

需要注意的是,由于函数参数传递的方式是将参数Push到Stack中,被调用函数执行时到Stack中访问相关参数,所以SAVE_ALL中将各个寄存器压栈的顺序是经过精心安排的,不能够随意更改。我们看栈顶的6个寄存器依次为%ebx,%ecx,%edx,%esi,%edi,%ebp,对于不需要参数的系统调用功能函数来说,它不需要访问任何保存在栈中的寄存器的值;对于需要1个参数的寄存器来说,栈顶%ebx寄存器的内容就是它所需的参数的值;对于需要2个参数的寄存器来说,栈顶的%ebx,%ecx寄存器的内容就是它所需的2个参数的值,其中%ebx对应第一个参数,%ecx对应第2个参数……依次类推,对于需要6个参数的寄存器来说,栈顶的6个寄存器的内容就依次是它所需的6个参数的值。

另外,假如系统只提供了100个系统调用功能函数(0至99),而应用程序通过%eax寄存器传入的系统调用功能号为100以上的值,这很显然是一个非法调用,直接返回-ENOSYS错误。

当系统调用表(sys_call_table)中的某个系统调用功能函数被执行后,由于此函数运行在内核态,内核当然应该信任自身的任何代码,所以它可以访问任何资源,可以调用任何操作系统内核中的函数。此时我们认为用户应用程序运行在内核态。