第5章系统调用
在现代操作系统中,内核提供了用户进程与内核交互的一组接口。这些接口让应用程序受限地访问硬件设备,提供了创建新进程并与已有进程进行通信的机制,也提供了申请操作系统其它资源的能力。这些接口在应用程序和内核之间扮演了使者的角色,应用程序发出各种请求,而内核负责满足这些请求。
5.1 与内核通信
系统调用在用户空间进程和硬件设备之间添加了一个中间层。该层主要作用有三个。首先,为用户空间提供一种硬件的抽象接口。例如,当读文件时,应用程序可以不管磁盘类型和介质,甚至不用去管文件所在的文件系统是哪种类型。第二,系统调用保证系统的稳定性和安全。作为硬件设备和应用程序之间的中间人,内核可以基于权限、用户类型和其他一些规则对需要进行的访问进行裁决。例如,这样可以避免应用程序不正确地使用硬件设备,窃取其他进程资源,或做出其他危害系统的事情。第三,每个进程都运行在虚拟系统中,而在用户空间和系统的其余部分提供这样一层公共接口,也是出于这种考虑。如果应用程序可以随意访问硬件而内核一无所知的话,几乎就没法实现多任务和虚拟内存,也不可能实现良好的稳定性和安全性。在Linux中,系统调用是用户空间访问内核的唯一手段;除异常和陷入外,是内核唯一的合法入口。实际上,其他的像设备文件和/proc之类的方式,最终也是通过系统调用进行访问的。
5.2 API、POSIX(可移植操作系统接口)和C库
一般情况下,应用程序通过在用户空间实现的应用编程接口来编程。因为应用程序使用的这种编程接口实际上并不需要和内核提供的系统调用对应。一个API定义了一组应用程序使用的编程接口。它们可以实现成一个系统调用,也可以通过调用多个系统调用来实现,而完全不使用任何系统调用也不存在问题。API可以在各种不同的操作系统上实现,给应用程序提供完全相同的接口,而它们本身在这些系统上的实现却可能不同。图5-1给出POSIX、API、C库以及系统调用之间的关系。
在Unix中,应用编程接口是基于POSIX(可移植操作系统接口)标准的。从技术角度来看,POSIX是由IEEE的一组标准组成,其目标是提供一套大体上基于Unix的可移植操作系统标准。POSIX是说明API和系统调用之间关系的一个极好例子。在大多数Unix系统上,根据POSIX定义的API函数和系统调用之间有着直接关系。
Linux的系统调用像大多数Unix系统一样,作为C库的一部分提供。C库实现了Unix系统的主要API,包括标准C库函数和系统调用接口。所有的C程序都可以使用C库,此外,C库提供了POSIX的绝大部分API。
从程序员角度来看,系统调用无关紧要,只需要跟API打交道就可以了。内核只跟系统调用打交道;库函数及应用程序怎么使用系统调用,不是内核关心的。关于Unix的接口是提供机制而不是策略。Unix的系统调用抽象出了用于完成某种确定的目的函数。至于这些函数怎么用完全不需要内核去关心。
5.3系统调用
要访问系统调用,通常通过C库中定义的函数调用来进行。通常都需要定义0个、1个或几个参数(输入)而且可能产生一些副作用。系统调用还会通过一个long类型的返回值来表示成功或者错误。通常,但也不绝对,用一个负的返回值来表明错误。返回一个0值通常表明成功。系统调用在出现错误的时候C库会把错误码写入errno全局变量。通过调用perror()库函数,可以把该变量翻译成用户可以理解的错误字符串。
系统调用具有一种明确的操作。例如getpid()系统调用,根据定义会返回当前进程的PID。内核中的实现如下:
kernel/timer.c
/**
* sys_getpid - return the thread group id of the current process
*
* Note, despite the name, this returns the tgid not the pid. The tgid and
* the pid are identical unless CLONE_THREAD was specified on clone() in
* which case the tgid is the same in all threads of the same group.
*
* This is SMP safe as current->tgid does not change.
*/
SYSCALL_DEFINE0(getpid)
{
return task_tgid_vnr(current);
}
注意:SYSCALL_DEFINE0只是一个宏,定义一个无参数的系统调用(因此这里数字为0),展开后的代码如下:
include/linux/syscalls.h
asmlinkage long sys_getpid(void);//函数原型声明
如何定义系统调用。首先,函数声明中的asmlinkage限定词,是一个编译指令,通知编译器仅从栈中提取该函数的参数。其次,函数返回long。为了保证32位和64位系统的兼容,系统调用在用户空间和内核空间有不同的返回值类型,在用户空间为int,在内核空间为long。最后,系统调用getpid()在内核中被定义为sys_getpid()。这是Linux中所有系统调用都应该遵守的命名规则,系统调用bar()在内核中也实现为sys_bar()函数。
1、系统调用号
在Linux中,每个系统调用被赋予一个系统调用号,通过这个系统调用号关联系统调用。当用户空间的进程执行一个系统调用时,这个系统调用号指明要执行哪个系统调用;进程不会提及系统调用的名称。
系统调用号相当重要,一旦分配不能变更,否则编译好的应用程序会崩溃。此外,如果一个系统调用被删除,它所占用的系统调用号也不允许被回收利用,否则,以前编译过的代码会调用这个系统调用,但事实上却调用的是另一个系统调用。Linux有一个未实现系统调用sys_ni_syscall(),它除了返回-ENOSYS外不做任何其他工作,这个错误号是专门针对无效的系统调用而设的。但如果一个系统调用被删除,或者变得不可用,这个函数负责填补空缺。
内核记录系统调用表中的所有已注册过的系统调用的列表,存储在sys_call_table中。每一种体系结构中,都明确定义这个表,在x86-64中,定义在arch/x86/kernel/syscall_64.c文件中。这个表为每一个有效的系统调用指定唯一的系统调用号。
2、系统调用的性能
Linux系统调用比其他许多操作系统执行得要快。Linux很短的上下文切换时间是一个原因,进出内核都被优化得简洁高效。另外一个原因是系统调用处理程序和每个系统调用本身都非常简洁。
5.4 系统调用处理程序
用户空间的程序无法直接执行内核代码,不能直接调用内核空间中的函数,因为内核驻留在受保护的地址空间上。如果进程可以直接在内核的地址空间上读写的话,系统的安全性和稳定性将不存在。
应用程序应该以某种方式通知系统,告诉内核自己需要执行一个系统调用,希望系统切换到内核态,内核就可以代表应用程序在内核空间执行系统调用。
通知内核的机制是靠软中断实现的:通过引发一个异常来促使系统切换到内核态去执行异常处理程序。此时的异常处理程序是系统调用处理程序。不管系统调用处理程序被如何调用,用户空间引起异常或陷入内核是一个重要的概念。
1、指定恰当的系统调用
因为所有的系统调用陷入内核的方式都一样,仅仅是陷入内核空间是不够的。必须把系统调用号一并传给内核。
system_call()函数通过将给定的系统调用号与NR_syscalls做比较来检查其有效性。如果大于或等于NR_syscalls,该函数就返回-ENOSYS。否则,执行相应的系统调用:
call *sys_call_table(,%rax,8)
2、参数传递
除系统调用号外,大部分系统调用还需要一些外部的参数输入。在发生陷入时,把这些参数从用户空间传给内核。最简单的办法就是像传递系统调用号一样,把这些参数放在寄存器里。给用户空间的返回值也通过寄存器传递。
5.5 系统调用的实现
一个Linux的系统调用在实现时并不需要关心它和系统调用处理程序之间的关系。实现Linux系统调用所需的步骤:
1、实现系统调用
实现一个新的系统调用第一步是决定它的用途。它要做些什么?每个系统调用都应该有一个明确的用途。Linux中不提倡采用多用途的系统调用(一个系统调用通过传递不同的参数值来选择完成不同的工作)。
新系统调用的参数、返回值和错误码该是什么?系统调用的接口应该简洁,参数尽可能少。系统调用的语义和行为非常关键;因为应用程序依赖它们,所以它们应力求稳定,不做改动。设计接口时要尽量为将来多做考虑。系统调用设计得越通用越好。Unix是提供机制而不是策略。当写一个系统调用时,要时刻注意可移植性和健壮性,要考虑当前和将来。
2、参数验证
系统调用必须检查所有的参数是否合法有效。系统调用在内核空间执行,如果任由用户将不合法的输入传递给内核,那么系统的安全和稳定面临极大的考验。
例如,与文件IO相关的系统调用必须检查文件描述符是否有效。与进程相关的函数必须检查提供的PID是否有效。必须检查每个参数,保证不但合法有效,而且正确。进程不应当让内核去访问该进程无权访问的资源。
最重要的一种检查是检查用户提供的指针是否有效。在接收一个用户空间的指针之前,内核必须保证:
指针指向的内存区域属于用户空间。进程不能哄骗内核去读内核空间的数据。
指针指向的内存区域在进程的地址空间里。进程不能哄骗内核去读其他进程的数据。
如果是读,该内存应被标记为可读;如果是写,该内存应被标记为写;如果是可执行,该内存被标记为可执行。进程决不能绕过内存访问限制。
内核提供两个方法来完成必须的检查和内核空间与用户空间之间数据的来回拷贝。注意:内核无论何时都不能轻率地接受来自用户空间的指针,这两个方法中必须经常有一个被使用。
为了向用户空间写入数据,内核提供copy_to_user(),需要三个参数。第一个参数是进程空间中的目的内存地址,第二个是内核空间内的源地址,最后一个参数是需要拷贝的数据长度。
为了从用户空间读取数据,内核提供copy_from_user()。这个函数把第二个参数指定的位置上的数据拷贝到第一个参数指定的位置上,拷贝的数据长度由第三个参数决定。
如果执行失败,这两个函数返回的都是没能完成拷贝的字节数。如果成功,则返回0。当出现错误时,系统调用返回标志
-EFAULT。
5.6 系统调用上下文
内核在执行系统调用时处于进程上下文。current指针指向当前任务,即引发系统调用的那个进程。
在进程上下文中,内核可以休眠并且可以抢占。首先,能够休眠说明系统调用可以使用内核提供的绝大部分功能。休眠的能力会给内核编程带来极大便利。在进程上下文中能够被抢占表明,当前的进程可以被其他进程抢占。因为新的进程可以使用相同的系统调用,所以必须小心,保证系统调用是可重入的。这也是在SMP中必须同样关心的问题。
当系统调用返回时,控制权仍然在system_call()中,它最终会负责切换到用户空间,并让用户进程继续执行下去。
1、绑定一个系统调用的最后步骤
1)首先,在系统调用表的最后加入一个表现。每种支持该系统调用的硬件体系都必须做这样的工作。从0开始算起,系统调用在该表中的位置是它的系统调用号。如第10给系统调用分配到的系统调用号为9。
2)对于所支持的各种体系结构,系统调用号都必须定义在<asm/unistd.h>中。
3)系统调用必须被编译进内核映像。这只要把它放进kernel/下的一个相关文件中就可以了,比如sys.c,它包含了各种各样的系统调用。
通过一个虚构的系统调用foo()来观察一下步骤。首先,要把sys_foo加入到系统调用表中。对于大多数体系结构,该表位于entry.S文件中,形式如下:
ENTRY(sys_call_table)
.long sys_restart_syscall /* 0 */
.long sys_exit
.long sys_fork
.long sys_read
.long sys_write
.long sys_open /* 5 */
.long sys_close
.long sys_waitpid
.long sys_creat
.long sys_link
.long sys_unlink /* 10 */
...........................
.long sys_inotify_init1
.long sys_preadv
.long sys_pwritev /* 335 */
.long sys_rt_tgsigqueueinfo
.long sys_perf_event_open
.long sys_recvmmsg
把新的系统调用加到这个表的末尾
.long sys_foo
虽然没有明确指定编号,但加入的这个系统调用被按照次序分配给339这个系统调用号。对于每种体系需要支持的体系结构,都必须将自己的系统调用加入到其系统调用表中去。每种体系结构不需要对应相同的系统调用号。系统调用号是专属于体系结构ABI的部分。通常,需要让系统调用适应每种体系结构。注意一下,每隔5个表项就加入一个调用号注释的习惯,可以在查找系统调用对应的调用号时提供方便。
然后,将系统调用号加入到<asm/unistd.h>中,格式如下:
/*
* This file contains the system call numbers.
*/
#define __NR_restart_syscall (__NR_SYSCALL_BASE+ 0)
#define __NR_exit (__NR_SYSCALL_BASE+ 1)
#define __NR_fork (__NR_SYSCALL_BASE+ 2)
#define __NR_read (__NR_SYSCALL_BASE+ 3)
#define __NR_write (__NR_SYSCALL_BASE+ 4)
#define __NR_open (__NR_SYSCALL_BASE+ 5)
#define __NR_close (__NR_SYSCALL_BASE+ 6)
/* 7 was sys_waitpid */
.......................................................................................................
#define __NR_preadv (__NR_SYSCALL_BASE+361)
#define __NR_pwritev (__NR_SYSCALL_BASE+362)
#define __NR_rt_tgsigqueueinfo (__NR_SYSCALL_BASE+363)
#define __NR_perf_event_open (__NR_SYSCALL_BASE+364)
#define __NR_recvmmsg (__NR_SYSCALL_BASE+365)
然后,在该列表中加入这行:
#define __NR_foo 366
最后,来实现foo()系统调用。无论何种配置,该系统调用都必须编译到核心的内核映象中去,在这个例子中把它放进kernel/sys.c文件中。也可以将其放到与其功能联系最紧密的代码中去,假如功能与调度相关,也可以把它放到kernel/sched.c中。
这样,现在就可以启动内核并在用户空间调用foo()系统调用了。
2、从用户空间访问系统调用
通常,系统调用靠C库支持。用户程序通过包含头标准头文件和C库链接,就可以使用系统调用。但如果仅仅写出系统调用,glibc库恐怕并不提供支持。
Linux本身提供一组宏,用于直接对系统调用进行访问。它会设置好寄存器并调用陷入指令。这些宏是_syscalln(),其中n的范围从0到6,代表需要传递给系统调用的参数个数,这是由于该宏必须了解到底有多少参数按照什么次序压入寄存器。例如,open()系统调用的定义是:
long open(const char *filename, int flags, int mode)
而不靠库支持,直接调用系统调用的宏的形式为:
#define NR_open 5
_syscall3(long, open, const char *, filename, int, flags, int, mode)
这样,应用程序就可以直接使用open()。
对于每个宏来说,都有2+2xn个参数。第一个参数对应着系统调用的返回值类型。第二个参数是系统调用的名称。再以后是按照系统调用参数的顺序排列的每个参数的类型和名称。
_NR_open在<asm/unistd.h>中定义,是系统调用号。该宏会被扩展成为内嵌汇编的C函数;由汇编语言执行前面所讨论的步骤,将系统调用号和参数压入寄存器并触发软中段来陷入内核。调用open()系统调用直接把上面的宏放置在应用程序中就可以了。
例如,写一个宏来使用前面编写的foo系统调用以及测试代码。
3、为什么不通过系统调用的方式实现
建立一个新的系统调用的好处:系统调用创建容易且使用方便;Linux系统调用的高性能显而易见。
问题是:
需要一个系统调用号,而这需要一个内核在处于开发版本时由官方分配。
系统调用被加入稳定内核后就被固化了,为了避免应用程序的崩溃,它的接口不允许做改动。
需要将系统调用分别注册到每个需要支持的体系结构中去。
在脚本中不容易调用系统调用,也不能从文件系统直接访问系统调用。
由于需要系统调用号,在主内核树之外是很难维护和使用系统调用的。
替代方法:
实现一个设备节点,并对此实现read()和write()。使用ioctl()对特定的设置进行操作或者对特定的信息进行检索。
例如信号量的接口,可以用文件描述符来表示,可以按照上述方式对其进行操作。
把增加的信息作为一个文件放在sysfs的合适位置。
新系统调用增添频率很低也反映出Linux是一个相对较为稳定并且功能已经较为完善的操作系统。