3.1 系统调用
系统调用是受控的内核入口,借助于这一机制,进程可以请求内核以自己的名义去执行某些动作。以应用程序编程接口(API)的形式,内核提供有一系列服务供程序访问。这包括创建新进程、执行 I/O,以及为进程间通信创建管道等。
在深入系统调用的运作方式之前,务必关注以下几点。
-
系统调用将处理器从用户态切换到核心态,以便 CPU 访问受到保护的内核内存。
-
系统调用的组成是固定的,每个系统调用都由一个唯一的数字来标识。
-
每个系统调用可辅之以一套参数,对用户空间(亦即进程的虚拟地址空间)与内核空间之间(相互)传递的信息加以规范。
从编程角度来看,系统调用与 C 语言函数的调用很相似。但是在执行系统调用会有步骤。书中按事件发生的顺序对这些步骤加以分析。
1、应用程序通过调用 C 语言函数库中的外壳(wrapper)函数,来发起系统调用。
2、通过堆栈,系统调用参数传入外壳函数,但内核却希望将这些参数置入特定寄存器。因此,外壳函数会将上述参数复制到寄存器。
3、外壳函数将系统调用编号复制到一个特殊的 CPU 寄存器(%eax)中。
4、外壳函数执行一条中断机器指令(int 0x80),引发处理器从用户态切换到核心态,并执行系统中断 0x80 (十进制数 128)的中断矢量所指向的代码。
5、为响应中断 0x80,内核会调用 system_call()来处理这次中断,具体如下。
-
a)在内核栈中保存寄存器值。
-
b)审核系统调用编号的有效性。
-
c)以系统调用编号对存放所有调用服务例程的列表(内核变量 sys_call_table)进行索引,发现并调用相应的系统调用服务例程。若系统调用服务例程带有参数,那么将首先检查参数的有效性。例如,先检查地址指向用户空间的内存位置是否有效。随后,该服务例程会执行必要的任务,这可能涉及对特定参数中指定地址处的值进行修改,以及在用户内存和内核内存间传递数据。最后,该服务例程会将结果状态返回给system_call()。
-
d)从内核栈中恢复各寄存器值,并将系统调用返回值置于栈中。
-
e)返回至外壳函数,同时将处理器切换回用户态。
6、若系统调用服务例程的返回值表明调用有误,外壳函数会使用该值来设置全局变量 errno。然后,外壳函数会返回到调用程序,并同时返回一个整型值,以表明系统调用是否成功。
【注】系统调用服务调用成功则返回非负值。发生错误时,会对相应 errno 常量取反,返回一负值。C 语言函数库的外壳函数随即对其再次取反,将结果拷贝至 errno,同时以-1 作为外壳函数的返回值返回,向调用程序表明有错误发生。
图 3-1 系统调用的执行步骤
其要点在于即便对于一个简单的系统调用,仍要完成相当多的工作(系统调用是有开销的),因此系统调用的开销虽小,却也不容忽视。
3.2 处理来自系统调用和库函数的错误
几乎每个系统调用和库函数都会返回某类状态值,用以表明调用成功与否。要了解调用是否成功,必须坚持对状态值进行检查。若调用失败,那么必须采取相应行动。至少,程序应该显示错误消息,警示有意想不到的事件发生。
【注】少数几个系统函数在调用时从不失败。例如,getpid()总能成功返回进程的 ID,而_exit()总能终止进程。无需对此类系统调用的返回值进行检查。
3.2.1 处理系统调用错误
每个系统调用的手册页记录有调用可能的返回值,并指出了哪些值表示错误。通常,返回值为-1 表示出错。
系统调用失败时,会将全局整形变量 errno 设置为一个正值,以标识具体的错误。程序应包含<errno.h>头文件,该文件提供了对 errno 的声明,以及一组针对各种错误编号而定义的常量。所有这些符号名都以字母E 打头。在每个手册页内标题为ERRORS 的章节内,都刊载有一份相应系统调用可能返回的 errno 值表。以下便是利用 errno 诊断系统调用错误的一个简单示例:
cnt = read(fd, buf, numbytes);
if (cnt == -1)
{
if (errno == EINTR)
fprintf(stderr, "read was interrupted by a signal\n")
else
{
/* Some other error occurred */
}
}
如果调用系统调用和库函数成功,errno 绝不会被重置为 0,故此,该变量值不为 0,可能是之前调用失败造成的。此外,SUSv3 允许在函数调用成功时,将 errno 设置为非零值(当然,几乎没有函数会这么做)。因此,在进行错误检查时,必须坚持首先检查函数的返回值是否表明调用出错,然后再检查 errno 确定错误原因。少数系统调用(比如,getpriority())在调用成功后,也会返回−1。要判断此类系统调用是否发生错误,应在调用前将 errno 置为 0,并在调用后对其进行检查。
系统调用失败后,常见的做法之一是根据 errno 值打印错误消息。提供库函数 perror()和strerror(),就是出于这一目的。函数perror()会打印出其 msg 参数所指向的字符串,紧跟一条与当前 errno 值相对应的消息。void peeror(const char *msg)
;函数 strerror()会针对其 errnum 参数中所给定的错误号,返回相应的错误字符串。void strerror(int errnum)
,由 strerror()所返回的字符串可以是静态分配的,这意味着后续对 strerror()的调用可能会覆盖该字符串。若无法识别 errnum 所含的错误编号,则 strerror()会返回“Unknown error nnn.”形式的字符串。在某些其他的实现中,在这种情况下,strerror()会返回 NULL。
以下是对系统调用错误进行处理的一种简单方式:
fd = open(pathname, flags, mode);
if (fd == -1)
{
perror("open");
exit(EXIT_FAILURE);
}
3.2.2 处理来自库函数的错误
不同的库函数在调用发生错误时,返回的数据类型和值也各不相同。(参见每个函数的手册页。)从错误处理的角度来说,可将库函数划分为以下几类。
-
某些库函数返回错误信息的方式与系统调用完全相同——返回值为−1,伴之以 errno号来表示具体错误。remove()便是其中一例,可使用该函数来删除文件(调用 unlink()系统调用)或目录(调用 rmdir()系统调用)。对此类函数所发生的错误进行诊断,其方式与系统调用完全相同。
-
某些库函数在出错时会返回−1 之外的其他值,但仍会设置 errno 来表明具体的出错情况。例如,fopen()在出错时会返回一个 NULL 指针,还会根据出错的具体底层系统调用来设置 errno。函数 perror()和 strerror()都可用来诊断此类错误。
-
还有些函数根本不使用 errno。对此类函数来说,确定错误存在与否及其起因的方法各不相同,可见诸于相应函数的手册页中,不应使用 errno、perror()或 strerror()来诊断错误。
3.3 一些说明
3.3.1 常用的函数及头文件
常用头文件:
#include <sys/types.h> /*很多程序都需要用到的类型定义*/
#include <stdio.h> /*标准输入输出函数*/
#include <stdlib.h> /*定义了五种类型、一些宏和通用工具函数。 类型例如size_t、
wchar_t、div_t、ldiv_t和lldiv_t; 宏例如EXIT_FAILURE、EXIT_SUCCESS、RAND_MAX和
MB_CUR_MAX等等;常用的函数如malloc()、calloc()、realloc()、free()、system()、
atoi()、atol()、rand()、srand()、exit()等等。*/
#include <unistd.h> /*包括大量针对系统调用的封装*/
#include <errno.h> /*定义了通过错误码来回报错误资讯的宏*/
#include <string.h> /*定义了一个变量类型、一个宏和各种操作字符数组的函数*/
3.3.2 一些错误诊断函数
使用 errMsg()、errExit()、err_exit()以及 errExitEN()函数,以诊断调用系统调用和库函数时所发生的错误。
#include "tlpi_hdr.h"
/* errMsg()函数会打印出与当前 errno 值相对应的错误文本,其中包括了错误名(比如,EPERM)
以及由 strerror()返回的错误描述,外加由参数列表指定的格式化输出。 */
void errMsg(const char *format, ...);
/* errExit()函数的操作方式与errMsg()相似,只是还会以如下两种方式之一来终止程序。其一,
调用exit()退出。其二,若将环境变量EF_DUMPCORE 定义为非空字符串,则调用abort()退出,
同时生成核心转储(core dump)文件,供调试器调试之用。 */
void errExit(const char *format, ...);
/* 函数 err_exit()类似于 errExit(),但存在两方面的差异。a、打印错误消息之前,
err_exit()不会刷新标准输出;b、err_exit()终止进程使用的是_exit(),而非 exit()。
这一退出方式,略去了对 stdio 缓冲区的刷新以及对退出处理程序(exit handler)的调用。 */
void err_exit(const char *format, ...);
/* 在功能上,errExitEN()函数与 errExit()大体相同,区别仅仅在于:与 errExit()打印与
当前errno 值相对应的错误文本不同,errExitEN()只会打印与 errnum 参数中给定的错误号
(error number)相对应的文本。 */
void errExitEN(int errnum, const char *foramt, ...);
在调用了 POSIX 线程 API 的程序示例中,主要使用 errExitEN()来处理错误。与传统的 UNIX 系统调用返回-1 表示错误不同,POSIX 线程函数会在其结果中返回一个(POSIX线程函数返回 0 表示成功)错误号(正数,类型为 errno 所专用)。针对 POSIX 线程函数,可使用如下代码来诊断错误:
errno = pthread_create(&thread, NULL, func, &arg);
if (errno != 0)
errExit("pthread_create");
/*然而,这一方法效率不高,因为在线程程序中,errno 实际已被定义为宏,展开后是返回可修改左值
的一个函数调用。因此,每次使用 errno 都会引发一次函数调用。使用 errExitEN()改写上述代码,
功能相同,但更为高效,如下所示:*/
int s;
s = pthread_create(&thread, NULL, func, &arg);
if (s != 0)
errExitEN(s, "pthread_create");
诊断其他类型的错误时,本书使用的是 fatal()、usageErr()以及 cmdLineErr()。
#include "tlpi_hdr.h"
/* 函数 fatal()用来诊断一般性错误,其中包括未设置 errno 的库函数错误。除了将一个终止换行符
自动追加到输出字符串尾部以外,fatal()的参数列表与 printf()基本相同。该函数会在标准错误上
打印格式化输出,然后,像 errExit()那样终止程序。 */
void fatal(const char *format,...);
/* 函数 usageErr()用来诊断命令行参数使用方面的错误。其参数列表风格与 printf()相同,并在
标准错误上打印字符串“Usage:”,随之以格式化输出,然后调用 exit()终止程序。 */
void usageErr(const char *format,...);
/* 函数 cmdLineErr()酷似 usageErr(),但其错误诊断是针对于特定程序的命令行参数。 */
void cmdLineErr(const char *format,...);
3.3.3 解析数值型命令行参数的函数
之后将频繁用于解析整形命令行参数:getInt()和 getLong()。较之于 atoi()、atol()以及 strtol(),它们的主要优点在于针对数值型参数提供了一些基本的有效性检查。
#include "tlpi_hdr.h"
/* 将 arg 指向的字符串转换为 int */
int getInt(const char *arg, int flags, const char *name);
/* 将 arg 指向的字符串转换为 long */
long getLong(const char *arg, int flags, const char *name)
如果 arg 未包含一个有效的整数字符串(即仅包含数字以及字符“+”和“-”),那么这两个函数会打印一条错误消息,并终止程序。
若参数 name 非空,则所含内容应为一字符串,用于标识 arg 对应于命令行中相应参数的名称。在上述两函数中,无论打印任何错误消息,该字符串都是消息中的一部分。
可通过 flags 参数对 getInt()和 getLong()函数的操作施加一些控制。默认情况下,两个函数会处理包含有符号十进制整数的字符串。若将定义于程序清单 3-5 中的一个或多个 GN_*系列常量与 flags 相或,则既可以选择其他的转换进制,也能将数值范围限制为非负或正整数。
【注】上文中的错误诊断函数以及解析数值型命令行参数的函数都是本书作者自己写的,有需要可以从该网站获取https://man7.org/tlpi/code/download/tlpi-240311-book.tar.gz