Contents
  1. 1. 简介
  2. 2. API和系统调用
  3. 3. 系统调用、用户编程接口(API)、系统命令、和内核函数的关系
  4. 4. 系统调用处理程序及服务例程
  5. 5. 参数传递

摘要:操作系统为在用户态运行的进程与硬件设备(如CPU、磁盘、打印机等等)进行交互提供了一组接口。在应用程序和硬件之间设置一个额外层具有很多优点。首先,这使得编程更加容易,那用户从学习硬件设备的低级编程特性中解放出来。其次,这极大地提高了系统的安全性,因为内核在试图满足某个请求之前在接口级就可以检查这种请求的正确性。最后,更重要的是这些接口使得程序更具有可移植性,因为只要内核所提供的一组接口相同,那么在任一内核上就可以正确地编译和执行程序。

简介

系统调用是在内核中实现的,再通过一定的方式把系统调用给用户,一般都通过门(gate)陷入(trap)实现。系统调用是用户程序和内核交互的接口。 

Linux系统在CPU的保护模式下提供了四个特权级别,目前内核都只用到了其中的两个特权级别,分别为“特权级0”和“特权级3”,[软间隔]级别0也就是我们通常所讲的内核模式,级别3也就是我们通常所讲的用户模式。划分这两个级别主要是对系统提供保护。内核模式可以执行一些特权指令和进入用[软间隔]户模式,而用户模式则不能。 

系统服务之所以需要通过系统调用提供给用户空间的根本原因是为了对系统“保护”,因为我们知道Linux的运行空间分为内核空间与用户空间,它们各自运行在不同的级别中,逻辑上相互隔离。所以用户进程在通常情况下不允许访问内核数据,也无法使用内核函数,它们只能在用户空间操作用户数据,调用户用空间函数。比如我们熟悉的“hello world”程序(执行时)就是标准的户空间进程,它使用的打印函数printf就属于用户空间函数,打印的字符“hello word”字符串也属于用户空间数据。

但是很多情况下,用户进程需要获得系统服务(调用系统程序),这时就必须利用系统提供给用户的“特殊”接口——系统调用了,它的特殊性主要在于规定了用户进程进入内核的具体位置;换句话说用户访问内核的路径是事先规定好的,只能从规定位置进入内核,而不准许肆意跳入内核。有了这样的陷入内核的统一访问路径限制才能保证内核安全无虞。我们可以形象地描述这种机制:作为一个游客,你可以买票要求进入野生动物园,但你必须老老实实的坐在观光车上,按照规定的路线观光游览。当然,不准下车,因为那样太危险,不是让你丢掉小命,就是让你吓坏了野生动物。

API和系统调用

应用编程接口(API)与系统调用有所不同,前者只是一个函数定义,说明了如何获得一个给定的服务;而后者是通过软中断向内核态发出一个明确的请求。

从编程者的观点看,API和系统调用之间的差别是没有关系的:唯一相关的事情就是函数名、参数类型及返回代码的含义。然而,从内核设计者的观点看,这种差别确实有关系,因为系统调用属于内核,而用户态的库函数不属于内核。

系统调用、用户编程接口(API)、系统命令、和内核函数的关系

系统调用并非直接和程序员或系统管理员打交道,它仅仅是一个通过软中断机制(我们后面讲述)向内核提交请求,获取内核服务的接口。而在实际使用中程序员调用的多是用户编程接口——API,而管理员使用的则多是系统命令。

用户编程接口其实是一个函数定义,说明了如何获得一个给定的服务,比如read()、malloc()、free()、abs()等。它有可能和系统调用形式上一致,比如read()接口就和read系统调用对应,但这种对应并非一一对应,往往会出现几种不同的API内部用到统一个系统调用,比如malloc()、free()内部利用brk( )系统调用来扩大或缩小进程的堆;或一个API利用了好几个系统调用组合完成服务。更有些API甚至不需要任何系统调用——因为它不必需要内核服务,如计算整数绝对值的abs()接口。

另外要补充的是Linux的用户编程接口遵循了在Unix世界中最流行的应用编程界面标准——POSIX标准,这套标准定义了一系列API。在Linux中(Unix也如此)这些API主要是通过C库(libc)实现的,它除了定义的一些标准的C函数外,一个很重要的任务就是提供了一套封装例程(wrapper routine)将系统调用在用户空间包装后供用户编程使用。

不过封装并非必须的,如果你愿意直接调用,Linux内核也提供了一个syscall()函数来实现调用,我们看个例子来对比一下通过C库调用和直接调用的区别。

例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <syscall.h>
#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>
int main(void) {
long ID1, ID2;
/*-----------------------------*/
/* 直接系统调用*/
/* SYS_getpid (func no. is 20) */
/*-----------------------------*/
ID1 = syscall(SYS_getpid);
printf ("syscall(SYS_getpid)=%ld\n", ID1);
/*-----------------------------*/
/* 使用"libc"封装的系统调用 */
/* SYS_getpid (Func No. is 20) */
/*-----------------------------*/
ID2 = getpid();
printf ("getpid()=%ld\n", ID2);
return(0);
}

系统命令相对编程接口更高了一层,它是内部引用API的可执行程序,比如我们常用的系统命令ls、hostname等。Linux的系统命令格式遵循系统V的传统,多数放在/bin和/sbin下(相关内容可看看shell等章节)。

有兴趣的话可以通过strace ls或strace hostname 命令查看一下它们用到的系统调用,你会发现诸如open、brk、fstat、ioctl 等系统调用被用在系统命令中。

下一个需要解释一下的问题是内核函数和系统调用的关系,内核函数大家不要想像的过于复杂,其实它们和普通函数很像,只不过在内核实现,因此要满足一些内核编程的要求[3]。系统调用是一层用户进入内核的接口,它本身并非内核函数,进入内核后,不同的系统调用会找到对应到各自的内核函数——换个专业说法就叫:系统调用服务服务例程。实际对请求服务的是内核函数而非调用接口。

