Linux内核分析(四)系统调用,用户态及内核态

禹晓博+ 原创作品转载请注明出处 + 欢迎加入《Linux内核分析》MOOC网易云课堂学习

一、什么是系统调用

       我们知道由于种种原因(就是安全稳定性大部分)的考虑,操作系统是不能让用户直接进行一些有可能破换系统的行为,实际上还有另外一部分原因及时基于封装性的考虑。操作系统往往需要进行很多硬件的适配工作,而程序员关心的是如何正确执行一个功能。这两者之间有很多矛盾(同样的功能实际上由于硬件的不同可能会在实现上大相径庭)。所以系统会向上给用户(就是程序员)提供一些函数接口(就是一些功能模块,你用他们就可以忽略种种差异,完成相同的功能)有一些很和平就是没什么危险了比如你算个sin(x)一般不会把系统搞崩溃。但是有些就比较恐怖,比如文件操作,内存访问。这些会导致系统进入一种不安全可能的状态。实际上大多数情况下我们也并不是要做什么坏事,但是这个高级别的访问如果代码 出现错误和bug那么就很容易引起系统错误(CSAPP中有描述利用输入堆栈的漏洞来串改程序执行路径,详见CSAPP书上的官方实验)。所以这些时候系统会给用户一个权限告诉系统用户要做的这种操作(系统调用号和跳转地址也就是所谓的中断向量)的种类和操作所需要的输入的参数(入口参数),然后具体的功能让系统内核中的一些能够实现这个功能系统函数来实现。这样会避免不必要的麻烦,同时也体现了对不同硬件的透明性。(就是我只要open了,无论是在PC上open还是嵌入式设备上open我都是打开一个文件的意思,open就是个系统调用实际上)

         那么重点来了我们来总结一下:“操作系统为用户态进程与硬件设备进行交互提供了一组接口——系统调用”。其作用是什么呢?1、把用户从底层的硬件编程中解放出来;2、极大的提高了系统的安全性;3、使用户程序具有可移植性。

        那么问题来了用户如何通知系统进行这种调用呢?我们有一个叫做用户程序接口(application program interface, API)的专门就是用来给系统说明用什么功能的。和系统调用不同的是他仅仅在我们开来就是一个函数,而系统调用是通过软中断向内核发出一个明确的功能请求。(稍后的实验我们可以看到这一点)。

        那么还有一个问题就是所谓的软中断的入口参数是什么呢,实际上就和函数的参数列表一样,我们除了知道他的中断号还要知道功能代码还有就是要知道他的输入是什么(见Linux内核分析一)这就是入口参数。那么系统调用 通过软中断或系统调用指令向内核发出一个明确的请求,内核将调用内核相关函数来实现(如sys_read() , sys_write() , sys_fork())。用户程序不能直接调用这些Sys_read,sys_write等函数。这些函数运行在内核态。

