在应用程序开发过程中经常会进行IO设备的操作,比如磁盘的读写,网卡的读写,键盘,鼠标的读入等,大多数应用开发人员使用高级语言进行开发,例如C,C++,java,python等,这些高级语言都提供了标准库或者API去操作IO设备,不过标准库或者API最终还是通过系统调用来实现操作IO设备的,系统调用是操作系统提供的,它是操作系统内核的一部分。
系统调用封装了对硬件操作的所有细节,而标准库或者SDK又在系统调用的基础上做了高度抽象的封装和优化,因此使得应用程序开发人员的日子好过多了,开发效率也提高了不少。
1
本篇文章主要阐述以下两部分:
1.什么是系统调用?
2.系统调用的实现?
主要以Linux 操作系统和IA-32处理器举例,高级语言以C语言为例,同时也会掺杂一些其它操作系统和处理器。
什么是系统调用?
对于现代的操作系统来说,应用程序运行的时候是没有权限去访问系统资源的,操作系统为了防止各类应用程序可能会破坏系统资源,对系统资源做了保护,阻止应用程序直接去访问这些资源,而应用程序又有访问这些系统资源的需求,因此操作系统提供了系统调用,让所有的应用程序统一通过系统调用来访问系统资源,这里所说的系统资源包括文件,网络 ,内存,各类IO设备等。
应用程序可以进行系统调用,也可以调用标准库或者API,一个系统调用的内部有很多的步骤,比如需要进行用户态模式到内核态模式的互相切换。
这里简单介绍下模式切换,我们知道一个完整的应用程序分为两部分,一部分是应用程序的代码和数据,另一部分是内核的代码和数据,切换模式就是这两部分的分水岭,意味着处理器进入了一个不同的模式,不同的模式就是不同的世界,不同的世界就有不同的权限,而内核态模式就是王者,可以掌握所有的资源,用户态模式只能掌握自己的一亩三分地。
正如上面所说,系统调用需要进行模式切换,而每个完整的应用程序都有两个栈,一个用户栈,一个内核栈,这两个栈是独立的,用户栈在用户空间,内核栈在内核空间,因此切换模式时,栈也得切换。
因此我们可以将系统调用的执行步骤分为三步:1.执行前的准备工作。2.执行处理程序(处理函数)。3.执行后的善后工作,当然内核模式切换和栈切换就是1和3的工作了,这里的三步都是在内核模式下执行的,如下图所示
应用程序直接系统调用步骤
从上图得知,执行一个系统调用很复杂,需要干很多的活,Linux的编译器提供了很多共享库(so文件)来提供系统调用,例如Linux的glibc库就提供了文件操作相关的系统调用,例如下面的代码:
int read(int fd,void *buf,int count);//读文件数据
int write(int fd,const void *buf,int couint);//写文件数据
int open(const char * pathname,int flags,mode_t mode);//打开文件
上面的代码只是glibc库中几个比较有代表性的例子,linux操作系统提供了几百个系统调用,这些系统调用分散在各个共享库中,这里就不再阐述。
Windows操作系统提供了API,简称Windows API或者SDK,它不是系统调用而是对系统调用做了二次封装,这些API是由各类DLL(动态链接库)提供的,开发人员导入这些DLL就可以通过Windows API来开发Windows应用程序,因此Widows应用程序执行系统调用的步骤就变成了如下图所示
Windows应用程序系统调用步骤
正如上文所述,每个操作系统都提供它各自的系统调用,那么写一段C代码怎样能做到跨操作系统呢?答案是C语言标准库,C语言标准库的目的就是让开发人员写一段C代码,这些C代码使用的是C标准库,那么这段代码不需要进行任何修改就可以跨操作系统,前提是经过不同操作系统编译器的编译,C标准库的调用关系如下图
C标准库
由上图得知,Linux通过共享库直接提供系统调用,而Windows则通过Windows API间接进行提供系统调用,中间增加了一个C标准库,它将不同操作系统之间系统调用标准化,做了二次封装,简化了系统调用的复杂度,提供给应用程序。
标准库也有它的缺点,缺点就是只能取各个操作系统系统调用的交集,这意味着只有操作系统都有的功能才能纳入到标准库,然后有的时候,需要一些操作系统专有的功能时,还得直接进行系统调用或者调用API,这个就会出现跨系统的问题。
对于用标准库开发的应用程序,它的系统调用步骤可以总结如下图
C标准库系统调用
好了,【什么是系统调用】的话题介绍到这里了,下面来看看系统调用具体是怎么实现的。
2
系统调用的实现?
上个环节阐述的是【什么是系统调用】以及系统调用的大致步骤,这个环节将以Linux操作系统为例来阐述系统调用的实现原理和细节,当然其它操作系统系统调用的实现原理比较相似,可以举一反三。
主流的操作系统如Linux和Windows是通过中断来实现系统调用的。
以操作系统Linux(2.5以前),处理器为Inter IA-32为例,看看fork这个系统调用是怎么实现的,其它的Linux系统调用类似,整体过程如下图
Linux系统调用过程
上图为系统调用涉及到的9个步骤,我们逐个看起
1.应用程序调用linux库提供的fork函数,发起一个fork系统调用,这个系统调用的目的是创建一个子进程,这个子进程拷贝一份父进程的虚拟进程空间。
2.fork函数的第一步就是将2放入寄存器eax,每个系统调用都有一个编号,2就是fork系统调用的编号,eax是默认用于传递系统调用编号的寄存器。
如果系统调用有参数,则将参数传入到如下的寄存器EBX,ECX,EDX,ESI,EDI,EBP,可以看出系统调用最多支持6个参数,fork系统调用没有参数。
fork函数的第二步就是执行中断指令int 0x80,中断指令int用于发送中断信号给处理器,0x80为中断向量号,这个向量号是系统调用中断处理程序专用。
int指令同时也会将模式从用户态切换到内核态,用户栈切换到内核栈,同时会将当前被中断的应用程序,中断时的寄存器内容入栈(SS,ESP,EFLAGS,CS,EIP),这里的入栈指的是入内核栈(每一个应用程序都一个用户栈和内核栈)。
整体来看,2步骤的汇编代码如下:
push EAX,2;//设置fork系统调用的系统调用编号
mov EBX,arg1;//可选,参数1
mov ECX,arg1;//可选,参数2
mov EDX,arg1;//可选,参数3
mov ESI,arg1;//可选,参数4
mov EDI,arg1;//可选,参数5
mov EBP,arg1;//可选,参数6
int 0x80;//发送系统调用中断信号
3.处理器执行完当前的指令后,会检查处理器的中断引脚,发现有中断信号,然后检查状态寄存器(EFLAGS),发现中断屏蔽IF标志是打开的(系统调用中断信号不会被屏蔽),处理器根据中断信号,分析出中断向量号,然后根据中断向量号去查找中断描述符表,找到了该中断向量号对应的中断处理程序。
4.操作系统跳转到中断处理程序,然后开始执行中断处理程序,0x80对应的中断处理程序是系统调用中断处理程序(system_call)。
该中断处理程序首先会将EAX,EBX,ECX,EDX,ESI,EDI,EBP这几个寄存器入栈,之所以入栈,就是为了防止后续的工作覆盖这些寄存器,核心汇编指令如下:
push EAX;
push EBX;
push ECX;
push EDX;
push ESI;
push EDI;
push EBP;
5.系统调用中断处理程序紧接着根据系统调用号(这里就是fork系统调用号即2),去系统调用表进行查找,可以找到该系统调用号对应的处理程序(也可以叫处理函数),Linux操作系统的系统处理函数一般以sys开头,fork的系统处理函数就是sys_fork。
6.找到了系统处理函数后,开始执行该函数,处理函数可以从内核栈中获取函数的参数,函数执行完成后,函数的返回值,默认采用EAX寄存器进行返回。
7~8.系统处理函数执行完成后,回到了系统调用中断处理程序,中断处理程序执行iret指令,iret指令负责从内核态切换到用户态,将内核态入栈的寄存器数据出栈到SS,ESP,EFLAGS,CS,EIP这几个寄存器,然后跳转到系统调用处。
9.系统调用fork返回到应用程序。
3
Linux操作系统(2.5以前)的系统调用实现原理阐述完了,Windows操作系统的系统调用也采用类似的机制,另外要说的是,自从Linux(2.5)以上,处理器Inter 奔腾二代以后,为了提高系统调用的效率,Inter处理器提供了两个指令来进行系统调用的进入和退出即sysenter和sysexit指令。
sysenter指令代替了int中断指令发起系统调用,执行这个指令后,会直接跳转到一个系统调用的处理函数地址处,去执行系统调用,这个处理函数的地址是存储在一个指定的寄存器中,sysenter这个指令也负责模式的切换和应用程序现场寄存器的备份,这一点同int一样,处理函数参数的传递跟以前一样,还是通过寄存器的方式传递,没有变化。
sysexit指令代替了iret恢复指令,它负责模式切换和现场寄存器的恢复,这一点同iret指令相似。
其它的操作系统例如Power PC,AMD的系统调用与Linux(2.5以上)类似,不同的是,它们采用不同的指令来进行模式切换和寄存器备份,参数的传递也是采用寄存器的方式,只是寄存器个数和名称不一样罢了。
转自:一口Linux 并做整理
推荐阅读:
专辑|Linux文章汇总
专辑|程序人生
专辑|C语言
我的知识小密圈
关注公众号,后台回复「1024」获取学习资料网盘链接。
欢迎点赞,关注,转发,在看,您的每一次鼓励,我都将铭记于心~