系统调用接口的主要任务是把进程从用户态切换到内核态。在具有保护机制的计算机系 统中,用户必须通过
软件中断
或陷阱
,才能使进程从用户态切换为内核态。系统调用通过软中断0x80陷入内核,跳转到系统调用处理程序system_call函数,并执行相应的服务例程(内核函数)。
《Linux内核修炼之道》第5章讲解系统调用,它是应用程序和内核间的桥梁,学习并理解它是我们走向内核的一个很好的过渡。
1、系统调用
一个稳定运行的Linux操作系统需要内核和用户应用程序之间的完美配合,
内核提供各种各样的服务
,然后用户应用程序通过某种途径使用这些服务
,进而契合用户的不同需求。用户应用程序访问并使用内核所提供的各种服务的途径即是
系统调用
。在内核和用户应用程序相交界的地方
,内核提供了一组系统调用接口
,通过这组接口,应用程序可以访问系统硬件和各种操作系统资源。
比如用户可以通过文件系统相关的系统调用,请求系统打开文件、关闭文件或读写文件;可以通过时钟相关的系统调用,获得系统时间或设置定时器等。内核提供的这组系统调用通常也被称之为
系统调用接口层
。系统调用接口层作为内核和用户应用程序之间的中间层,扮演了一个桥梁
,或者说中间人的角色
。系统调用把应用程序的请求传达给内核,待内核处理完请求后再将处理结果返回给应用程序。
1.1、用户程序如何进行系统调用
- 方式一:通过C库函数,C库函数封装了所有的系统调用。
- 方式二:2.6.18版本之前的内核可以使用_syscall宏。但是自2.6.19版本开始,_syscall宏被废除,我们需要使用syscall函数,通过指定
系统调用号
和一组参数来调用系统调用。
syscall函数原型为:
int syscall(int number, ...);
其中number是系统调用号
,number后面应顺序接上该系统调用的所有参数。
下面是gettid系统调用的调用实例。
00 #include <unistd.h>
01 #include <sys/syscall.h>
02 #include <sys/types.h>
03
04 #define __NR_gettid 224
05
06 int main(int argc, char *argv[])
07 {
08 pid_t tid;
09
10 tid = syscall(__NR_gettid); //括号内参数直接写224也可以
11 }
大部分系统调用都包括了一个SYS_符号常量来指定自己到系统调用号的映射,因此上面第10行可重写为:
tid = syscall(SYS_gettid);
1.2、为什么需要系统调用
- 为什么需要系统调用?主要有以下
两方面原因。
(1)系统调用可以为用户空间提供访问硬件资源的统一接口
,以至于应用程序不必去关注具体的硬件访问操作。比如,读写文件时,应用程序不用去管磁盘类型,甚至于不用关心是哪种文件系统。
(2)系统调用可以对系统进行保护,保证系统的稳定和安全
。系统调用的存在规定了用户进程进入内核的具体方式,换句话说,用户访问内核的路径是事先规定好
的,只能从规定位置进入内核,而不准许肆意跳入内核。有了这样的进入内核的统一访问路径限制才能保证内核的安全。
我们可以形象地描述这种机制:作为一个游客,你可以买票要求进入野生动物园,但你必须老老实实地坐在观光车上,按照规定的路线观光游览。当然,不准下车,因为那样太危险,不是让你丢掉小命,就是让你吓坏了野生动物。
1.3、系统调用执行过程——把进程从用户态切换到内核态
- 系统调用通过软中断0x80陷入内核,跳转到系统调用处理程序system_call函数,并执行相应的服务例程。
- 主要分为两个阶段:
1)通过软中断使进程从用户空间转换到内核空间。
如图所示,系统调用的执行需要一个用户空间到内核空间的状态转换,不同的平台具有不同的指令可以完成这种转换,这种指令也被称作操作系统陷入
(operating system trap)指令。
Linux通过软中断来实现这种陷入
,具体对于X86架构来说,是软中断0x80,也即int $0x80汇编指令
。软中断和我们常说的中断(硬件中断)不同之处在于-它由软件指令触发而并非由硬件外设引发。
int 0x80指令被封装在C库中,对于用户应用来说,基于可移植性的考虑,不应该直接调用int $0x80指令。陷入指令的平台依赖性,也正是系统调用需要在C库进行封装的原因之一。
通过软中断0x80,系统会跳转到一个预设的内核空间地址
,它指向了系统调用处理程序
(不要和系统调用服务例程相混淆),即在arch/i386/kernel/entry.S文件中使用汇编语言编写的system_call函数
。
2)system_call函数到系统调用服务例程。
所有的系统调用都会统一跳转到这个地址进而执行system_call函数
1、进入system_call函数前,用户应用将参数存放到对应寄存器中,system_call函数执行时会首先将这些寄存器压入堆栈。
2、软中断指令int 0x80执行时,系统调用号
会被放入eax寄存器,同时,系统调用表
sys_call_table每一项占用4个字节。这样,如上图所示,system_call函数可以读取eax寄存器获得当前系统调用的系统调用号,偏移地址=4x%eax
,然后以sys_call_table为基址,基址加上偏移地址
所指向的内容即是应该执行的系统调用服务例程(内核函数)的地址
。
3、对于系统调用服务例程,可以直接从system_call函数压入的堆栈中获得参数,对参数的修改也可以一直在堆栈中进行。在system_call函数退出后,用户应用可以直接从寄存器中获得被修改过的参数。
2、C库提供操作系统应用编程接口(API)
- 用户应用程序在
某些时候
可以直接通过系统调用来访问内核;但更多时候
, 应用程序是通过操作系统提供的应用编程接口(API——C库的函数)而不是直接通过系统调用来编程。 - 在UNIX世界里,最通用的操作系统API基于POSIX(Portable Operating System Interface of UNIX,可移植操作系统接口)标准。
- 即POSIX就是一种统一的标准的API编写规范。便于用户程序在各种不同的UNIX和LINUX操作系统下调用的API函数都能正常运行。
- 操作系统API的主要作用是把操作系统的功能完全展示出来,提供给应用程序,基于该操作系统,与文件、内存、时钟、网络、图形、各种外设等互操作的能力。此外,操作系统API通常还提供许多工具类的功能,比如操纵字符串、各种数据类型、时间日期等。
- 各种操作系统都会提供类似的C库,C库中的那些函数接口就是API。
3、C库函数(API)内部封装系统调用(函数)
- UNIX和LINUX操作系统API通常都以C库的方式提供。C库提供了POSIX的绝大部分API。
- 内核提供的每个系统调用在C库中都具有相应的
封装函数
。系统调用与其C库封装函数的名称常常相同,比如,read系统调用在C库中的封装函数即为read函数。当然,也会有挺多C库封装函数和系统调用名称不同,特别是存在多个C库封装函数内部封装了相同的系统调用的时候。 - 系统调用和C库函数之间并不是一一对应的关系。可能几个不同的函数会调用到同一个系统调用,即
多对一关系
,比如C库函数malloc和free都是通过brk系统调用来扩大或缩小进程的堆栈,execl、execlp、execle、execv、execvp和execve这些C库函数都是通过execve系统调用来执行一个可执行文件。也有可能一个函数调用多个系统调用,即一对多关系
。 - 更有些C库API函数并不依赖于任何系统调用,比如strcpy函数(复制字符串)和atoi函数(转换ASCII为整数),因为它们并不需要向内核请求任何服务。
- 实际上,从用户的角度看,系统调用和C库之间的区别并不重要,他们只需通过C库函数完成所需功能。相反,从内核的角度看,需要考虑的则是提供哪些针对确定目的的系统调用,并不需要关注它们如何被使用。
4、系统命令
- 系统命令利用C库实现的可执行程序,比如最为常用的ls、cd等命令。
- strace工具可以跟踪命令的执行,使用希望跟踪的命令为参数,并显示出该命令执行过程中所使用到的所有系统调用。比如,如果希望了解在执行pwd命令时都调用了哪些系统调用,可以使用下面的命令:
$strace pwd
结果会产生大量的信息,显示出pwd命令执行过程中所调用到的各个系统调用:
……
write(1, "/usr/src/linux-2.6.23\n", 22/usr/src/linux-2.6.23) = 22
close(1) = 0
munmap(0xb7f5a000, 4096) = 0
exit_group(0)
5、系统调用——>内核函数
- 内核函数与C库函数的区别仅仅是内核函数在内核实现,因此必须遵守内核编程的规则。
- 系统调用最终必须具有明确的操作。用户应用程序通过系统调用进入内核后,会执行各个系统调用对应的
内核函数,即系统调用服务例程
,比如系统调用getpid的服务例程是内核函数sys_getpid。 - 系统调用服务例程之外,内核中存在着大量的内核函数。有些局限于某个内核文件自己使用,有些则是export出来供内核其他部分共同使用。对于export出来的内核函数,可以使用ksyms命令或通过/proc/ksyms文件查看。
6、系统调用号——>系统调用表——>系统调用服务例程(内核函数)
6.1系统调用号
每个系统调用都拥有一个独一无二的系统调用号,用户应用程序通过它,而不是系统调用的名称,来指明要执行哪个系统调用。
系统调用号的定义在include/asm-i386/unistd.h文件。
008 #define __NR_restart_syscall 0
007 #define __NR_exit 1
009 #define __NR_fork 2
010 #define __NR_read 3
011 #define __NR_write 4
012 #define __NR_open 5 ——系统调用号
……
326 #define __NR_getcpu 318
327 #define __NR_epoll_pwait 319
328 #define __NR_utimensat 320 ——系统调用号
329 #define __NR_signalfd 321
330 #define __NR_timerfd 322
331 #define __NR_eventfd 323
332 #define __NR_fallocate 324
将其与系统调用表的定义相比较可以发现,每个系统调用号都依次对应了系统调用表中的某一项。内核正是将系统调用号作为下标去获取系统调用表中的服务例程函数地址。
系统调用号与系统调用为相依相生的关系,一旦分配就不能再有任何变更,即使该系统调用被删除,它所拥有的系统调用号也不能被回收利用。
6.2系统调用表sys_call_table
- 系统调用表集中存放了所有系统调用服务例程(内核函数)的地址,那么系统调用在内核中的执行就可以转化为从该表获取对应的服务例程并执行的过程。
- 系统调用表存储了所有系统调用对应的服务例程的函数地址,在arch/i386/kernel/syscall_table.S文件中被定义:
001 ENTRY(sys_call_table)
002 .long sys_restart_syscall /* 0 - old
"setup()" system call, used for restarting */
003 .long sys_exit
004 .long sys_fork
005 .long sys_read
006 .long sys_write
007 .long sys_open /* 5 ——系统调用号*/
……
320 .long sys_getcpu
321 .long sys_epoll_pwait
322 .long sys_utimensat /* 320 ——系统调用号*/
323 .long sys_signalfd
324 .long sys_timerfd
325 .long sys_eventfd
326 .long sys_fallocate
- 从中可发现两个特别之处。
首先,
所有系统调用服务例程的命名均遵守一定的规则,即在系统调用名称之前增加"sys_"前缀,比如open系统调用对应sys_open函数。
其次,
内核提供的系统调用数目非常有限,到2.6.23版本的内核也不过才达到仅仅325个,使用"man 2 syscalls"命令即可以浏览到所有系统调用的添加历史。 - 这也是系统调用与C库函数的区别之一:系统调用通常只提供最小的接口,C库函数则在此基础之上提供更多复杂的功能。
6.3系统调用服务例程
- 系统调用最终由系统调用服务例程完成明确的操作。所有的系统调用服务例程集中
声明函数原型在include/linux/syscalls.h文件
,但分散定义在很多不同的文件。比如getpid系统调用用于获取当前进程的PID,它的服务例程sys_getpid在kernel/timer.c文件中定义为:
954 asmlinkage long sys_getpid(void)
955 {
956 return current->tgid;
957 }
除了都具有
"sys_"前缀
之外,所有的系统调用服务例程命名与定义还必须遵守其他的一些规则。首先,函数定义中必须添加asmlinkage标记
,通知编译器仅从堆栈中获取该函数的参数。其次,必须返回一个long类型的返回值表示成功或错误
,通常返回0表示成功,返回负值表示错误。当然,getpid系统调用非常简单,不可能会失败,通过命令"man 2 getpid"可以查看它的手册,里面也明确指出了这一点。每个系统调用的系统调用号、命名以及操作目的都是固定的,但内核如何去实现并没有明确规定,不同版本、不同架构的内核实现都有可能会有所变化。