二、系统调用与API之间的关系

        通常API函数库(如glibc)中的函数会调用封装例程,封装例程负责发起系统调用(通过发软中断或系统调用指令),这些都运行在用户态。内核开始接收系统调用后,cpu从用户态切换到内核态(cpu处于什么状态,程序就叫处于什么状态,所以很多地方也说程序从用户态切换到内核态,实际是cpu运行级别的切换,通常cpu 运行在3级表示用户态,cpu 运行在0级表示内核态),内核调用相关的内核函数来处理再逐步返回给封装例程,cpu进行一次内核态到用户态的切换,API函数从封装例程拿到结果,再处理完后返回给用户。

         但是PI函数不一定需要进行系统调用,如某些数学函数,没有必要进行系统调用,直接glibc里面就给处理了,整个过程运行在用户态。所以作为我们编写linux用户程序的时候,是不能直接调用内核里面的函数的,内核里面的函数位于进程虚拟地址空间里面的内核空间,用户空间函数及函数库都处于进程虚拟地址空间里面的用户空间,用户空间调用内核空间的函数只有一个通道,这个通道就是系统调用指令,所以通常要调用glibc等库的接口函数,glibc也是用户空间的,但glibc自己实现了调用特殊的宏汇编系统调用指令进行cpu运行状态的切换,把进程从用户空间切换到内核空间。

      下面我们看一张比较经典的图:


        上面这张图可以比较清楚地看到这一过程:用户态xyz()函数,内核最终一般会调用形如sys_xyz()的服务例程来处理(当然了名字肯定不会这么对应,我们只是想表达xyz()在内核中有对应的系统调用)  函数xyz()是提供给用户编程使用的。系统则是通过sys_xyz()来实现这个过程的。图中“SYSCALL”,“S Y S E X I T”表示真正的汇编指令(汇编指令具体调用的是哪个暂时不关心,我们只需在此关注发起和退出了一个系统调用)。

        在发起系统调用的时候我们看到,xyz()函数执行的过程中会执行SYSCALL汇编指令,此指令将会把cpu从用户态切换到内核态。SYACALL汇编指令中会包含将要调用的内核函数的系统调用号和参数,内核在上图系统调用处理程序中去查一个sys_call_talbe数组来找到这个系统调用号对应的服务例程(如sys_xyz())函数的地址,然后调用这个地址的函数执行。(这里glibc里面的系统调用号和内核里面的系统调用号必须完全相等,当然,这是约定好的)

        系统用返回:服务例程(如sys_xyz())函数返回值一般返回正数和0表示系统调用成功结束,而负数表示一个出错条件。紧接着S Y S E X I T退出系统调用,此指令将cpu从内核态切换到用户态,glibc针对系统调用返回值如果出错则需要设置好errno(通常在c库头文件/usr/include/errno.h中),然后返回一个值做为glibc封装例程的返回值(如xyz()的返回值)。这里errno是libc自己用来定义的出错码,不一定是最后gblic封装例程的返回值。

三、系统调用实现分析         

         实际上偶尔们可以去看看这个sys_call_talbe,他就在./include/sys.h中


        这就是个那个数组实际上每个都指向了一个系统调用功能,我们知道系统调用是一个软中断,中断号是0x80,它是上层应用程序与Linux系统内核进行交互通信的唯一接口。这个中断的设置在kernel/sched.c中。


        最后一句就将0x80中断与system_call(系统调用)联系起来。通过int 0x80,就可使用内核资源。不过,通常应用程序都是使用具有标准接口定义的C函数库间接的使用内核的系统调用,即应用程序调用C函数库中的函数,C函数库中再通过int 0x80进行系统调用。所以,系统调用过程是这样的:应用程序调用libc中的函数->libc中的函数引用系统调用宏->系统调用宏中使用int 0x80完成系统调用并返回。而之前的那个sys_call_table的类型就是个函数指针类型,其中sys_call_tabal[0]元素就是sys_setup,他的类型也是一个函数指针,实际上函数指针就是一个函数的入口地址,函数从哪里开始执行(那个内存地址)。

         下面的代码有助于我们进一步了解函数指针:


         我们看到了上面我们定义了一个MyFunc的函数指针。它指向了Func2这样我们就可以利用它来运行Func2了。实际上内核中好多的代码都是这种技术。尤其是在一些驱动代码的编写上。实际上我们可以结合我们之前学习的汇编知识分析一下我们的system_call():

//int 0x80 --linux 系统调用入口点(调用中断int 0x80,eax 中是调用号)。  
.align 2  
_system_call:  
cmpl $nr_system_calls-1,%eax //调用号如果超出范围的话就在eax 中置-1 并退出。  
ja bad_sys_call  
push %ds                     //保存原段寄存器值。  
push %es  
push %fs  
pushl %edx                   //ebx,ecx,edx 中放着系统调用相应的C 语言函数的调用参数。  
pushl %ecx                   //push %ebx,%ecx,%edx as parameters  
pushl %ebx                   //to the system call  
movl $0x10,%edx              //set up ds,es to kernel space  
mov %dx,%ds                  //ds,es 指向内核数据段(全局描述符表中数据段描述符)。  
mov %dx,%es  
movl $0x17,%edx              //fs points to local data space  
mov %dx,%fs                  //fs 指向局部数据段(局部描述符表中数据段描述符)。  

/*下面这句操作数的含义是:调用地址 = _sys_call_table + %eax * 4。参见列表后的说明。  
 * 对应的C 程序中的sys_call_table 在include/linux/sys.h 中,其中定义了一个包括72 个  
 *系统调用C 处理函数的地址数组表。
 */ 
