跳转至

Cheaper 6 进程

词汇速查

  • 进程 process
  • 用户内存空间 user-space memory
  • 进程号 process ID
  • 父进程号 parent process ID
  • 段 segment
  • 栈:stack
  • 栈帧:stack frames
  • 堆:heap
  • 堆顶: program break
  • 不可重入的: nonreentrant
  • 重入:reentrancy
  • 访问局部性:locality of reference
  • 空间局限性:Spatial locality
  • 时间局部性:Temporal locality
  • 驻留集:resident set
  • 页面错误:page fault
  • 页表:page table
  • 虚拟地址空间:virtual address space
  • 栈指针:stack pointer
  • 环境列表:environment list

笔记

进程是一个可执行文件的实例,换言之,实际运行中的程序称为进程。

程序是包含一系列信息的文件。包括

  • 二进制格式标识:早年为汇编输出 a.out,COFF,现在一般使用 ELF 格式
  • 机器语言指令
  • 程序入口地址
  • 数据:包含变量初始化的值和程序使用的字面常量值
  • 符号表及重定向表:描述程序中函数和变量的位置和名称,用于调试和运行时的符号解析(动态链接)
  • 共享库和动态链接信息:列出程序运行时需要使用的共享库,以及加载共享库的动态链接器的路径
  • 其他信息

一个程序可以创建多个进程,许多进程可以是同一个程序。

从内核的角度,进程由用户内存空间和一些内核数据结构构成。用户内存空间包含程序代码及代码使用的变量,内核数据结构维护进程状态信息。内核数据结构包括与进程相关的标识号,虚拟内存表,打开文件的描述符表,信号传递及处理的信息,进程资源的使用与限制,当前的工作目录和大量的其他信息。

进程号和父进程号

每个进程都有一个进程号 (PID),进程号是一个正数,用于唯一标识系统中的某个进程。

getpid() 返回当前调用的进程的进程号

#include <unistd.h>

pid_t getpid(void)

除了个别特殊的进程(如 init) 以外, 进程号和程序没有任何关系。

Linux 内核的进程号大小限制可以从 /proc/sys/kernel/pid_max 中查看,新进程创建时,内核将按顺序将下一个可用的 PID 分配给进程,当进程号达到限制时,内核将 reset 计数器。

每个进程有一个创建自己的父进程,使用系统调用 getppid() 可查看。

#include <unistd.h>

pid_t getppid(void);

每个进程的父进程号反映了系统进程间的树状关系。每个进程最终都能归宿到 PID 为 1 的进程 init 。使用 pstree(1) 可查看系统进程树。

如果子进程的父进程终止,则子进程会变成孤儿进程,init 进程就会收养该进程,该进程对 getppid() 的调用将返回 1.

通过 /proc/PID/status 中的 PPid 字段,也可获知每个进程的父进程。

进程的内存布局

每个进程的内存由多个部分组成,称之为

  • 文本段:包含进程运行的机器语言命令,只读,可共享,同一个程序可以共享同一文本段内存
  • 已初始化数据段:又名用户初始化数据段 (user-initialized data segment),包含显式初始化的全局变量和静态变量。
  • 未初始化数据段:又名零初始化数据段 (zero-initialized data segment),包含未进行显式初始化的全局变量和静态变量。程序启动前该段被初始化为 0。历史上该段被称作 BSS 段,且程序在硬盘存储时不需要对该段分配硬盘空间,只需要记录未初始化数据段的位置和所需大小,在运行时再分配空间
  • 栈:一个动态增长和收缩的段,由栈帧组成。系统为每个当前调用的函数分配一个栈帧,栈帧中存储了函数的局部变量,实参和返回值。
  • 堆:在运行时动态分配内存的一块区域,堆顶称作 program break.

此处讨论的段为操作系统中虚拟内存的概念,而非硬件体系中的概念。

有时函数返回的指针指向的内存是静态分配的,即内存可能分配在已初始化或未初始化数据段中。即若返回的内容存在于静态的内存中,则下一次对该函数的调用可能会覆盖上一次的结果。称作该函数 不可重入

大多 UNIX 实现提供了三个全局符号来获取相应文本段的地址,但该做法未经 SUSv3 标准化

要使用该符号需先显式声明

extern char etext, edata, end;
// 分别为程序文本段,已初始化数据段和未初始化数据段结尾处下一字节。可使用取地址符获取地址。

虚拟内存管理

现代操作系统基本都使用了虚拟内存管理技术。

大多程序体现了两种类型的访问局部性:

  • 空间局部性:程序倾向于访问在最近访问过的内存地址附近的内存。
  • 时间局部性:程序倾向于在不久的将来再次访问最近刚访问过的内存。(由于循环)

