读linux programming interface chapter 6

读linux programming interface chapter 6

程序是描述如何在运行时构建一个进程的文件。包括:

  • 二进制格式识别:每个程序文件都包含metadata,描述了可执行文件的格式。使内核能够解释文件中的其余信息。历史上UNIX可执行文件两种广泛使用的格式是原始的a.out(“汇编器输出”)格式和后来更复杂的COFF(通用对象文件格式)。如今大多数UNIX实现(包括Linux)使用可执行和链接格式(ELF)
  • 机器语言指令:编码了程序的算法
  • 程序入口点地址:标识了程序执行应该开始的指令位置
  • 数据:用于初始化变量的值,及程序使用的字面常量(如字符串)
  • 符号和重定位表:这述程序中函数和变量的位置和名称。这些表用于多种目的,包括调试和运行时符号解析(动态链接)
  • 共享库和动态链接信息:包含列出程序在运行时需要使用的共享库,以及用于加载这些库的动态链接器的路径名
  • 其他信息:包含描述如何构建进程的各种其他信息。一个程序可用来构建许多进程,或者相反地,许多进程可能正运行相同程序

进程(process)是内核定义的抽象实体,系统资源被分配给它以执行程序

从内核角度看,进程由用户空间内存组成,该内存包含程序代码和该代码使用的变量,及一系列内核数据结构(维护有关进程状态的信息)。内核数据结构中记录的信息包括与进程相关联的各种标识符号(ID)、虚拟内存表、打开的文件描述符表、信号传递和处理的信息、进程资源使用和限制、当前工作目录等

kill()允许调用者向特定进程ID的进程发送信号。如果需要构建对进程唯一的标识符,进程ID也很有用。一个常见例子是将进程ID作为进程唯一文件名的一部分

#include <unistd.h>
pid_t getpid(void);
// 总是成功返回调用者的进程ID

getpid()返回值的pid_t数据类型是SUSv3指定的整数类型,用于存储进程ID

Linux内核将进程ID限制为小于或等于32767。创建新进程时,它将被分配下一个顺序可用的进程ID。每当达到32,767的极限时,内核会重置其进程ID计数器,以便从低整数值开始分配进程ID。进程ID计数器将重置为300而不是1。因为许多低编号的进程ID被系统进程和守护进程永久使用,在这个范围内搜索未使用的进程ID将浪费时间

Linux 2.4之前,32,767的进程ID限制由内核常量PID_MAX定义。Linux 2.6后虽然默认上限是32,767,但可通过Linux特有的/proc/sys/kernel/pid_max文件中的值(该值比最大进程ID大1)调整。32位平台上此文件的最大值为32,768,64位平台上可以调整为任意高达2^22(约400万)的值

进程可以使用getppid()系统调用找出其父进程的进程ID。

#include <unistd.h>
pid_t getppid(void);
// 总是成功返回调用者父进程的进程ID

如果子进程因为其“出生”父进程终止而成为孤儿,那么该子进程将被init进程收养,子进程中后续对getppid()的调用将返回1(见第26.2节)。

可以通过查看Linux特有的/proc/PID/status文件中提供的PPid字段来找到任何进程的父进程。

分配给每个进程的内存由多个部分组成,通常被称为段(segments)。这些段包括:

  • 文本段(text segment)包含进程运行的程序的机器语言指令。文本段被设置为只读,以防止进程通过错误的指针值意外修改自己的指令。由于许多进程可能运行相同的程序,文本段被设置为可共享,以便单个程序代码副本可以映射到所有进程的虚拟地址空间中。
  • 初始化数据段(initialized data segment)包含明确初始化的全局变量和静态变量。当程序加载到内存时,这些变量的值从可执行文件中读取。
  • 未初始化数据段(uninitialized data segment)包含未明确初始化的全局变量和静态变量。在启动程序之前,系统将此段中的所有内存初始化为0。出于历史原因,这通常被称为bss段,这个名字来自一个旧的汇编器助记符,“由符号开始的块”。将初始化的全局和静态变量放置在与未初始化的变量分开的段中的主要原因是,当程序存储在磁盘上时,不需要为未初始化的数据分配空间。相反,可执行文件只需要记录未初始化数据段所需的位置和大小,这些空间在运行时由程序加载器分配。
  • 栈(stack)是一个动态增长和收缩的段,包含栈帧。每个当前被调用的函数都分配一个栈帧。一个帧存储函数的局部变量(所谓的自动变量)、参数和返回值。栈帧在第6.5节中有更详细的讨论。
  • 堆(heap)是一个区域,可以在运行时动态分配内存(用于变量)。堆的顶端被称为程序中断点。

