The Linux Programming interface 书籍阅读记录

5 阅读25分钟

The Linux Programming interface 书籍阅读记录

第二章 基础概念

标准输入输出库 stdio

为了执行文件 I/O,C 程序通常使用标准 C 库中包含的 I/O 函数。这一系列函数被称为 stdio 库,包括 fopen()、fclose()、scanf()、printf()、fgets()、fputs()等。stdio 函数建立在 I/O 系统调用(open()、close()、read()、write()等)之上。

过滤器

过滤器通常是指一种程序,它从 stdin 读取输入,对输入执行某些转换,并将转换后的数据写入 stdout。过滤器的例子包括 cat、grep、tr、sort、wc、sed 和 awk。

命令行参数

在 C 语言中,程序可以访问命令行参数,即当程序运行时在命令行上提供的单词。 要访问命令行参数,程序的 main() 函数应声明如下: ​int main(int argc, char *argv[]) argc 变量包含命令行参数的总数,而各个参数作为指向 argv 数组成员的字符串可用。这些字符串中的第一个,argv[0], ,标识程序本身的名称。

进程

最简单来说,进程是一个正在执行程序的实例。当程序被执行时,内核将程序的代码加载到虚拟内存中,为程序变量分配空间,并设置内核账本数据结构来记录关于进程的各种信息(例如进程 ID、终止状态、用户 ID 和组 ID)。 从内核的角度来看,进程是内核必须共享的计算机各种资源的实体。对于有限的资源,例如内存,内核最初会为进程分配一定数量的资源,并在进程生命周期内,根据进程的需求和系统对该资源的需求总量调整这种分配。当进程终止时,所有这些资源都会被释放以供其他进程重用。其他资源,例如 CPU 和网络带宽,是可再生的,但必须公平地分配给所有进程。

进程内存布局

进程在逻辑上分为以下部分,称为段: 文本:程序的指令。数据:程序使用的静态变量。堆:程序可以动态分配额外内存的区域。栈:随着函数被调用和返回而增长和缩小的内存区域,用于分配局部变量和函数调用链接信息。

进程创建和程序执行

一个进程可以使用 fork() 系统调用创建一个新的进程。调用 fork() 的进程被称为父进程,而新创建的进程被称为子进程。内核通过复制父进程来创建子进程。

子进程会继续执行同一代码段中的不同函数,或者更常见的情况是,使用 execve()系统调用加载并执行一个全新的程序。一个 execve()调用会销毁现有的文本、数据、栈和堆段,并用基于新程序代码的新段进行替换。

进程终止与终止状态

进程可以通过两种方式终止:使用 _exit() 系统调用(或相关的 exit() 库函数)请求自身终止,或因收到信号而被终止。在任一情况下,进程都会产生一个终止状态,这是一个小的非负整数值,父进程可以使用 wait() 系统调用进行检查。在 _exit()调用的情况下,进程明确指定自己的终止状态。如果一个进程被信号终止,终止状态会根据导致进程死亡的信号类型来设置。(有时,我们会将传递给 _exit() 的参数称为进程的退出状态,以区别于终止状态,后者是传递给 _exit() 的值或杀死进程的信号指示。)。

按惯例,终止状态为 0 表示进程成功,非零状态表示发生了错误。大多数外壳会通过名为 $? 的 shell 变量提供最后执行程序的终止状态。

能力

自内核 2.2 版本起,Linux 将传统上赋予超级用户的权限划分为一组不同的单元,称为能力。每个特权操作都与一个特定的能力相关联,并且只有当进程拥有相应的能力时才能执行该操作。一个传统的超级用户进程(有效用户 ID 为 0)对应于一个所有能力都已启用的进程。 向进程授予一部分能力,允许它执行通常允许超级用户执行的部分操作,同时阻止它执行其他操作。 能力在第 39 章中详细描述。在本书的其余部分,当指出某个操作只能由特权进程执行时,我们通常会以括号中的形式标明具体的能力。能力名称以 CAP_前缀开头,例如 CAP_KILL。

进程 init

在启动系统时,内核会创建一个名为 init 的特殊进程,即“所有进程的父进程”,它源自程序文件/sbin/init。系统上的所有进程都是由 init 或其子孙进程(使用 fork())创建的。init 进程始终具有进程 ID 1,并以超级用户权限运行。init 进程无法被杀死(即使是超级用户也无法杀死),并且它只有在系统关闭时才会终止。init 的主要任务是为正在运行的系统创建和监控一系列所需的进程。(详细信息,请参阅 init(8)手册页。)

守护进程

守护进程是一种特殊用途的进程,它与其他进程一样由系统创建和管理,但具有以下特征: 它是长生命周期的。守护进程通常在系统启动时启动,并在系统关闭前一直存在。 它在后台运行,并且没有控制终端,无法从中读取输入或向其写入输出。z 守护进程的例子包括 syslogd,它记录系统日志中的消息,以及 httpd,它通过超文本传输协议(HTTP)提供网页。

环境列表

每个进程都有一个环境列表,它是一组环境变量,这些变量在进程的用户空间内存中维护。该列表的每个元素都由一个名称和关联的值组成。当通过 fork()创建新进程时,它会继承其父进程的环境副本。因此,环境提供了一种机制,使父进程能够向子进程传递信息。当进程使用 exec()替换它正在运行的程序时,新程序要么继承旧程序使用的环境,要么接收 exec()调用中指定的新环境。 环境变量通常使用 export 命令在大多数外壳中创建(或在 C shell 中使用 setenv 命令), 例如以下示例: ​$ export MYVAR='Hello world' C 程序可以使用外部变量(char **environ)访问环境,并且各种库函数允许进程检索和修改其环境中的值。环境变量用于多种目的。例如,外壳定义并使用一系列变量,这些变量可以被从外壳执行的脚本和程序访问。这些包括变量 HOME,它指定用户登录目录的路径名, 以及变量 PATH,它指定外壳在查找与用户输入的命令对应的程序时应搜索的目录列表。

资源限制

每个进程都会消耗资源,例如打开的文件、内存和 CPU 时间。通过使用 setrlimit() 系统调用,一个进程可以为其消耗的各种资源设定上限。每种这样的资源限制都有两个关联值:一个软限制,它限制了进程可以消耗的资源量;以及一个硬限制,它是软限制可以调整到的值的上限。非特权进程可以将其特定资源的软限制更改为从零到相应硬限制范围内的任何值,但只能降低其硬限制。当使用 fork() 创建新进程时,它会继承其父进程的资源限制设置。可以使用 ulimit 命令(C shell 中的 limit)调整外壳的资源限制。这些限制设置会被外壳创建的用于执行命令的子进程继承。

内存映射

mmap() 系统调用会在调用进程的虚拟地址空间创建一个新的内存映射。映射分为两类: 文件映射将文件的一个区域映射到调用进程的虚拟内存中。映射后,可以通过对相应内存区域中的字节进行操作来访问文件的内容。映射的页面会根据需要自动从文件中加载。 相比之下,匿名映射没有对应的文件。相反,映射的页面会被初始化为 0。 一个进程的映射中的内存可以与其他进程的映射共享。这可能是由于两个进程映射了文件的同一段区域,也可能是由于由 fork()创建的子进程继承了父进程的映射。当两个或多个进程共享相同的页面时,每个进程是否能看到其他进程对页面内容所做的更改,取决于映射是创建为私有还是共享。当映射是私有时,对映射内容的修改对其他进程不可见,也不会传递到底层文件。当映射是共享时,对映射内容的修改对共享相同映射的其他进程可见,并且会传递到底层文件。 内存映射有多种用途,包括从可执行文件对应的段初始化进程的文本段、分配新的(已清零的)内存、文件 I/O(内存映射 I/O)以及通过共享映射进行进程间通信。

静态和共享库

对象库是一个包含一组(通常逻辑上相关的)函数的编译对象代码的文件,这些函数可以从应用程序程序中调用。将一组函数的代码放在单个对象库中可以简化程序创建和维护的任务。现代 UNIX 系统提供两种类型的对象库:静态库和共享库

静态库

