Unix/Linux操作系统的体系结构及系统调用介绍
什么是操作系统和系统调用
操作系统是从硬件抽象出来的虚拟机,在该虚拟机上用户可以运行应用程序。它负责直接与硬件交互,向用户程序提供公共服务,并使它们同硬件特性隔离。因为程序不应该依赖于下层的硬件,只有这样应用程序才能很方便的在各种不同的Unix系统之间移动。系统调用是Unix/Linux操作系统向用户程序提供支持的接口,通过这些接口应用程序向操作系统请求服务,控制转向操作系统,而操作系统在完成服务后,将控制和结果返回给用户程序。
Unix/Linux系统体系结构
一个Unix/Linux系统分为三个层次:用户、核心以及硬件。 其中系统调用是用户程序与核心间的边界,通过系统调用进程可由用户模式转入核心模式,在核心模式下完成一定的服务请求后在返回用户模式。
系统调用接口看起来和C程序中的普通函数调用很相似,它们通常是通过库把这些函数调用映射成进入操作系统所需要的原语。
这些操作原语只是提供一个基本功能集,而通过库对这些操作的引用和封装,可以形成丰富而且强大的系统调用库。这里体现了机制与策略相分离的编程思想——系统调用只是提供访问核心的基本机制,而策略是通过系统调用库来体现。
例:execv, execl,
execlv, opendir , readdir...
Unix/Linux运行模式,地址空间和上下文
运行模式(运行态):
一种计算机硬件要运行Unix/Linux系统,至少需要提供两种运行模式:高优先级的核心模式和低优先级的用户模式。
实际上许多计算机都有两种以上的执行模式。如:intel 80x86体系结构就有四层执行特权,内层特权最高。Unix只需要两层即可以了:核心运行在高优先级,称之为核心态;其它外围软件包括shell,编辑程序,Xwindow等等都是在低优先级运行,称之为用户态。之所以采取不同的执行模式主要原因时为了保护,由于用户进程在较低的特权级上运行,它们将不能意外或故意的破坏其它进程或内核。程序造成的破坏会被局部化而不影响系统中其它活动或者进程。当用户进程需要完成特权模式下才能完成的某些功能时,必须严格按照系统调用提供接口才能进入特权模式,然后执行调用所提供的有限功能。
每种运行态都应该有自己的堆栈。在Linux中,分为用户栈和核心栈。用户栈包括在用户态执行时函数调用的参数、局部变量和其它数据结构。有些系统中专门为全局中断处理提供了中断栈,但是x86中并没有中断栈,中断在当前进程的核心栈中处理。
地址空间:
采用特权模式进行保护的根本目的是对地址空间的保护,用户进程不应该能够访问所有的地址空间:只有通过系统调用这种受严格限制的接口,进程才能进入核心态并访问到受保护的那一部分地址空间的数据,这一部分通常是留给操作系统使用。另外,进程与进程之间的地址空间也不应该随便互访。这样,就需要提供一种机制来在一片物理内存上实现同一进程不同地址空间上的保护,以及不同进程之间地址空间的保护。
Unix/Linux中通过虚存管理机制很好的实现了这种保护,在虚存系统中,进程所使用的地址不直接对应物理的存储单元。每个进程都有自己的虚存空间,每个进程有自己的虚拟地址空间,对虚拟地址的引用通过地址转换机制转换成为物理地址的引用。正因为所有进程共享物理内存资源,所以必须通过一定的方法来保护这种共享资源,通过虚存系统很好的实现了这种保护:每个进程的地址空间通过地址转换机制映射到不同的物理存储页面上,这样就保证了进程只能访问自己的地址空间所对应的页面而不能访问或修改其它进程的地址空间对应的页面。
虚拟地址空间分为两个部分:用户空间和系统空间。在用户模式下只能访问用户空间而在核心模式下可以访问系统空间和用户空间。系统空间在每个进程的虚拟地址空间中都是固定的,而且由于系统中只有一个内核实例在运行,因此所有进程都映射到单一内核地址空间。内核中维护全局数据结构和每个进程的一些对象信息,后者包括的信息使得内核可以访问任何进程的地址空间。通过地址转换机制进程可以直接访问当前进程的地址空间(通过MMU),而通过一些特殊的方法也可以访问到其它进程的地址空间。
尽管所有进程都共享内核,但是系统空间是受保护的,进程在用户态无法访问。进程如果需要访问内核,则必须通过系统调用接口。进程调用一个系统调用时,通过执行一组特殊的指令(这个指令是与平台相关的,每种系统都提供了专门的trap命令,基于x86的Linux中是使用int 指令)使系统进入内核态,并将控制权交给内核,由内核替代进程完成操作。当系统调用完成后,内核执行另一组特征指令将系统返回到用户态,控制权返回给进程。
上下文:
一个进程的上下文可以分为三个部分:用户级上下文、寄存器上下文以及系统级上下文。
用户级上下文:正文、数据、用户栈以及共享存储区;
寄存器上下文:程序寄存器(IP),即CPU将执行的下条指令地址,处理机状态寄存器(EFLAGS),栈指针,通用寄存器;
系统级上下文:进程表项(proc结构)和U区,在Linux中这两个部分被合成task_struct,区表及页表(mm_struct , vm_area_struct, pgd,
pmd, pte等),核心栈等。
全部的上下文信息组成了一个进程的运行环境。当发生进程调度时,必须对全部上下文信息进行切换,新调度的进程才能运行。进程就是上下文的集合的一个抽象概念。
系统调用的功能和分类
操作系统核心在运行期间的活动可以分为两个部分:上半部分(top half)和下半部分(bottom half),其中上半部分为应用程序提供系统调用或自陷的服务,是同步服务,由当前执行的进程引起,在当前进程上下文中执行并允许直接访问当前进程的数据结构;而下半部分则是由处理硬件中断的子程序,是属于异步活动,这些子程序的调用和执行与当前进程无关。上半部分允许被阻塞,因为这样阻塞的是当前进程;下半部分不允许被阻塞,因为阻塞下半部分会引起阻塞一个无辜的进程甚至整个核心。
系统调用可以看作是一个所有Unix/Linux进程共享的子程序库,但是它是在特权方式下运行,可以存取核心数据结构和它所支持的用户级数据。系统调用的主要功能是使用户可以使用操作系统提供的有关设备管理、文件系统、进程控制进程通讯以及存储管理方面的功能,而不必要了解操作系统的内部结构和有关硬件的细节问题,从而减轻用户负担和保护系统以及提高资源利用率。
系统调用分为两个部分:与文件子系统交互的和进程子系统交互的两个部分。其中和文件子系统交互的部分进一步由可以包括与设备文件的交互和与普通文件的交互的系统调用(open,
close, ioctl, create, unlink, . . . );与进程相关的系统调用又包括进程控制系统调用(fork, exit,
getpid, . . . ),进程间通讯,存储管理,进程调度等方面的系统调用。
2.Linux下系统调用的实现 (以i386为例说明) A.在Linux中系统调用是怎样陷入核心的?系统调用在使用时和一般的函数调用很相似,但是二者是有本质性区别的,函数调用不能引起从用户态到核心态的转换,而正如前面提到的,系统调用需要有一个状态转换。
在每种平台上,都有特定的指令可以使进程的执行由用户态转换为核心态,这种指令称作操作系统陷入(operating system
trap)。进程通过执行陷入指令后,便可以在核心态运行系统调用代码。
在Linux中是通过软中断来实现这种陷入的,在x86平台上,这条指令是int 0x80。也就是说在Linux中,系统调用的接口是一个中断处理函数的特例。具体怎样通过中断处理函数来实现系统调用的入口将在后面详细介绍。
这样,就需要在系统启动时,对INT 0x80进行一定的初始化,下面将描述其过程:
1.使用汇编子程序setup_idt(linux/arch/i386/kernel/head.S)初始化idt表(中断描述符表),这时所有的入口函数偏移地址都被设为ignore_int
( setup_idt:
lea ignore_int,�x
movl $(__KERNEL_CS << 16),�x
movw %dx,%ax
movw $0x8E00,%dx
lea SYMBOL_NAME(idt_table),�i
mov $256,�x
rp_sidt:
movl �x,(�i)
movl �x,4(�i)
addl $8,�i
dec �x
jne rp_sidt
ret
selector = __KERNEL_CS, DPL = 0, TYPE = E, P =
1);
2.Start_kernel()(linux/init/main.c)调用trap_init()(linux/arch/i386/kernel/trap.c)函数设置中断描述符表。在该函数里,实际上是通过调用函数set_system_gate(SYSCALL_VECTOR,&system_call)来完成该项的设置的。其中的SYSCALL_VECTOR就是0x80,而system_call则是一个汇编子函数,它即是中断0x80的处理函数,主要完成两项工作:a. 寄存器上下文的保存;b. 跳转到系统调用处理函数。在后面会详细介绍这些内容。
(补充说明:门描述符
set_system_gate()是在linux/arch/i386/kernel/trap.S中定义的,在该文件中还定义了几个类似的函数set_intr_gate(),
set_trap_gate, set_call_gate()。这些函数都调用了同一个汇编子函数__set_gate(),该函数的作用是设置门描述符。IDT中的每一项都是一个门描述符。
#define _set_gate(gate_addr,type,dpl,addr)
set_gate(idt_table+n,15,3,addr);
门描述符的作用是用于控制转移,其中会包括选择子,这里总是为__KERNEL_CS(指向GDT中的一项段描述符)、入口函数偏移地址、门访问特权级(DPL)以及类型标识(TYPE)。Set_system_gate的DPL为3,表示从特权级3(最低特权级)也可以访问该门,type为15,表示为386中断门。)
B.与系统调用相关的数据结构
1.系统调用处理函数的函数名的约定
函数名都以“sys_”开头,后面跟该系统调用的名字。例如,系统调用fork()的处理函数名是sys_fork()。
asmlinkage int sys_fork(struct pt_regs
regs);
(补充关于asmlinkage的说明)
2.系统调用号(System Call
Number)
核心中为每个系统调用定义了一个唯一的编号,这个编号的定义在linux/include/asm/unistd.h中,编号的定义方式如下所示:
#define __NR_exit 1
#define __NR_fork 2
#define __NR_read 3
#define __NR_write 4
. . . . . .
用户在调用一个系统调用时,系统调用号号作为参数传递给中断0x80,而该标号实际上是后面将要提到的系统调用表(sys_call_table)的下标,通过该值可以找到相映系统调用的处理函数地址。
3.系统调用表
系统调用表的定义方式如下:(linux/arch/i386/kernel/entry.S)
ENTRY(sys_call_table)
.long
SYMBOL_NAME(sys_ni_syscall)
.long SYMBOL_NAME(sys_exit)
.long SYMBOL_NAME(sys_fork)
.long SYMBOL_NAME(sys_read)
.long SYMBOL_NAME(sys_write)
. . . . . .
系统调用表记录了各个系统调用处理函数的入口地址,以系统调用号为偏移量很容易的能够在该表中找到对应处理函数地址。在linux/include/linux/sys.h中定义的NR_syscalls表示该表能容纳的最大系统调用数,NR_syscalls
= 256。
C.系统调用函数接口是如何转化为陷入命令
如前面提到的,系统调用是通过一条陷入指令进入核心态,然后根据传给核心的系统调用号为索引在系统调用表中找到相映的处理函数入口地址。这里将详细介绍这一过程。
我们还是以x86为例说明:
由于陷入指令是一条特殊指令,而且依赖与操作系统实现的平台,如在x86中,这条指令是int 0x80,这显然不是用户在编程时应该使用的语句,因为这将使得用户程序难于移植。所以在操作系统的上层需要实现一个对应的系统调用库,每个系统调用都在该库中包含了一个入口点(如我们看到的fork,
open, close等等),这些函数对程序员是可见的,而这些库函数的工作是以对应系统调用号作为参数,执行陷入指令int
0x80,以陷入核心执行真正的系统调用处理函数。当一个进程调用一个特定的系统调用库的入口点,正如同它调用任何函数一样,对于库函数也要创建一个栈帧。而当进程执行陷入指令时,它将处理机状态转换到核心态,并且在核心栈执行核心代码。
这里给出一个示例(linux/include/asm/unistd.h):
#define _syscallN(type, name, type1, arg1, type2, arg2,
. . . ) /
type name(type1 arg1,type2 arg2) /
{ /
long __res; /
__asm__ volatile ("int $0x80" /
: "=a" (__res) /
: "" (__NR_##name),"b" ((long)(arg1)),"c"
((long)(arg2))); /
. . . . . .
__syscall_return(type,__res); /
}
在执行一个系统调用库中定义的系统调用入口函数时,实际执行的是类似如上的一段代码。这里牵涉到一些gcc的嵌入式汇编语言,不做详细的介绍,只简单说明其意义:
其中__NR_##name是系统调用号,如name == ioctl,则为__NR_ioctl,它将被放在寄存器eax中作为参数传递给中断0x80的处理函数。而系统调用的其它参数arg1, arg2, …则依次被放入ebx, ecx, . . .等通用寄存器中,并作为系统调用处理函数的参数,这些参数是怎样传入核心的将会在后面介绍。
下面将示例说明:
int func1()
{
int fd, retval;
fd = open(filename, ……);
……
ioctl(fd, cmd, arg);
. . .
}
func2()
{
int fd, retval;
fd = open(filename, ……);
……
__asm__ __volatile__(/
"int $0x80/n/t"/
:"=a"(retval)/
:"0"(__NR_ioctl),/
"b"(fd),/
"c"(cmd),/
"d"(arg));
}
这两个函数在Linux/x86上运行的结果应该是一样的。
若干个库函数可以映射到同一个系统调用入口点。系统调用入口点对每个系统调用定义其真正的语法和语义,但库函数通常提供一个更方便的接口。如系统调用exec有集中不同的调用方式:execl,
execle,等,它们实际上只是同一系统调用的不同接口而已。对于这些调用,它们的库函数对它们各自的参数加以处理,来实现各自的特点,但是最终都被映射到同一个核心入口点。
D.系统调用陷入内核后作何初始化处理
当进程执行系统调用时,先调用系统调用库中定义某个函数,该函数通常被展开成前面提到的_syscallN的形式通过INT
0x80来陷入核心,其参数也将被通过寄存器传往核心。
在这一部分,我们将介绍INT 0x80的处理函数system_call。
思考一下就会发现,在调用前和调用后执行态完全不相同:前者是在用户栈上执行用户态程序,后者在核心栈上执行核心态代码。那么,为了保证在核心内部执行完系统调用后能够返回调用点继续执行用户代码,必须在进入核心态时保存时往核心中压入一个上下文层;在从核心返回时会弹出一个上下文层,这样用户进程就可以继续运行。
那么,这些上下文信息是怎样被保存的,被保存的又是那些上下文信息呢?这里仍以x86为例说明。
在执行INT指令时,实际完成了以下几条操作:
1.由于INT指令发生了不同优先级之间的控制转移,所以首先从TSS(任务状态段)中获取高优先级的核心堆栈信息(SS和ESP);2.把低优先级堆栈信息(SS和ESP)保留到高优先级堆栈(即核心栈)中; 3.把EFLAGS,外层CS,EIP推入高优先级堆栈(核心栈)中。 4.通过IDT加载CS,EIP(控制转移至中断处理函数)
然后就进入了中断0x80的处理函数system_call了,在该函数中首先使用了一个宏SAVE_ALL,该宏的定义如下所示:
#define SAVE_ALL /
cld;
/
pushl %es; /
pushl %ds; /
pushl �x; /
pushl �p; /
pushl �i; /
pushl %esi; /
pushl �x; /
pushl �x; /
pushl �x; /
movl $(__KERNEL_DS),�x; /
movl �x,%ds; /
movl �x,%es;
该宏的功能一方面是将寄存器上下文压入到核心栈中,对于系统调用,同时也是系统调用参数的传入过程,因为在不同特权级之间控制转换时,INT指令不同于CALL指令,它不会将外层堆栈的参数自动拷贝到内层堆栈中。所以在调用系统调用时,必须先象前面的例子里提到的那样,把参数指定到各个寄存器中,然后在陷入核心之后使用SAVE_ALL把这些保存在寄存器中的参数依次压入核心栈,这样核心才能使用用户传入的参数。下面给出system_call的源代码:
ENTRY(system_call)
pushl
�x # save orig_eax
SAVE_ALL
GET_CURRENT(�x)
cmpl $(NR_syscalls),�x
jae badsys
testb $0x20,flags(�x) # PF_TRACESYS
jne tracesys
call *SYMBOL_NAME(sys_call_table)(,�x,4)
. . . . . .
在这里所做的所有工作是: 1.保存EAX寄存器,因为在SAVE_ALL中保存的EAX寄存器会被调用的返回值所覆盖; 2.调用SAVE_ALL保存寄存器上下文; 3.判断当前调用是否是合法系统调用(EAX是系统调用号,它应该小于NR_syscalls); 4.如果设置了PF_TRACESYS标志,则跳转到syscall_trace,在那里将会把当前进程挂起并向其父进程发送SIGTRAP,这主要是为了设 置调试断点而设计的; 5.如果没有设置PF_TRACESYS标志,则跳转到该系统调用的处理函数入口。这里是以EAX(即前面提到的系统调用号)作为偏移,在系 统调用表sys_call_table中查找处理函数入口地址,并跳转到该入口地址。
(补充说明: 1.GET_CURRENT宏
#define GET_CURRENT(reg) /
movl %esp, reg; /
andl $-8192, reg;
其作用是取得当前进程的task_struct结构的指针返回到reg中,因为在Linux中核心栈的位置是task_struct之后的两个页面处(8192bytes),所以此处把栈指针与-8192则得到的是task_struct结构指针,而task_struct中偏移为4的位置是成员flags,在这里指令testb $0x20,flags(�x)检测的就是task_struct->flags。
2.堆栈中的参数
正如前面提到的,SAVE_ALL是系统调用参数的传入过程,当执行完SAVE_ALL并且再由CALL指令调用其处理函数时,堆栈的结构应该如上图所示。这时的堆栈结构看起来和执行一个普通带参数的函数调用是一样的,参数在堆栈中对应的顺序是(arg1, ebx),(arg2,
ecx),(arg3, edx). . . . . .,这正是SAVE_ALL压栈的反顺序,这些参数正是用户在使用系统调用时试图传送给核心的参数。下面是在核心的调用处理函数中使用参数的两种典型方法:
asmlinkage int sys_fork(struct pt_regs regs);
asmlinkage int sys_open(const char * filename, int
flags, int mode);
在sys_fork中,把整个堆栈中的内容视为一个struct pt_regs类型的参数,该参数的结构和堆栈的结构是一致的,所以可以使用堆栈中的全部信息。而在sys_open中参数filename,
flags, mode正好对应与堆栈中的ebx, ecx,
edx的位置,而这些寄存器正是用户在通过C库调用系统调用时给这些参数指定的寄存器。
__asm__ __volatile__(/
"int $0x80/n/t"/
:"=a"(retval)/
:"0"(__NR_open),/
"b"(filename),/
"c"(flags),/
"d"(mode));
3.核心如何使用用户空间的参数
在使用系统调用时,有些参数是指针,这些指针所指向的是用户空间DS寄存器的段选择子所描述段中的地址,而在2.2之前的版本中,核心态的DS段寄存器的中的段选择子和用户态的段选择子描述的段地址不同(前者为0xC0000000, 后者为0x00000000),这样在使用这些参数时就不能读取到正确的位置。所以需要通过特殊的核心函数(如:memcpy_fromfs,
mencpy_tofs)来从用户空间数据段读取参数,在这些函数中,是使用FS寄存器来作为读取参数的段寄存器的,FS寄存器在系统调用进入核心态时被设成了USER_DS(DS被设成了KERNEL_DS)。在2.2之后的版本用户态和核心态使用的DS中段选择子描述的段地址是一样的(都是0x00000000),所以不需要再经过上面那样烦琐的过程而直接使用参数了。2.2及以后的版本linux/arch/i386/head.S
ENTRY(gdt_table)
.quad
0x0000000000000000
.quad 0x0000000000000000
.quad 0x00cf9a000000ffff
.quad 0x00cf92000000ffff
.quad 0x00cffa000000ffff
.quad 0x00cff2000000ffff
2.0 linux/arch/i386/head.S
ENTRY(gdt).quad
0x0000000000000000
.quad 0x0000000000000000
.quad 0xc0c39a000000ffff
.quad 0xc0c392000000ffff
.quad 0x00cbfa000000ffff
.quad 0x00cbf2000000ffff
.long
SYMBOL_NAME(sys_ni_syscall) 5.在linux/include/i386/中增加hello.h,里面至少应包括这样几条语句:
#include
#ifdef __KERNEL
#else
inline _syscall1(int, hello, char *, str);
#endif
这样就可以使用系统调用hello了