读linux programming interface chapter 3

系统调用允许进程从内核请求服务。最简单的系统调用与用户空间的函数调用相比,有相当大的开销,因为系统必须暂时切换到内核模式来执行系统调用,且内核必须验证系统调用参数,在用户内存和内核内存之间传输数据
系统调用将处理器状态从用户模式改为内核模式,以便CPU访问受保护的内核内存
系统调用的集合是固定的。每个系统调用由唯一的编号标识(编号方案通常对程序不可见,程序通过名称标识系统调用)
每个系统调用可能有一组参数,用于指定从用户空间(即进程的虚拟地址空间)到内核空间的信息传输,反之亦然。
- 应用程序通过在C库(如glibc)中调用包装器(wrapper)函数来进行系统调用
- 包装器函数将所有系统调用参数提供给系统调用陷阱处理例程。这些参数通过stack传递给包装器,但内核期望它们在特定的寄存器中。包装器函数将参数复制到这些寄存器
- 所有系统调用以相同的方式进入内核,内核需要某种方法来识别系统调用。包装器函数将系统调用号复制到特定的CPU寄存器(%eax)
- 包装器函数执行陷阱机器指令
int 0x80,导致处理器从用户模式切换到内核模式并执行系统的陷阱向量位置0x80(128十进制)指向的代码 - 更新的x86-32架构实现了
sysenter指令,与传统的int 0x80陷阱指令相比,提供了更快进入内核模式的方法。从2.6内核和glibc 2.3.2开始支持使用sysenter - 响应到位置
0x80的陷阱,内核调用其system_call()例程(位于汇编文件arch/x86/kernel/entry.S中)来处理陷阱。这个处理程序:
- 将寄存器值保存到内核堆栈
- 检查系统调用号的有效性
- 调用适当的系统调用服务例程,通过使用系统调用号来索引所有系统调用服务例程的表(内核变量
sys_call_table)来找到它。如果系统调用服务例程有任何参数,首先检查它们的有效性;如检查地址是否指向用户内存中的有效位置。然后服务例程执行所需的任务,这可能涉及修改给定参数中指定的地址处的值,并在用户内存和内核内存之间传输数据(如在I/O操作中)。最后服务例程返回结果状态给system_call()例程 - 从内核堆栈中恢复寄存器值,将系统调用返回值放在stack上
- 返回包装函数,将处理器返回用户模式
- 如果系统调用服务例程的返回值表示error,包装器函数使用此值设置全局变量errno。然后包装函数返回给调用者一个整数返回值,表示系统调用的成功或失败
- Linux上系统调用服务例程遵循返回非负值表示成功的约定。出错时例程返回一个负数,是errno常数之一的负值。返回负值时C库包装函数将其取反使其为正,将结果复制到errno中并返回
- 该约定依赖于系统调用服务例程在成功时不返回负值的假设。然而对于其中的一些例程,这个假设并不成立。通常这不是问题,因为负值的errno值的范围与有效的负返回值不重叠。然而这个约定在一个情况下确实造成了问题:
fcntl()系统调用的F_GETOWN操作
陷阱指令(trap instruction)是特殊的机器指令,实现从用户模式到内核模式的转换。当程序执行陷阱指令时,会引发中断,将控制权交给内核。这就是为什么被称为“陷阱”——像是一个触发点,一旦执行,就会“捕捉”程序并将其上下文转移到内核
Linux/x86-32上,execve()是系统调用号11(__NR_execve)。因此sys_call_table向量中条目11包含sys_execve()的地址,是此系统调用的服务例程。(Linux上系统调用服务例程常具有sys_xyz()形式的名称,xyz()是所讨论的系统调用)
对于简单的系统调用也必须做很多工作,因此系统调用有小但可感知的开销
从C程序的角度看,调用C库包装函数=调用相应的系统调用服务例程
库函数只是构成标准C库的众多函数之一
许多库函数不使用系统调用(如字符串操作函数)
有些库函数基于系统调用。如fopen()库函数使用open()系统调用来打开文件。通常库函数的设计目的是为了提供比底层系统调用更友好的接口。如printf()函数提供了输出格式化和数据缓冲,而write()系统调用仅输出一个字节块。malloc()和free()函数执行各种记账任务,比底层的brk()系统调用更容易分配和释放内存
记账任务指
malloc()和free()在执行内存分配和释放时进行的各种管理操作。包括
- 跟踪已分配和未分配内存块的大小和位置
- 合并相邻的未分配块以避免碎片化
- 处理内存对齐
这种记账确保了内存的有效利用,避免了内存泄漏和碎片化,同时也帮助检测和防止一些常见的错误(如重复释放内存)
各种UNIX实现上有不同的标准C库实现。Linux上最常用的实现是GNU C库(glibc)
某些Linux发行版中GNU C库的路径并不是位于/lib/libc.so.6。确定库位置的一种方法是针对动态链接到glibc的可执行文件(大多数可执行文件都以这种方式链接)运行ldd(列出动态依赖)程序。然后检查生成的库依赖性列表,找到glibc共享库的位置:
$ ldd myprog | grep libc
libc.so.6 => /lib/tls/libc.so.6 (0x4004b000)(>∀<) ldd /run/current-system/sw/bin/grep
linux-vdso.so.1 (0x00007ffec51d7000)
libpcre2-8.so.0 => /nix/store/s17581vckgq5k3aiq3s3lavd7bixglw9-pcre2-10.42/lib/libpcre2-8.so.0 (0x00007fe99eacf000)
libc.so.6 => /nix/store/9y8pmvk8gdwwznmkzxa6pwyah52xy3nk-glibc-2.38-27/lib/libc.so.6 (0x00007fe99e8e7000)
/nix/store/9y8pmvk8gdwwznmkzxa6pwyah52xy3nk-glibc-2.38-27/lib/ld-linux-x86-64.so.2 => /nix/store/9y8pmvk8gdwwznmkzxa6pwyah52xy3nk-glibc-2.38-27/lib64/ld-linux-x86-64.so.2 (0x00007fe99eb6c000)有些系统调用不会失败。getpid()总是成功返回进程ID,_exit()总是终止进程。不需要检查此类系统调用的返回值
fd = open(pathname, flags, mode);
if (fd == -1) {
/* Code to handle the error */
}
...
if (close(fd) == -1) {
/* Code to handle the error */
}系统调用失败时,会将全局整数变量errno设置为一个正值,该值识别特定的错误
cnt = read(fd, buf, numbytes);
if (cnt == -1) {
if (errno == EINTR)
fprintf(stderr, "read被信号中断\n");
else {
/* 发生了其他错误 */
}
}成功的系统调用和库函数不会将errno重置为0,因此由于前一个调用的错误,此变量可能具有非零值。SUSv3允许成功的函数调用将errno设置为非零值(尽管很少有函数这样做)。因此检查错误时,应首先检查函数返回值是否指示出错,再检查errno以确定错误原因
少数系统调用(如getpriority())可在成功时合法返回-1。为确定此类调用中是否发生错误,在调用之前将errno设置为0,然后检查它
系统调用失败后常见操作是根据errno值打印错误消息。为此目的提供了perror()和strerror()库函数
perror()函数打印其msg参数指向的字符串,然后打印与errno的当前值相对应的消息
#include <stdio.h>
void perror(const char *msg);更简单的办法:
fd = open(pathname, flags, mode);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}strerror() 函数返回其 errnum 参数给出的错误编号对应的错误字符串
#include <string.h>
char *strerror(int errnum);由 strerror() 返回的字符串可能是静态分配的,这意味着它可能会被后续对 strerror() 的调用覆盖
如果 errnum 指定了一个未被识别的错误编号,strerror() 返回形如 Unknown error nnn 的字符串。在一些其他实现中,这种情况下 strerror() 返回 NULL
perror() 和 strerror() 函数受区域设置影响,错误描述以当地语言显示
- 有些库函数以与系统调用完全相同的方式返回错误信息:返回值为 -1,errno 指示特定的错误。例子是
remove(),它删除一个文件(使用unlink()系统调用)或一个目录(使用rmdir()系统调用) - 有些库函数在错误时返回除 -1 以外的值,但仍然设置 errno 以指示特定的错误条件。如
fopen()在错误时返回 NULL 指针,而 errno 的设置取决于哪个底层系统调用失败了。可使用perror()和strerror()函数来诊断 - 其他库函数不使用 errno。确定错误的存在和原因的方法取决于特定的函数,并在该函数的手册页中有文档说明
各种数据类型使用标准C类型表示,如进程ID、用户ID和文件偏移量。虽然可以使用C基础类型如int和long来声明存储此类信息的变量,但这减少了UNIX系统之间的可移植性:
- 这些基本类型的大小在UNIX实现之间是不同的(如一个系统上的long可能是4字节,另一个系统上是8字节),或者在同一实现的不同编译环境中有时也不同的。不同的实现可能使用不同的类型来表示相同的信息。如进程ID在一个系统上可能是int,在另一个系统上可能是long
- 即使在单一的UNIX实现上,用于表示信息的类型也可能在实现的版本间有所不同。Linux上的用户和组ID。2.2及更早版本中这些值用16位表示。2.4及更高版本中是32位值
为避免此类可移植性问题,SUSv3指定了各种标准系统数据类型,并要求implementation适当地定义和使用这些类型。每种类型使用C的typedef特性来定义。如pid_t数据类型用于表示进程ID,Linux/x86-32上这种类型的定义:
typedef int pid_t;大多标准系统数据类型名称以_t结尾。其中许多在头文件<sys/types.h>中声明,有些在其他头文件中定义
小心不要在printf()调用中包含表示依赖性。C的参数提升规则将short类型的值转换为int,但保持int和long类型的值不变。这意味着根据系统数据类型的定义,printf()调用中传递的是int或long。然而printf()在运行时无法确定参数类型,调用者必须使用%d或%ld格式说明符显式提供信息。问题在于在printf()调用中简单地编码其中一个说明符会创建一个实现依赖性。通常解决方案是使用%ld说明符,并总将相应的值转换为long:
虽然 long 的大小在不同平台上可能不同,但它提供的范围至少与 int 相等,通常情况下更大。这意味着使用 long 可以处理更广泛范围的数值,减少了因数据类型溢出而导致的错误; 通过显式地将值转换为 long 并使用 %ld,程序员能够清晰地表达其意图,减少了因隐式类型提升导致的潜在问题
pid_t mypid;
mypid = getpid();
printf("我的PID是 %ld\n", (long) mypid);一个例外: 某些编译环境中,off_t数据类型的大小是long long,所以将off_t值转换为long long并使用%lld
UNIX 实现指定了一系列标准结构体,用于各种系统调用和库函数。例如sembuf结构体,用于表示由 semop() 系统调用执行的信号量操作:
struct sembuf {
unsigned short sem_num; /* 信号量编号 */
short sem_op; /* 要执行的操作 */
short sem_flg; /* 操作标志 */
};虽然 SUSv3 指定了像 sembuf 这样的结构体,但重要的是:
- 通常情况下字段定义的顺序并未指定
- 某些情况下可能包含额外实现的特定字段
因此如下结构体初始化器是不可移植的:
struct sembuf s = { 3, -1, SEM_UNDO };正确做法:
struct sembuf s;
s.sem_num = 3;
s.sem_op = -1;
s.sem_flg = SEM_UNDO;如果使用 C99,可用新语法编写等效的初始化:
struct sembuf s = { .sem_num = 3, .sem_op = -1, .sem_flg = SEM_UNDO };某些情况下某个宏可能不会在所有 UNIX 实现中都被定义。如WCOREDUMP() 宏(检查子进程是否生成了核心转储文件)是广泛可用的,但它没有在 SUSv3 中。因此这个宏可能不出现在某些 UNIX 实现中。为了可移植地处理这种可能性,可使用 C 预处理器 #ifdef 指令:
#ifdef WCOREDUMP
/* 使用 WCOREDUMP() 宏 */
#endif