静态库(有时也称为存档库)是早期 UNIX 系统上唯一的库类型。静态库本质上是一个结构化的编译对象模块捆绑包。要使用静态库中的函数,我们在构建程序时使用的链接命令中指定该库。在从主程序解析到静态库中模块的各种函数引用后,链接器从库中提取所需对象模块的副本,并将这些副本复制到生成的可执行文件中。我们称此类程序是静态链接的。 静态链接的每个程序都包含其所需的库对象模块的副本这一事实,造成了一些缺点。其中之一是,不同可执行文件中的对象代码重复浪费了磁盘空间。当同时执行使用相同库函数的静态链接程序时,还会发生相应的内存浪费;每个程序都需要将其自己的函数副本驻留在内存中。此外,如果库函数需要修改,那么在重新编译该函数并将其添加到静态库后,所有需要使用更新函数的应用程序都必须重新链接该库。

共享库

共享库的设计旨在解决静态库的问题。如果一个程序链接了共享库,那么链接器不会将库的对象模块复制到可执行文件中,而是写入一条记录到可执行文件中,以指示在运行时该可执行文件需要使用该共享库。当可执行文件在运行时加载到内存中时,一个称为动态链接器的程序会确保找到并加载可执行文件所需的共享库到内存中,并执行运行时链接,将可执行文件中的函数调用解析到共享库中相应的定义。在运行时,共享库的代码只需要驻留在内存中一个副本;所有正在运行的程序都可以使用该副本。 共享库包含函数的唯一编译版本,这可以节省磁盘空间。它还大大简化了确保程序使用函数最新版本的工作。只需用新的函数定义重新构建共享库,现有程序在下一次执行时就会自动使用新的定义。

进程间通信与同步

正在运行的 Linux 系统由许多进程组成,其中许多进程独立于彼此运行。然而,某些进程需要合作以实现其预期目的,这些进程需要与其他进程通信以及同步其操作的方法。进程间通信的一种方式是通过读写磁盘文件中的信息。然而,对于许多应用程序来说,这种方式过于缓慢且不够灵活。

因此,Linux,如同所有现代 UNIX 实现一样,提供了一套丰富的进程间通信(IPC)机制,包括以下这些: 信号,用于指示事件已发生; 管道(外壳用户熟悉的 | 操作符)和 FIFO,可用于在进程之间传输数据; 套接字,可用于在同一主机计算机或通过网络的异构主机之间传输数据; 文件锁定,允许进程锁定文件区域以防止其他进程读取或更新文件内容; 消息队列,用于在进程之间交换消息(数据包); 信号量,用于同步进程的操作; 以及共享内存,允许多个进程共享一块内存。当某个进程更改共享内存的内容时,所有其他进程都能立即看到这些更改。

UNIX 系统上的各种 IPC 机制,其功能有时重叠,部分原因是它们在不同版本的 UNIX 系统下演变以及各种标准的需求。例如,FIFO 和 UNIX 域套接字本质上执行相同的功能,即允许同一系统上的无关进程交换数据。两者都存在于现代 UNIX 系统中,因为 FIFO 源自 System V,而套接字源自 BSD。

信号

尽管我们在上一节中将信号列为了进程间通信(IPC)的一种方法,但信号通常在更广泛的上下文中使用,因此值得更深入的讨论。 信号通常被描述为“软件中断”。信号的到达会通知进程发生了某些事件或异常情况。有各种类型的信号,每种信号都标识了不同的事件或条件。每种信号类型都由不同的整数标识,其符号名称形式为 SIGxxxx。 信号由内核、另一个具有适当权限的进程或进程本身发送给进程。例如,当发生以下情况之一时,内核可能会向进程发送信号: 用户在键盘上输入了中断字符(通常是 Ctrl‑C); 进程的一个子进程已终止; 进程设置的计时器(闹钟)已到期; 或进程尝试访问无效的内存地址。 在外壳中,可以使用 kill 命令向进程发送信号。kill()系统调用在程序中提供相同的功能。当进程接收到信号时,它会根据信号采取以下行动: 它忽略信号; 它被信号终止; 或者它被挂起,直到稍后通过接收专用信号而恢复。 对于大多数信号类型,程序可以选择忽略默认信号行为(如果默认行为不是忽略),或者建立信号处理程序。信号处理程序是一个程序员定义的函数,当信号传递给进程时自动被调用。该函数执行一些针对生成信号的条件适当的行为。在信号生成和传递之间的时间间隔内,信号被认为是进程的挂起信号。通常,挂起的信号在接收进程下次调度运行时立即传递,或者如果进程已经在运行,则立即传递。然而,也可以通过将信号添加到进程的信号掩码中来阻塞信号。如果信号在阻塞时生成,它将保持挂起,直到稍后解阻塞(即从信号掩码中移除)。

线程

在现代 UNIX 实现中,每个进程可以拥有多个执行线程。一种设想线程的方式是将其视为一组共享相同虚拟内存以及其他一系列属性的过程。每个线程都在执行相同的程序代码,并共享相同的数据区和堆。然而,每个线程都有它自己的栈,其中包含局部变量和函数调用链接信息。 线程可以通过它们共享的全局变量进行相互通信。线程 API 提供了条件变量和互斥锁,这些是原语,使一个进程的线程能够通信并同步它们的操作,特别是它们对共享变量的使用。线程还可以使用 2.10 节中描述的 IPC 和同步机制进行相互通信。 使用线程的主要优势在于它们使得在协作线程之间通过全局变量共享数据变得容易,并且某些算法转接到多线程实现比转接到多进程实现更为自然。此外,多线程应用程序可以透明地利用多处理器硬件的并行处理可能性。

进程组和外壳作业控制

外壳执行的每个程序都在新的进程中启动。例如,外壳会创建三个进程来执行以下命令管道(该命令会按文件大小对当前工作目录中的文件列表进行排序): ​$ ls -l | sort -k5n | less 所有主要外壳,除了 Bourne shell,都提供了一个名为作业控制(job control)的交互式功能,允许用户同时执行和操作多个命令或管道。在支持作业控制的外壳中,管道中的所有进程都会被放入一个新的进程组或作业中。(在简单的单命令 shell 命令行中,会创建一个仅包含单个进程的新进程组。)进程组中的每个进程都有相同的整数进程组标识符,该标识符与组中某个进程的进程 ID 相同,该进程被称为进程组领导者。内核允许对进程组中的所有成员执行各种操作,尤其是发送信号。作业控制外壳使用此功能,允许用户挂起或恢复管道中的所有进程,如下一节所述。

日期和时间

两种时间类型对进程感兴趣: 实时时间是从某个标准点(日历时间)或从某个固定点(通常是进程生命周期中的开始点)进行测量(经过时间或墙时钟时间)。在 UNIX 系统中,日历时间以 1970 年 1 月 1 日凌晨午夜为基准点,以世界协调时间(通常缩写为 UTC)计算,并根据通过英国格林尼治的经线定义的时间区基准点进行协调。这个日期接近 UNIX 系统的诞生,被称为纪元。

进程时间,也称为 CPU 时间,是指进程自启动以来使用的总 CPU 时间。CPU 时间进一步分为系统 CPU 时间(即内核模式下执行代码的时间,即代表进程执行系统调用和提供其他内核服务的时间)和用户 CPU 时间(即用户模式下执行代码的时间,即执行正常程序代码的时间)。

time 命令显示实际时间、系统 CPU 时间和用户 CPU 时间,这些时间用于执行管道中的进程。

客户端‑服务器架构

在本书的多个地方,我们讨论了客户端‑服务器应用程序的设计和实现。 客户端‑服务器应用程序是一种被拆分为两个组件进程的应用程序:一个客户端,它通过向服务器发送请求消息来要求服务器执行某些服务;以及一个服务器,它检查客户端的请求、执行适当的操作,然后向客户端发送响应消息。 有时,客户端和服务器可能会进行一系列请求和响应的长时间对话。通常,客户端应用程序与用户交互,而服务器应用程序提供对某些共享资源的访问。通常情况下,有多个客户端进程实例与一个或几个服务器进程实例进行通信。客户端和服务器可以驻留在同一台主机计算机上,也可以驻留在通过网络连接的独立主机上。为了相互通信,客户端和服务器使用第 2.10 节中讨论的进程间通信机制。服务器可以实现多种服务,例如: 提供对数据库或其他共享信息资源的访问; 通过网络提供对远程文件的访问; 封装某些业务逻辑; 提供对共享硬件资源(例如,打印机)的访问; 或提供网页服务。 将服务封装在单个服务器内,出于以下一些原因而有用: 效率:与在每台计算机上本地提供相同资源(例如,打印机)相比,由服务器管理的一个资源实例可能更便宜。控制、协调和安全:通过将资源(尤其是信息资源)集中在一个位置,服务器可以协调对资源的访问(例如,以防止两个客户端同时更新同一信息),或对其进行保护,使其仅对选定的客户端可用。 异构环境中的运行:在网络中,各种客户端和服务器可以运行在不同的硬件和操作系统平台上。