size(1)命令显示二进制可执行文件的文本段、初始化数据段和未初始化数据段(bss)的大小。

正文中使用的“段”这个术语不应与某些硬件架构(如x86-32)上使用的硬件分段混淆。而是指UNIX系统上进程虚拟内存的逻辑划分。 在本书的许多地方,我们指出库函数返回指向静态分配内存的指针。这意味着内存是在初始化数据段或未初始化数据段中分配的。(在某些情况下,库函数可能一次性在堆上动态分配内存;然而,这种实现细节与我们在这里描述的语义点无关。)了解库函数通过静态分配的内存返回信息的情况很重要,因为该内存的存在独立于函数调用,并且可能会被同一函数的后续调用(或在某些情况下,相关函数的后续调用)覆盖。使用静态分配的内存使函数变得不可重入

展示了C语言中不同类型的变量,以及注释说明了每个变量所在的段。这些注释假设了一个非优化编译器和一个所有参数都通过栈传递的应用程序二进制接口(ABI)。实际上,优化编译器可能会将频繁使用的变量分配在寄存器中,或者完全优化掉一个变量。此外,一些ABI要求函数参数和函数结果通过寄存器传递,而不是通过栈。尽管如此,这个示例有助于演示C变量与进程段之间的映射。

proc/mem_segments.c
#include <stdio.h>
#include <stdlib.h>
 
char globBuf[65536]; /* 未初始化数据段 /
int primes[] = { 2, 3, 5, 7 }; / 初始化数据段 */
 
static int square(int x) /* 分配在square()的帧中 /
{
  int result; / 分配在square()的帧中 /
  result = x * x;
  return result; / 返回值通过寄存器传递 */
}
 
static void doCalc(int val) /* 分配在doCalc()的帧中 /
{
  printf("The square of %d is %d\n", val, square(val));
  if (val < 1000) {
    int t; / 分配在doCalc()的帧中 */
    t = val * val * val;
    printf("The cube of %d is %d\n", val, t);
  }
}
 
int main(int argc, char argv[]) / 分配在main()的帧中 /
{
  static int key = 9973; / 初始化数据段 /
  static char mbuf[10240000]; / 未初始化数据段 */
  char p; / 分配在main()的帧中 /
  p = malloc(1024); / 指向堆段中的内存 */
  doCalc(key);
  exit(EXIT_SUCCESS);
}

应用程序二进制接口(ABI)是一套规则,指定二进制可执行文件应如何在运行时与某些服务(例如,内核或库)交换信息。除其他外,ABI指定用于交换此信息的寄存器和栈位置,以及赋予交换值的含义。一旦为特定ABI编译,二进制可执行文件应该能够在呈现相同ABI的任何系统上运行。这与标准化的API(如SUSv3)形成对比,后者只保证从源代码编译的应用程序的可移植性。

虽然SUSv3中没有规定,但大多数UNIX实现(包括Linux)在C程序环境中提供了三个全局符号:etext、edata和end。这些符号可以在程序中使用,以获取分别是程序文本末尾、初始化数据段末尾和未初始化数据段末尾的下一个字节的地址。要使用这些符号,我们必须显式声明它们,如下所示:

extern char etext, edata, end; /* 例如,&etext给出程序文本结束处/初始化数据开始处的下一个字节的地址 */

图6-1显示了x86-32架构上各种内存段的布局。此图顶部标记为argv, environ的空间保存了程序命令行参数(在C中通过main()函数的argv参数可用)和进程环境列表(我们稍后讨论)。图中显示的十六进制地址可能会因内核配置和程序链接选项而有所不同。灰色区域代表进程虚拟地址空间中的无效范围;即,尚未为其创建页表的区域(见下文关于虚拟内存管理的讨论)。

