在现代的操作系统里,程序运行的时候,本身是没有权利访问多少系统资源的。由于系统有限的资源有可能被多个不同的应用程序同时访问,因此,如果不加以保护,那么各个应用程序难免产生冲突。所以现代操作系统都将可能产生冲突的系统资源给保护起来,阻止应用程序直接访问。这些系统资源包括文件、网络、IO、各种设备等。举个例子,无论在Windows下还是Linux下,程序员都没有机会擅自去访问硬盘的某扇区上面的数据,而必须通过文件系统;也不能擅自修改任意文件,所有的这些操作都必须经由操作系统所规定的方式来进行,比如我们使用fopen
去打开一个没有权限的文件就会发生失败。
此外,有一些行为,应用程序不借助操作系统是无法办到或不能有效地办到的。例如,我们如果要让程序等待一段时间,不借助操作系统的唯一办法就是:
int i;
for (i = 0; i < 1000000; i++);
这样实现等待可以勉强达到目的,但是等待的时间会拜拜浪费CPU的资源,而且在不同的硬件环境,等待的时间也可能会不同。
系统调用覆盖的功能很广,有程序运行所必需的支持,例如创建/退出进程和线程、进程内存管理,也有对系统资源的访问,例如文件、网络、进程间通信、硬件设备的访问,也可能有对图形界面的操作支持,例如Windows下的GUI机制。
在x86下,系统调用由0x80
中断完成,各个通用寄存器用于传递参数,EAX寄存器用于表示系统调用的接口号,比如EAX=1表示退出进程(exit);EAX=2表示创建进程(fork),接口号的定义位于/usr/include/xxx/asm/unistd.h
。以下是MIPS下的“Linux version 4.19.167-rc4.lnd.3-loongson-3”系统关于接口号的定义:
/* 文件位于 /usr/include/mips64el-linux-gnuabi64/asm/unistd.h */
/*
* Linux o32 style syscalls are in the range from 4000 to 4999.
*/
#define __NR_Linux 4000
#define __NR_syscall (__NR_Linux + 0)
#define __NR_exit (__NR_Linux + 1)
#define __NR_fork (__NR_Linux + 2)
#define __NR_read (__NR_Linux + 3)
#define __NR_write (__NR_Linux + 4)
#define __NR_open (__NR_Linux + 5)
...
/*
* Linux 64-bit syscalls are in the range from 5000 to 5999.
*/
#define __NR_Linux 5000
#define __NR_read (__NR_Linux + 0)
#define __NR_write (__NR_Linux + 1)
#define __NR_open (__NR_Linux + 2)
#define __NR_close (__NR_Linux + 3)
#define __NR_stat (__NR_Linux + 4)
#define __NR_fstat (__NR_Linux + 5)
...
每个系统调用都对应于内核源代码中的一个函数,它们都是以”sys_
”开头的,比如exit调用对应内核中的sys_exit
函数。Linux内核版本提供了很多个系统调用,这些系统调用都可以在程序里面直接使用,它的C语言形式被定义在/usr/include/unistd.h
中,比如我们完全可以绕过glibc的fopen
、fread
、fclose
打开读取和关闭文件,而直接使用open()
、read()
和close()
来实现文件的读取,使用write向屏幕输出字符串(标准输出的文件句柄为0),使用read系统调用来实现读取用户输入(标准输入的文件句柄为1)。不过由于绕过了glibc的文件读取机制,所以所有位于glibc中的缓冲、按行读取文本文件等这些机制都没有了,读取的就是文件的原始数据。如果我们希望获得更高的文件读写性能,直接绕开glib使用系统调用是一个比较好的办法。
我们也可以使用Linux的man命令查看每个系统调用的详细说明,如: man 2 read
。
系统调用完成了应用程序和内核交流的工作,事实上,包括Linux,大部分操作系统的系统调用都有两个特点:
为了解决这个问题,“万能法则”就可以发挥作用了。“解决计算机的问题可以通过增加层来实现”,于是运行库挺身而出,它作为系统调用与程序之间的一个抽象层可以保持着这样的特点:
运行时库将不同的操作系统的系统调用包装为统一固定的接口,使得同样的代码,在不同的操作系统下都可以直接编译,并产生一致的效果。这就是源代码级上的可移植性。但是运行库也有运行库的缺陷,比如C语言的运行库为了保证多个平台之间能够相互通用,于是它只能取各个平台之间功能的交集。比如linux和window都有文件读写,那么运行库就可以拥有文件读写功能;但window原生支持图形和用户交流系统,而linux不是原生支持,所以CRT(C Runtime Library)要把这一部分功能去掉。
现代的CPU常常可以在多种截然不同的特权级别下执行指令,在现代操作系统中,通常也据此有两种特权级别,分别为用户模式(User Mode)和内核模式(Kernel Mode),也被称为用户态和内核态。由于有多种特权模式的存在,操作系统就可以让不同的代码运行在不同的模式上,以限制它们的权力,提供稳定性和安全性。
一般来说,运行在高特权级的代码将自己降低至低特权级是允许的,但是反过来低特权级的代码将自己提升至高特权级则不是轻易就能进行的。在将低特权级的环境转为高特权级时,需要使用一种较为受控和安全的形式,以防止低特权级模式的代码破坏高特权模式代码的执行。
系统调用是运行在内核态的,而应用程序基本都是运行在用户态的。用户态的程序如何运行内核态的代码呢?操作系统一般是通过中断(Interrupt)来从用户态切换到内核态。什么是中断呢?中断是一个硬件或软件发出的请求,要求CPU暂停当前的工作转手去处理更加重要的事情。举个例子,当你打字敲击键盘的时候,CPU如何获知这一点呢?一种方法称为轮询(Poll),即每隔一小段时间CPU去询问键盘是否有键被按下,但是这样大部分时间得到的都是“没有被按下”的回应,这操作就白白浪费资源。另一种就是CPU不理睬键盘,而当键盘被按下时,键盘上的芯片发出一个信号给CPU,CPU接受到信号之后就知道键盘被按下了,然后再去询问键盘被按下的键是哪个。这样的信号就是一种中断。
中断一般具有两个属性,一个称为中断号(从0开始),一个称为中断处理程序(Interrupt Service Routine, ISR)。不同的中断具有不同的中断号,而同时一个中断处理程序一一对应一个中断号。在内核中,有一个数组称为中断向量表(Interrupt Vector Table),这个数组的第n项包含了指向第n号中断的中断处理程序的指针。当中断到来时,CPU会暂停当前执行的代码,根据中断的中断号,在中断向量表中找到对应的中断处理程序,并调用它。中断处理程序执行完成之后,CPU会继续执行之前的代码。
通常意义上,中断有两种类型,一种称为硬件中断,这种中断来自于硬件的异常或其它事件的发生,如电源掉电、磁盘被按下等。另一种称为软件中断,软件中断通常是一条指令,带有一个参数记录中断号,使用这条指令用户可以手动触发某个中断并执行其中断处理程序。例如i386下,int 0x80
这条指令会调用第0x80号中断的处理程序。
由于中断号是很有限的,操作系统不会舍得用一个中断号来对应一个系统调用,而更倾向于用一个或少数几个中断号来对应所有的系统调用。
以下是以fork为例的Linux系统调用的执行流程,基本可以分为三个步骤:触发中断、切换堆栈、中断处理程序。
触发中断
当程序在代码里调用一个系统调用时,是以一个函数的形式调用的。首先会保存现场,以便恢复;然后将系统调用的接口号(__NR_fork等)存放到指定的寄存器,将调用函数用到的参数放入参数寄存器;最后开始执行中断指令(int 0x80
),特权状态切换到内核态,CPU便开始查找中断向量表中的0x80号元素。
切换堆栈
在实际执行中断向量表的第0x80号元素所对应的函数之前,CPU首先还要进行栈的切换。在Linux中,用户态和内核态使用的是不同的栈,两者各自负责各自的函数调用,互不干扰。但在应用程序调用0x80号中断时,程序的执行流程从用户态切换到内核态,这时程序的当前栈必须也相应地从用户栈切换到内核栈。从中断处理函数中返回时,程序的当前栈还要从内核栈切换回用户栈。
所谓的当前栈,指的是ESP
寄存器的值所在的栈空间。如果ESP
位于用户栈的范围内,那么程序的当前栈就是用户栈,反之亦然。此外,寄存器SS
的值还应该指向当前栈所在的值。所以,将当前栈从用户栈切换为内核栈的实际行为是:
将当前栈从内核栈切换为用户栈的实际行为:
中断处理程序
在int
指令合理地切换了栈之后,程序的流程就切换到了中断向量表中记录的0x80号中断处理程序system_call
,中断处理程序system_call
会带上系统调用的接口号,在系统调用表中查找对应的系统调用函数,然后将控制流程跳到系统函数的执行逻辑。