实时

实时应用是指那些需要及时响应输入的应用。通常,这种输入来自外部传感器或专用输入设备,而输出则表现为控制某些外部硬件。具有实时响应要求的应用实例包括自动化装配线、银行自动取款机和飞机导航系统。尽管许多实时应用需要快速响应输入,但决定性因素是响应必须在触发事件后的特定截止时间内得到保证。提供实时响应能力,尤其是在需要短响应时间的情况下,需要底层操作系统的支持。大多数操作系统原生不提供此类支持,因为实时响应的要求可能与多用户分时操作系统的要求相冲突。传统的 UNIX 实现不是实时操作系统,尽管已经设计了实时变体。Linux 也有实时变体,而且最近的 Linux 内核正朝着为实时应用提供完全原生支持的方向发展。即使它们并不严格属于实时范畴,但如今大多数 UNIX 实现都支持其中部分或全部这些扩展。(在本书中,我们将描述 Linux 支持的 POSIX.1b 特性。)

POSIX.1b 为 POSIX.1 定义了若干扩展,以支持实时应用。这些扩展包括异步 I/O、共享内存、内存映射文件、内存锁定、实时时钟和计时器、替代调度策略、实时信号、消息队列以及信号量。尽管它们并不严格属于实时范畴,但如今大多数 UNIX 实现都支持其中部分或全部这些扩展。(在本书中,我们将描述 Linux 支持的 POSIX.1b 特性。) 在本书中,我们使用术语“实时(real time)”来指代日历或经过的时间的概念,而使用术语“实时(realtime)”来表示提供本节所述响应类型的操作系统或应用程序。

/proc 文件系统

与许多其他 UNIX 实现类似,Linux 提供了一个 /proc 文件系统,该文件系统由一组挂载在/proc 目录下的目录和文件组成。 /proc 文件系统是一个虚拟文件系统,它以类似于文件系统上的文件和目录的形式提供对内核数据结构的接口。这提供了一种查看和更改各种系统属性的简便机制。此外,一组以/proc/PID 形式命名的目录(其中 PID 是进程 ID)允许我们查看系统上每个正在运行的进程的信息。 /proc 文件的内容通常以人类可读的文本形式存在,可以被 shell 脚本解析。一个程序可以简单地打开并从所需文件中读取,或向其写入。在大多数情况下,一个进程必须具有特权才能修改/proc 目录中文件的内容。 在描述 Linux 编程接口的各个部分时,我们也会描述相关的/proc 文件。第 12.1 节提供了关于这个文件系统的更多一般性信息。/proc 文件系统没有被任何标准指定,我们所描述的细节都是 Linux 特有的。

第三章 系统编程概念

本章涵盖了系统编程的各个主题,这些主题是系统编程的先决条件。我们首先介绍系统调用,并详细说明它们执行过程中的步骤。然后,我们考虑库函数以及它们与系统调用的区别,并辅以对(GNU)C库的描述。

系统调用

一个系统调用是进入内核的受控入口点,允许一个进程请求内核代表该进程执行某些操作。例如,创建一个新的进程,执行 I/O,并为进程间通信创建一个管道。(syscalls(2) 手册页面列出了Linux 系统调用。)

一个系统调用会改变处理器状态从用户模式切换到内核模式,以便 CPU 可以访问受保护的内核内存。系统调用的集合是固定的。
每个系统调用由一个唯一的编号标识。(这种编号方案通常对程序不可见,程序通过名称来标识系统调用。)
每个系统调用可能有一组参数,用于指定信息在用户空间(即进程的虚拟地址空间)和内核空间之间进行传递。

从编程角度来看,调用系统调用看起来很像调用一个 C 函数。然而,在幕后,在系统调用的执行过程中会发生许多步骤。

  1. 应用程序通过调用 C 库中的包装函数来执行系统调用。

  2. 包装函数必须将所有系统调用参数传递给系统调用陷阱处理例程(稍后描述)。这些参数通过栈传递给包装函数,但内核期望它们在特定的寄存器中。包装函数将这些参数复制到这些寄存器中。

  3. 由于所有系统调用都以相同的方式进入内核,内核需要某种方法来识别系统调用。为此,包装函数将系统调用号复制到特定的 CPU 寄存器 (%eax) 中。

  4. 包装函数执行一个陷阱机器指令 (int 0x80),这会导致处理器从用户模式切换到内核模式,并执行系统陷阱向量中地址 0x80 (128 十进制) 指向的代码。

    更新的 x86‑32 架构实现了 sysenter 指令,它提供了一种比传统的 int 0x80中断指令更快的方法来进入内核模式。从 2.6 内核开始以及 glibc 2.3.2 及更高版本都支持使用 sysenter 。

  5. 在响应到位置 0x80 的中断时,内核会调用其系统_call() 例程(位于汇编文件arch/i386/entry.S)来处理该中断。该处理程序:

    1. a) 将寄存器值保存到内核栈(第6.5节)。
    2. b) 检查系统调用编号的有效性。
    3. c) 调用相应的系统调用服务例程,该例程通过使用系统调用编号索引所有系统调用服务例程的表(内核变量sys_call_table)。如果系统调用服务例程有任何参数,它首先检查它们的有效性;例如,它检查地址指向用户内存中的有效位置。然后服务例程执行所需任务,这可能涉及修改给定参数中指定地址的值,并在用户内存和内核内存之间传输数据(例如,在 I/O 操作中)。最后,服务例程向系统_call()例程返回一个结果状态。
    4. d) 从内核栈中恢复寄存器值,并将系统调用返回值放在栈上。
    5. e) 返回到包装函数,同时将处理器返回到用户模式。
  6. 如果系统调用服务例程的返回值指示出现错误,包装函数会使用此值设置全局变量errno(参见第3.4节)。然后包装函数返回给调用者,提供一个整数值来指示系统调用的成功或失败。

    在 Linux 上,系统调用服务例程遵循返回非负值以指示成功的约定。在出现错误的情况下,例程会返回一个负数,该负数是 errno 常量之一取反后的值。当返回负值时,C 库包装函数会将其取反(使其变为正数),将结果复制到errno 中,并将 ‑1 作为包装函数的函数结果返回,以向调用程序指示错误。

    此约定依赖于系统调用服务例程在成功时不返回负值的假设。然而,对于其中少数例程,这一假设并不成立。通常这不是问题,因为取反的errno值的范围与有效的负返回值范围不重叠。但是,此约定在一种情况下确实会导致问题:fcntl()系统调用的F_GETOWN 操作,我们将在第63.3节中描述。

在 Linux/x86‑32 上,execve() 是系统调用编号 11 (__NR_execve)。因此,在 sys_call_表向量中,条目 11包含 sys_execve() 的地址,这是该系统调用的服务例程。(在 Linux 中,系统调用服务例程通常命名为 sys_xyz(),其中 xyz() 是所讨论的系统调用。)

即使是对于简单的系统调用,也必须完成大量工作,因此系统调用具有微小但可感知的开销。作为系统调用的开销示例,考虑 getppid() 系统调用,它简单地返回调用进程的父进程的进程 ID。在一台运行 Linux 2.6.25 的作者 x86‑32 系统上,1000 万次 getppid() 调用大约需要 2.2 秒才能完成。这相当于每次调用约 0.3微秒。相比之下,在相同系统上,1000 万次调用一个简单地返回整数的 C 函数只需要 0.11 秒,或者 getppid() 调用所需时间的二十分之一。当然,大多数系统调用的开销都比 getppid() 大得多。

附录 A 描述了 strace 命令,该命令可用于跟踪程序所进行的系统调用,无论是出于调试目的,还是仅仅为了调查程序正在做什么。

库函数