现代内核一样,Linux使用了一种称为虚拟内存管理的技术。这种技术的目的是通过利用大多数程序的一个典型特性——局部性引用——来高效利用CPU和RAM(物理内存)。大多数程序表现出两种局部性:

  • 空间局部性是指程序引用的内存地址靠近它最近访问的地址的倾向(由于指令的顺序处理,有时是由于数据结构的顺序处理)。
  • 时间局部性是指程序在不久的将来访问它最近过去访问过的相同内存地址的倾向(由于循环)。

局部性引用的结果是,可以在只将其地址空间的一部分保留在RAM中的情况下执行程序。

虚拟内存方案将每个程序使用的内存分割成小的、固定大小的单元,称为页。相应地,RAM被划分为相同大小的一系列页面帧。在任何时候,只有程序的一些页面需要驻留在物理内存页面帧中;这些页面构成所谓的驻留集。程序未使用的页面副本保留在交换区中——一个用于补充计算机RAM的磁盘空间保留区——并根据需要加载到物理内存中。当进程引用当前不在物理内存中的页面时,会发生页面错误,在这一点上,内核暂停进程的执行,直到页面从磁盘加载到内存中。

在x86-32上,页面大小为4096字节。一些其他Linux实现使用更大的页面大小。例如,Alpha使用8192字节的页面大小,而IA-64有可变的页面大小,通常默认为16,384字节。程序可以使用sysconf(_SC_PAGESIZE)调用确定系统虚拟内存页面大小

为了支持这种组织,内核为每个进程维护一个页面表(图6-2)。页面表描述了进程虚拟地址空间中每个页面的位置(进程可用的所有虚拟内存页面的集合)。页面表中的每个条目要么指示虚拟页面在RAM中的位置,要么指示它当前驻留在磁盘上。 进程虚拟地址空间中的所有地址范围都不需要页面表条目。通常,潜在的虚拟地址空间的大范围是未使用的,因此不需要维护相应的页面表条目。如果进程尝试访问没有相应页面表条目的地址,它将收到SIGSEGV信号。 进程的有效虚拟地址范围可能会随着其生命周期的变化而变化,因为内核为进程分配和释放页面(和页面表条目)。这可能发生在以下情况:

  • 随着栈向下增长超过之前达到的限制;
  • 通过使用brk()、sbrk()或malloc系列函数在堆上分配或释放内存(第7章);
  • 使用shmat()附加和使用shmdt()分离System V共享内存区域(第48章);
  • 使用mmap()创建和使用munmap()取消映射内存映射(第49章)。

虚拟内存的实现需要分页内存管理单元(PMMU)的硬件支持。PMMU将每个虚拟内存地址引用转换为相应的物理内存地址,并在特定的虚拟内存地址对应于不在RAM中的页面时通知内核页面错误

优势:

  • 进程彼此隔离,与内核隔离,因此一个进程无法读取或修改另一个进程或内核的内存。这是通过让每个进程的页面表条目指向RAM中不同的物理页面集(或交换区)来实现的。
  • 在适当的情况下,两个或多个进程可以共享内存。内核通过让不同进程的页面表条目指向相同的RAM页面来实现这一点。内存共享在两种常见情况下发生:
    • 执行相同程序的多个进程可以共享程序代码的单个(只读)副本。当多个程序执行相同的程序文件(或加载相同的共享库)时,会隐式进行此类共享。
    • 进程可以使用shmget()和mmap()系统调用显式请求与其他进程共享内存区域。这是为了进程间通信而完成的。
  • 实现内存保护方案变得更容易;即,页面表条目可以标记为指示相应页面的内容是可读的、可写的、可执行的,或者这些保护的某种组合。在多个进程共享RAM页面的情况下,可以指定每个进程对内存具有不同的保护;例如,一个进程可能对页面具有只读访问权限,而另一个具有读写访问权限。
  • 程序员和编译器、链接器等工具不需要关心程序在RAM中的物理布局。
  • 由于只需将程序的一部分保留在内存中,因此程序加载和运行速度更快。此外,进程的内存占用(即虚拟大小)可以超过RAM的容量。
  • 虚拟内存管理的最后一个优势是,由于每个进程使用的RAM较少,可以同时在RAM中保持更多的进程。这通常导致更好的CPU利用率,因为它增加了在任何时刻都有至少一个进程可以被CPU执行的可能性。

