System Call 1
System Call
操作系统的重要任务之一就是为应用程序提供服务,而提供服务的最直接手段就是允许
应用程序能够调用操作系统的相关服务代码。作为程序员,我们知道,一个程序如果想调用
某段代码,它必须知道这段代码的入口地址。对于 DOS这种非保护模式操作系统,如果我
们知道其内核某段代码的入口地址,我们的应用程序只需要执行一条 CALL 指令,就可以
执行这段内核代码,以获取服务。
对于这一点,操作系统的
原则有两种:即对应用程序是信任的,或是不信任的。如
果操作系统对于应用程序总是信任的,那就意味着操作系统认为应用程序总是按照它们之间
的约定或协议在做事情,应用程序永远不会违反这些协议,无论是有意的还是无意的;但很
明显,即使一个程序员总是在善意的写程序,他也无法保证其程序没有错误,无论其水平有
多高,经验如何丰富;而对于恶意的程序,由于这种操作系统几乎不设防,一个水平不用太
高的程序员就可以很轻松的写一个程序让系统崩溃。这正是 DOS时代病毒肆虐的原因。对
于一个单任务(Single-Task)的操作系统来说,当问
程序运行引起系统崩溃时,只会影
响当前存在问题的任务。但对于多任务操作系统来说,一个问题程序引起其它健康程序无法
运行,无疑是一种非常不公平的结果。
所以,今天运行在 PC 机或更高平台(包括工作站,小型机,大型机)的操作系统对应
用程序几乎都是采取不信任原则,也就是说,操作系统假定运行在其上的任何应用程序都可
能是不安全的,这种类型的操作系统非常保守,尽其所能对内核以及其它应用程序给予保护,
任何不遵守协议的应用程序都无法正常运行,却又不会影响操作系统自身的安全,也不会影
响其它正常应用程序的运行。一个安全的社会总需要完善的法制来保障,这就是生活。
在具有保护模式的操作系统中,操作系统内核运行在特权级别(内核态),而用户应用
程序运行在非特权级别(用户态)。应用程序无法直接调用任何操作系统的代码,即使应用
程序非常清楚这些代码的入口地址,但由于硬件给予的保护,应用程序对于这些入口地址的
调用,会引起异常。操作系统会捕获到这个异常,并在这个异常的处理中将进行非法调用的
应用程序杀掉。
但另一方面,操作系统必须向应用程序提供服务,否则,应用程序几乎无法作任何有价
值的事情,甚至无法运行。既然直接调用的
会导致系统的不安全,那么只能进行间接调
用,那就是使用中断机制。
即使对于非保护模式的 DOS来说,它也是通过中断的方法向应用程序提供服务,这就
是 DOS程序员熟悉的 INT 21H。事实上,通过中断的方法,应用程序无需知道相应的操作
系统服务例程的入口地址。因为中断服务程序知道它们,而这一切都是由操作系统设定和维
护的。应用程序只需要设定好相应的寄存器,然后执行一条 INT 指令就可以对这些操作系
统服务例程进行调用,并通过寄存器获取执行的结果。
2 Developing Your Own Unix-Like OS on IBM PC
对于保护机制的操作系统来说,中断机制本身也是受保护的,在 IBM PC 上,Intel 规
定多达 255个中断号,但只有授权给应用程序保护等级的中断号才是可以被应用程序调用
的,对于未被授权的中断号,如果应用程序进行调用,同样会引起保护异常,而导致自己被
操作系统杀死。比如,Linux仅仅给应用程序授权了 4个中断号— — 3,4,5,以及 80h。
前三个中断号是为了提供给应用程序调试(单步跟踪)的手段,而 80h正是我们本节讨论
的系统调用(system call)的中断号。
应用程序在执行用户态代码时,被称为运行在用户态;在通过系统调用执行内核服务代
码时,被称为运行在内核态— —这是绝大多数基于整体内核实现的各种 Unix的实现模型。
在这种模型里,系统调用形象的被称作应用程序的陷阱(Trap)。应用程序通过系统调用从用
户态陷入内核态,就像一个人从路面行走,突然掉入了陷阱,而应用程序重返用户态,就像
这个人从陷阱中爬了出来,非常有趣!某些硬件系统有专门的陷阱指令,其机制仍然是中断
机制,或类似于中断机制。
对于基于消息机制的微内核操作系统来说,当应用程序需要申请内核服务的时候,是通
过系统调用通知内核向某个内核服务进程发送一条消息,然后等待一条来自于目标进程的应
答消息。这是一种 Client/Server 模式。如 Figure 1所示。
1. Mechanism
前面我们已经讨论,系统调用是通过中断机制实现的,并且一个操作系统的所有系统调
用都通过同一个中断来实现。如下的一些例子都是 Unix系统的系统调用函数:
Call
User
Kernel
User
Kernel
Send
Receive
Receive Send
A: 整体内核模型 B: 基于消息机制的微内核模型
Figure 1 – System Call Model
int fork();
void exit(int status);
int open(char* path, int flags);
int lseek(int fildes, off_t offset, int wherence);
System Call 3
从这些例子可以看出,它们有 3个特点:
参数个数不固定,可以没有参数,也可以有多个参数;
函数的返回值要么为 void,要么为 32-bit 类型(integer);
函数的形式参数都为 32-bit 类型;
DOS程序员都知道如何通过 INT 21H 来进行 DOS功能调用,应用程序只需要将功能
号装入%ax寄存器,将参数(如果存在的话)按照需要装入%bx,%cx,%dx,%si,%di
寄存器,然后调用 INT 21H 指令。等中断服务程序完成后,%ax 寄存器用来存放返回值(如
果有的话),其它寄存器用来存放执行结果(如果存在的话)。依赖于 Intel 80x86芯片的
通用寄存器只有%eax,%ebx,%ecx,%edx,%esi,%edi等 6个寄存器,其中%eax要用
来存放功能号,使用这种手段最多只能允许有 5个参数。
1. 设置相应的通用寄存器内容;
2. 调用中断指令“int $0x80”,陷入操作系统内核,调用相应的 ISR;
3. ISR将各个寄存器按照一定顺序压栈;
4. 随后,ISR以%eax寄存器的值为索引,到系统调用函数入口表中进程查找并调用;
5. 从 Stack中恢复系统调用前寄存器的值;
6. 通过 iret指令返回用户态;
7. 通过%eax寄存器取得系统调用返回值。
Figure 2 – Linux System Call Handling Procedure
Function Code
Parameter 1
Parameter 2
Parameter 3
Parameter 4
Parameter 5
%eax
%ebx
%ecx
%edx
%esi
%edi
(1)
(2)
User
Kernel
function 1 entry
function 2 entry
function 3 entry
⋯⋯
Function n-1 entry
function n entry
(4)
系统调用函数入口查找表
内核栈
(3)
%ebp
%edi
%esi
%edx
%ecx
%ebx
%eax
Other Registers
%esp
(5) (6)
(7)
4 Developing Your Own Unix-Like OS on IBM PC
保护模式的操作系统也可以使用相同的手段来进行系统调用的处理——通过通用寄存
器来传递系统调用所需要的参数。
我们以 Linux为例,如 Figure 2所示,一个 Application在进行系统调用时,它使用
%eax存放系统调用功能号,在内核中每一个功能号对应一个系统调用功能,比如0对应exit,
1对应 fork,2对应 read。下面列出的就是部分 Linux系统调用号的定义。
如果对应的系统调用函数需要参数,那么应用程序需要按照此函数要求的参数个数分别
设置%ebx,%ecx,%edx,%esi,%edi。这种方法所允许的参数也最多为 5个。为了能够
定义6个参数的系统调用功能函数,Linux使用了%ebp,但%ebp对于函数来说是一个非常
重要的寄存器,所以使用前必须进行压栈保存。由于这些寄存器都是 32-bit 宽度的,所以要
求参数也都应该为 32-bit 类型的。当所需寄存器设置完成之后,通过调用指令”int $0x80”,
系统陷入内核。
下面是 Linux带有 3个参数的系统调用的宏定义:
这个宏被定义之后,可以使用它来定义一个被 Application使用的 3参数的系统调
用函数,下面就是一个系统调用 write的定义。
Linux捕获到这个中断后,将执行其对应的中断服务程序(system_call),system_call
首先将必要的寄存器(包括这 6个通用寄存器)push到栈中。然后以%eax中的值作为索引
#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 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); \
}
static inline syscall3(int,write,int,fd, \
const char *,buf,off_t,count)
System Call 5
到其所维护的系统调用功能函数入口表(sys_call_table)中查找并调用相应函数。等系统调用
完成后,Linux内核将返回值装入%eax寄存器(如果不是 void 的话),但不用其它寄存器
来返回执行结果。
Linux通过汇编语言使用静态的方法定义了系统调用函数入口表 sys_call_table,下面
就是相关的代码,为了节约篇幅,这里省去了绝大多数入口定义。
下面是 Linux 2.4代码中用来保存相关寄存器内容的 Macro。
由于函数参数传递的方式是将参数 Push到 Stack中,被调用函数执行时到 Stack 中访
问相关参数,所以 SAVE_ALL 中将各个寄存器压栈的顺序是经过精心安排的,不能够随意
更改。我们看栈顶的6个寄存器依次为%ebx,%ecx,%edx,%esi,%edi,%ebp。对于不
需要参数的系统调用功能函数来说,它不需要访问任何保存在栈中的寄存器的值;对于需要
1个参数的寄存器来说,栈顶%ebx寄存器的内容就是它所需的参数的值;对于需要2个参
/* 此 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;
.data
ENTRY(sys_call_table)
.long SYMBOL_NAME(sys_ni_syscall) /* 0 - old
"setup()" system call*/
.long SYMBOL_NAME(sys_exit)
.long SYMBOL_NAME(sys_fork)
.long SYMBOL_NAME(sys_read)
.long SYMBOL_NAME(sys_write)
/* 为了节约篇幅,这里省略的其它函数入口数据 */
.rept NR_syscalls-(.-sys_call_table)/4
.long SYMBOL_NAME(sys_ni_syscall)
.endr
6 Developing Your Own Unix-Like OS on IBM PC
数的寄存器来说,栈顶的%ebx,%ecx 寄存器的内容就是它所需的2个参数的值,其中%ebx
对应第一个参数,%ecx对应第2个参数… … 依次类推,对于需要6个参数的寄存器来说,
栈顶的6个寄存器的内容就依次是它所需的6个参数的值。
下面是 Linux 2.4中 80h对应的中断服务例程 system_call的实现。
假如系统只提供了 100个系统调用功能函数(0至 99),而应用程序通过%eax 寄存器
传入的系统调用功能号为 100以上的值,这很显然是一个非法调用,直接返回-ENOSYS错
误。
当系统调用表(sys_call_table)中的某个系统调用功能函数被执行后,由于此函数运行在
内核态,内核当然应该信任自身的任何代码,所以它可以访问任何资源,可以调用任何操作
系统内核中的函数。此时我们认为用户应用程序运行在内核态。
当内核执行了相应的系统调用函数之后,使用%eax保存相应的返回值,然后从 Stack
中 pop出各个之前压栈的寄存器的内容,最后执行指令 iret,返回到用户态。下面是 Linux
恢复寄存器内容的 Macro——RESTORE_ALL的定义。
/* 为了便于理解,system_call中几条与此关键机制
* 无关的指令被删去 */
ENTRY(system_call)
pushl %eax # save orig_eax
SAVE_ALL
# %eax中存放的系统调用号必须为一个存在的号
cmpl $(NR_syscalls),%eax
jae badsys # 否则返回-ENOSYS错误
# 以%eax为索引,调用相应系统调用函数
call *SYMBOL_NAME(sys_call_table)(,%eax,4)
movl %eax,EAX(%esp) # save the return value
badsys:
movl $-ENOSYS,EAX(%esp)
jmp ret_from_sys_call
#define RESTORE_ALL \
popl %ebx; \
popl %ecx; \
popl %edx; \
popl %esi; \
popl %edi; \
popl %ebp; \
popl %eax; \
popl %ds; \
popl %es; \
addl $4,%esp; \
iret;
System Call 7
在入口 ret_from_sys_call处,上面定义的宏 RESTORE_ALL 被调用。下面就是入口
ret_from_sys_call的代码。
上面代码中,第 2,3行是关于进程调度的相关代码,第 4,5行是信号处理的相关代码,
这些会在相关章节中讨论,这里我们就不再赘述。
最后,当 iret 指令执行后,系统返回用户态,由于系统调用返回前,曾经将系统调用的
返回值保存在%eax寄存器中,所以此时用户进程可以通过%eax获取到返回值,并保存到
全局变量 errno 中。比如带有一个参数的系统调用 Macro 定义为:
其中,__syscall_return定义为:
0 ENTRY(ret_from_sys_call)
1 cli # need_resched and signals atomic test
2 cmpl $0,need_resched(%ebx)
3 jne reschedule
4 cmpl $0,sigpending(%ebx)
5 jne signal_return
6 restore_all:
7 RESTORE_ALL
#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); \
}
#define __syscall_return(type, res) \
do { \
if ((unsigned long)(res) >= (unsigned long)(-125)) { \
errno = -(res); \
res = -1; \
} \
return (type) (res); \
} while (0)
8 Developing Your Own Unix-Like OS on IBM PC
2. Conclusion
本章我们结合 Linux 2.4的实现,讨论了 System Call的实现原理和机制。在不同的平
台上,System Call的实现会有一些细节上的差别,但基本原理是一致的。
搞清楚 System Call 的原理,对我们进一步了解保护机制的操作系统原理非常重要。