Chinaunix首页 | 论坛 | 博客
  • 博客访问: 2091780
  • 博文数量: 249
  • 博客积分: 1305
  • 博客等级: 军士长
  • 技术积分: 4733
  • 用 户 组: 普通用户
  • 注册时间: 2011-12-17 10:37
个人简介

不懂的东西还有很多,随着不断的学习,不懂的东西更多,无法消灭更多不懂的东西,那就不断的充实自己吧。 欢迎关注微信公众号:菜鸟的机器学习

文章分类

全部博文(249)

文章存档

2015年(1)

2014年(4)

2013年(208)

2012年(35)

2011年(1)

分类: C/C++

2013-08-23 09:56:48


一、什么是系统调用
    系统调用是应用程序(运行库也是应用程序的一部分)与操作系统内核之间的接口,它决定了应用程序如何与内核打交道。无论程序是直接调用系统调用还是通过运行库,最终还是会到达系统调用这个层面上。
    为了让操作系统有能力访问系统资源,也为了让程序借助操作系统做一些由操作系统支持的行为,每个操作系统都会提供一套接口,以供应用程序使用。这些接口往往通过中断来实现,比如Linux使用0x80号中断作为系统调用的入口,Windows采用0x2E号中断作为系统调用入口。
    
二、Linux系统调用   
    下面我们来看看Linux系统调用的定义。在x86下,系统调用由0x80中断完成,各个通过寄存器用于传递参数,EAX寄存器用于表示系统调用的接口号,比如EAX = 1表示退出进程(exit);EAX = 2表示创建进程(fork);EAX = 3表示读取文件或IO(read);EAX = 4表示写文件或IO(write)等,每个系统调用都对应于内核源代码中的一个函数,它们都是以“sys_”开头的,比如exit调用对应内核中的sys_exit函数。当系统调用返回时,EAX作为调用结果的返回值。
    我们可以使用Linux的man命令查看每个系统调用的详细说明,比如查看read(man参数2表示系统调用手册):
  1. $ man 2 read
    
三、系统调用原理
    1、特权级与中断
    现代的CPU常常可以在多种截然不同的特权级别下执行指令,在现代操作系统中,通常也有两种特权级别,分别为用户模式(User Mode)和内核模式(Kernel Mode),也被称为用户态和内核态。
    系统调用是运行在内核态的,而应用程序基本都是运行在用户态的。用户态的程序如何运行内核态的代码呢?操作系统一般是通过中断(Interrupt)来从用户态切换到内核态。
    什么是中断呢?中断是一个英俊或软件发出的请求,要求CPU暂停当前的工作转手去处理更加重要的事情。
    中断一般具有两个属性,一个称为中断号,一个称为中断处理程序。不同的中断具有不同的中断号,而同时一个中断处理程序一一对应一个中断号。在内核中,有一个数组称为中断向量表,这个数组的第n项包含了指向第n号中断的处理程序指针。当中断到来时,CPU暂停当前执行的代码,根据中断的中断号,在中断向量表中找到对应的中断处理程序,并调用它。中断处理程序执行完成以后,CPU会继续执行之前的代码。一个简单的示意图如下图所示。
    
    通常意义上,中断有两种类型,一种称为硬件中断,这种中断来自于硬件的异常或者其他事件的发生,如电源掉电、键盘被按下等。另一种称为软件中断,软件中断通常是一条指令(i386下是int),带有一个参数记录中断号,使用这条指令用户可以手动触发某个中断并执行期中断处理程序。例如在i386下,int 0x80这条指令会调用第0x80号中断的处理程序。
    由于中断是有限的,OS不会舍得用一个中断号来对应一个系统调用,而更倾向于用一个或少数几个中断号来对应所有的系统调用。例如,i386下Windows里绝对多数系统调用都是由int 0x2E来触发的,而Linux则使用int 0x80来触发所有的系统调用。
    对于同一个中断号,操作系统如何知道哪一个系统调用被调用呢?和中断一样,系统调用都有一个系统调用号,就像身份标识一样来表明是哪一个系统调用,这个系统调用号通车就是系统调用在系统调用表中的位置,例如Linux里fork的系统调用号是2。这个系统调用号在执行int指令前会被放置在某个固定的寄存器中,对应的中断代码会取得这个系统调用号,并且调用正确的函数。以Linux的int 0x80为例,系统调用号是由eax来传入的。用户将系统调用号放入eax,然后使用int 0x80调用中断,中断服务器程序就可以从eax里面取得系统调用号,进而调用对应的函数。