栈在函数调用和返回时线性增长和收缩。对于Linux在x86-32架构(以及大多数其他Linux和UNIX实现)上,栈位于内存的高端,并向下增长(朝向堆)。一个特殊用途的寄存器,即栈指针,跟踪栈的当前顶部。每次调用一个函数时,栈上就会分配一个额外的帧,当函数返回时,这个帧就会被移除。

尽管栈是向下增长的,我们仍然称增长端为栈的顶部,因为在抽象意义上,它就是这样。栈增长的实际方向是一个(硬件)实现细节。Linux的一个实现,HP PA-RISC,确实使用向上增长的栈。 在虚拟内存术语中,随着栈帧的分配,栈段的大小会增加,但在大多数实现中,在这些帧被释放后,它不会减小(当新的栈帧被分配时,内存简单地被重用)。当我们谈论栈段增长和收缩时,我们是从逻辑角度考虑帧被添加到栈上和从栈上移除的情况。

有时,术语用户栈被用来区分我们在这里描述的栈和内核栈。内核栈是内核内存中维护的每个进程的内存区域,用作在系统调用执行期间内部调用的函数的栈。(内核不能使用用户栈来实现这一目的,因为它位于未受保护的用户内存中。) 每个(用户)栈帧包含以下信息:

  • 函数参数和局部变量:在C语言中,这些被称为自动变量,因为它们在函数被调用时自动创建。这些变量在函数返回时也自动消失(因为栈帧消失),这构成了自动变量与静态(和全局)变量之间的主要语义区别:后者具有独立于函数执行的永久性存在。
  • 调用链接信息:每个函数使用某些CPU寄存器,如程序计数器,指向要执行的下一个机器语言指令。每当一个函数调用另一个函数时,这些寄存器的副本被保存在被调函数的栈帧中,以便在函数返回时,可以为调用函数恢复适当的寄存器值。

由于函数可以相互调用,栈上可能有多个帧。(如果一个函数递归地调用自己,那么栈上将有该函数的多个帧。)

每个C程序都必须有一个名为main()的函数,这是程序执行的起点。当程序被执行时,命令行参数(由shell解析的单独的单词)通过main()函数的两个参数提供。第一个参数,int argc,指示有多少个命令行参数。第二个参数,char *argv[],是指向命令行参数的指针数组,每个参数都是一个以null结尾的字符字符串。这些字符串中的第一个,在argv[0]中,(按照惯例)是程序本身的名称。argv中的指针列表以NULL指针结束(即argv[argc]为NULL)。 argv[0]包含用于调用程序的名称这一事实可以被用来执行一个有用的技巧。我们可以为同一个程序创建多个链接(即名称),然后让程序查看argv[0]并根据用于调用它的名称采取不同的行动。gzip(1)、gunzip(1)和zcat(1)命令提供了这种技术的一个例子:在某些发行版上,这些都是指向同一个可执行文件的链接。

proc/necho.c

#include "tlpi_hdr.h"
int main(int argc, char *argv[])
{
  int j;
  for (j = 0; j < argc; j++)
    printf("argv[%d] = %s\n", j, argv[j]);
  exit(EXIT_SUCCESS);
}
 
// 逐行输出仅命令行参数
char **p;
for (p = argv; *p != NULL; p++)
  puts(*p);

argc/argv机制的一个限制是,这些变量仅作为main()的参数可用。为了在其他函数中可移植地使命令行参数可用,我们必须将argv作为参数传递给这些函数,或设置一个指向argv的全局变量。

有几种非可移植的方法可以在程序的任何地方访问部分或全部这些信息:

  • 任何进程的命令行参数可以通过Linux特定的/proc/PID/cmdline文件读取,每个参数以null字节结束。(程序可以通过/proc/self/cmdline访问自己的命令行参数。)
  • GNU C库提供了两个全局变量,可在程序中的任何地方使用,以获取用于调用程序的名称(即第一个命令行参数)。这两个变量中的第一个,program_invocation_name,提供用于调用程序的完整路径名。第二个,program_invocation_short_name,提供了去掉任何目录前缀的该名称的版本(即路径名的basename部分)。可以通过定义宏_GNU_SOURCE从<errno.h>中获取这两个变量的声明

