LinuxUNIX系统编程手册——(六)进程

6.1 进程和程序

进程是一个可执行程序的实例。程序是包含了一系列信息的文件,这些信息描述了如何在运行时创建一个进程,所包括的内容如下所示。

  • **二进制格式标识:**每个程序文件都包含用于描述可执行文件格式的元信息(metainformation)。内核(kernel)利用此信息来解释文件中的其他信息。
  • **机器语言指令:**对程序算法进行编码。
  • **程序入口地址:**标识程序开始执行时的起始指令位置。
  • **数据:**程序文件包含的变量初始值和程序使用的字面常量(literal constant)值(比如字符串)。
  • **符号表及重定位表:**描述程序中函数和变量的位置及名称。这些表格有多种用途,其中包括调试和运行时的符号解析(动态链接)。
  • **共享库和动态链接信息:**程序文件所包含的一些字段,列出了程序运行时需要使用的共享库,以及加载共享库的动态链接器的路径名。
  • **其他信息:**程序文件还包含许多其他信息,用以描述如何创建进程。

进程是由内核定义的抽象的实体,并为该实体分配用以执行程序的各项系统资源。

从内核角度看,进程由用户内存空间(user-space memory)和一系列内核数据结构组成,其中用户内存空间包含了程序代码及代码所使用的变量,而内核数据结构则用于维护进程状态信息。记录在内核数据结构中的信息包括许多与进程相关的标识号(IDs)、虚拟内存表、打开文件的描述符表、信号传递及处理的有关信息、进程资源使用及限制、当前工作目录和大量的其他信息。

6.2 进程号和父进程号

每个进程都有一个进程号(PID),进程号是一个正数,用以唯一标识系统中的某个进程。对各种系统调用而言,进程号有时可以作为传入参数,有时可以作为返回值。

系统调用 getpid()返回调用进程的进程号。

pid_t getpid(void);				/* 返回进程ID,该系统调用总是成功 */

Linux 内核限制进程号需小于等于 32767。新进程创建时,内核会按顺序将下一个可用的进程号分配给其使用。每当进程号达到 32767 的限制时,内核将重置进程号计数器,以便从小整数开始分配。但是内核会将进程号计数器重置为 300,而不是 1。之所以如此,是因为低数值的进程号为系统进程和守护进程所长期占用,在此范围内搜索尚未使用的进程号只会是浪费时间。

【注】尽管进程号的默认上限仍是 32767,但可以通过 Linux系统特有的/proc/sys/kernel/pid_max 文件来进行调整(其值=最大进程号+1)。在 32 位平台
中,pid_max 文件的最大值为 32768,但在 64 位平台中,该文件的最大值可以高达到 2 22 2^{22} 222 (约400 万),系统可能容纳的进程数量会非常庞大。

每个进程都有一个创建自己的父进程。使用系统调用getppid()可以检索到父进程的进程号。

pid_t getppid(void);             /* 返回父进程ID,该系统调用总是成功 */

实际上,每个进程的父进程号属性反映了系统上所有进程间的树状关系。每个进程的父进程又有自己的父进程,以此类推,回溯到 1 号进程 — init 进程,即所有进程的始祖。使用pstree(1)命令可以查看到这一“家族树”(family tree)。如果子进程的父进程终止,则子进程就会变成“孤儿”,init 进程随即将收养该进程,子进程后续对 getppid()的调用将返回进程号 1

6.3 进程内存布局

每个进程所分配的内存由很多部分组成,通常称之为“段(segment)”。如下所示。

  • 文本段包含了进程运行的程序机器语言指令。文本段具有只读属性,以防止进程通过错误指针意外修改自身指令。因为多个进程可同时运行同一程序,所以又将文本段设为可共享,这样,一份程序代码的拷贝可以映射到所有这些进程的虚拟地址空间中。
  • 初始化数据段包含显式初始化的全局变量和静态变量。当程序加载到内存时,从可执行文件中读取这些变量的值。
  • 未初始化数据段包含了未进行显式初始化的全局变量和静态变量。程序启动之前,系统将本段内所有内存初始化为 0。出于历史原因,此段常被称为 BSS 段,这源于老版本的汇编语言助记符“block started by symbol”。将经过初始化的全局变量和静态变量与未经初始化的全局变量和静态变量分开存放,其主要原因在于程序在磁盘上存储时,没有必要为未经初始化的变量分配存储空间。相反,可执行文件只需记录未初始化数据段的位置及所需大小,直到运行时再由程序加载器来分配这一空间。
  • (stack)是一个动态增长和收缩的段,由栈帧(stack frames)组成。系统会为每个当前调用的函数分配一个栈帧。栈帧中存储了函数的局部变量(所谓自动变量)、实参和返回值。6.5 节将深入讨论栈帧。
  • (heap)是可在运行时(为变量)动态进行内存分配的一块区域。堆顶端称作program break。

