禹晓博+ 原创作品转载请注明出处 + 欢迎加入《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函数库(如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