argv和environ数组,以及它们最初指向的字符串,驻留在一个连续的内存区域中,就在进程栈的上方。(我们在下一节中描述environ,它保存了程序的环境列表。)这个区域中可以存储的字节总数有一个上限。SUSv3规定使用ARG_MAX常量(在<limits.h>中定义)或调用sysconf(_SC_ARG_MAX)来确定此限制。(我们在第11.2节中描述sysconf()。)SUSv3要求ARG_MAX至少为_POSIX_ARG_MAX(4096)字节。大多数UNIX实现允许远高于此的限制。SUSv3没有指定一个实现是否将开销字节(用于终止null字节、对齐字节以及argv和environ指针数组)计入ARG_MAX限制。

在Linux上,ARG_MAX历史上固定为32页(即Linux/x86-32上的131,072字节),并包括用于开销字节的空间。从2.6.23内核开始,用于argv和environ的总空间限制可以通过RLIMIT_STACK资源限制进行控制,允许argv和environ的限制更大。该限制计算为execve()调用时生效的软RLIMIT_STACK资源限制的四分之一

每个进程都有一个关联的字符串数组,称为环境列表或简称环境。这些字符串中的每一个都是形式为name=value的定义。因此,环境代表了一组可以用于保存任意信息的名称-值对。列表中的名称被称为环境变量。 当创建一个新进程时,它会继承其父进程的环境的副本。这是一种原始但经常使用的进程间通信形式——环境提供了一种从父进程向其子进程传递信息的方式。由于子进程在创建时获得其父进程环境的副本,这种信息传递是单向的且一次性的。在创建子进程之后,任何一个进程都可以更改自己的环境,而这些更改不会被另一个进程看到。 环境变量的一个常见用途是在shell中。通过将值放入其自身环境中,shell可以确保这些值被传递给它创建的执行用户命令的进程。

一些库函数允许通过设置环境变量来修改它们的行为。这允许用户控制使用该函数的应用程序的行为,而无需更改应用程序的代码或重新链接它与相应的库。这种技术的一个例子是getopt()函数(附录B),其行为可以通过设置POSIXLY_CORRECT环境变量来修改。

任何进程的环境列表都可以通过Linux特定的/proc/PID/environ文件查看,每个NAME=value对以null字节结束。

在C程序中,可以使用全局变量char **environ来访问环境列表。(C运行时启动代码定义了这个变量,并将环境列表的位置分配给它。)与argv一样,environ指向一个以NULL结尾的指向以null结尾的字符串的指针列表

#include "tlpi_hdr.h"
extern char **environ;
int main(int argc, char *argv[])
{
  char **ep;
  for (ep = environ; *ep != NULL; ep++)
    puts(*ep);
  exit(EXIT_SUCCESS);
}

访问环境列表的另一种方法是声明main()函数的第三个参数:

int main(int argc, char *argv[], char *envp[])

这个参数可以像environ一样处理,不同之处在于它的作用域局限于main()。尽管这个特性在UNIX系统上被广泛实现,但应该避免使用它,因为除了作用域限制之外,它还没有在SUSv3中指定。

getenv()函数从进程环境中检索单个值。

#include <stdlib.h>
char *getenv(const char *name);

使用getenv()时请注意以下可移植性考虑:

  • SUSv3规定应用程序不应修改getenv()返回的字符串。这是因为(在大多数实现中)这个字符串实际上是环境的一部分(即name=value字符串的值部分)。如果我们需要更改环境变量的值,那么我们可以使用setenv()或putenv()函数(下面描述)。
  • SUSv3允许getenv()的实现使用静态分配的缓冲区返回其结果,该缓冲区可能会被后续对getenv()、setenv()、putenv()或unsetenv()的调用覆盖。尽管glibc实现的getenv()不以这种方式使用静态缓冲区,但需要保留getenv()调用返回的字符串的可移植程序应该在对这些函数进行后续调用之前将其复制到不同的位置。

有时候,进程修改自己的环境是非常有用的。一个原因是为了在该进程随后创建的所有子进程中都可见地进行某些更改。另一种可能是我们想要设置一个对新程序可见的变量,这个新程序将被加载到这个进程的内存中(“执行exec()”)。从这个意义上讲,环境不仅仅是一种进程间通信的方式,也是一种程序间通信的方法。

putenv()函数向调用进程的环境中添加一个新变量,或修改现有变量的值。

#include <stdlib.h>
int putenv(char *string);
// 成功时返回0,错误时返回非零值