许多库函数并不使用任何系统调用(例如,字符串处理函数)。另一方面,一些库函数是建立在系统调用之上的。例如,fopen()库函数使用open()系统调用来实际打开一个文件。通常,库函数的设计目的是提供比底层系统调用更友好的调用者接口。例如,printf()函数提供输出格式化和数据缓冲,而write()系统调用输出一个字节块。类似地,malloc() 和 free() 函数执行各种账目管理任务,这使得它们成为比底层 brk() 系统调用更简单的方式来分配和释放内存。

标准C库;GNU C 库 glibc

在各个UNIX实现中,标准C库有不同的实现。在Linux上最常用的实现是GNU C库(glibc,www.gnu.org/software/li… C库的主要开发者和维护者最初是Roland McGrath。如今,这项任务由Ulrich Drepper负责。各种其他 C 库可用于 Linux,包括内存需求较小的库,用于嵌入式设备应用程序。例如 uClibc (www.uclibc.org/) 和 diet libc(www.fefe.de/dietlibc/)。… glibc,因为它是 Linux 上大多数应用程序使用的 C 库。

确定系统上的 glibc 版本

我们需要确定系统上的 glibc 版本。从 Shell 中,我们可以通过将 glibc 共享库文件像可执行程序一样运行来做到这一点。当我们像可执行文件一样运行库时,它会显示各种文本,包括其版本号:/lib/libc.so.6

在一些 Linux 发行版中,GNU C 库位于 /lib/libc.so.6 之外的其他路径名下。确定库位置的一种方法是运行 ldd(列出动态依赖项)程序,针对一个动态链接了glibc 的可执行文件(大多数可执行文件都是以此方式链接)。然后,我们可以检查生成的库依赖项列表,以找到 glibc 共享库的位置:ldd myprog | grep libc​,libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6

应用程序可以通过两种方式确定系统上安装的GNU C库版本:通过测试常量调用库函数。从2.0版本开始,glibc定义了两个可以在编译时(在#ifdef 语句中)测试的常量,即__GLIBC__和__GLIBC_MINOR__。在一个安装了glibc 2.12的系统上,这些常量的值分别为2和12。然而,对于在一种系统上编译但在另一种使用不同glibc的系统上运行的应用程序来说,这些常量的作用有限。为了处理这种情况,应用程序可以调用gnu_get_libc_version()函数来确定运行时可用的glibc版本。

我们也可以通过使用 confstr() 函数来获取 (glibc 特有的) _CS_GNU_LIBC_VERSION 配置变量的值来获取版本信息。此调用返回一个字符串,例如 glibc 2.12。

处理系统调用和库函数的错误

几乎每个系统调用和库函数都会返回某种状态值,指示调用是否成功。应该始终检查此状态值,以确定调用是否成功。如果调用未成功,则应采取适当的措施——至少,程序应显示错误消息,警告出现了意外情况。

尽管通过省略这些检查来节省打字时间很有诱惑力(尤其是在看到UNIX和Linux程序示例,其中状态值没有被检查之后),但这是一种本末倒置的做法。由于没有检查系统调用或库函数的返回状态,而某个“不可能失败”的调用却真的失败了,导致许多小时的调试时间被浪费。

一些系统调用永远不会失败。例如,getpid() 总是成功返回一个进程的 ID,而 _exit() 总是终止一个进程。没有必要检查这些系统调用的返回值。

处理系统调用错误

每个系统调用的手册页都记录了该调用的可能返回值,并标明哪些值表示错误。通常,错误通过返回 –1 来指示。因此,可以使用如下代码检查系统调用:

fd = open(pathname, flags, mode); /* system call to open a file */
if (fd == -1) {
/* Code to handle the error */ 
}
if (close(fd) == -1) {
/* Code to handle the error */
}

当系统调用失败时,它会将全局整型变量 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,因此该变量可能因先前调用的错误而具有非零值。此外,SUSv3 允许成功的函数调用将 errno 设置为非零值(尽管很少有函数这样做)。因此,在检查错误时,我们应该首先检查函数返回值是否指示错误,然后才检查 errno 以确定错误的根本原因。

一些系统调用(例如 getpriority())在成功时可以合法地返回 –1。为了确定此类调用是否发生错误,我们在调用之前将 errno 设置为 0,然后在调用之后检查它。如果调用返回 –1 且 errno 不为零,则发生了错误。(类似的声明也适用于某些库函数。)

在系统调用失败后,一个常见的操作是根据 errno 值打印错误消息。为此,库提供了 perror() 和 strerror() 函数。perror() 函数打印其 msg 参数指向的字符串,后跟一个与当前 errno 值对应的错误消息。一种处理系统调用错误的方法如下:

fd = open(pathname, flags, mode);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}

strerror() 函数返回其 errnum 参数中指定的错误编号对应的错误字符串。strerror() 返回的字符串可能是静态分配的,这意味着它可能会被后续对 strerror() 的调用覆盖。如果 errnum 指定了一个未识别的错误编号,strerror() 会返回一个形式为 "Unknown error nnn" 的字符串。在其他一些实现中,strerror() 在此情况下会返回 NULL 。因为 perror() 和 strerror() 函数是区域设置敏感的(第 10.4 节),错误描述会以本地语言显示。

处理库函数的错误

各种库函数返回不同的数据类型和不同的值来表示失败。(请查阅每个函数的手册页。)根据我们的目的,库函数可以分为以下几类:

  1. 一些库函数以与系统调用完全相同的方式返回错误信息:返回‑1,并通过errno指示具体错误。remove()函数就是一个例子,它删除文件(使用unlink()系统调用)或目录(使用rmdir()系统调用)。这些函数的错误与系统调用的错误一样,可以以相同的方式诊断。
  2. 有些库函数在出错时返回非‑1的值,但仍然设置errno来指示具体错误情况。例如,fopen()在出错时返回一个NULL 指针,errno的设置取决于哪个底层系统调用失败。可以使用perror()和strerror()函数来诊断这些错误。
  3. 其他一些库函数根本不使用errno。确定错误存在性和原因的方法取决于具体函数,并在该函数的手册页中有说明。对于这些函数,使用errno、perror()或strerror()来诊断错误是错误的。

本书中示例程序说明

在本节中,我们描述了本书中提供的示例程序通常采用的多种约定和特性。

命令行选项和参数

本书中的许多示例程序依赖于命令行选项和参数来确定它们的行为。

传统的 UNIX 命令行选项由一个初始连字符、一个标识选项的字母以及一个可选的参数组成。(GNU 工具提供了扩展的选项语法,由两个初始连字符组成,后面跟着一个标识选项的字符串和一个可选的参数。)为了解析这些选项,我们使用标准的 getopt() 库函数(如附录 B 所述)。

常用函数和头文件

大部分示例程序包含一个包含常用定义的头文件,并且它们还使用一组常用函数。我们在本节中讨论头文件和函数。

通用头文件

列表 3‑1 是本书中几乎所有程序使用的头文件。此头文件包含许多示例程序使用的其他各种头文件,定义了一个布尔数据类型,并定义了用于计算两个数值的最小值和最大值的宏。使用此头文件可以使示例程序稍微短一些。

// 列表 3-1 大多数示例程序使用的头文件
//––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– lib/tlpi_hdr.h
#ifndef TLPI_HDR_H
#define TLPI_HDR_H /* Prevent accidental double inclusion */
#include <sys/types.h> /* Type definitions used by many programs */
#include <stdio.h> /* Standard I/O functions */
#include <stdlib.h> /* Prototypes of commonly used library functions
plus EXIT_SUCCESS and EXIT_FAILURE constants */
#include <unistd.h> /* Prototypes for many system calls */
#include <errno.h> /* Declares errno and defines error constants */
#include <string.h> /* Commonly used string-handling functions */
#include "get_num.h" /* Declares our functions for handling numeric
arguments (getInt(), getLong()) */
#include "error_functions.h" /* Declares our error-handling functions */
typedef enum { FALSE, TRUE } Boolean;
#define min(m,n) ((m) < (n) ? (m) : (n))
#define max(m,n) ((m) > (n) ? (m) : (n))
#endif
//–––––––––––––––––––––––––––––––––––––––––––––––––––––––––––lib/tlpi_hdr.h

错误诊断功能

为了简化示例程序中的错误处理,我们使用在清单 3‑2 中显示的错误诊断函数。

// 列表 3-2:常用错误处理函数的声明
//––––––––––––––––––––––––––––––––––––––––––––––––––––– lib/error _functions.h
#ifndef ERROR_FUNCTIONS_H
#define ERROR_FUNCTIONS_H
void errMsg(const char *format, ...);
#ifdef __GNUC__
/* This macro stops 'gcc -Wall' complaining that "control reaches
end of non-void function" if we use the following functions to
terminate main() or some other non-void function. */
#define NORETURN __attribute__ ((__noreturn__))
#else
#define NORETURN
#endif
void errExit(const char *format, ...) NORETURN ;
void err_exit(const char *format, ...) NORETURN ;
void errExitEN(int errnum, const char *format, ...) NORETURN ;
void fatal(const char *format, ...) NORETURN ;
void usageErr(const char *format, ...) NORETURN ;
void cmdLineErr(const char *format, ...) NORETURN ;
#endif
//––––––––––––––––––––––––––––––––––––––––––––––––––––– lib/error _functions.h

为了诊断来自系统调用和库函数的错误,我们使用 errMsg()、errExit()、err_exit() 和errExitEN()。

#include "tlpi_hdr.h"
void errMsg(const char *format, ...);
void errExit(const char *format, ...);
void err_exit(const char *format, ...);
void errExitEN(int errnum, const char *format, ...);

errMsg() 函数在标准错误上打印消息。其参数列表与 printf() 相同,不同之处在于输出字符串会自动追加一个换行符。errMsg() 函数打印与当前 errno 值对应的错误文本——这包括错误名称,例如 EPERM,以及由 strerror() 返回的错误描述——然后是参数列表中指定的格式化输出。

errExit() 函数与 errMsg() 类似,但还会终止程序,通过调用 exit(),或者如果环境变量 EF_DUMPCORE 已定义带有非空字符串值,通过调用 abort() 生成用于调试器的核心转储文件。(我们在第 22.1 节中解释核心转储文件。)

err_exit() 函数与 errExit() 类似,但在两个方面有所不同:

它不会在打印错误消息之前刷新标准输出。它通过调用 _exit() 而不是 exit() 来终止进程。这会导致进程在不刷新 stdio 缓冲区或调用退出处理程序的情况下终止。

这些在 err_exit() 运行操作中的差异将在第 25 章中变得更加清晰,在那里我们描述了 _exit() 和 exit() 之间的差异,并考虑了由 fork() 创建的子进程中 stdio 缓冲区和退出处理程序的处理方式。目前,我们只需注意,如果编写一个创建子进程的库函数,而该子进程需要由于错误而终止,那么 err_exit() 特别有用。这种终止应该在不刷新子进程的父进程(即调用进程)的 stdio 缓冲区副本的情况下发生,并且不调用父进程建立的退出处理程序。

主要地,我们在使用 POSIX 线程 API 的程序中使用 errExitEN()。与传统的UNIX 系统调用不同,后者在出错时返回 –1,POSIX 线程函数通过返回一个错误号(即通常放在 errno 中的正数类型)作为其函数结果来诊断错误。(POSIX 线程函数在成功时返回 0。)

我们可以使用如下代码来诊断 POSIX 线程函数的错误:

errno = pthread_create(&thread, NULL, func, &arg);
if (errno != 0)
	errExit("pthread_create");

然而,这种方法效率低下,因为 errno 在线程程序中定义为宏,该宏展开为返回可修改左值的函数调用。因此,每次使用 errno 都会导致函数调用。rrExitEN() 函数允许我们编写上述代码更高效的等价代码:

int s;
s = pthread_create(&thread, NULL, func, &arg);
if (s != 0)
	errExitEN(s, "pthread_create");

*在 C 术语中,lvalue 是一个引用存储区域的表达式。lvalue 最常见的例子是变量的标识符。某些运算符也会产生 lvalue。例如,如果 p 是一个指向存储区域的指针,那么 p 就是一个 lvalue。在 POSIX 线程 API 下,errno 被重新定义为返回一个指向线程特定存储区域(参见第 31.3 节)的指针。

要诊断其他类型的错误,我们使用 fatal()、usageErr() 和 cmdLineErr()。

#include "tlpi_hdr.h"
void fatal(const char *format, ...);
void usageErr(const char *format, ...);
void cmdLineErr(const char *format, ...);

致命函数(fatal())用于诊断一般错误,包括来自未设置errno的库函数的错误。其参数列表与printf()相同,只是输出字符串会自动附加一个换行符。它在标准错误上打印格式化的输出,然后像errExit()一样终止程序。

usageErr() 函数用于诊断命令行参数使用错误。它接受与 printf() 类似的参数列表,并将字符串 Usage: 后跟格式化的输出打印到标准错误,然后通过调用exit() 终止程序。(本书中的一些示例程序提供了它们自己的 usageErr() 函数的扩展版本,名为 usageError()。)

cmdLineErr() 函数与 usageErr() 类似,但用于诊断传递给程序的命令行参数中的错误。

我们的错误诊断函数的实现显示在列表 3‑3 中。

//清单 3‑3: 所有程序使用的错误处理函数
––––––––––––––––––––––––––––––––––––––––––––––––––––– lib/error_functions.c
#include <stdarg.h>
#include "error_functions.h"
#include "tlpi_hdr.h"
#include "ename.c.inc" /* Defines ename and MAX_ENAME */
#ifdef __GNUC__
__attribute__ ((__noreturn__))
#endif
static void
terminate(Boolean useExit3)
{
 char *s;
 /* Dump core if EF_DUMPCORE environment variable is defined and
 is a nonempty string; otherwise call exit(3) or _exit(2),
 depending on the value of 'useExit3'. */
 s = getenv("EF_DUMPCORE");
 if (s != NULL && *s != '\0')
 abort();
 else if (useExit3)
 exit(EXIT_FAILURE);
 else
 _exit(EXIT_FAILURE);
}

static void
outputError(Boolean useErr, int err, Boolean flushStdout,
 const char *format, va_list ap)
{
#define BUF_SIZE 500
 char buf[BUF_SIZE], userMsg[BUF_SIZE], errText[BUF_SIZE];
 vsnprintf(userMsg, BUF_SIZE, format, ap);
 if (useErr)
 snprintf(errText, BUF_SIZE, " [%s %s]",
 (err > 0 && err <= MAX_ENAME) ?
 ename[err] : "?UNKNOWN?", strerror(err));
 else
 snprintf(errText, BUF_SIZE, ":");
 snprintf(buf, BUF_SIZE, "ERROR%s %s\n", errText, userMsg);
 if (flushStdout)
 fflush(stdout); /* Flush any pending stdout */
 fputs(buf, stderr);
 fflush(stderr); /* In case stderr is not line-buffered */
}

void
errMsg(const char *format, ...)
{
 va_list argList;
 int savedErrno;
 savedErrno = errno; /* In case we change it here */
 va_start(argList, format);
 outputError(TRUE, errno, TRUE, format, argList);
 va_end(argList);
 errno = savedErrno;
}

void
errExit(const char *format, ...)
{
 va_list argList;
 va_start(argList, format);
 outputError(TRUE, errno, TRUE, format, argList);
 va_end(argList);
 terminate(TRUE);
}

void
err_exit(const char *format, ...)
{
 va_list argList;
 va_start(argList, format);
 outputError(TRUE, errno, FALSE, format, argList);
 va_end(argList);
 terminate(FALSE);
}

void
errExitEN(int errnum, const char *format, ...)
{
 va_list argList;
 va_start(argList, format);
 outputError(TRUE, errnum, TRUE, format, argList);
 va_end(argList);
 terminate(TRUE);
}

void
fatal(const char *format, ...)
{
 va_list argList;
 va_start(argList, format);
 outputError(FALSE, 0, TRUE, format, argList);
 va_end(argList);
 terminate(TRUE);
}

void
usageErr(const char *format, ...)
{
 va_list argList;
 fflush(stdout); /* Flush any pending stdout */
 fprintf(stderr, "Usage: ");
 va_start(argList, format);
 vfprintf(stderr, format, argList);
 va_end(argList);
 fflush(stderr); /* In case stderr is not line-buffered */
 exit(EXIT_FAILURE);
}

void
cmdLineErr(const char *format, ...)
{
 va_list argList;
 fflush(stdout); /* Flush any pending stdout */
 fprintf(stderr, "Command-line usage error: ");
 va_start(argList, format);
 vfprintf(stderr, format, argList);
 va_end(argList);
 fflush(stderr); /* In case stderr is not line-buffered */
 exit(EXIT_FAILURE);
}
//––––––––––––––––––––––––––––––––––––––––––––––––––––– lib/error_functions.c

文件 enames.c.inc 由 Listing 3‑3 引入,在 Listing 3‑4 中显示。该文件定义了一个字符串数组 ename,其中包含与每个可能的 errno 值对应的符号名称。我们的错误处理函数使用该数组来打印出与特定错误编号对应的符号名称。这是一种解决方法,用于处理以下事实:一方面,strerror() 返回的字符串并不标识其错误消息对应的符号常量;另一方面,手册页面使用符号名称来描述错误。打印出符号名称为我们提供了一种在手册页面中查找错误原因的简便方法。

ename.c.inc 文件的内容是架构特定的,因为errno值在不同的Linux硬件架构之间略有不同。Listing 3‑4中显示的版本是为Linux 2.6/x86‑32系统准备的。这个文件是使用本书源代码分发中包含的脚本(lib/Build_ename.sh)构建的。这个脚本可以用来构建一个适用于特定硬件平台和内核版本的ename.c.inc 版本。

请注意,ename 数组中的一些字符串为空。这些对应于未使用的错误值。此外,ename 中的一些字符串包含两个用斜杠分隔的错误名称。这些字符串对应于两个符号错误名称具有相同数值的情况。

从 ename.c.inc 文件中,我们可以看到 EAGAIN 和 EWOULDBLOCK 错误具有相同的值。(SUSv3 明确允许这种情况,并且这些常量的值在大多数(但并非所有)其他 UNIX 系统上是相同的。) 这些错误是由系统调用在通常会阻塞(即被迫在完成前等待)的情况下返回的,但调用者请求系统调用返回错误而不是阻塞。EAGAIN 源于 System V,并且是执行 I/O、信号量操作、消息队列操作和文件锁定(fcntl())的系统调用返回的错误。EWOULDBLOCK 源于 BSD,并且是由文件锁定(flock())和套接字相关的系统调用返回的错误。

在SUSv3中, EWOULDBLOCK 仅在套接字相关接口的规范中被提及。对于这些接口,SUSv3允许非阻塞调用返回EAGAIN 或EWOULDBLOCK 。对于所有其他非阻塞调用,SUSv3中仅规定了错误EAGAIN 。

//列表 3‑4: Linux 错误名称 (x86‑32 版本)
//––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– lib/ename.c.inc
static char *ename[] = {
/* 0 */ "",
/* 1 */ "EPERM", "ENOENT", "ESRCH", "EINTR", "EIO", "ENXIO", "E2BIG",
/* 8 */ "ENOEXEC", "EBADF", "ECHILD", "EAGAIN/EWOULDBLOCK", "ENOMEM",
/* 13 */ "EACCES", "EFAULT", "ENOTBLK", "EBUSY", "EEXIST", "EXDEV" ,
/* 19 */ "ENODEV", "ENOTDIR", "EISDIR", "EINVAL", "ENFILE", "EMFILE" ,
/* 25 */ "ENOTTY", "ETXTBSY", "EFBIG", "ENOSPC", "ESPIPE", "EROFS" ,
/* 31 */ "EMLINK", "EPIPE", "EDOM", "ERANGE", "EDEADLK/EDEADLOCK" ,
/* 36 */ "ENAMETOOLONG", "ENOLCK", "ENOSYS", "ENOTEMPTY", "ELOOP", "" ,
/* 42 */ "ENOMSG", "EIDRM", "ECHRNG", "EL2NSYNC", "EL3HLT", "EL3RST" ,
/* 48 */ "ELNRNG", "EUNATCH", "ENOCSI", "EL2HLT", "EBADE", "EBADR" ,
/* 54 */ "EXFULL", "ENOANO", "EBADRQC", "EBADSLT", "", "EBFONT", "ENOSTR" ,
/* 61 */ "ENODATA", "ETIME", "ENOSR", "ENONET", "ENOPKG", "EREMOTE",
/* 67 */ "ENOLINK", "EADV", "ESRMNT", "ECOMM", "EPROTO", "EMULTIHOP" ,
/* 73 */ "EDOTDOT", "EBADMSG", "EOVERFLOW", "ENOTUNIQ", "EBADFD" ,
/* 78 */ "EREMCHG", "ELIBACC", "ELIBBAD", "ELIBSCN", "ELIBMAX" ,
/* 83 */ "ELIBEXEC", "EILSEQ", "ERESTART", "ESTRPIPE", "EUSERS" ,
/* 88 */ "ENOTSOCK", "EDESTADDRREQ", "EMSGSIZE", "EPROTOTYPE" ,
/* 92 */ "ENOPROTOOPT", "EPROTONOSUPPORT", "ESOCKTNOSUPPORT" ,
/* 95 */ "EOPNOTSUPP/ENOTSUP", "EPFNOSUPPORT", "EAFNOSUPPORT" ,
/* 98 */ "EADDRINUSE", "EADDRNOTAVAIL", "ENETDOWN", "ENETUNREACH" ,
/* 102 */ "ENETRESET", "ECONNABORTED", "ECONNRESET", "ENOBUFS", "EISCONN",
/* 107 */ "ENOTCONN", "ESHUTDOWN", "ETOOMANYREFS", "ETIMEDOUT" ,
/* 111 */ "ECONNREFUSED", "EHOSTDOWN", "EHOSTUNREACH", "EALREADY",
/* 115 */ "EINPROGRESS", "ESTALE", "EUCLEAN", "ENOTNAM", "ENAVAIL" ,
/* 120 */ "EISNAM", "EREMOTEIO", "EDQUOT", "ENOMEDIUM", "EMEDIUMTYPE" ,
/* 125 */ "ECANCELED", "ENOKEY", "EKEYEXPIRED", "EKEYREVOKED" ,
/* 129 */ "EKEYREJECTED", "EOWNERDEAD", "ENOTRECOVERABLE", "ERFKILL"
};
#define MAX_ENAME 132
//––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– lib/ename.c.inc

解析数值型命令行参数的函数

Listing 3‑5 中的头文件提供了两个我们经常用于解析整数命令行参数的函数的声明:getInt() 和 getLong()。使用这些函数而不是 atoi()、atol() 和 strtol() 的主要优势在于它们提供了一些基本的数值参数有效性检查。

#include "tlpi_hdr.h"
int getInt(const char *arg, int flags, const char *name);
long getLong(const char *arg, int flags, const char *name);
// 都返回arg转换后的数值格式

getInt()和getLong()函数分别将arg指向的字符串转换为int或long。如果arg不包含有效的整数字符串(即,仅包含数字和字符+ 和-),则这些函数会打印错误消息并终止程序。标志参数为getInt()和getLong()函数提供了一些操作控制。默认情况下,这些函数期望包含有符号十进制整数的字符串。通过将Listing 3‑5中定义的GN_* 常量之一或多个与flags进行按位或运算(|),我们可以选择不同的转换基数,并限制数字的范围为非负或大于0。getInt() 和 getLong() 函数的实现提供在列表 3‑6 中。

尽管 flags 参数允许我们执行主文中描述的范围检查,但在某些情况下,我们的示例程序甚至不请求此类检查,尽管这样做似乎很合理。例如,在清单47‑1 中,我们不检查 init‑value 参数。这意味着用户可以将负数指定为信号量的初始值,这会导致后续的 semctl() 系统调用出现错误 (ERANGE),因为信号量不能有负值。在这种情况下省略范围检查,使我们不仅可以实验系统调用和库函数的正确使用,还可以看看当提供无效参数时会发生什么。实际应用程序通常会对其命令行参数实施更严格的检查。

//清单 3‑5:get_num.c 的头文件
//–––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– lib/get_num.h
#ifndef GET_NUM_H
#define GET_NUM_H
#define GN_NONNEG 01 /* Value must be >= 0 */
#define GN_GT_0 02 /* Value must be > 0 */
 /* By default, integers are decimal */
#define GN_ANY_BASE 0100 /* Can use any base - like strtol(3) */
#define GN_BASE_8 0200 /* Value is expressed in octal */
#define GN_BASE_16 0400 /* Value is expressed in hexadecimal */
long getLong(const char *arg, int flags, const char *name);
int getInt(const char *arg, int flags, const char *name);
#endif
//–––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– lib/get_num.h

//清单 3‑6: 用于解析数值命令行参数的函数
//–––––––––––––––––––––––––––––––––––––––––––––––––––––– lib/get_num.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>
#include <errno.h>
#include "get_num.h"
static void
gnFail(const char *fname, const char *msg, const char *arg, const char *name)
{
 fprintf(stderr, "%s error", fname);
 if (name != NULL)
 fprintf(stderr, " (in %s)", name);
 fprintf(stderr, ": %s\n", msg);
 if (arg != NULL && *arg != '\0')
 fprintf(stderr, " offending text: %s\n", arg);
 exit(EXIT_FAILURE);
}

static long
getNum(const char *fname, const char *arg, int flags, const char *name)
{
 long res;
 char *endptr;
 int base;
 if (arg == NULL || *arg == '\0')
 gnFail(fname, "null or empty string", arg, name);
 base = (flags & GN_ANY_BASE) ? 0 : (flags & GN_BASE_8) ? 8 :
 (flags & GN_BASE_16) ? 16 : 10;
 errno = 0;
 res = strtol(arg, &endptr, base);
 if (errno != 0)
 gnFail(fname, "strtol() failed", arg, name);
 if (*endptr != '\0')
 gnFail(fname, "nonnumeric characters", arg, name);
 if ((flags & GN_NONNEG) && res < 0)
 gnFail(fname, "negative value not allowed", arg, name);
 if ((flags & GN_GT_0) && res <= 0)
 gnFail(fname, "value must be > 0", arg, name);
 return res;
}

long
getLong(const char *arg, int flags, const char *name)
{
 return getNum("getLong", arg, flags, name);
}

int
getInt(const char *arg, int flags, const char *name)
{
 long res;
 res = getNum("getInt", arg, flags, name);
 if (res > INT_MAX || res < INT_MIN)
 gnFail("getInt", "integer out of range", arg, name);
 return (int) res;
}
//–––––––––––––––––––––––––––––––––––––––––––––––––––––– lib/get_num.c

功能测试宏

各种标准规定了系统调用和库函数 API 的行为(参见第 1.3 节)。其中一些标准由标准组织定义,例如 The Open Group(单UNIX规范),而另一些标准则由两个历史上重要的 UNIX 实现定义:BSD 和 System V Release 4(以及相关的System V 接口定义)。有时,在编写可移植应用程序时,我们可能希望各种头文件仅暴露遵循特定标准的定义(常量、函数原型等)。为此,我们在编译程序时定义下面列出的一个或多个特性测试宏。我们可以通过在包含任何头文件之前在程序源代码中定义宏来做这件事:#define _BSD_SOURCE 1​,或者,我们可以使用 C 编译器的 –D 选项:$ cc -D_BSD_SOURCE prog.c

术语“特性测试宏”可能看起来令人困惑,但如果我们从实现的视角来看待,它就很有意义了。实现通过使用 #if 测试应用程序为这些宏定义了哪些值,来决定在每个头文件中应使其可见的特性。

以下特性测试宏由相关标准指定,因此它们的使用可移植到所有支持这些标准的系统:

_POSIX_SOURCE 如果定义(具有任何值),则暴露符合 POSIX.1‑1990 和 ISOC(1990)的定义。此宏被 _POSIX_C_SOURCE 取代。

_POSIX_C_SOURCE 如果定义为值 1,这具有与 _POSIX_SOURCE 相同的效果。如果定义为大于或等于 199309 的值,也公开 POSIX.1b(实时)的定义。如果定义为大于或等于 199506 的值,也公开 POSIX.1c(线程)的定义。如果定义为值200112,也公开 POSIX.1‑2001 基本规范的定义(即,XSI 扩展被排除)。(在版本 2.3.3 之前,glibc 头文件不会解释 _POSIX_C_SOURCE 的值 200112。)如果使用 200809的值定义,也请公开 POSIX.1‑2008 基本规范的定义。(在 2.10 版本之前,glibc 头文件不会解释 _POSIX_C_SOURCE 的值 200809。)

_XOPEN_SOURCE 如果定义(使用任何值),则公开 POSIX.1、POSIX.2 和 X/Open(XPG4) 定义。如果使用 500 或更大的值定义,则还公开 SUSv2(UNIX98 和 XPG5)扩展。设置为 600 或更大,此外还公开 SUSv3 XSI(UNIX 03)扩展和 C99 扩展。(在版本 2.2 之前,glibc 头文件不解释_XOPEN_SOURCE 的值 600。)设置为 700 或更大,还公开 SUSv4 XSI 扩展。(在版本 2.10 之前,glibc 头文件不解释 _XOPEN_SOURCE 的值 700。)对于_XOPEN_SOURCE 的值 500、600 和 700 是这样选择的,因为 SUSv2、SUSv3 和 SUSv4 分别是 X/Open 规范的第 5、第 6 和第 7 个问题。

以下列出的特性测试宏是 glibc 特有的:

_BSD_SOURCE 如果定义(使用任何值),则暴露 BSD 定义。定义此宏还会将_POSIX_C_SOURCE 定义为值 199506。显式设置仅此宏会导致在标准冲突的某些情况下优先使用 BSD 定义。

_SVID_SOURCE 如果定义(使用任何值),公开 System V 接口定义 (SVID) 定义。

_GNU_SOURCE 如果定义(使用任何值),则通过设置所有前面的宏以及各种 GNU 扩展来公开所有提供的定义。

如果定义了单个宏,或者编译器以其中一种标准模式(例如,cc –ansi 或 cc –std=c99)被调用,那么仅提供所请求的定义。有一个例外:如果 _POSIX_C_SOURCE 没有被其他方式定义,并且编译器没有以其中一种标准模式被调用,那么 _POSIX_C_SOURCE 将被定义为值 200809(对于 glibc 版本 2.4 到 2.9,为 200112;对于 glibc 版本早于 2.4的,为 199506)。

定义多个宏是累积的,因此我们可以,例如,使用以下 cc 命令显式选择与默认提供相同的宏设置:

$ cc -D_POSIX_SOURCE -D_POSIX_C_SOURCE=199506 \
-D_BSD_SOURCE -D_SVID_SOURCE prog.c

<features.h> 头文件和 feature_test_macros(7) 手册页提供了关于每个功能测试宏被分配的确切值的更多信息。

本書中所有的源代碼示例都是編寫的,以便它們可以使用默認的 GNU C 编译器選項或以下選項進行編譯:

$ cc -std=c99 -D_XOPEN_SOURCE=600

系统数据类型

各种实现数据类型使用标准 C 类型表示,例如进程 ID、用户 ID 和文件偏移量。虽然可以使用 C 基本类型(如 int 和 long)来声明存储此类信息的变量,但这会降低跨 UNIX 系统的可移植性,原因如下:

  1. 这些基本类型的尺寸在不同的 UNIX 实现中有所不同(例如,一个 long 类型在一个系统上可能是 4 字节,在另一个系统上可能是 8 字节),有时甚至在同一实现的不同编译环境中也有所不同。此外,不同的实现可能会使用不同的类型来表示相同的信息。例如,一个进程 ID 在一个系统上可能是 int 类型,在另一个系统上可能是 long 类型。
  2. 即使在单个 UNIX 实现中,用来表示信息的类型在不同的实现版本之间也可能不同。Linux 上的一个显著例子是用户和组ID。在 Linux 2.2 及更早版本中,这些值是用 16 位表示的。在 Linux 2.4 及更高版本中,它们是 32 位值。

为避免此类可移植性问题,SUSv3 指定了多种标准系统数据类型,并要求实现者适当地定义和使用这些类型。每种类型都使用 C typedef 功能进行定义。例如,pid_t 数据类型用于表示进程ID,在 Linux/x86‑32 上该类型定义如下:typedef int pid_t;

大部分标准系统数据类型的名称以 _t 结尾。其中许多在头文件 <sys/types.h> 中声明,尽管也有一些在其他头文件中定义。

一个应用程序应该使用这些类型定义来可移植地声明它使用的变量。例如,以下声明将允许应用程序在任何符合SUSv3的系统上正确表示进程ID:

pid_t mypid;

表 3‑1 列出了本书中我们将遇到的一些系统数据类型。对于此表中的某些类型,SUSv3 要求该类型应作为算术类型实现。这意味着实现可以选择底层类型为整数或浮点(实数或复数)类型。

Table 3-1: 精选系统数据类型

数据类型SUSv3 类型要求描述
blkcnt_tsigned integer文件块计数(见 15.1 节)
blksize_tsigned integer文件块大小(见 15.1 节)
cc_tunsigned integer终端特殊字符(见 62.4 节)
clock_tinteger or real-floating时钟滴答形式的系统时间(见 10.7 节)
clockid_tan arithmetic typePOSIX.1b 时钟和计时器函数的时钟标识符(见 23.6 节)
comp_tnot in SUSv3压缩时钟滴答(见 28.1 节)
dev_tan arithmetic type设备号,包含主设备号和次设备号(见 15.1 节)
DIRno type requirement目录流(见 18.8 节)
fd_setstructure typeselect () 函数的文件描述符集(见 63.2.1 节)
fsblkcnt_tunsigned integer文件系统块计数(见 14.11 节)
fsfilcnt_tunsigned integer文件计数(见 14.11 节)
gid_tinteger数值型组标识符(见 8.3 节)
id_tinteger用于存储标识符的通用类型;足够大以至少容纳 pid_t、uid_t 和 gid_t
in_addr_t32-bit unsigned integerIPv4 地址(见 59.4 节)
in_port_t16-bit unsigned integerIP 端口号(见 59.4 节)
ino_tunsigned integer文件索引节点号(见 15.1 节)
key_tan arithmetic typeSystem V IPC 键(见 45.2 节)
mode_tinteger文件权限和类型(见 15.1 节)
mqd_tno type requirement, but shall not be an array typePOSIX 消息队列描述符
msglen_tunsigned integerSystem V 消息队列允许的字节数(见 46.4 节)
msgqnum_tunsigned integerSystem V 消息队列中的消息计数(见 46.4 节)
nfds_tunsigned integerpoll () 函数的文件描述符数量(见 63.2.2 节)
nlink_tinteger文件的(硬)链接计数(见 15.1 节)
off_tsigned integer文件偏移量或大小(见 4.7 节和 15.1 节)
pid_tsigned integer进程 ID、进程组 ID 或会话 ID(见 6.2 节、34.2 节和 34.3 节)
ptrdiff_tsigned integer两个指针值之间的差值(有符号整数形式)
rlim_tunsigned integer资源限制(见 36.2 节)
sa_family_tunsigned integer套接字地址族(见 56.4 节)
shmatt_tunsigned integerSystem V 共享内存段的附加进程计数(见 48.8 节)
sig_atomic_tinteger可原子访问的数据类型(见 21.1.3 节)
siginfo_tstructure type信号来源信息(见 21.4 节)
sigset_tinteger or structure type信号集(见 20.9 节)
size_tunsigned integer对象的字节大小
socklen_tinteger type of at least 32 bits套接字地址结构的字节大小(见 56.3 节)
speed_tunsigned integer终端线路速度(见 62.7 节)
ssize_tsigned integer字节计数或(负数)错误指示
stack_tstructure type备用信号栈描述(见 21.3 节)
suseconds_tsigned integer allowing range [–1, 1000000]微秒级时间间隔(见 10.1 节)
tcflag_tunsigned integer终端模式标志位掩码(见 62.2 节)
time_tinteger or real-floating从纪元(Epoch)开始的日历时间(秒数)(见 10.1 节)
timer_tan arithmetic typePOSIX.1b 间隔计时器函数的计时器标识符(见 23.6 节)
uid_tinteger数值型用户标识符(见 8.1 节)

当在后续章节中讨论表 3‑1 中的数据类型时,我们将经常做出一些声明,即某些类型“是 [SUSv3指定的] 整数类型”。这意味着 SUSv3 要求将类型定义为整数,但并不要求使用特定的原生整数类型(例如 short、int 或 long)。(通常,我们不会说明在 Linux 中实际用于表示每个系统数据类型的具体原生数据类型,因为可移植应用程序应该编写得使其不关心使用哪种数据类型。)

打印系统数据类型值

当打印表 3‑1 中所示的一种数值系统数据类型的值(例如,pid_t 和 uid_t)时,我们必须小心不要在 printf() 调用中包含表示依赖性。表示依赖性可能会发生,因为C 的参数提升规则将 short 类型的值转换为 int,但保持 int 和 long 类型的值不变。这意味着,根据系统数据类型的定义,要么传递 int,要么传递 long 到 printf() 调用中。然而,因为 printf() 没有办法在运行时确定其参数的类型,调用者必须使用%d 或 %ld 格式说明符显式地提供此信息。问题是,在 printf() 调用中简单地编码其中一个说明符会创建实现依赖性。通常的解决方案是使用 %ld 说明符,并始终将相应的值转换为 long,如下所示:

pid_t mypid;
mypid = getpid(); /* Returns process ID of calling process */
printf("My PID is %ld\n", (long) mypid);

C99标准定义了printf()的 z 长度修饰符,用于指示接下来的整数转换对应于size_t或ssize_t类型。因此,我们可以写%zd 而不是使用%ld 加上强制转换来处理这些类型。尽管这个修饰符在glibc中可用,但我们避免使用它,因为它并非所有UNIX实现都支持。

C99 标准还定义了 j 长度修饰符,它指定相应的参数类型为 intmax_t(或 uintmax_t),这是一种保证足够大的整数类型,能够表示任何类型的整数。最终,使用 (intmax_t) 强制转换加上 %jd 修饰符应该取代 (long) 强制转换加上 %ld 修饰符,作为打印数值系统数据类型值的最佳方式,因为前者方法也能处理 long long 值以及任何扩展整数类型,例如 int128_t。但是(再次)我们避免这种技术,因为它在所有 UNIX 实现中并非都可行。

其它可移植性问题

在这一节中,我们考虑在编写系统程序时可能遇到的其他可移植性问题。

初始化和使用结构体

每个UNIX实现都指定了一系列标准结构,这些结构在系统调用和库函数中用于各种用途。例如,考虑sembuf结构,它用于表示semop()系统调用要执行的信号量操作:

struct sembuf {
unsigned short sem_num; /* Semaphore number */
short sem_op; /* Operation to be performed */
short sem_flg; /* Operation flags */
};

尽管SUSv3指定了如sembuf结构等结构,但重要的是要认识到以下几点:

通常情况下,此类结构体中字段定义的顺序并未指定。
在某些情况下,此类结构体中可能包含额外的、与实现相关的字段。

因此,使用如下结构体初始化器是不可移植的:struct sembuf s = { 3, -1, SEM_UNDO };

尽管此初始化器在 Linux 上可以工作,但在另一个实现中(其中 sembuf 结构中的字段定义顺序不同)则无法工作。为了可移植地初始化此类结构,我们必须使用显式赋值语句,如下所示:

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
/* Use WCOREDUMP() macro */
#endif

跨实现差异化的所需头文件

在某些情况下,为了原型设计各种系统调用和库函数,所需的头文件在不同的UNIX实现中可能会有所不同。在本书中,我们展示了Linux的要求,并注明了与SUSv3的任何差异。在 Linux 或 SUSv3 上不是必需的,但由于某些其他(尤其是较旧的)实现可能需要它,我们在可移植程序中应该包含它。

练习

当使用 Linux 特有的 reboot() 系统调用重启系统时,第二个参数 magic2 必须指定为一组魔法数字(例如,LINUX_REBOOT_MAGIC2)中的一个。这些数字有什么意义?(将它们转换为十六进制可以提供线索。)

reboot 此系统调用失败(返回错误 EINVAL),除非 magic 等于 LINUX_REBOOT_MAGIC1(即 0xfee1dead)
且 magic2 等于 LINUX_REBOOT_MAGIC2(即 0x28121969)。
Linux 2.1.17 也允许 LINUX_REBOOT_MAGIC2A(即 0x05121996)作为 magic2 的值,
Linux 2.1.97 也允许 LINUX_REBOOT_MAGIC2B(即 0x16041998)作为值,
Linux 2.5.71 也允许 LINUX_REBOOT_MAGIC2C(即 0x20112000)作为值。(这些常量的十六进制值是有意义的。)

不难看出其格式为日月年,很容易让人联想到这是开发者妻子与三个女儿的生日组合。