因此,即便只有部分地址空间真正存在于 RAM 中,程序仍然可以运行。

虚拟内存即将程序使用的内存分割成小的,固定大小的内存页 (page) 单元,将 RAM 划分成一系列与虚拟内存页大小相同的页帧。每个程序在特定时间内只有部分页需要驻留在物理内存中,这些页即为驻留集。程序未使用的页拷贝存放在交换区中 (swap area),仅在需要时才会载入内存。若进程要访问的页面目前未驻留在物理内存中,会发生页面错误,此时内核挂起进程,从磁盘的交换区中将该页面载入内存。

通常,x86_32 平台页大小为 4096 字节。若 CPU 支持且系统配置使用可以调用大页。

内核会为进程维护一张页表,该表描述每个内存页在进程虚拟地址空间的位置。每个条目要么指出一个页在内存中的位置,要么指出当前驻留在硬盘上。

可能存在大段虚拟地址空间未被使用,故也无必要维护其页表条目,但当进程试图访问无页表条目对应的内存时,进程将收到一个 SIGSEGV 信号。

进程的有效虚拟地址范围也会在进程的生命周期中发生变化,例如:

  • 由于栈向下增长超出之前达到过的位置
  • 当在堆中分配或释放内存时
  • 当调用 shmat() 连接 SysV 共享内存区时,或调用 shmdt() 脱离共享内存区时。
  • 当调用 mmap() 创建内存映射调用 munmap() 解除内存映射时。

虚拟内存带来了许多优点:

  • 进程与进程间,进程与内核间互相隔离,一个进程不能读写另一个进程或内核的内存。
  • 两个或更多进程可以在特定条件下共享内存。
  • 便于实现内存保护机制
  • 程序员和编译器,链接器等无需关注程序在 RAM 中的实际物理布局
  • 由于需要实际驻留在内存中的只是程序的一部分,因此程序加载和运行的速度会提升,且进程对内存的占用可超出物理 RAM 容量
  • 每个进程使用的 RAM 减少,RAM 中可同时容纳更多进程,提升了 CPU 的利用率

栈和栈帧

栈通常在流行的计算机体系结构中驻留在内存的高端并向下增长(朝着堆的方向),并存在一个专用的寄存器栈指针用于追踪当前栈顶。每次调用函数时,在栈上分配新的一个帧,当函数返回时再将此帧移去。

在多数操作系统的实际实现中,释放栈帧后栈的大小并不会减少,在分配新的栈帧时会重新利用这些内存。以上的讨论只是逻辑上的。

有时也称这里的栈为用户栈 (user stack),因每个进程保留在内核中的内存区域称为内核栈,且内核在处理系统调用时由于内存保护机制的存在无法使用用户栈,只能使用预留的内核栈。

每个用户栈帧包括

  • 函数实参和局部变量
  • 函数调用的链接信息:每个函数需要用到一些 CPU 寄存器,当函数调用另一函数时,会在被调函数的栈帧中保存现在寄存器的副本,以便函数在返回时能为函数调用者将寄存器恢复原状

因函数能嵌套调用,因此栈中可能存在多个栈帧,若一个函数递归调用,则函数在栈中会存在多个栈帧。

命令行参数

C 语言程序使用 main() 函数作为程序的入口点,执行程序时,命令行参数通过两个入参传递给 main() 函数,第一个参数 int argc 表示命令行参数的个数,第二个参数 char *argv[] 是一个指向命令行参数的指针的数组,每一个参数是以空字符结尾的一个字符串。第一个字符串,即 argv[0] 指向的通常是该程序的名称,argv 的指针列表以 NULL 指针结尾,即 argv[argc] = NULL

argv[0] 包含了调用程序的名称,例如 busybox 这类单文件打包的二进制即使用了这个技巧,通过 argv[0] 判断不同的名称跳转到不同的实际命令。

由于 argv 列表以 NULL 终止,可以将其用于循环的边界判断:

char **p;
for(p = argv; *p != NULL;p++)
    puts(*p);

也可以使用 argc 来循环:

int i;

for(i = 0;i < argc;i++)
{
    puts(argv[i]);
}

参数机制的问题是他仅限 main() 函数可用,在保证移植性的情况下,若要保证命令行参数可被其他函数使用,需要将 argv 以参数形式传递给函数,或是设置一个指向 argv 的全局变量。

还有一些不可移植的方法如下:

  • Linux 中可使用 /proc/PID/cmdline 读取任意进程的命令行参数,每个参数以空字节终止。
  • GNU C 库提供了两个全局变量,可通过全局变量访问调用程序时的程序名,program_invocation_name 全局变量提供了用于调试程序的完整路径名,program_invocation_short_name 提供了不含完整目录的程序名称本身。定义 _GNU_SOURCE 宏后即可从 errno.h 中获得这两个全局变量的声明。

