最近C++技术交流群发现了很多水平很高的朋友,欢迎大家来加喵哥微信,进群一起讨论计算机知识!
正文:系统调用就是调用操作系统提供的一系列内核功能函数,因为内核总是对用户程序持不信任的态度,一些核心功能不能交由用户程序来实现执行。用户程序只能发出请求,然后内核调用相应的内核函数来帮着处理,将结果返回给应用程序。如此才能保证系统的稳定和安全,关于系统调用的这些理论知识不多说,书本上有一大堆,本文旨在捋清楚系统调用这条线。
总述
Linux 里系统调用是由中断来实现的,既然利用中断实现,那么总体来说系统调用的过程应该与中断的过程相似。也的确如此,总体流程是差不多,但也有所区别。
每一种中断都会有一个中断向量号或中断类型号,有相应的中断服务程序也就是处理中断的函数。但是我们应该知道,系统调用是有很多的,比如 fork
,read
,write
等等。虽然中断向量号有空缺多余的,但系统调用数目更多,到2.6.23版的 Linux,就已经有325个,而中断向量号只有 256
个,明显为每一个系统调用单独分配一个中断向量号不现实。
那怎么解决呢,采用的办法是直接为所有的系统调用分配一个中断类型号,一般是 0x80
,再用系统调用号来区分各个不同的系统调用。
所以我们的系统调用大致流程变为根据中断向量号去IDT
中索引相应的中断门描述符,得到选择子和偏移量,根据选择子去GDT
中索引相应的段描述符得到段基址,与上面得到的偏移量相加得到中断服务程序的地址。中断处理程序根据系统调用号再调用相应的系统调用函数做具体的处理,最后返回。
上述为系统调用的大致过程,下面我们一步步地来具体看看系统调用的过程,或者说系统调用是如何实现的。
1. 用户接口
我们平常编写程序调用的是操作系统或者说 C 库
提供的用户接口,也就是常说的 API
,而并不是直接使用系统调用来编程,用户接口可以看作实际的系统调用函数的封装。
这里要注意我们平常所说的 API 和系统调用之间并没有一定的对应关系。一个 API 可以对应一个系统调用,也可以对应多个系统调用,甚至不依赖任何系统调用,更甚多个API对应一个系统调用。所以 API 就只是一个接口,具体使用哪些系统调用实现什么功能,从理论上来讲只要逻辑没问题随便怎么定义怎么实现都可以,但是为了可移植兼容的考虑,还是必须得遵循一定的规则,大多操作系统 API 都是遵循POSIX标准的。
上述说过系统调用的用户接口可以看作是系统调用的封装,咱们以 getpid 来举例具体看看:
int getpid(){
return _syscall0(SYS_getpid);
}
2. 系统调用接口
系统调用接口指的就是上面那个 _syscall
函数,早期的 Linux 里面的 _syscall
是用宏来实现的,一共有 7
个,后面跟不同的数字来区分,如_syscall0
,_syscall1
,分别支持0—6个参数。咱们在这儿也不搬出具体代码解释说明,有兴趣的朋友可以自己去看看,这7个宏的实现原理都一样,主要做了以下三件事:
系统调用号传给 eax 寄存器 传入参数 int 80h
传参,如果参数少,直接存到寄存器里即可,采用寄存器传参方便而且速度快。在下x86的系统上,前5个参数按顺序存放在ebx
, ecx
,edx
, esi
,edi
5 个寄存中。而如果参数过多,会使用一个单独的寄存器存放所有参数在用户空间的地址,陷入内核后再将参数从用户空间拷贝到内核。
系统调用号和最后的返回值都存在 eax
寄存器中,约定俗成的东西。
接着就是 int n
指令,int n
就相当于发生了一个n号中断,属于软中断,虽然引发中断的方式不同,但对中断的处理基本是一样的,中断这一块前文讲述的应该很清楚了,这里不再赘述只是简单说明一下:
有特权级变化的话压入 ss
和esp
,因为是系统调用,特权级是肯定发生了变化的压入 eflags
,cs
,eip
寄存器根据中断类型号索引 IDT
中的中断门描述符,取出里面的内容修改cs
,eip
寄存器的值;根据cs
里面的选择子又去GDT
中索引段描述符,获取段基址。再根据eip
中的偏移量找到系统调用服务程序。
这里对于用户态的 ss
和 esp
寄存器值保存作为题外话补充说明一下。不知大家有没有想过这个问题,用户态下的 ss
和 esp
怎么保存到内核栈里面去的,切换到内核栈需要改变 ss
和 esp
,那原 ss
和esp
不就丢掉了吗?所以处理器会临时保存 ss
和 esp
的值,切换到内核态时再重新拷贝一份用户态的 ss
和 esp
的值。之后再压入 eflags
,cs
,eip
寄存器,当然如果特权级没有发生变化,也就不会有上述过程。
这一块儿在我写的中断文章里面忘记说了,在此补上,这些所有有关处理器的规则约定功能都由指令集体系结构ISA
所管,它规定了我们需要做什么,提供什么,然后它就自动完成一些事情。就像调用 API
编程一样,我们提供合理的参数,然后相应的函数自动完成一些工作。对于CPU
而言同样的道理,只是更偏向于底层具体的物理实现,但从逻辑上来讲是相通的。
3. 系统调用号
每个系统调用都有自己的专属号码,其实就是个索引号,如下面所示:
/*...................*/
#define __NR_eixt 1
#define __NR_fork 2
#define __NR_read 3
/*...................*/
4. 系统调用服务例程
系统调用服务例程才是具体干事的内核功能函数,前面的那些用户接口,系统调用接口,中断服务程序都不是具体干事的,全都相当于接口一类,而这个系统调用服务例程才是具体做事的一个函数,举个简单例子,用 getpid
这个系统调用来说明:
int sys_getpid(void){
return current->pid //current指向当前进程
}
5. 系统调用表
每个系统调用都对应着一个服务例程,将它们的首地址集中起来放在一个数组里方便使用系统调用号来索引,这个表(数组一个意思)在Linux里面是 sys_call_table
,就像这样:
ENTRY(sys_call_table)
.long sys_restart_syscall
.long sys_exit
.long sys_fork
6. 系统调用服务程序
这个系统调用服务程序就是中断服务程序,以前的哪些外设引发的中断相应的服务程序会处理实际的事务,而系统调用前面说过不太一样,它交给系统调用服务例程来处理的,下面来仔细看看:
system_call:
SAVE_ALL #保存上下文
push arg #压入参数
call *sys_call_table(,%eax,4) #根据eax里面的系统调用号调用相应服务例程
mov %eax, 24(%esp) #将服务例程的返回值保存到上下文中的eax处
syscall_exit:
#返回退出
系统调用利用中断实现,所以处理中断要先保存上下文,因为系统调用不具体处理事务而是调用其他函数来处理,所以压入参数然后调用函数。这是调用函数前的一惯做法:先压入参数再调用。参数从何而来?还记得前面把参数放在寄存器里面吧,所以这儿push arg
就是压入寄存器,就不具体写了,知道就好。
系统调用服务例程的运行结果是要传回到用户态的,eax
里面存放的返回值,所以当服务例程运行完后,只要将当前寄存器 eax 里面的值保存到上下文里面的 eax
处即可。在Linux2.6
里面栈顶向上 24 个字节处就是用户态下的 eax
,这个用户态下eax
的位置与具体保存上下文时如何压栈有关,前后能够对应上就行。
注:上述是根据 Linux2.6 简化来的伪码,Linux2.6里面是确有 SAVE_ALL
这个宏的,其中压入参数就是 SAVE_ALL
的一部分,在这儿只是为了过程更清晰所以单独写了出来。
7. 总结捋线
上述就是系统调用的大概过程,这儿再总结总结捋一捋:
调用用户接口函数 用户接口封装的是系统调用接口,早期的 Linux 里就是那7个宏 _syscall
传系统调用号,传参,int 80h
int 80h
陷入内核,保存ss
,esp
,eflags
,cs
,eip
寄存器根据中断向量号 80h 去IDT中索引中断门描述符,根据其内容修改 cs
,eip
的值根据 cs
里的选择子去GDT
中索引段描述符,获得中断(系统调用)服务程序的段基址,结合eip
里面的偏移量就得到系统调用服务程序的地址系统调用服务程序中 system_call
保存上下文,压入系统调用服务例程需要的参数根据 eax
里面的系统调用号索引sys_call_table
,然后调用执行修改上下文中 eax
处的值,将其修改为服务例程返回值返回,相当于第4步的逆过程
大致的过程图如下所示:
并不是所有的系统调用都有上述的过程,在这儿只是从头至尾的捋一捋,知晓有这么一个过程就好,毕竟本文的目的就是捋一捋系统调用这条线嘛
8. syscall说明
_syscall
宏这种形式的系统调用在 Linux 里面已经废弃不再提供库实现支持,因为这种方式最多支持6个参数,而且每个参数还要提供相应的类型,总共就是2n
个参数。但是这种实现方式思路清晰简单,所以上述我也是以这种实现为基来说明的。
现在 Linux 的系统调用都是用库函数syscall
来实现的,原型为:
int syscall(int number, ...);
number
指的是系统调用号。从这原型就能看出,库函数这种实现方式支持变参(...),所以能够将所有的系统调用统一起来,不像宏实现方式不同参数的系统调用还需要使用不同的宏。
往期推荐