这一篇介绍一下系统调用,熟悉一下流程。很多做客户端的同学根本不知道这些内容。建议花时间看看相关的知识。最好的方式还是去看源码,反汇编,才能深刻的理解。
系统调用
程序运行的时候,本身是没有权限访问多少系统资源的。系统资源有限,如果操作系统不进行控制,那么各个程序难免会产生冲突。线程操作系统都将可能产生冲突的系统资源保护起来,阻止程序直接访问。比如文件、网络、IO、各种设备等。
比如无论在Windows还是Linux中,程序员都不能直接去访问硬盘的某扇区上的数据,必须通过文件系统,也不能擅自修改任意文件。所有这些操作必须经过操作系统规定的方式进行。比如用fopen打开没有权限的文件就会失败。
比如:想要程序延迟执行一段时间,不借助操作系统就是使用循环,这样会白白消耗CPU,造成资源浪费。如果使用操作系统提供的定时器就可以方便有效。
系统调用涵盖的功能很广,有程序运行锁必须的支持,如创建和退出进程、线程,进程内存管理,对系统资源的访问等。
Linux系统调用
在x86下,系统调用是通过0x80中断完成,各个通用寄存器用于传递参数。EAX寄存器用于表示系统调用的接口号。
比如:EAX=1表示退出进程,EAX=2表示创建进程,EAX=3表示读文件或IO,EAX=4表示写文件或IO。每个系统调用对应到内核源码中的一个函数,他们都是以sys_
开头的,比如exit调用对应内核中的sys_exit函数
Linux内核提供了几百个系统调用,下面列举部分
所以完全可以不使用glibc封装的fopen、fread、fclose操作文件,而直接使用系统函数open,read,close实现。
技巧:Linux中可以使用man查看系统调用详情,使用参数2表示系统调用手册(比如 man 2 read)
如果直接使系统调用会有非常多的问题:
- 使用不方便,操作系统提供的系统调用接口往往过于原始。程序员需要了解很多跟操作系统相关的细节
- 各个操作系统之间系统调用不兼容
于是增加一个层来解决,系统调用与程序之间增加一个抽象层。这个层就是前面所说的glibc,或者API.
系统调用原理
这里单单以Linux为例,至于Windows调用原理暂时省略。
用户态、内核态及中断
现代操作系统中有两种特权级别,分为用户模式和内核模式。
多个模式存在,那么操作系统就可以让不同代码运行在不同模式下,进而限制代码的权限,提高稳定性、安全性。一般普通程序在用户态,操作会受到限制。系统调用运行在内核态,应用程序基本都是运行在用户态被限制。
用户态的程序通过中断来运行内核态的代码,进而从用户态切换到内核态。
中断
中断是操作系统的一个概念。中断是一个硬件或者软件发出的请求,要求CPU暂停当前的工作转手去执行更加重要的事情。
比如在编辑文本的时候,敲击键盘那CPU如何得知道呢?一种是轮询,CPU每隔一小段时间就去询问键盘是否有键按下,但是除非一直打字,否则大部分轮训都是得到没有键按下的结果。这样就白白浪费掉了很多资源。
另一种方式就是当键盘按下的时候,键盘芯片发一个信号给CPU,然后CPU接收到信号之后就只到键盘按下了。然后再去询键盘具体是哪个键被按下,这样的信号就是一种中断。——硬件中断
中断一般有两个属性,一个是中断号(从0开始),一个称为中断处理程序(ISR),不同中断具有不同的中断号,一个中断号对应一个中断处理程序。在内核中保存了一个数组,叫做中断向量表,这个数组第n项包含了指向第n个中断号的中断处理程序的指针。当中断来的时候,CPU就会暂停当前执行的代码,根据中断的中断号,在中断向量表中找到对应的中断处理程序,并且调用他,处理完之后,CPU继续执行之前的代码。
中断有硬件中断,这种来至于硬件的异常或者其他时间的发生比如断电,键盘按下。另一种是软件中断,软件中断一般是一条指令(i386下是int),带有一个参数记录中断号,这个指令用户可以手动触发某个中断并执行中断处理程序。比如int 0x80这条指令就会调用第0x80号的中断处理程序。
中断号是有限的,不可能每一个系统调用都对应一个中断号,更加合理的是用一个或少数几个中断号来对应所有的系统滴啊用。比如Linux中int 0x80来触发所有的系统调用。
系统调用会有一个系统调用号,表明是哪一个系统调用,这个系统调用通常就是系统调用在系统调用表中的位置。比如Linux中的fork函数调用号是2,这个系统调用号在执行int指令前就会被放到某个固定的寄存器中,对应的中断代码会取得这个系统调用号,并且调用正确的函数。
比如Linux中int 0x80为例,系统调用号使用eax来传入,用户将系统调用号保存在eax,然后使用int 0x80调用中断,中断服务传给信号就可以从eax取得系统调用号,进行调用相应的函数。
基于int的Linux经典系统调用
下面以fork为例
触发中断
首先当程序在代码里面调用一个系统调用时,是以一个函数的形式调用的,比如fork:
int main() {
fork();
}
fork函数是对系统调用fork的封装,可以用下面的宏定义:
_syscall0(pid_t, fork)
_syscall0
是一个宏,用于定义一个没有参数的系统调用封装。他的第一个参数为这个系统调用的返回值类型,pid_t代表进程的id。第二个参数是系统调用的名字。_syscall0
展开之后 会形成一个与系统调用名称同名的函数。下面是i386的syscall0
汇编解释:
-
__asm__
是一个gcc关键字,表示接下来要嵌入汇编代码,volatile关键字告诉GCC对这段代码不进行任何优化 -
__asm__
第一个参数是一个字符串,代表汇编代码的文本,这里汇编代煤制油int $0x80
,表示要调用0x80号中断 -
=a(__res)
表示用eax(a表示eax)输出返回数据并存储到_res中 -
0(_NR ##name)
表示用_NR ##name
为输入,0
指示由编译器选择和输出相同的寄存器(eax)来传递参数。
__syscal_return
是另外一个宏
最终fork函数汇编之后
当系统调用如果有参数的话会用syscall1
相比syscall0多了个
b
,它表示把arg1强制转换为long,然后保存在ebx最为输入。
所以如果系统内调用如果有1个参数,则参数通过ebx来传递。x86下的linux系统调用参数最多有6个。分别使用6个寄存器来传递。分别是ebx,ecx,ed想,esi,edi和ebp。
当进行系统调用的时候,CPU执行到int $0x80时,会保存现场以便恢复。接着切换到内核态,然后CPU查找中断向量表低0x80元素。
切换堆栈
在实际执行中断向量表中的第0x80好中断之前,CPU还要进行堆栈的奇幻,在Linx中,用户态和内核态使用的是不同的栈,两者各自负责各自的函数调用,互不干扰。
在执行0x80中断的时候,程序从用户态切换到内核态。这时相应的栈也必须切换到内核栈,从中断处理函数中返回时,程序当前栈需要从内核栈切换到用户栈。
当前栈是指ESP值所在的栈空间,如果ESP的值位于用户栈范围能,那么程序的当前栈就是用户栈。内核栈同理。并且SS的值需要指向当前栈所在的页。所以用户栈切换到内核栈就是:
- 保存当前ESP、SS的值
- 将ESP、SS的值设置为内核栈的值
(反过来同理)
- 恢复原理ESP、SS的值
- 用户态的ESP和SS的值保存在内核栈上,
当发生中断的时候,CPU除了进入内核态之外还会做如下事情:
- 找到当前进程的内核栈(每一个进程都有一个内核栈)
- 在内核栈中一猜压入用户态的寄存器SS、ESP、EFLAGS、CS、EIP
当内核从系统调用中返回,则调动iret指令回到用户态,iret指令会从内核栈里面弹出SS、ESP、EFLAGS、CS、EIP的值。使得栈恢复到用户态的状态。
中断处理程序
在int指令切换了栈之后,程序就切换到中断向量表转给你记录0x80号中断处理陈旭。下面是linux i386中断服务流程
内核的系统调用函数往往以sys_加上系统调用函数名了,比如sys_fork,sys_open等。
关于sys开头的系统内调用函数如何从用户那里获得参数的。是通过寄存实现的。我们知道用户调用系统调用时,根据系统电泳参数数量不同,一次将参数放入EBX,EXC,EDX,ESI,EBP这6个寄存器。如果一个参数的系统调用就是EBX,两个参数的系统调用就是EBX和ECX
小结
通过阅读,归根结底还是要懂汇编并且去看源码才能把整个过程分析正确。平时所使用的把所有的细节都已经屏蔽了。地下还深藏着许多玄机。——而这一点确实大都数开发人员的短板