第3章 系统编程概念
本章涉及到的多个主题是系统编程的预备知识。我们首先会介绍系统调用(system calls)以及在执行过程中的细节。然后我们会讲到库函数,以及它们与系统调用之间的区别,同时对C库(GNU)进行相关描述。
当我们使用系统调用或者调用一个库函数时,我们应该经常检查一下返回的状态,以确保是否成功返回了结果。我们会描述如何执行这些检查,并且给出一组函数,用于诊断示例程序中的系统调用和库函数执行是否正确。
3.1 System Calls
系统调用(system call)是内核的一个受控的切入点,进程通过该切入点可以向内核请求为它执行一些操作。通过系统调用应用编程接口(API),内核为程序提供了一系列可用的服务。这些服务包括,例如:创建一个新的进程、执行I/O操作、为进程间通信创建管道等。
在深入了解系统调用如何工作之前,我们注意到:
- 系统调用将处理器从用户模式(user mode)切换到内核模式(kernel mode),因此CPU可以访问受保护的内核内存。
- 系统调用的集合是固定的。每个系统调用都被唯一的一个数字所标识(这些数字标识对程序而言是不可见的,程序通过名称来识别系统调用)。
- 每个系统调用可能存在一组参数,用于接收从用户空间 (user space,也就是进程的虚拟地址空间) 传到内核空间(kernel space)的信息。
从编程的角度看,调用一个系统调用看上去更像是调用了一个C函数。然而在后台,在系统调用执行的这个过程中,会产生很多步骤。为了说明这些,我们按顺序来描述发生的这些步骤。这里采用x86-32这个特定的硬件实现,步骤如下:
- 应用程序通过调用C库中的 包装函数(wrapper function) 来产生一个系统调用。
- 该包装函数必须传入处理trap的 系统调用例程(system call routine) 所需的所有参数。这些参数通过栈传入到包装函数中,但是内核希望这些参数在特定的寄存器(registers)中。所有包装函数将这些参数复制到寄存器中。
- 因为所有的系统调用以相同的方式进入内核,所以内核需要通过某些方法来识别这些系统调用。为了能让内核能够识别,包装函数需要将系统调用的标识符复制到特定的CPU寄存器中。
- 包装函数执行 trap 机器指令(int 0x80),该指令会使处理器从用户模式切换到内核模式,并执行系统trap向量中位置0x80所指向的代码。
较新的x86-32架构实现了 sysenter 指令,该指令与传统的 int 0x80 trap 指令相比,能够更快地切换到内核模式。从2.6内核(2.6 kernel)和glibc2.3.2开始支持sysenter指令。
- 为响应位置0x80的trap,内核调用system_call()例程来解决这个trap:
a) 将寄存器的值保存到 内核栈(kernel stack) 中。
b) 检查系统调用的标识符(number)的有效性。
c) 根据 系统调用标识符 从 系统调用列表(内核变量sys_call_table) (包含所有系统调用服务例程的列表) 中找到的相应的系统调用服务例程(system call service routine),并调用。如果该系统调用服务例程带有任何参数,那么首先会检查参数的有效性。例如,它会检查这个地址所指向的用户内存的位置是否有效。然后服务例程会执行这个请求的任务,如修改该参数所指向地址的值,从用户内存和内核内存之间传输数据(例如 I/O操作)。最后,服务例程将结果状态值返回给system_call()例程。
d) 从内核栈中恢复寄存器的值,将系统调用(system call)返回的值放入栈中。
e) 返回给包装函数,同时将处理器切换到用户模式。 - 如果系统调用服务例程返回的值是一个表示产生了错误(an error)的值,那么包装函数会将该值赋给全局变量 errno(正整数) 。然后从包装函数返回一个整型值给调用者(caller),告知这次系统调用是成功的还是失败的。
在Linux中,系统调用服务例程按照惯例,如果返回一个 非负数 则表示运行成功。如果出现了错误,则会返回一个 负数 ,这个数正好是errno常量的负值。当返回一个负数时,C库包装函数会对这个值再取负(使它变成正数),将结果赋给errno,并将-1作为包装函数的结果进行返回,告知调用程序产生了一个错误。
图3.1使用execve()系统调用描述了上面的一系列步骤。在Linux/x86-32中,execve()的系统调用标识符是11 (__NR_execve)。因此,在sys_call_table向量中,条目11包含了sys_execve()的地址,sys_execve()就是这个系统调用所需的服务例程。
在Linux中,系统调用服务例程 名称的形式一般是 sys_xyz() , xyz() 是 ++系统调用++ 的名称。
上面段落中描述的信息已经超过了我们所需要知道的细节。但是,它告诉我们,即使是一个简单的系统调用,其实有许多工作需要做,因此系统调用的开销虽然小但还是可以感知的。
举一个系统调用开销的例子,getppid() 这个系统调用仅仅是返回调用进程的父进程的ID。但是在装有Linux2.6.25的x86-32系统中,执行一次getppid()需要1000万次调用,大概耗时2.2秒才能完成。每次调用大概是0.3毫秒。相比之下,在相同系统中,对C函数进行1000万次调用仅仅只需0.11秒。大概只需要getppid()二十分之一的时间。当然,还有些系统调用的开销远远大于getppid()
因此,从C程序的角度看,调用C库包装函数跟调用相应的系统调用服务例程是差不多的。在本书的剩下内容中,出现类似“调用系统调用xyz()” (invoking the system call xyz()) 就是指“通过调用包装函数来调用系统调用xyz()” (calling the wrapper function that invokes the system call xyz())。
3.2 Library Functions
库函数(library function) 仅仅是众多函数中的一种,它们组成了标准的C库(为简洁起见,在本书的剩下章节中,我们用 函数(function) 指代 库函数(library function) )。这些函数的作用是多样的,例如打开文件、将时间转换成可读格式或者比较两个字符串。
很多库函数不会使用系统调用(例如字符串操作函数)。而有些库函数依赖于系统调用。例如,fope()库函数实际使用了open()系统调用来打开文件。通常,库函数设计成比系统调用使用起来更加方便。例如,printf() 函数提供了输出格式和数据缓存,而 write() 系统调用仅仅输出一些字节。类似地,使用 malloc() 和 free() 函数比 brk() 系统调用可以更容易的分配和释放一块内存。
3.3 The Standard C Library; The GNU C Library (glibc)
在各种UNIX系统实现中,对标准C库的实现也是各不相同的。在Linux中,最常用的C库是 GNU C库 (GNU C library)(glibc,http://www.gnu.org/software/libc/)。
Determining the version of glibc on the system
有时,我们需要知道系统中glibc的版本。glibc是一个可执行的程序,所以我们可以在shell中运行glibc共享库文件来得到它的版本号:
$ /lib/libc.so.6
在有些Linux系统中,GNU C库不是在/lib/libc.so.6这个位置。确定该库位置的一种方法是运行 ldd() 打印出某个可执行程序所依赖的共享库列表(大部分程序都对libc.so.6有依赖):
$ ldd myprog | grep libc
ldd (list dynamic depedencies) 命令用于打印程序或者库文件所依赖的共享库列表。
我的测试结果如下:
# include <stdio.h>
# include <gnu/libc-version.h>
int main()
{
printf("glibc's version: %s\n", gnu_get_libc_version());
return 0;
}
3.4 Handling Errors from system Calls and Library Funtions
略
3.5 Notes on the Example Programs in This Book
略
3.6 Portability Issues
略
3.7 summary
略