系统调用在用户空间与硬件设备之间添加一个中间层。该中间层主要作用有三个。第一,它为用户空间提供了一种硬件的抽象接口。第二,系统调用保证了系统的稳定和安全。第三,每个进程都运行在虚拟的系统中,而在用户空间和系统的其余部分提供这样一层公共接口,也是处于这种考虑。在Linux世界中,系统调用是用户空间访问内核的惟一手段;除异常和陷入外,它们是内核惟一的合法入口。x86系统上,Linux系统大约有250个左右系统调用。
API、POSIC和C库
一般情况下,应用程序通过应用编程接口(API)而不是直接通过系统调用来编程。这点很重要,因为应用程序使用的这种编程接口实际上并不需要和内核提供的系统调用对应。一个API定义了一组应用程序使用的编程接口。它们可以实现一个系统调用,也可以通过调用多个系统调用来实现,而完全不使用任何系统调用也可以。
在UNIX世界中,最流行的应用编程接口是基于POSIX(PortableOperatingSystem Interface)标准的。Linux系统是与POSIX兼容的。POSIX是说明API和系统调用之间关系的一个极好例子。在大多数Unix系统上,根据POSIX而定义的API函数和系统调用之间有着直接联系。
Linux的系统调用和大多数Unix系统一样,作为C库的一部分提供。C库提供了POSIX的绝大部分API。从程序员的角度来看,系统调用无关紧要;他们只需要跟API打交道就可以了。相反,内核只是跟系统调用打交道;库函数及应用程序是怎么使用系统调用不是内核所关心的。但是,内核必须时刻牢记系统调用所有潜在的用途并保证它们有良好的通用性和灵活性。
Figure 5.1. The relationship between applications, the C library, and the kernel with a call toprintf().
关于Unix的界面设计有一句通用的格言“提供机制而不是策略”。换句话说,Unix的系统调用抽象出了用于完成某种确定目的的函数。至于这些函数怎么用完全不需要内核去关心。区别对待机制(mechanism)和策略(policy)是Unix设计中的一大亮点。大部分的编程问题都可以被分割为两个部分:“需要提供什么功能(机制)”和“怎样去实现(策略)”。如果由程序中的独立部分分别负责机制和策略的实现,那么开发软件就更容易,也更容易适应不同的需求。
系统调用
系统调用(在Linux中常称作syscalls,man 2 syscalls)通常通过函数进行调用。它们通常都需要定义一个或几个参数,而且可能产生一些副作用。在Linux中,每个系统调用被赋予一个系统系统调用号。这样通过这个系统调用号就可以关联系统调用。系统调用号非常关键,一旦分配就不能再有任何变更,否则编译好的应用程序就会崩溃。此外,如果一个系统调用被删除,它所占用的系统调用号也运行回收利用。否则,以前编译过的代码会调用这个系统调用,但事实上却调用的另外一个系统调用。
由于Linux系统的上下文切换时间非常短,进出内核都被优化的简洁高效,所以系统调用在Linux中执行的非常快。
系统调用处理程序
用户空间的程序无法直接执行内核代码。它们不能直接调用内核空间的函数,因为内核驻留在受保护的地址空间上。如果进程可以直接在内核空间上读写的话,系统安全就会失去控制。所以,应用程序应该以某种方式通知系统,告诉内核自己需要执行一个系统调用,希望系统切换到内核态,这样内核就能代表应用程序来执行该系统调用了。通知内核的机制是靠软中断实现的:通过引发一个异常来促使系统切换到内核态去执行异常处理程序。此时的异常处理程序实际上就是系统调用程序。在x86系统上的软中断由int $0x80指令产生。这条指令会触发一个异常导致系统切换到内核态并执行第128号异常处理程序,而该程序正是系统调用处理程序。这个处理程序的名字是system_call(),它与硬件体系结构紧密相关,通常在entry.S文件中用汇编语言编写。
因为所有的系统调用陷入内核的方式都一样,所以仅仅是陷入内核空间是不够的。因此必须把系统调用号一并传给内核。在x86系统中,系统调用号是通过eax寄存器传递给内核的。在陷入内核之前,用户空间就把相应的系统调用所对应的号放入eax中了。system_call()函数通过将给定的系统调用号与NR_syscalls做比较来检查其有效性。如果它大于或者等于NR_syscalls,该函数就返回-ENOSYS,否则执行相应的系统调用。
除了系统调用号之外,大部分系统调用都还需要一些外部的参数输入。所以,在发生异常的时候,应该把这些参数从用户空间传递给内核。最简单的办法就是像传递系统调用号一样,把这些参数也存放在寄存器中。在x86系统中,ebx、ecx、edx、esi和edi按照顺序存放前五个参数。如果参数多于五个就需要使用一个寄存器来保存所有这些参数在用户空间的地址。给用户空间返回值也是通过寄存器传递。在x86系统上,它存放在eax寄存器中。
系统调用的实现
给Linux添加一个新的系统调用并不是一件困难的事情,怎样设计和实现一个系统调用是难题所在。系统调用必须仔细检查它们所有的参数是否合法有效。
内核必须保证:
- 指针指向的内存区域属于用户空间。进程决不能哄骗内核去读内核空间的数据。
- 指针指向的内存区域在进程的地址空间中。进程决不能哄骗内核去读其它进程的数据。
- 如果是读,该内存应被标记为可读。如果是写,该内存应被标记为可写。进程决不能绕过内存访问限制。
另外,必须检查是否有合法的权限。举个例子,capable(CAP_SYS_NICE)可以检查调用者是否有权限变更其它进程的nice值。
建立一个新的系统调用的好处:
- 系统调用创建容易使用方便。
- Linux系统调用的高性能显而易见。
问题是:
- 需要一个系统调用号,而这需要在一个内核处于开发版本的时候就由官方分配给你。
- 系统调用被加入稳定内核之后就被固化了,为了避免应用程序的崩溃,它的界面不允许做改动。
- 需要将系统调用分别注册到每个需要支持的体系结构中去。
- 在脚本中不容易使用系统调用,也不能从文件系统直接访问系统调用。
- 如果仅仅进行简单的信息交换,系统调用就大才小用了。
替代方法:
- 创建一个设备节点,通过read()和write()访问它。用ioctl()进程特别的设置操作和获取特别的信息。
- 一些接口如信号量,可以通过文件描述符表示以进行操作。像信号量这样的某些接口,可以用文件描述符来表示,因此就可以按上述方式对其进行操作。
- 把增加的信息作为一个文件放在sysfs的合适位置。
小结
描述了系统调用到底是什么,它们与库函数和应用程序接口(API)有怎样的关系。然后考察了Linux内核如何实现系统调用,以及执行系统调用的连锁反应:陷入内核,传递系统调用号和参数,执行正确的系统调用函数,并把返回值带回用户空间。
参考资料:
《Linux内核设计与实现》,第二版