【注】size(1)命令可显示二进制可执行文件的文本段、初始化数据段、非初始化数据段(bss)的段大小。上文中的段是对 UNIX 系统中进程虚拟内存的逻辑划分,不应与一些硬件体系架构中使用的硬件分段(segmentation)相混淆。有时库函数返回的指针指向静态分配的内存。这意味着,该内存既可在初始化数据段中分配,也可在非初始化数据段中分配。

下面的程序例程展示了不同类型的 C 语言变量,并以注释说明每种变量分属于哪个段。这些说明正确的前提是假定使用了非优化的编译器,且在应用程序二进制接口(ABI)中,是通过栈来传递所有参数的。实际上,优化编译器会将频繁使用的变量分配于寄存器中,或者索性将变量彻底剔除。此外,一些 ABI 需要通过寄存器,而不是栈,来传递函数实参和结果。尽管如此,本例只是意在展示 C 语言变量和进程各段间的映射关系。

【注】应用程序二进制接口(ABI)是一套规则,规定了二进制可执行文件在运行时应如何与某些服务(诸如内核或函数库所提供的服务)交换信息。ABI 特别规定了使用哪些寄存器和栈地址来交换信息以及所交换值的含义,一旦针对某个特定 ABI 进行了编译,其二进制可执行文件应能在 ABI 相同的任何系统上运行。

    static int key = 9973;      /* Initialized data segment */
    static char mbuf[10240000]; /* Uninitialized data segment */
    char *p;
    p = malloc(1024);           /* Points to memory in heap segment */

下图展示了各种内存段在 x86-32 体系结构中的布局,该图的顶部标记为 argv、environ的空间用来存储程序命令行实参(通过 C 语言中 main()函数的 argv 参数获得)和进程环境列表(稍后讨论),图中十六进制的地址会因内核配置和程序链接选项差异而有所不同。图中标灰的区域表示这些范围在进程虚拟地址空间中不可用,也就是说,没有为这些区域创建页表(page table)。

在这里插入图片描述

大多数 UNIX 实现(包括 Linux)中 C 语言编程环境提供了 3个全局符号(symbol):etext、edata 和 end,可在程序内使用这些符号以获取相应程序文本段、初始化数据段和非初始化数据段结尾处下一字节的地址。使用这些符号,必须显式声明如下:

extern char etext, edata, end;		/* &etext为文本段的结束地址以及初始化数据段的开始地址;&edata为初始化
数据段的结束地址以及未初始化数据段的开始地址;而&end为未初始化数据段的结束地址以及队数据段的开始地址 */

6.4 虚拟内存管理

Linux,像多数现代内核一样,采用了虚拟内存管理技术。该技术利用了大多数程序的一个典型特征,即访问局部性(locality of reference),以求高效使用 CPU 和 RAM(物理内存)资源。大多数程序都展现了两种类型的局部性。

  • 空间局部性(Spatial locality):是指程序倾向于访问在最近访问过的内存地址附近的
    内存(由于指令是顺序执行的,且有时会按顺序处理数据结构)。
  • 时间局部性(Temporal locality):是指程序倾向于在不久的将来再次访问最近刚访问
    过的内存地址(由于循环)。

正是由于访问局部性特征,使得程序即便仅有部分地址空间存在于 RAM 中,依然可能得以执行。

虚拟内存的规划之一是将每个程序使用的内存切割成小型的、固定大小的“页”(page)单元。相应地,将 RAM 划分成一系列与虚存页尺寸相同的页帧。任一时刻,每个程序仅有部分页需要驻留在物理内存页帧中。这些页构成了所谓驻留集(resident set)。程序未使用的页拷贝保存在交换区(swap area)内 — 这是磁盘空间中的保留区域,作为计算机RAM 的补充 —仅在需要时才会载入物理内存。若进程欲访问的页面目前并未驻留在物理内存中,将会发生页面错误(page fault),内核即刻挂起进程的执行,同时从磁盘中将该页面载入内存。