call _sys_call_table(,%eax,4)  
pushl %eax                   //把系统调用号入栈。  
movl _current,%eax           //取当前任务(进程)数据结构地址??eax。  


/*下面行查看当前任务的运行状态。如果不在就绪状态(state 不等于0)就去执行调度程序。  
 *如果该任务在就绪状态但counter[??]值等于0,则也去执行调度程序。
 */ 
 
cmpl $0,state(%eax)           //state  
jne reschedule  
cmpl $0,counter(%eax)         //counter  
je reschedule  
//以下这段代码执行从系统调用C 函数返回后,对信号量进行识别处理。  
ret_from_sys_call:  
//首先判别当前任务是否是初始任务task0,如果是则不必对其进行信号量方面的处理,直接返回。  
//103 行上的_task 对应C 程序中的task[]数组,直接引用task 相当于引用task[0]。  
movl _current,%eax            //task[0] cannot have signals  
cmpl _task,%eax  
je 3f                         //向前(forward)跳转到标号3。  
/*通过对原调用程序代码选择符的检查来判断调用程序是否是超级用户。如果是超级用户就直接  
 *退出中断,否则需进行信号量的处理。这里比较选择符是否为普通用户代码段的选择符0x000f  
 *(RPL=3,局部表,第1 个段(代码段)),如果不是则跳转退出中断程序。
 */ 
cmpw $0x0f,CS(%esp)           //was old code segment supervisor ?  
jne 3f  
//如果原堆栈段选择符不为0x17(也即原堆栈不在用户数据段中),则也退出。  
cmpw $0x17,OLDSS(%esp)        //was stack segment = 0x17 ?  
jne 3f  
/*下面这段代码(109-120)的用途是首先取当前任务结构中的信号位图(32 位,每位代表1 种信号),  
 *然后用任务结构中的信号阻塞(屏蔽)码,阻塞不允许的信号位,取得数值最小的信号值,再把  
 *原信号位图中该信号对应的位复位(置0),最后将该信号值作为参数之一调用do_signal()。  
 *do_signal()在(kernel/signal.c,82)中,其参数包括13 个入栈的信息。 
 */ 
movl signal(%eax),%ebx         //取信号位图??ebx,每1 位代表1 种信号,共32 个信号。  
movl blocked(%eax),%ecx        //取阻塞(屏蔽)信号位图??ecx。  
notl %ecx                      //每位取反。  
andl %ebx,%ecx                 //获得许可的信号位图。  
bsfl %ecx,%ecx                 //从低位(位0)开始扫描位图,看是否有1 的位,  
//若有,则ecx 保留该位的偏移值(即第几位0-31)。  
je 3f                          //如果没有信号则向前跳转退出。  
btrl %ecx,%ebx                 //复位该信号(ebx 含有原signal 位图)。  
movl %ebx,signal(%eax)         //重新保存signal 位图信息??current->signal。  
incl %ecx                      //将信号调整为从1 开始的数(1-32)。  
pushl %ecx                     //信号值入栈作为调用do_signal 的参数之一。  
call _do_signal                //调用C 函数信号处理程序(kernel/signal.c,82)  
popl %eax                      //弹出信号值。  
3: popl %eax  
popl %ebx  
popl %ecx  
popl %edx  
pop %fs  
pop %es  
pop %ds  
iret  

四、实验过程

        下面我们来体验一下这个过程,首先使用API的方式完成一个fpid的获取。首先我们看一下实验代码:

        上面这个过程很简单了首先是fork()了一个进程结果在fpid。这个是用户态的fork的使用,下面我们可以看以下运行结果。

        下面我们看看我们用中断的方式如何完成相同功能。

       这里我们使用论文终端号是2号的系统调用实际上他就说fork对应的系统调用sys_fork()/stub32_fork()首先是将终端号传递到eax寄存器中然后指向int0x80程序就会进入内核态开始调用sys_fork()这个系统调用。我们可以看一下程序的事项结果。

        我们看到实际上效果是一样的这里面实际上没有参数的传递,是因为fork不需要这些参数,所以就不用了如果用了别的就需要参数传递了。我们下面来看一个简单的例子。使用软中断实现一个Hello的代码来体会参数传递的过程。

