系统调用
概述
系统调用就是内核提供给用户的一组接口,通过这组接口,用户可以受限地访问硬件设备或者操作系统的其他资源。通过系统调用,应用程序可以主动地从用户态 陷入 内核态,进而获得更高地权限,访问操作系统更多的资源。用户态转换为内核态的方法为:
- 外部中断(外部输入设备中断,例如硬盘的读写)
- 异常(缺页异常,操作异常如除0)
- 系统调用 (后面会详细介绍)
为了保证系统的稳定和安全性,操作系统将程序运行状态分为 用户态 和 内核态。
内核态
大多数系统交互式操作需求在内核态执行。如设备IO操作或者进程间通信。(特权指令:一类只能在核心态下运行而不能在用户态下运行的特殊指令)。不同的操作系统特权指令会有所差异,但是一般来说主要是和硬件相关的一些指令。
用户态
用户程序只在用户态下运行,有时需要访问系统核心功能,这时通过系统调用接口使用系统调用。应用程序有时会需要一些危险的、权限很高的指令,如果把这些权限放心地交给用户程序是很危险的(比如一个进程可能修改另一个进程的内存区,导致其不能运行),但是又不能完全不给这些权限。
于是有了系统调用,危险的指令被包装成系统调用,用户程序只能调用而无权自己运行那些危险的指令。另外,计算机硬件的资源是有限的,为了更好的管理这些资源,所有的资源都由操作系统控制,进程只能向操作系统请求这些资源。操作系统是这些资源的唯一入口,这个入口就是系统调用。
库函数
系统调用是一组 api 但是他和库函数不同,系统调用是操作系统提供,不同的操作系统系统调用不同,而库函数是语言提供,例如C语言库函数,或者是系统库。语言库可以屏蔽不同操作系统的差异,封装出统一的接口,使得程序具有可移植性。
库函数主要由两方面提供:一是操作系统提供的;另一类是由第三方提供的(语言库函数)。
系统提供的这些函数把系统调用进行封装或者组合,可以实现更多的功能,这样的库函数能够实现一些对于内核来说比较复杂的操作。比如read函数根据参数,直接就能读文件,而背后隐藏的文件比如在那个磁道,那个扇区,加载到那个内存,是程序员不必关心的问题。这些操作里面也包含了系统调用。比如write()这个系统函数,会调用同名的系统调用,来完成写入操作。
对于第三方库,其实和系统库一样,只是他直接利用系统调用的可能性要小一些,而是系统提供的 API 接口来是实现。比如printf,实际上调用了write()这个系统函数。 第三方库函数大部分是对系统函数的封装。
系统调用号
在 Linux 系统中每个系统调用都被赋予一个系统调用号。这样通过这个独一无二的号就可以关联到系统调用。当用户空间执行一个系统调用的时候,这个系统调用号指明到底是要执行哪个系统调用,进程不会提及系统调用的函数名称(这个在后续会用)
一个系统调用号相当重要,一旦确定,以后都不能更改,否则编译好的程序就会崩溃。如果一个系统调用被删除,它所占用的系统调用号也不能被回收利用,否则以前编译过的代码会调用这个系统调用而实际上它掉用的却是另一个。Linux有一个 sys_ni_syscall()
用于作为未实现的系统调用号的默认函数,填补空缺,它只返回一个 -ENOSYS 不做任何操作。
内核通过 sys_call_table 记录所有已经注册过的系统调用列表。这个表为每个有效的系统调用指定了唯一的系统调用号。
原理
用户空间无法直接执行内核代码,他们不能直接调用内核空间的函数,更不能直接操作内核地址空间,这都是出于系统稳定全的考虑。所以应用程序应该以某种方式告诉内核自己要执行一个系统调用,希望系统切换到内核态,这样内核就可以代替应用程序在内核空间执行系统调用。
通知机制是靠软中断实现的。前面说了用户态->内核态切换的三种方式,外中断 异常 和系统调用,系统调用是一种特殊的中断类型(软中断)。
在x86的机器中,用一个8bit的数字(0~255)来区分各种中断,这个数字被称为中断向量(vector)。其中一个中断向量,即128 (0x80),专门被用于执行系统调用。在Linux系统中,存有一个系统表,叫做Interrupt DescriptorTable (中断向量表),简称IDT。IDT表共有256项,存放了从中断向量到相应处理例程(interrupt or exceptionhandler)的映射关系。当某个中断发生时,CPU从IDT表中查找到相应的处理例程的地址来执行。而第128号中断处理程序 即 system_call()
确定系统调用
通过上述可以看出,所有的系统调用都是使用相同的方式陷入内核,即触发 128 软中断进入内核,根据中断向量表找到 system_call 函数来处理具体的系统调用。
但是仅仅是这不够,用户还得告诉内核调用的是那个系统调用函数,因此,还要将系统调用号写入 exa 寄存器,即通过exa寄存器告诉具体的系统调用号。system_call()
函数通过将给定的系统调用号与 NR_syscalls 做比较检查有效性,如果有效 就通过 系统调用号 找到对应的 系统调用函数的地址。
参数传递
找到了具体的系统调用函数,通常系统调用函数还有输入输出参数。所以,在发生陷入的时候,应该把这些参数从用户空间传给内核,简单的方法是和系统调用号一样,把这些参数也存放在寄存器中。在x84-32系统上,ebx ecx edx 和 edi 寄存器存放前五个参数,需要六个以上的参数,应该通过一个单独的寄存器存放指向所有这些参数在用户空间的地址。
给用户的返回值也通过寄存器传递,在x86系统上,它存放在 eax 寄存器中
系统调用流程
当系统调用发生时,通过中断机制,系统调用例程system_call被调用。它的执行过程大概分为4个步骤:
- 从寄存器中取出系统调用号和输入参数,然后将这些寄存器的值压入kernel栈中。
- 根据系统调用号查找系统调用分派表(system call dispatch table),找到系统调用服务例程(一个内核函数)
- 调用查到的系统调用服务例程。
- 将系统调用服务例程的返回值出栈,重新保存在寄存器中。
上面描述的系统调用例程system_call在kernel空间中执行。在执行前,系统调用号和输入参数已经存入了寄存器,这个存入过程由user空间的代码完成。实际上,如同第一节所讲,每个真正的系统调用基本上都有一个封装它的库函数,一般是在这个库函数中完成系统调用号和输入参数的保存动作。当系统调用例程system_call执行完毕后,返回值通过寄存器再传回user空间的库函数。
新增一个系统调用
….