为支持这一组织方式,内核需要为每个进程维护一张页表(page table)(见下图)。该页表描述了每页在进程虚拟地址空间(virtual address space)中的位置(可为进程所用的所有虚拟内存页面的集合)。页表中的每个条目要么指出一个虚拟页面在 RAM 中的所在位置,要么表明其当前驻留在磁盘上。

在这里插入图片描述

在进程虚拟地址空间中,并非所有的地址范围都需要页表条目。通常情况下,由于可能存在大段的虚拟地址空间并未投入使用,故而也无必要为其维护相应的页表条目。若进程试图访问的地址并无页表条目与之对应,那么进程将收到一个 SIGSEGV 信号。

由于内核能够为进程分配和释放页(和页表条目),所以进程的有效虚拟地址范围在其生命周期中可以发生变化。

虚拟内存的实现需要硬件中分页内存管理单元(PMMU)的支持。PMMU 把要访问的每个虚拟内存地址转换成相应的物理内存地址,当特定虚拟内存地址所对应的页没有驻留于 RAM 中时,将以页面错误通知内核。

虚拟内存管理使进程的虚拟地址空间与 RAM 物理地址空间隔离开来,这带来许多优点。

  • 进程与进程、进程与内核相互隔离,所以一个进程不能读取或修改另一进程或内核的内存。这是因为每个进程的页表条目指向 RAM(或交换区)中截然不同的物理页面集合。
  • 适当情况下,两个或者更多进程能够共享内存。这是由于内核可以使不同进程的页表条目指向相同的 RAM 页。内存共享常发生于如下两种场景。
    • 执行同一程序的多个进程,可共享一份(只读的)程序代码副本。当多个程序执行相同的程序文件(或加载相同的共享库)时,会隐式地实现这一类型的共享。
    • 进程可以使用 shmget()和 mmap()系统调用显式地请求与其他进程共享内存区。这么做是出于进程间通信的目的。
  • 便于实现内存保护机制;也就是说,可以对页表条目进行标记,以表示相关页面内容是可读、可写、可执行亦或是这些保护措施的组合。多个进程共享 RAM 页面时,允许每个进程对内存采取不同的保护措施。例如,一个进程可能以只读方式访问某页面,而另一进程则以读写方式访问同一页面。
  • 程序员和编译器、链接器之类的工具无需关注程序在 RAM 中的物理布局。
  • 因为需要驻留在内存中的仅是程序的一部分,所以程序的加载和运行都很快。而且,一个进程所占用的内存(即虚拟内存大小)能够超出 RAM 容量。

虚拟内存管理的最后一个优点是:由于每个进程使用的 RAM 减少了,RAM 中同时可以容纳的进程数量就增多了。这增大了如下事件的概率:在任一时刻,CPU 都可执行至少一个进程,因而往往也会提高 CPU 的利用率。

6.5 栈和栈帧

函数的调用和返回使栈的增长和收缩呈线性。栈驻留在内存的高端并向下增长(朝堆的方向)。专用寄存器 —栈指针(stack pointer),用于跟踪当前栈顶。每次调用函数时,会在栈上新分配一帧,每当函数返回时,再从栈上将此帧移去。

【注】虽然栈向下增长,但仍将栈的增长端称为栈顶,栈的实际增长方向是个(属于硬件范畴的)实现细节。就虚拟内存而言,分配栈帧后,栈段的大小将会增长,但在大多数(Linux)实现中,释放这些栈帧后,栈的大小并未减少(在分配新的栈帧时,会对这些内存重新加以利用)。当谈论栈段的增长和收缩时,只是从逻辑视角来看待栈帧在栈中的增减情况。

有时,会用用户栈(user stack)来表示此处所讨论的栈,以便与内核栈区分开来。内核栈是每个进程保留在内核内存中的内存区域,在执行系统调用的过程中供(内核)内部函数调用使用。(由于用户栈驻留在不受保护的用户内存中,所以内核无法利用用户栈来达成这一目的。)每个(用户)栈帧包括如下信息。因为函数能够嵌套调用,所以栈中可能有多个栈帧。(若一函数递归调用自身,则该函数在栈中将有多个栈帧。)

  • **函数实参和局部变量:**由于这些变量都是在调用函数时自动创建的,因此在 C 语言中称其为自动变量。函数返回时将自动销毁这些变量(因为栈帧会被释放),这也是自动变量与静态(以及全局)变量主要的语义区别:后者与函数执行无关,且长期存在。
  • **(函数)调用的链接信息:**每个函数都会用到一些 CPU 寄存器,比如程序计数器,其指向下一条将要执行的机器语言指令。每当一函数调用另一函数时,会在被调用函数的栈帧中保存这些寄存器的副本,以便函数返回时能为函数调用者将寄存器恢复原状。