四、基于int的Linux的经典系统调用实现
    下面我们将了解当应用程序调用系统时,程序是如何一步步进入操作系统内核调用相应函数的。下图是以fork为例的Linux系统调用的执行流程。
    
    解下来,让我们一步一步地了解这个过程的细节。
    1、触发中断
    首先,当程序在代码里调用一个系统调用时,是以一个函数的形式调用的,例如程序调用fork:
  1. int main()
  2. {
  3.   fork();
  4. }
    fork函数是一个对系统调用fork的封装,可以用下列宏来定义它:
  1. _syscall0(pid_t, fork);
    _syscall0是一个宏函数,用于定义一个没有参数的系统调用的封装。它的第一个参数为这个系统调用的返回值类型,这里为pit_t,是Linux自定义类型,代表进程的id。_syscall0的第二个参数是系统调用的名称,_syscall0展开之后会形成一个与系统调用名称同名的函数。下面的代码是i386版本的_syscall0的定义:
    
    如果对这种格式的汇编不熟悉,请看下面的解释。
    (1)首先__asm__是一个gcc的关键字,表示接下来要嵌入汇编代码。volatile关键字告诉gcc对这段代码不做任何优化;
    (2)__asm__的第一个参数是一个字符串,代码汇编代码的文本。这里的汇编代码只有一句:int $0x80,这就要调用0x80中断;
    (3)“=a”(__res)表示用eax(a表示eax)输出返回数据并存储在__res里;
    (4)"0"(__NR_##name)表示__NR_##name为输入,"0"指示由编译器选择和输出相同的寄存器(即eax)来传递参数;
    更直观一点,可以把这段汇编改写成更为可读的格式:
    
    __NR_fork是一个宏,表示fork系统调用的调用号,对于x86体系结构,该宏的定义可以在Linux/include/asm-x86/unistd_32.h里找到:
    
    而__syscall_return是另一个宏,定义如下:
    
    这个宏用于检查系统调用的返回值,并把它相应的转换为C语言的errno错误码。在Linux里面,系统调用使用返回值传递错误码,如果返回值为负数,那么表明调用失败,返回值的绝对值就是错误码。而在C语言里则不然,C语言里的大多数函数都是以返回-1表示调用失败,而将出错信息存储在一个名为errno的全局变量(在多线程库中,errno存储在TLS中)里。__syscall_return就负责将系统调用的返回值存储在errno中。这样,fork函数在汇编之后,就会形成类似如下的汇编代码:
    
    如果系统调用本身有参数要如何实现呢?下面是x86Linux下的syscall,用于带1个参数的系统调用:
    
    这段代码和_syscall0不同的是,它多了一个"b"((long)(arg1))。这一句的意思是先把arg1强制转换为long,然后放在EBX(b代表EBX)里作为输入。编译器还会生成相应的代码来保护原来的EBX的值不被破坏。这段汇编可以改写为:
    
    可见,如果系统调用有1个参数,那么参数通过EBX来传入。x86下Linux支持的系统调用参数至多有6个,分别使用6个寄存器来传递,它们分别是EBX、ECX、EDX、ESI、EDI和EBP。
    当用户调用某个系统调用时,实际上是执行了上面一段汇编代码。CPU执行到int $0x80时,会保存现成以便恢复,接着会将特权状态切换到内核态。然后CPU便会查找中断向量表中的第0x80号元素。
    以上是Linux实现系统调用入口的思路,不过也许你会想知道glibc是否真的是如此封装函数调用呢?答案是否定的。glibc使用了另外一套调用系统调用的方法,尽管原理上仍然是使用0x80号中断,但细节上却是不一样的。由于这种方法与上面的方法本质上是一样的,所以这里不介绍了。

    2、切换堆栈
    在实际执行中断向量表中的第0x80号元素所对应的函数之前,CPU首先还要进行栈切换。在Linux中,用户态和内核态使用的是不同的栈,两者各自负责各自的函数调用,互不干扰。但在应用程序调用0x80号中断时,程序的执行流程从用户态切换到内核态,这时程序的当前栈必须也相应地从用户栈切换到内核栈。从中断处理函数中返回时,程序的当前栈还有从内核栈切换回用户栈。
    所谓的“当前栈”,指的是ESP的值所在的栈空间。如果ESP的值位于用户栈的范围内,那么程序的当前栈就是用户栈,反之亦然。此外,寄存器SS的值还应该指向当前栈所在的页。所以,将当前栈由用户栈切换到内核栈的实际行为就是:
    (1)保存当前的ESP、SS的值;
    (2)将ESP、SS的值设置为内核栈的相应值;
    反过来,将当前栈的内核栈切换为用户栈的实际行为则是:
    (1)恢复原来的ESP、SS的值;
    (2)用户态的ESP和SS的值保存到哪里呢?答案就是内核栈上。这一行为由i386的中断指令自动地由硬件完成。
    当0x80号中断发生的时候,CPU除了切入内核态以外,还自动完成下列几件事:
    (1)找到当前进程的内核栈(每个进程都有自己的内核栈);
    (2)在内核栈中依次压入用户态的寄存器SS、ESP、EFLAGS、CS、EIP;
    而当内核从系统调用中返回的时候,需要调用iret指令返回到用户态,iret指令则会从内核栈里弹出寄存器SS、ESP、EFLAGS、CS、EIP的值,使得栈恢复到用户态的状态,这个过程可以用下图来表示。
    
    
    3、中断处理程序
    在int指令合理的切换了栈之后,程序的流程就切换到了中断向量表中记录的0x80号中断处理程序。Linux内部的i386中断服务流程如下图所示。
    
    i386的中断向量表在Linux源代码的Linux/arch/i386/kernel/traps.c里可见一部分。在该文件的末尾,我们能看到一个函数trap_init,该函数用于初始化中断向量表。
    
    以上代码中的函数set_intr_gate/set_trap_gate/set_system_gate/set_system_intr_gate用于设置某个中断号上的中断处理程序。之所以区分为3种名字,是因为在i386下对中断有更加细致的划分。
    从这段代码可以看到0-19号中断对应的中断处理程序,其中包含算数异常(除零、溢出)、页缺失、无效指令等。在最后一行:
    
    可看出这事系统调用对应的中断号,在linux/include/asm-i386/mach-default/irq_vectors.h里面可以找到SYSCALL_VECTOR的定义:
    
    可见i386下Linux下的系统调用对应的中断号确实为0x80。必然的,用户调用int 0x80之后,最终执行的函数是system_call,该函数在linux/arch/i386/kernel/entry.s里可以找到定义。下面一点一点的来进行解析。
    
    这一段是system_call的开头,中间省略了一些不太重要的代码。在这里一开始使用宏SAVE_ALL将各种寄存器压入栈中,以免它们的值被后续执行的代码所覆盖。接下来使用cmpl指令比较eax和nr_syscalls的值,nr_syscalls是比最大的有效系统调用号大1的值,因此,如果eax(即用户传入的系统调用号)大于等于nr_syscalls,那么这个系统调用就是无效的,如果这样,接着就会跳转到后面的syscall_badsys执行。如果系统调用号是有效的,那么程序执行下面的代码:
    
    确定系统调用号有效并且保存了寄存器之后,接下来要执行的就是调用*sys_call_table(0, %eax, 4)来查找中断服务器程序并执行。执行结束之后,使用宏RESTORE_REGS来恢复之前被SAVE_ALL保存的寄存器。最后通过指令从中断处理程序中返回。
    究竟什么是*sys_call_table(0, %eax, 4)呢?在linux/arch/i386/kernel/syscall_table.s里面能找到定义:
    
    这就是Linux的i386系统调用表,这个表里的每个元素(long, 4字节)都是一个系统调用地址。那么不难推知*sys_call_table(0, %eax, 4)指的是sys_call_table上的偏移量为0 + %eax + 4上的那个元素的值指向的函数,也就是%eax所记录的系统调用号所对应的系统调用函数。接下来系统就会去调用相应的系统调用函数。例如,如果%eax = 2,那么sys_fork就会被调用。
    整个调用过程如下所示。
    
    
    问题:内核以sys开头的系统调用函数是如何从用户那里获得参数的?
    答案:我们知道用户调用系统调用时,根据系统调用参数数量的不同,依次将参数放入EBX、ECX、EDX、ESI、EDI和EBP这6个寄存器中传递。例如一个参数的系统调用就是用EBX,而两个参数的系统调用就使用EBX和ECX,以此类推。
    而进入系统调用的服务程序system_call的时候,system_call调用了一个宏SAVE_ALL来保存各个寄存器。SAVE_ALL实际与系统调用的参数传递息息相关,所以这里有必要提一下。
    SAVE_ALL的作用为保存寄存器,因此其内容就是讲各个寄存器压入栈中。SAVE_ALL的大致内容如下:
    
    抛开SAVE_ALL的最后3个move指令不看(这3条指令用于设置内核数据段,它们不影响栈),我们可以发现SAVE_ALL是一系列push指令的最后6条所压入栈的寄存器恰好就是用来存放系统调用参数的6个寄存器,连顺序都一样,这当前不少一个巧合。
    再回到system_call的源码,可以发现,在执行SAVE_ALL与执行call *sys_call_table(0, %eax, 4)之间,没有任何代码会影响栈。因此刚刚进入sys开头的内核系统调用函数的时候,栈上恰恰是这样的情景,如下图所示。
    
    可以说,系统调用的参数被SAVE_ALL“阴差阳错”放置在了栈上。
    另一方面,所有以sys开头的内核系统调用函数,都有一个asmlinkage的标志,例如:asmlinkage pid_t sys_fork(void);
    asmlinkage是一个宏,定义为:__attribute__((regparm(0)))
    这个扩展关键字的意义是让这个函数只从栈上获取参数。因为gcc对普通函数有优化措施,会使用寄存器来传递参数,而SAVE_ALL将参数全部放置于栈上,因此必须使用asmlinkage来强迫函数从栈上获取参数。这样一来,内核里的系统调用函数就可以正确地获取用户提供的参数了。整个过程如下图所示。
    
    







































阅读(1724) | 评论(0) | 转发(0) |
给主人留下些什么吧!~~