源自
http://www.360doc.com/content/05/0927/12/1440_15128.shtml
- 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等 ),核心栈等。
全部的上下文信息组成了一个进程的运行环境。当发生进程调度时,必须对全部上下文信息进行切换,新调度的进程才能运行。进程就是上下文的集合的一个抽象概念。
- 系统调用的功能和分类
- 什么是操作系统和系统调用
系统调用可以看作是一个所有 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,%edx
movl $(__KERNEL_CS << 16),%eax
movw %dx,%ax /* selector = 0x0010 = cs */
movw $0x8E00,%dx /* interrupt gate - dpl=0, present */
lea SYMBOL_NAME(idt_table),%edi
mov $256,%ecx
rp_sidt:
movl %eax,(%edi)
movl %edx,4(%edi)
addl $8,%edi
dec %ecx
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.系统调用表
- B.与系统调用相关的数据结构
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.系统调用陷入内核后作何初始化处理
在这一部分,我们将介绍 INT 0x80的处理函数 system_call。
思考一下就会发现,在调用前和调用后执行态完全不相同:前者是在用户栈上执行用户态程序,后者在核心栈上执行核心态代码。那么,为了保证在核心内部执行完 系统调用后能够返回调用点继续执行用户代码,必须在进入核心态时保存时往核心中压入一个上下文层;在从核心返回时会弹出一个上下文层,这样用户进程就可以 继续运行。
那么,这些上下文信息是怎样被保存的,被保存的又是那些上下文信息呢?这里仍以 x86为例说明。
在执行 INT指令时,实际完成了以下几条操作:
1.由于 INT指令发生了不同优先级之间的控制转移,所以首先从 TSS(任务状态段)中获取高优先级的核心堆栈信息( SS和 ESP);2.把低优先级堆栈信息( SS和 ESP)保留到高优先级堆栈(即核心栈)中;
3.把 EFLAGS,外层 CS, EIP推入高优先级堆栈(核心栈)中。
4.通过 IDT加载 CS, EIP(控制转移至中断处理函数)
#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;
该宏的功能一方面是将寄存器上下文压入到核心栈中,对于系统调用,同时也是系统调用参数的传入过程,因为在不同特权级之间控制转换时, INT指令不同于 CALL指令,它不会将外层堆栈的参数自动拷贝到内层堆栈中。所以在调用系统调用时,必须先象前面的例子里提到的那样,把参数指定到各个寄存器中,然后在陷入核心之后使用 SAVE_ALL把这些保存在寄存器中的参数依次压入核心栈,这样核心才能使用用户传入的参数。下面给出 system_call的源代码:ENTRY(system_call)
pushl %eax # save orig_eaxSAVE_ALL
GET_CURRENT(%ebx)
cmpl $(NR_syscalls),%eax
jae badsys
testb $0x20,flags(%ebx) # PF_TRACESYS
jne tracesys
call *SYMBOL_NAME(sys_call_table)(,%eax,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(%ebx)检测的就是 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.核心如何使用用户空间的参数
ENTRY(gdt_table)
.quad 0x0000000000000000/* NULL descriptor */.quad 0x0000000000000000/* not used */
.quad 0x00cf9a000000ffff /* 0x10 kernel 4GB code at 0x00000000 */
.quad 0x00cf92000000ffff /* 0x18 kernel 4GB data at 0x00000000 */
.quad 0x00cffa000000ffff /* 0x23 user 4GB code at 0x00000000 */
.quad 0x00cff2000000ffff /* 0x2b user 4GB data at 0x00000000 */
2.0 linux/arch/i386/head.S
ENTRY(gdt) .quad 0x0000000000000000 /* NULL descriptor */.quad 0x0000000000000000 /* not used */
.quad 0xc0c39a000000ffff /* 0x10 kernel 1GB code at 0xC0000000 */
.quad 0xc0c392000000ffff /* 0x18 kernel 1GB data at 0xC0000000 */
.quad 0x00cbfa000000ffff /* 0x23 user 3GB code at 0x00000000 */
.quad 0x00cbf2000000ffff /* 0x2b user 3GB data at 0x00000000 *
在 2.0版的内核中 SAVE_ALL宏定义还有这样几条语句:
"movl $" STR(KERNEL_DS) ",%edx/n/t" /
"mov %dx,%ds/n/t" /
"mov %dx,%es/n/t" /
"movl $" STR(USER_DS) ",%edx/n/t" /
"mov %dx,%fs/n/t" /
"movl $0,%edx/n/t" /
E.调用返回
调用返回的过程要做的工作比其响应过程要多一些,这些工作几乎是每次从核心态返回用户态都需要做的,这里将简要的说明:
1.判断有没有软中断,如果有则跳转到软中断处理;
2.判断当前进程是否需要重新调度,如果需要则跳转到调度处理;
3.如果当前进程有挂起的信号还没有处理,则跳转到信号处理;
4.使用用RESTORE_ALL来弹出所有被 SAVE_ALL压入核心栈的内容并且使用 iret返回用户态。
F.实例介绍
前面介绍了系统调用相关的数据结构以及在 Linux中使用一个系统调用的过程中每一步是怎样处理的,下面将把前面的所有概念串起来,说明怎样在 Linux中增加一个系统调用。这里实现的系统调用 hello仅仅是在控制台上打印一条语句,没有任何功能。
1.修改 linux/include/i386/unistd.h,在里面增加一条语句:
-
#define __NR_hello ???(这个数字可能因为核心版本不同而不同)
2.在某个合适的目录中(如: linux/kernel)增加一个 hello.c,修改该目录下的 Makefile(把相映的 .o文件列入 Makefile中就可以了)。
3.编写 hello.c. . . . . .
asmlinkage int sys_hello(char * str)
{
printk(“My syscall: hello, I know what you say to me: %s ! /n”, str);
return 0;
}
4.修改 linux/arch/i386/kernel/entry.S,在里面增加一条语句:ENTRY(sys_call_table)
. . . . . .
.long SYMBOL_NAME(sys_hello)
并且修改:
.rept NR_syscalls-??? /* ??? = ??? +1 */
.long SYMBOL_NAME(sys_ni_syscall)
5.在 linux/include/i386/中增加 hello.h,里面至少应包括这样几条语句:
#ifdef __KERNEL
#else
inline _syscall1(int, hello, char *, str);
#endif
这样就可以使用系统调用 hello了