五、总结

        下面我们总结一下这次实验的过程,前面已经较为详细的叙述了系统调用这一过程,所以这段我们来总结一下有关使用这个中断的一些需要知道的东西。
       首先是系统调用号:
        内核通过自己的系统调用分派表sys_call_table(可以理解为一个系统调用号,对应一个函数入口地址)找到这个具体的系统调用服务例程对应的函数入口地址,如上面sys_read,sys_write等。
       然后是传递的参数规则:
        在发起系统调用前,eax寄存器里面存储了系统调用号。如用户程序fork()函数,glibc 发出int 0x80或sysenter指令前,eax寄存器就会设置好内核的sys_fork函数对应的系统调用号,这是glibc里面的封装例程会自动设置好的,程序员无需关心。 有些系统调用可能调用很多参数(除了系统调用号之外),普通c函数的参数传递是通过把参数值写入活动的程序栈(用户态栈或者内核态栈)实现的。因为系统调用是一种跨用户态和内核态的特殊函数,所以这两个栈都不能用。在发出系统调用之前,系统调用的参数写入了cpu的寄存器(如glibc去写好这些寄存器),然后发出系统调用之后,而在内核调用服务例程(如sys_fork()服务例程)之前,内核再把存放在cpu中的参数拷贝的内核态的堆栈中(因为sys_fork只是普通的c函数,前面说过普通c函数的参数传递是通过把参数值写入活动的程序栈(用户态栈或者内核态栈)实现的)。内核为什么不直接把用户态的栈拷贝到内核态的栈而要去通过寄存器来传呢?首先,同事操作两个栈是比较复杂的,其次,寄存器的使用使得系统调用处理程序的结构与其它异常处理程序的结构类似。
        使用寄存器传递参数,必须满足两个条件:
        每个参数的长度不能超过寄存器的长度(比如寄存器长度32位,那参数长度就不能超过32位);
        参数的个数不能超过6个(除了eax中传递的系统调用号),因为80x86处理器的寄存器的数量是有限的。
        第一个条件总能成立,因为POSIX标准规定,如果寄存器里面装不下那个长度的参数,那么必须改用参数的地址来传递。
        第二个条件有的系统调用参数大于6个,这种情况下,必须用一个单独的寄存器执行进程地址空间的这些参数所在的一个内存区。
        最后是SYSCALL,S Y S E X I T: int0x80/ iret
        向量128(0x80)对应于内核入口点,在内核初始化期间调用的函数trap_init(0,用以下方式建立对应于向量128的中断描述符表项set_system_gate(0x80,&system_call).
        当用户态进程发出int $0x80指令时,cpu切换到内核态并开始从地址system_call处开始执行指令。System_call()函数首先把系统调用号和这个异常处理程序可以用到的所有cpu寄存器保存到相应的内核栈中,不包括由控制单元已自动保存的eflags,cs,eip,ss,esp寄存器。随后,在ebx中存放当前进程的thread_info数据结构的地址,这是通过获得内核栈指针的值并把它取整到4kb或8kb的倍数而完成的。然后检查thread_info结构flag字段的TIF_SYSCALL_TRACE和TIF_SYSCALL_AUDIT标识之一是否被设置为1,也就是检查是否有某一调试程序正在跟踪执行程序对系统调用的调用。如果被置1,那么system_call()函数两次调用do_syscall_trace()函数:一次正好在这个系统调用服务例程执行之前,一次在其之后。Do_syscall_trace函数停止current,并因此允许调试进程收集关于current的信息。
        系统调用退出:
        (1)用户态的寄存器刚进来到系统调用的时候被保存到了内核栈中,错误的返回值会被写的刚开始传递系统调用号的那个eax寄存器所在栈的位置。(那么将来当用户态恢复执行的时候,eax寄存器里面的内容就是系统调用的返回码了。)
        (2)禁止本地中断,并检查current的thread_info结构中的标志。如果有任何标志被设置,那么在返回到用户态之前还需要完成一些工作。


参考

nodeathphoenix的博客  http://blog.csdn.net/nodeathphoenix/
xiaochen77的博客          http://blog.csdn.net/liuxiaochen77
闫明的博客                      http://blog.csdn.net/geekcome/















  • 2
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值