当前位置: 首页 > 科技观察

来看看系统调用

时间:2023-03-22 00:21:51 科技观察

本文转载自微信公众号《兰德》,作者兰德。转载本文请联系Rand公众号。系统调用就是调用操作系统提供的一系列内核函数,因为内核对用户程序总是抱着一种不信任的态度,有些核心功能是用户程序无法实现的。用户程序只能发出请求,然后内核调用相应的内核函数帮助处理,并将结果返回给应用程序。只有这样才能保证系统的稳定性和安全性。系统调用的理论知识就不多说了。书中有很多。本文旨在阐明系统调用的线路。概述Linux中的系统调用是通过中断实现的。既然是用中断来实现的,那么系统调用的过程应该和一般的中断类似。诚然,整体流程大同小异,但也有区别。每个中断都会有一个中断向量号或中断类型号,以及对应的中断服务程序,也就是处理中断的函数。但是要知道系统调用有很多,比如fork、read、write等等。虽然中断向量号有空缺,但系统调用次数较多。到Linux2.6.23版本,已经有325个,而中断向量号只有256个。显然,每个系统调用都分配了一个单独的中断向量号。不切实际。那怎么解决呢,采用的方法是直接给所有的系统调用分配一个中断类型号,一般是0x80,然后用系统调用号来区分不同的系统调用。因此,我们系统调用的大体流程就变成了根据中断向量号去IDT索引对应的中断门描述符,得到selector和offset,根据selector去GDT索引对应的段描述符得到段基地址,加上上面得到的偏移量得到中断服务程序的地址。中断处理程序根据系统调用号调用相应的系统调用函数做具体处理,最后返回。以上就是系统调用的大致流程。下面我们一步步来看一下系统调用的过程,或者说系统调用是如何实现的。1.用户界面我们编写程序通常是调用操作系统或C库提供的用户界面,也就是常说的API,而不是直接使用系统调用来编程。用户界面可以看作是实际的系统调用函数。封装。这里需要注意的是,我们通常所说的API和系统调用之间并没有一定的对应关系。一个API可以对应一个系统调用,也可以对应多个系统调用,甚至不依赖任何一个系统调用,甚至多个API对应一个系统调用。所以API只是一个接口,使用哪些系统调用来实现什么功能。理论上,只要逻辑正确,可以用任何方式定义和实现。但是,为了便携性和兼容性,必须遵循一定的规则。大多数操作系统API都遵循POSIX标准。上面说了,系统调用的用户界面可以看作是对系统调用的封装。下面以getpid为例详细看看:intgetpid(){return_syscall0(SYS_getpid);}2.系统调用接口系统调用接口指的是上面的_syscall函数,早期Linux中的_syscall是通过宏实现的,有一共7个,后面用不同的数字来区分,比如_syscall0,_syscall1,分别支持0-6个参数。具体的代码这里就不做解释了,有兴趣的可以自己去看看。这7个宏的实现原理是一样的,主要做了以下三件事情:传递系统调用号到eax寄存器传递参数int80h传参,如果参数少,直接存入寄存器直接地。使用寄存器传递参数方便快捷。在x86系统上,前5个参数依次存放在ebx、ecx、edx、esi、edi寄存器中。而如果参数过多,则会用一个单独的寄存器来存放所有参数在用户空间的地址,落入内核后,再将参数从用户空间复制到内核。系统调用号和最终返回值都保存在eax寄存器中,这是约定俗成的。然后是intn指令,intn相当于一个n号中断,属于软中断。虽然触发中断的方式不同,但中断的处理基本相同。中断应该很清楚。这里就不细说了,简单说明一下:如果权限级有变化,按ss和esp,因为是系统调用,权限级肯定变了。按eflags、cs、eip寄存器根据中断类型号Gate描述符在IDT中索引中断,取出里面的内容修改cs和eip寄存器的值;根据cs中的selector去GDT中的索引段描述符中获取段基地址。然后根据eip中的偏移量找到系统调用服务程序。这里补充一下用户态下ss和esp寄存器的保值,作为题外话。不知道你有没有想过这个问题。用户态的ss和esp如何保存到内核栈?切换到内核栈需要改变ss和esp,那么原来的ss和esp会丢失吗?所以处理器会暂时保存ss和esp的值,然后在切换到内核态时,在用户态复制一份ss和esp的值。然后按eflags、cs和eip寄存器。当然,如果权限级别不变,就不会发生上述过程。这部分在我写的打断文章中忘记说了,这里补充一下。所有这些关于处理器的规章制度都是由指令集架构ISA来管理的,它规定了我们需要做什么,提供什么,然后它就自动做一些事情。就像调用API编程一样,我们提供合理的参数,然后相应的函数自动完成一些工作。CPU也是如此,只是更倾向于底层的具体物理实现,只是逻辑上是相连的。3、系统调用号每个系统调用都有自己唯一的编号,其实就是一个索引号,如下图:#define__NR_eixt1#define__NR_fork2#define__NR_read3/*.......*/4。SystemCallServiceRoutine系统调用服务例程是做具体工作的内核函数。之前的用户界面、系统调用界面、中断服务程序都不是具体的工作。它们都相当于接口。一类,而这个系统调用服务例程是一个做具体事情的函数。举一个简单的例子,用getpid系统调用来说明:intsys_getpid(void){returncurrent->pid//current指向当前进程}五、系统调用表每个系统调用对应一个服务例程,它们的首地址为收集并放置在一个数组中,以便使用系统调用号轻松索引。该表(数组表示一个)在Linux中是sys_call_table,如下所示:ENTRY(sys_call_table).longsys_restart_syscall.longsys_exit.longsys_fork6。系统调用服务程序该系统调用服务程序是一个中断服务程序。前面外设引起的中断对应的服务程序会去处理实际的事务,和前面讲过的系统调用不一样,是由系统调用服务程序来处理的,我们仔细看看:system_call:SAVE_ALL#保存上下文pusharg#推入参数call*sys_call_table(,%eax,4)#根据eax中的系统调用调用对应的服务例程mov%eax,24(%esp)#保存服务例程的返回值到上下文中的eax系统调用并不具体处理事务而是调用其他函数来处理,所以push参数再调用函数。这是调用函数前的常见做法:先压入参数再调用。参数从哪里来?记得把参数放到寄存器里,所以这里pusharg就是压入寄存器,就不写详细了,知道就知道了。系统调用服务例程的运行结果是要传回用户态的,返回值保存在eax中,所以当服务例程运行完毕后,只需将当前寄存器eax中的值保存到上下文中的eax中即可。在Linux2.6中,栈顶以上的24个字节在用户态是eax。eax在用户态的位置关系到保存上下文时如何入栈。注:以上是基于Linux2.6的简化伪代码。Linux2.6中确实有一个SAVE_ALL宏,按下的参数是SAVE_ALL的一部分。这里为了流程更清晰,单独写出来。7.总结以上是系统调用的大致流程,这里做一个总结:调用用户界面函数用户界面封装了系统调用接口。在早期的Linux中,七个宏_syscall传递的是系统调用号。传参,int80hint80h落入内核,根据中断向量号80h保存ss、esp、eflags、cs、eip寄存器到IDT中索引中断门描述符,根据其内容修改cs,和的值eip根据cs中的selector去到GDT中的索引段描述符,得到中断(系统调用)服务程序的段基地址,结合eip中的偏移量得到系统调用服务程序的地址。system_call将context保存在系统调用服务程序中,并根据eax中的系统调用号将例程索引sys_call_table所需的参数推送到系统调用服务中,然后调用执行修改context中eax处的值,并修改为返回服务例程的返回值,相当于步骤4的逆过程。大致过程图如下所示:并不是所有的系统调用都有上述过程,这里只是从中划一划从头到尾,知道有这样一个过程就好了,毕竟这篇文章的目的是划系统调用的线8.syscall描述_syscall宏这种形式的系统调用已经被废弃了Linux并不再提供库实现支持,因为这个方法最多支持6个参数,而且每个参数都要提供对应的类型,一共2n个参数。不过这个实现方式思路清晰简单,所以上面我也是基于这个实现来说明的。现在Linux系统调用都是用库函数syscall实现的,原型是:intsyscall(intnumber,...);number指的是系统调用号。从这个原型可以看出,库函数的实现支持可变参数(...),所以可以统一所有的系统调用,不像宏实现方法中不同参数的系统调用需要使用不同的宏。