6.6 环境列表

每一个进程都有与其相关的称之为环境列表(environment list)的字符串数组,或简称为环境(environment)。其中每个字符串都以名称=值(name=value)形式定义。因此,环境是“名称-值”的成对集合,可存储任何信息。常将列表中的名称称为环境变量(environment variables)。

新进程在创建之时,会继承其父进程的环境副本。这是一种原始的进程间通信方式,却颇为常用。环境(environment)提供了将信息从父进程传递给子进程的方法。由于子进程只有在创建时才能获得其父进程的环境副本,所以这一信息传递是单向的、一次性的。子进程创建后,父、子进程均可更改各自的环境变量,且这些变更对对方而言不再可见。

环境变量的常见用途之一是在 shell 中。通过在自身环境中放置变量值,shell 就可确保把这些值传递给其所创建的进程,并以此来执行用户命令。例如,环境变量 SHELL 被设置为 shell程序本身的路径名,如果程序需要执行 shell 时,大多会将此变量视为需要执行的 shell 名称。

可以通过设置环境变量来改变一些库函数的行为。正因如此,用户无需修改程序代码或者重新链接相关库,就能控制调用该函数的应用程序行为。getopt()函数就是其中一例,可通过设置 POSIXLY_CORRECT 环境变量来改变此函数的行为。

大多数 shell 使用 export 命令向环境中添加变量值。export SHELL=/bin/bash,这样就可以把一个值永久地添加到 shell 环境中,此后这个 shell 创建的所有子进程都将继承此环境。在任一时刻,可以使用 unset 命令撤销一个环境变量;而printenv 命令显示当前的环境列表。

【注】环境列表的排列是无序的,列表中的字符串顺序不过是最易于实现的排列形式。一般而言,无序的环境列表不是问题,因为通常都是访问单个的环境变量,而非环境列表中按序排列的一串。通过Linux专有的/proc/PID/environ文件检查任一进程的环境列表,每一个“NAME=value”对都以空字节终止。

6.6.1 从程序中访问环境

在 C 语言程序中,可以使用全局变量 char **environ 访问环境列表。(C 运行时启动代码定义了该变量并以环境列表位置为其赋值。)environ 与 argv 参数类似,指向一个以 NULL 结尾的指针列表,每个指针又指向一个以空字节终止的字符串。下图为与某一环境列表数据结构。

在这里插入图片描述

下面的程序通过访问 environ 变量来展示该进程环境中的所有值。该程序的输出结果与 printenv 命令的输出结果相同。程序中的循环利用指针来遍历 environ 变量。虽然可以把 environ 当成数组来使用,但这多少有些生硬,因为环境列表中各项的排列不分先后,而且也没有变量用来指定环境列表的长度。

extern char **environ;
char **ep;

for(ep = environ; *ep != NULL; ep++)
    puts(*ep);

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

char *getenv(const char *name);			/* name为环境变量名称,返回相应字符串指针,如果没有这个变量则返回NULL

以下是使用 getenv()函数时可移植性方面的注意事项:

  • SUSv3 规定应用程序不应修改 getenv()函数返回的字符串,这是由于(在大多数 UNIX实现中)该字符串实际上属于环境的一部分(即 name=value 字符串的 value 部分)。若需要改变一个环境变量的值,可以使用 setenv()函数或 putenv()函数(见下文)。
  • SUSv3 允许 getenv()函数的实现使用静态分配的缓冲区返回执行结果,后续对 getenv()、setenv()、putenv()或者 unsetenv()的函数调用可以重写该缓冲区。虽然 glibc 库的 getenv()函数实现并未这样使用静态缓冲区,但具备可移植性的程序如需保留getenv()调用返回的字符串,就应先将返回字符串复制到其他位置,之后方可对上述函数发起调用。

6.6.2 修改环境

有时,对进程来说,修改其环境很有用处。原因之一是这一修改对该进程后续创建的所有子进程均可见。另一个可能的原因在于设定某一变量,以求对于将要载入进程内存的新程序(“execed”)可见。从这个意义上讲,环境不仅是一种进程间通信的形式,还是程序间通信的方法。

putenv()函数向调用进程的环境中添加一个新变量,或者修改一个已经存在的变量值。

int putenv(char *string);		/* 参数 string 是一指针,指向 name=value 形式的字符串;
成功返回0,失败返回非0值!!!!<--注意 */