比如系统调用 getpid实际就是调用内核函数sys_getpid。

1
2
3
4
5
6
7
asmlinkage long sys_getpid(void)
{
       return current->tpid;
}

Linux系统种存在许多的内核函数,有些是内核文件种自己使用的,有些则是可以export出来供内核其他部分共同使用的,具体情况自己决定。

内核公开的内核函数——export出来的——可以使用命令ksyms 或 cat /proc/ksyms来查看。另外网上还有一本归纳分类内核函数的书叫作《The Linux Kernel API Book》,有兴趣的读者可以去看看。

总而言之,从用户角度向内核看,依次是系统命令、编程接口、系统调用和内核函数。

系统调用处理程序及服务例程

当用户态的进程调用一个系统调用时,CPU切换到内核态并开始执行一个内核函数,在80x86体系结构中,可以用两种不同的方式调用Linux的系统调用。两种方式的最终结果都是跳转到所谓系统调用处理程序(systerm call handler)的汇编语言函数。

因为内核实现了很多不同的系统调用,因此进程必须传递一个名为系统调用号(systerm call number)的参数来识别所需的系统调用,eax寄存器就用作此目的。

所有的系统调用都返回一个整数值。这些返回值与封装例程返回值的约定是不同的。在内核中,正数或0表示系统调用成功结束,而负数表示一个出错条件。在后一种情况下,这个值就是存放在errno变量中必须返回给应用程序的负出错码。内核没有设置或使用errno变量,而封装例程从系统调用返回之后设置这个变量。

xyz()系统调用对应的服务例程的名字通常是sys_xyz()。不过也会有些例外。

Linux中实现系统调用利用了0x86体系结构中的软件中断[4]。软件中断和我们常说的中断(硬件中断)不同之处在于——它是通过软件指令触发而并非外设,也就是说又编程人员出发的一种异常,具体的讲就是调用int $0x80汇编指令,这条汇编指令将产生向量为128的编程异常。

之所以系统调用需要借助异常实现,是因为当用户态的进程调用一个系统调用时,CPU便被切换到内核态执行内核函数[5],而我们在i386体系结构部分已经讲述过了进入内核——进入高特权级别——必须经过系统的门机制,这里异常实际上就是通过系统门陷入内核(除了int 0x80外用户空间还可以通过int3——向量3、into——向量4 、bound——向量5等异常指令进入内核,而其他异常用户空间程序无法利用,都是由系统使用的)。

我们更详细的解释一下这个过程。int $0x80指令目的是产生一个编号为128的编程异常,这个编程异常对应的中断描述符表IDT中的第128项——也就是对应的系统门描述符。门描述符中含有一个预设的内核空间地址,它指向了系统调用处理程序:system_call()(别和系统调用服务程序混淆,这个程序在entry.S文件中用汇编语言编写)。

很显然所有的系统调用都会统一的转到这个地址,但Linux一共有2、3百个系统调用都从这里进入内核后又该如何派发它们到各自的服务程序去呢?别发昏,解决这个问题的方法非常简单:首先Linux为每个系统调用都进行了编号(0—NR_syscall),同时在内核中保存了一张系统调用表,该表中保存了系统调用编号和其对应的服务例程,因此在系统调入通过系统门陷入内核前,需要把系统调用号一并传入内核,在x86上,这个传递动作是通过在执行int0x80前把调用号装入eax寄存器实现的。这样系统调用处理程序一旦运行,就可以从eax中得到数据,然后再去系统调用表中寻找相应服务例程了。

除了需要传递系统调用号以外,许多系统调用还需要传递一些参数到内核,比如sys_write(unsigned int fd, const char * buf, size_t count)调用就需要传递文件描述符号fd和要写入的内容buf和写入字节数count等几个内容到内核。碰到这种情况,Linux会有6个寄存器使用来传递这些参数:eax (存放系统调用号)、 ebx、ecx、edx、esi及edi来存放这些额外的参数(以字母递增的顺序)。具体做法是在system_call( )中使用SAVE_ALL宏把这些寄存器的值保存在内核态堆栈中。

有始便有终,当服务例程结束时,system_call( ) 从eax获得系统调用的返回值,并把这个返回值存放在曾保存用户态 eax寄存器栈单元的那个位置上。然后跳转到ret_from_sys_call( ),终止系统调用处理程序的执行。

当进程恢复它在用户态的执行前,RESTORE_ALL宏会恢复用户进入内核前被保留到堆栈中的寄存器值。其中eax返回时会带回系统调用的返回码。(负数说明调用错误,0或正数说明正常完成)

参数传递

与普通函数类似,系统调用通常也需要输入/输出参数,这些参数可能是实际的值,也可能是用户态进程地址空间的变量,甚至是指向用户态函数的指针的数据结构地址。

普通C函数的参数传递是通过把参数值写入活动的程序栈(用户态栈或内核态栈)实现的。因为系统调用是一种横跨用户和内核两大陆地的特殊函数,所以既不能使用用户态栈也不能使用内核态栈。更确切地说,在发怵系统调用之前,系统调用的参数被写入CPU寄存器,然后在调用系统调用服务例程之前,内核再把存放在CPU中的参数拷贝到内核态堆栈中,这是因为系统调用服务例程是普通的C函数。



本文参考自《深入理解linux内核》和linux系统调用

Contents
  1. 1. 简介
  2. 2. API和系统调用
  3. 3. 系统调用、用户编程接口(API)、系统命令、和内核函数的关系
  4. 4. 系统调用处理程序及服务例程
  5. 5. 参数传递