argv 和保存环境列表的 environ 数组,以及这些参数指向的字符串都驻留在进程栈上一个单一,连续的内存区域。该区域有大小上限,标准规定了最小上限至少为 4096 字节,但多数系统远大于这个设定。

可以使用 getopt() 函数解析命令行选项,注意这个库函数仅定义在 UNIX 标准中。

环境列表

每个进程都有与其相关的称为环境列表的字符串数组。也简称为环境 environment。每个字符串都以 name=value 的形式定义。将环境列表中的名称称为环境变量。

新的进程在创建时会继承父进程的环境副本。这也是一种原始的进程间通信方式,环境可将信息从父进程传递给子进程,但只有子进程在创建时才能获得父进程的拷贝,这个信息传递因此是单向的,一次性的。进程创建完毕后,子进程和父进程都可以改变他们的环境变量,且改变的环境变量不再被对方可见。

可以设置环境变量来改变一些库函数的行为。

多数 Shell (当然包括 bash)使用 export 向环境里添加变量值:

SHELL=/bin/bash
export SHELL

bashksh 中,可以写作:

export SHELL=/bin/bash

csh 中,需要使用 setenv 命令

setenv SHELL /bin/bash

以上将一个值永久添加到当前 Shell 环境中,此 Shell 之后创建的所有进程都继承此环境,可以使用 unset 撤销一个环境变量,csh 中则使用 unsetenv

bash 及其兼容的 Shell 中,可向执行的应用程序环境中添加一个变量,而不影响当前环境

NAME=value program

printenv 显示当前环境列表。

环境列表的排列无序。

通过 /proc/PID/environ 可访问任意进程的环境列表。每对以空字符终止。

在 C 程序中可使用全局变量 char **environ 访问环境列表。environargv 类似,指向了一个以 NULL 结尾的指针列表,每个指针指向一个以空字节结尾的字符串。

可以通过声明 main() 中的第三个参数来访问环境列表,即

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

但这样一来变量的作用域在 main() 函数以内,且这个特性不包含在 SUSv3 标准内。

getenv() 从进程环境中检索某个值。

#include <stdlib.h>

char *getenv(const char *name);

参数提供要查询的环境变量名,函数将返回相应的字符串指针。若不存在指定的环境变量,则 getenv() 返回 NULL。

使用时有几个注意事项

  • SUSv3 规定程序不能修改 getenv() 提供的字符串,由于大多实现中该字符串是环境中的一部分。若要修改值应使用 setenv()putenv() 函数。
  • SUSv3 允许 getenv() 使用静态分配的缓冲区返回执行结果,后续对 getenv()setenv(), putenv() unsetenv() 的调用可重写该缓冲区。但 GNU C 库并没有使用静态缓冲区。

使用 putenv() 可添加一新变量,或修改一个已经存在的变量值。

#include <stdlib.h>

int putenv(char *string)

参数是一个指向形如 name=value 的字符串的指针。调用函数后,该字符串就直接成为了环境的一部分,即 environ 中的某一个元素的指向将直接指向 string 指针的地址,而非 string 参数指向的字符串的拷贝。因此若之后再修改 string 指向的字符串会影响进程的环境。也因此,string 指向的地址空间不应分配在栈上。

putenv() 函数调用失败返回非 0 值。

GNU 版本还包含了一个非标准的扩展,若 string 参数不包含等号,则从环境列表中移除 string 命名的环境变量。

setenv() 可以代替 putenv() 函数,同样向环境中添加变量

#include <stdlib.h>

int setenv(const char *name, const char *value int overwrite)

setenv() 会分配一段内存,将 namevalue 指向的内容复制到该缓冲区。

name 标识的变量已经存在,且 overwrite 值为 0,则 setenv() 不改变环境,若 overwrite 值为 0,则 setenv() 将改变环境中的值。

unsetenv() 从环境中移除 name 标识的变量

#include <stdlib.h>
int unsetenv(const char *name)

有时需要完全清除整个环境,可以将 environ 赋值为 NULL 来清除环境。可使用 clearenv() 完成操作

#define _BSD_SOURCE  /* Or #define _SVID_SOURCE */
#include <stdlib.h>

int clearenv(void)

调用 setenv()clearenv() 确实会造成内存泄露。

setenv() 会分配一段缓冲区,其会称为环境的一部分。而 clearenv() 没有释放该缓冲区,若不停调用这两个函数确实会发生内存泄露。