【注意】调用 putenv()函数后,该字符串就成为环境的一部分,换言之,**putenv 函数将设定 environ 变量中某一元素的指向与 string 参数的指向位置相同,而非 string 参数所指向字符串的复制副本。**因此,如果随后修改 string 参数所指的内容,这将影响该进程的环境。出于这一原因,string 参数不应为自动变量(即在栈中分配的字符数组),因为定义此变量的函数一旦返回,就有可能会重写这块内存区域。此外,putenv()函数的 glibc 库实现还提供了一个非标准扩展。如果 string 参数内容不包含一个等号(=),那么将从环境列表中移除以 string 参数命名的环境变量。

setenv()函数可以代替 putenv()函数,向环境中添加一个变量。setenv()函数为形如 name=value 的字符串分配一块内存缓冲区,并将 name 和 value 所指向的字符串复制到此缓冲区,以此来创建一个新的环境变量。

int setenv(const char *name, const char *value, int overwrite);		/* 成功返回0, 失败返回-1 */

【注】不需要(实际上,是绝对不要)在 name 的结尾处或者 value 的开始处提供一个等号字符,因为 setenv()函数会在向环境添加新变量时添加等号字符。若以 name 标识的变量在环境中已经存在,且参数 overwrite 的值为 0,则 setenv()函数将不改变环境,如果参数 overwrite 的值为非 0,则 setenv()函数总是改变环境。

setenv()函数复制其参数(到环境中) —— 意味着与 putenv()函数不同,**之后对 name 和 value 所指字符串内容的修改将不会影响环境。**此外,使用自动变量作为 setenv()函数的参数也不会有任何问题。

unsetenv()函数从环境中移除由 name 参数标识的变量。同 setenv()函数一样,参数 name 不应包含等号字符。

int nusetenv(const char *name);		/* 成功返回0,失败返回-1 */

clearenv()函数用于清除整个环境。

int clearenv(void);			/* 成功返回0,失败返回非零值 */

在某些情况下,使用 setenv()函数和 clearenv()函数可能会导致程序内存泄露。前面已然提及:setenv()函数所分配的一块内存缓冲区,随之会成为进程环境的一部分。而调用 clearenv()时则没有释放该缓冲区(clearenv()调用并不知晓该缓冲区的存在,故而也无法将其释放)。反复调用这两个函数的程序,会不断产生内存泄露。实际上,这不大可能成为一个问题,因为程序通常仅在启动时调用 clearenv()函数一次,用于移除继承自其父进程(即调用 exec()函数来启动当前程序的程序)环境中的所有条目。

6.6.3 程序示例

#include <stdlib.h>
#include "tlpi_hdr.h"

extern char **environ;

int
main(int argc, char *argv[])
{
    int j;
    char **ep;

    clearenv();         /* 清除环境 */

    for (j = 1; j < argc; j++)			/* 将argv中的字符串添加到环境 */
        if (putenv(argv[j]) != 0)
            errExit("putenv: %s", argv[j]);

    if (setenv("GREET", "Hello world", 0) == -1)		/* 向环境中添加GREET变量 */
        errExit("setenv");

    unsetenv("BYE");			/* 移除BYE变量 */

    for (ep = environ; *ep != NULL; ep++)
        puts(*ep);

    exit(EXIT_SUCCESS);
}
vainx@DESKTOP-0DN0PNJ:~/wsl-code/tlpi-book/proc$ ./modify_env "GREET=Guten Tag" SHELL=/bin/bash BYE=Ciao
GREET=Guten Tag			# 以 GREET 标识的变量在环境中已经存在,且参数 overwrite 的值为 0,则 setenv()函数将不改变环境
SHELL=/bin/bash
vainx@DESKTOP-0DN0PNJ:~/wsl-code/tlpi-book/proc$ ./modify_env SHELL=/bin/bash BYE=byebye
SHELL=/bin/bash
GREET=Hello world		# # 以 GREET 标识的变量在环境中未存在,参数 overwrite 的值为 0,则 setenv()函数将添加以 GREET 标识的变量在环境
  • 7
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值