string参数是一个指向形式为name=value的字符串的指针。在putenv()调用之后,这个字符串成为环境的一部分。换句话说,environ的一个元素将被设置为指向与string相同的位置,而不是复制string指向的字符串。因此,如果我们随后修改string指向的字节,那么这个更改将影响进程环境。出于这个原因,string不应该是一个自动变量(即,在栈上分配的字符数组),因为一旦定义该变量的函数返回,这块内存区域可能会被覆盖。 注意,putenv()在错误时返回非零值,而不是-1。 glibc实现的putenv()提供了一个非标准的扩展。如果string不包含等号(=),那么由string标识的环境变量将从环境列表中移除。 setenv()函数是向环境中添加变量的putenv()的另一种选择。

#include <stdlib.h>
int setenv(const char *name, const char *value, int overwrite);
// 成功时返回0,错误时返回-1

setenv()函数通过为形式为name=value的字符串分配内存缓冲区,并将name和value指向的字符串复制到该缓冲区中,从而创建一个新的环境变量。注意,我们不需要(实际上,不得)在name的末尾或value的开头提供等号,因为setenv()在将新定义添加到环境中时会添加这个字符。 如果name标识的变量已经存在,并且overwrite的值为0,则setenv()函数不会改变环境。如果overwrite为非零,则环境始终会被更改。 setenv()复制其参数这一事实意味着,与putenv()不同,我们可以随后修改name和value指向的字符串内容,而不影响环境。这也意味着使用自动变量作为setenv()的参数不会引起任何问题。 unsetenv()函数从环境中移除由name标识的变量。

#include <stdlib.h>
int unsetenv(const char *name);
// 成功时返回0,错误时返回-1

与setenv()一样,name不应包含等号。 setenv()和unsetenv()都源自BSD,并且不如putenv()普遍。虽然它们没有在原始的POSIX.1标准或SUSv2中定义,但它们被包含在SUSv3中。

在glibc 2.2.2之前的版本中,unsetenv()被原型化为返回void。这是原始BSD实现中unsetenv()的原型,一些UNIX实现仍然遵循BSD原型。

有时,擦除整个环境然后用选定的值重建它是有用的。例如,我们可能会这样做,以便以安全的方式执行设置用户ID的程序(第38.8节)。我们可以通过将environ赋值为NULL来擦除环境: environ = NULL; 这正是clearenv()库函数执行的步骤。

#define _BSD_SOURCE            /* 或者: #define _SVID_SOURCE */
#include <stdlib.h>
int clearenv(void)
// 成功时返回0,或者错误时返回非零值

在某些情况下,使用setenv()和clearenv()可能会导致程序中的内存泄漏。我们上面提到setenv()分配的内存缓冲区随后成为环境的一部分。当我们调用clearenv()时,它不会释放这个缓冲区(它不能,因为它不知道缓冲区的存在)。一个反复使用这两个函数的程序将逐渐泄露内存。实际上,这不太可能成为问题,因为程序通常在启动时只调用一次clearenv(),以便从它的前身(即调用exec()启动这个程序的程序)那里移除所有从环境中继承的条目。

许多UNIX实现提供clearenv(),但它没有在SUSv3中指定。SUSv3规定,如果应用程序直接修改environ,如clearenv()所做的那样,则setenv()、unsetenv()和getenv()的行为是未定义的。(其背后的理由是,防止符合标准的应用程序直接修改环境,允许实现完全控制它用于实现环境变量的数据结构。)SUSv3允许应用程序清除其环境的唯一方法是获取所有环境变量的列表(通过从environ获取名称),然后使用unsetenv()移除这些名称中的每一个。

#define _GNU_SOURCE     /* To get various declarations from <stdlib.h> */
#include <stdlib.h>
#include "tlpi_hdr.h"
 
extern char **environ;
 
int main(int argc, char *argv[])
{
    int j;
    char **ep;
 
    clearenv();         /* Erase entire environment */
 
    for (j = 1; j < argc; j++)
        if (putenv(argv[j]) != 0)
            errExit("putenv: %s", argv[j]);
 
    if (setenv("GREET", "Hello world", 0) == -1)
        errExit("setenv");
 
    unsetenv("BYE");
 
    for (ep = environ; *ep != NULL; ep++)
        puts(*ep);
 
    exit(EXIT_SUCCESS);
}