SUSv3 标准实际定义,若程序直接修改 environ 变量,则不对 setenv()unsetenv()getenv() 作出定义,clearenv() 的方式实际是不被标准允许的,标准规定的清空环境变量的方式是通过 environ 变量获取所有环境变量的名称,然后逐一使用 unsetenv() 移除环境变量。

非局部跳转

C 和其他编程语言一样有 goto 关键字,但一直不被鼓励使用。但在特定的场合下 goto 也确实有他的用武之地。

C 的 goto 不能从一个函数跳转到另一个函数,由于 C 中所有函数的作用域层级相同,给定两个函数时,编译器无法判断在调用函数 A 时函数 B 的栈帧是否在栈上,也就无法判断从 A 函数跳转到 B 函数是否可行。

但偶尔还是有这种需求的。例如在一个深度嵌套调用的函数中发生了错误,此时需要放弃任务,从多层函数调用中返回。此时确实可以对每个函数都返回状态值,让函数的调用者做检查并处理,这是有效的且也是最好的方法。但有的时候直接从嵌套函数跳出会使编程更简单。

此时,setjmp()longjmp() 提供了这个功能。

#include <setjmp.h>

int setjmp(jmp_buf env)

void longjmp(jmp_buf env, int vol)

setjmp() 为后续 longjmp() 调用确立了跳转的目标,即发起 setjmp() 调用的位置。从编码角度来说,调用 longjmp() 之后就和第二次调用 setjmp() 一样。通过查看 setjmp() 的返回值即可区分 setjmp 的调用是初始返回(即设立跳转目标的第一次调用)还是第二次返回(即通过 longjmp() 跳转后回到 setjmp() 的位置)。初始返回值为 0,后续的返回值为 longjmp() 调用中 val 参数指定的任意值,可以使用这个特性来判断出程序跳至同一目标的不同起跳位置。特殊的,若 val 参数设为 0,则 longjmp() 会将其替换为 1 以便与初次返回区分开。

setjmp() 函数将当前进程环境的各种信息保存到 env 中,调用 lomgjmp() 时需指定相同的 env 变量。自然,由于调用处于不同的函数,因此 env 应设为全局变量,或作为函数的参数传递。

SUSv3 和 C99 规定了 setjmp() 的使用语境

  • 构成选择或迭代语句中 (if, switch, while 等)的整个表达式
  • 作为一元操作符 ! 的操作对象,其最终的表达式构成了选择或迭代控制语句的整个表达式
  • 作为比较操作的一部分,另一个对象必须是一个整数常量表达式,且最终的表达式构成选择或迭代语句的整个控制表达式
  • 作为独立的函数调用,且未嵌入到更大的表达式中。

注意,赋值语句不在上述之列,即形如 s = setjmp(env); 是不符合标准的。

由于 setjmp() 作为一个常规的函数实现无法保证拥有足够的信息来保存所有的寄存器值和封闭表达式中用到的临时栈的位置,以便于在 longjmp() 调用后此类信息能正常恢复,因此只能在足够简单且无需临时存储的表达式中调用 setjmp()

longjmp() 函数不能跳转到一个已经返回的函数中,因为当函数返回时,函数所使用的栈已经被回收即不再存在,此时程序将跳转到一个不存在的栈位置。程序可能会崩溃,也可能引起调用与返回间的死循环,程序就像真的从一个未执行的函数中返回了。

在 SUSv3 中规定如果从嵌套的信号处理器中调用 longjmp() 则行为未定义。

由于 setjmp()longjmp() 的跳转操作在运行时才能正确确立和运行,编译器优化时无法考虑,某些 ABI 接口又要求 longjmp() 恢复先前 setjmp() 调用所保存的 CPU 寄存器副本,此时可能会导致被优化的变量被赋予错误的值。此时应将调用 setjmp() 中涉及的变量使用 volatile 关键字声明,让编译器不对此变量进行优化。

在 gcc 中,加入 -Wextra 可输出有用的警告。

显然,与 goto 的原因类似,在实际编程中应避免使用非局部跳转。

练习

6-1

code: tlpi-dist/proc/mem_segments.c

显然,该 static char mbuf[10240000]; 的声明由于没有初始化,其被放入了未初始化数据段,程序编译时不会保存实际的数据,仅在运行时将其放入对应的内存位置并初始化。

6-2

code: c6/bad_longjmp.c

结果是由于访问了不存在的栈空间,返回了段错误。

6-3

code: c6/setenv_unsetenv.c

同样的,我没写测试,main() 函数只为了通过编译。

这里面需要做的判断条件较多。

比较麻烦的是 unsetenv() 在找到环境变量后需要把整个数组结构左移来删除元素。我猜测 environ 实际的实现应该也类似一个链表的结构,但 putenv() 给我们抽象掉了。

setenv() 实现时大可以使用之前实现的 unsetenv()