Linux 中的进程:
程序时一个预定义的指令序列,用来完成一个特定的任务。
C 编译器可以把每个源文件翻译成一个目标文件,链接器将所有的目标文件与一些必要的库链接在一起,产生一个可执行文件。当程序被执行时,操作系统将可执行文件复制到内存中,这就是程序的映像。
进程是一个程序正在执行的实例。每个这样的实例都有自己的地址空间与执行状态。进程必须有一个PID(Process ID,进程标识),以便操作系统能够区分各个不同的进程。操作系统记录进程的 PID 与状态,并根据这些信息来分配系统资源。当操作系统产生一个新的PID,生成对应的用于管理的数据结构,并为运行程序代码分配了必要的资源,一个新的进程就产生了。
可以认为进程就是一个执行的流程,在顺序执行时 CPU 的程序计数器总是指向下一条要执行的指令的地址,如果 CPU 或程序指令修改了程序计数器的内容,执行流程就发生了跳转。
一个进程具有如下核心要素:
◆ 程序映像:二进制指令序列。
◆ 地址空间:用于存放程序和执行程序。
◆ PCB(Process Control Block,进程控制块):内核中描述进程的主要数据结构。
进程管理是操作系统的核心功能。
创建进程:
Linux 系统上的进程间有父子关系。一个进程有且仅有一个父进程,但是可能有多个子进程。所有的进程都有一个共同的祖先,即 init 进程。init 是系统启动后创建的第一个用户态进程,它的 PID 为 1。init 进程对保持进程的正常运行十分重要,它会持续存在知道系统关闭,而且即使是超级用户也不能够通过信号使其终止。
Linux 系统中可以使用三个系统调用创建进程:fork,vfork 和 clone。前两个是所以类 UNIX 系统都提供的传统的创建进程的系统调用,而 clone 是 Linux 系统独有的用于创建线程的系统调用方式,它可以用来创建进程或线程。从可移植的角度考虑,不鼓励直接使用 clone 系统调用,如果要创建线程,可以使用 POSIX 的线程 API。
fork 系统调用是创建进程最常用的方式,其接口头文件与函数原型如下:
#include <unistd.h>
pid_t fork(void);
当 fork 调用成功返回时,系统中将会出现一个新的进程。新的进程成为原进程的子进程,而原进程则是新进程的父进程。子进程几乎完全克隆了父进程的一切特征,包括虚拟地址空间和执行进度。fork 函数返回一个 pid_t 型的进程 ID,从程序员的角度看,父子进程的唯一差异在于 fork 函数的返回值是不同的:父进程中返回非零值,是其子进程的进程ID,如果是 -1,就表示创建进程失败;而在子进程中永远返回 0。这就是在程序中判断是父进程还是子进程的依据。
创建进程的另外一个系统调用时 vfork,其接口头文件与函数原型如下:
#include <sys/types.h>
#include <unistd.h>
pid_t vfork(void);
vfork 系统调用与 fork 基本是一样的,但是用它创建了子进程后并不完全复制父进程的虚拟地址空间。在早期的操作系统上,fork 系统调用会为新创建的进程分配新的物理内存以容纳从父进程复制过来的虚拟地址空间。考虑到很多时候,子进程将会立即调用 exec 函数装载新的程序执行,这使得之前的内存分配与线性地址空间复制毫无意义并且浪费资源,vfork 的思想就是消除这个新的物理内存分配与虚拟地址空间复制的过程,让新进程的创建更有效率。使用 vfork 时,父进程会一直阻塞,直到子进程退出或调用 exec 执行新程序,并且在子进程中最好不要修改任何全局变量,因为实际上操作系统并没有为这些变量分配物理内存。
在现代的操作系统中,fork 调用都使用了所谓的“写时复制”(copy on write)技术,因此 fork 系统调用与 vfork 的工作效率几乎是一样的。“写时复制”的实现如下:
◆ 当一个进程创建时,它与父进程尽可能的共享同样的物理内存,内核仅仅复制进程的页表项并标明页的属性是“写时复制”。
◆ 当进程去修改内存时,就会引发一个页异常,在异常的处理过程中,内核会分配新的物理页并复制要修改的内存,然后重新进行内存映射。
◆ 当异常处理完毕返回后,进程修改的已经是新的物理内存,不会影响到原来与之共享内存的其他进程。
执行进程:
在 Linux 系统中有一系列的函数可以讲一个进程的执行流程从一个可执行程序转移到另一个可执行程序,也就是装载并运行一个程序。这些函数通常被称为 exec 函数族,它们的接口头文件和原型如下:
#include <unistd.h>
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
在解释这些函数的用法之前,我们先来了解一些变量 environ,它是一个每个程序都可以访问的全局变量,但访问前必须先进行声明:
extern char **environ;
实际上它是一个字符串数组的首地址,代表当前进程执行的环境变量。
execl,execv 和 execle 函数会执行由 path 参数指定的可执行文件。execl 和 execle 执行新程序时的命令行参数由参数 arg 及随后的可变个数参数给出,而 execv 函数执行新程序时的命令行参数由字符串数组参数 argv 给出。使用 execl 和 execv 函数时,执行新程序的环境变量取自 environ,即与当前进程在相同环境中执行,而使用 execle 函数时,执行新的环境变量由参数 envp 给出。
函数 可执行文件 参数形式 环境变量
execl 给出全路径 可变参数列表 不提供,取自 environ 变量
execlp 在 PATH 环境变量中查找 可变参数列表 不提供,取自 environ 变量
execle 给出全路径 可变参数列表 要提供
execv 给出全路径 字符串数组 不提供,取自 environ 变量
execvp 在 PATH 环境变量中查找 字符串数组 不提供,取自 environ 变量
以上的函数实际上都是利用下面这个系统调用来实现的:
int execve(const char *filename, char *const argv[], char *const envp[]);
◆ filename:要执行的程序文件。
◆ argv:以 NULL 结尾的字符串数组,表示命令行参数。
◆ envp:以 NULL 结尾的字符串数组,表示环境变量。
◆ 返回值:-1 表示执行失败,如果执行成功,则这个系统调用不会返回。
要理解 execve 函数的关键在于当它执行成功时是不会返回的,因为执行流程已经进入了一个新的程序。
在 execve 系统调用中,内核首先会查看文件的权限。进程的所有者必须有执行这个文件的权限。如果测试失败,execve 函数会返回 -1,并且将变量 errno 设置为 EPERM。通过权限检测后,内核就会去查看文件内容,检查程序是否真的是一个可执行文件。一般来说,Linux 系统中的可执行文件分为两类:可执行目标文件与可执行脚本。
◆ 可执行目标文件
经链接器链接后可直接执行的文件成为可执行目标文件。内核一般支持几种特定格式的可执行文件。ELF 格式是 Linux 系统中普遍使用的一种标准的可执行文件格式。
ELF 格式的文件在开头有四个字节的标签,以 0x7f 开始,随后是字符 E,L,F。内核据此来判断一个文件是否是 ELF 格式的文件。并非使用的 ELF 文件都是可执行的。编译过程中产生的目标文件也是 ELF 格式的,但没有经过最终的链接就不是可执行文件。当内核确认一个文件是 ELF 格式的文件后,就会检查 ELF 文件头,以确认文件是否真正可执行以及获取执行时所需的各种信息,然后将程序加载并执行。
◆ 可执行脚本
可执行脚本是一个特殊的文件,它能够指示内核启动一个解释器去执行后续的内容。这个解释器必须是可执行目标文件。如果没有合适的解释器,execve 将返回 -1 并且设置 errno 变量的值为 ENOEXEC。
一般情况下,脚本的解释器是 Shell,但内核也会查看脚本文件第一行,如果前两个字符是 #!,它就会将第一行的剩余部分解析为启动解释器的命令。如:
#!/bin/sh
这样内核将会启动 /bin/sh 作为脚本的解释器。
进程的内存布局:
进程可以认为是程序运行时的一个实例,程序中所使用的各种变量和内存在进程的虚拟地址空间中是有一定的分布规律的,如下面的例程:
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <unistd.h> 4 5 int z = 0; /* 全局变量在数据段中 */ 6 7 /* 函数在代码段中 */ 8 int main() 9 { 10 int *a = 0; /* 非静态的局部变量在用户栈中 */ 11 pid_t pid; 12 if((pid = fork())) 13 { 14 /* 父进程执行这里的代码 */ 15 a = (int *)malloc(100*sizeof(int)); /* 所分配的内存在父进程的堆中 */ 16 z = pid; 17 printf("z1 = %d\n", z); 18 } 19 20 else 21 { 22 /* 子进程执行这里的代码 */ 23 a = &z; 24 *a = pid; 25 printf("z2 = %d\n", z); 26 } 27 printf("pid = %d\n", pid); 28 29 return 0; 30 }
在例程中通过 fork 函数产生了一个子进程,注意它和父进程的虚拟地址空间是相互独立的,故对全局变量的访问互补影响。程序中定义的全局变量和静态变量在运行时将放在数据段,而非静态的局部变量则放在用户栈上,通过 malloc 等函数分配得到的内存则位于进程的堆中。如下图所示是一个进程的虚拟堆中空间的布局:
程序包含代码与数据,在运行前,它们要被载入内存,这就是程序的内存映像,也可以认为是进程的内存布局,程序文件中的代码部分成为代码段,包含 CPU 要执行的指令序列;程序文件的数据部分成为数据段,包含各种全局变量和静态变量。栈是实现函数调用的基础,由内核进行分配,程序中的非静态局部变量将在栈中动态创建。堆是进行动态内存分配的场所,由内核根据需要进行分配。当创建一个进程时,子进程完成复制了父进程的代码、数据、堆和栈等。