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
", z);
18 }
19
20 else
21 {
22 /* 子进程执行这里的代码 */
23 a = &z;
24 *a = pid;
25 printf("z2 = %d
", z);
26 }
27 printf("pid = %d
", pid);
28
29 return 0;
30 }
在例程中通过 fork 函数产生了一个子进程,注意它和父进程的虚拟地址空间是相互独立的,故对全局变量的访问互补影响。程序中定义的全局变量和静态变量在运行时将放在数据段,而非静态的局部变量则放在用户栈上,通过 malloc 等函数分配得到的内存则位于进程的堆中。如下图所示是一个进程的虚拟堆中空间的布局:
程序包含代码与数据,在运行前,它们要被载入内存,这就是程序的内存映像,也可以认为是进程的内存布局,程序文件中的代码部分成为代码段,包含 CPU 要执行的指令序列;程序文件的数据部分成为数据段,包含各种全局变量和静态变量。栈是实现函数调用的基础,由内核进行分配,程序中的非静态局部变量将在栈中动态创建。堆是进行动态内存分配的场所,由内核根据需要进行分配。当创建一个进程时,子进程完成复制了父进程的代码、数据、堆和栈等。
进程的状态迁移:
三个基本状态:就绪态、运行态、睡眠态。如下图:
图二。
进程的终止:
自愿终止 和 被迫终止。
自愿终止指的是应用程序中主动调用了执行退出过程的系统调用而终止,这个可以通过函数 exit 来做到,其
接口头文件与原型如下:
#include <stdlib.h>
void exit(int status);
被迫终止指的是应用程序中没有主动调用退出进程的系统调用而被内核强制终止的情形。
当一个进程终止时,内核会通知其父进程。在父进程进行处理前,这个进程成为所谓的僵尸进程,它所占的各
种资源已经被回收,但进程描述符仍然存在,以便父进程获取它的退出状态。父进程可以用 wait 函数或 waitpid 函
数获取子进程的退出状态,它们的接口头文件及原型如下:
#include <sys/types.h>
#inclde <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
调用 wait 函数使当前进程阻塞直到它的某个子进程退出,这时返回值是退出的子进程的 PID,而参数
status 指向的整数被设为子进程的退出状态(或称返回值)。如果有错误发生则返回 -1。
waitpid 函数则提供了比 wait 函数更为精细的控制,其各个参数及返回值含义如下:
◆ pid:指定要等待的子进程的 PID。
◆ status:用于获取子进程的退出状态。
◆ options:等待的参数,可以改变函数的行为。
◆ 返回值:退出的子进程的 PID,返回 -1 表示有错误发生。
其中 pid 参数有如下多种用法:
◆ pid > 0 ,表示某个特定的子进程。
◆ pid = 0 ,表示任何进程组 ID 等于当前进程的子进程。
◆ pid = -1 ,表示任何子进程。
◆ pid < -1 ,表示任何进程组 ID 等于 -pid 的子进程。
options 参数的一个常用值是 WNOHANG,它表示函数以非阻塞的方式执行,即如果没有子进程退出则对函数的
调用立刻以错误状态返回。
对 wait 函数的调用 wait(&status) 实际上等价于:
waitpid(-1, &status, 0);
如果一个进程没有子进程,则对上述函数的调用会立刻以错误状态返回,并且变量 errno 的值被设为 ECHILD
。
如果父进程在子进程退出前就已经退出,这时称子进程为孤儿进程。在进程退出时,内核会检视它的子进程并
使这些子进程成为 1 号进程的子进程,也就是说,子进程被 init 进程“收养”。
进程与信号:
信号是内核提供的一种异步消息机制,主要用于内核对进程发送异步通知事件,可以理解为对进程的执行流程
的一个“软中断”。
信号总是由内核递交给进程,但从应用程序的角度讲,信号的来源是多种多样的,如:
◆ 当进程在一个没有打开的管道上等待时,内核发出 SIGPIPE 信号。
◆ 进程在 Shell 中前台执行时,用户按下 Ctrl+C 组合键,将向进程发送 SIGINT 信号。
◆ 用户使用 kill 命令向某个进程发送信号。
◆ 进程访问非法的内存地址时内核向其发送“段错误”信号 SIGSEGV。
◆ 一个进程使用系统调用向另一个进程发送信号。
◆ 发生各种运行异常时,内核将向进程发送 SIGFPE 信号。
Linux 中的信号处理机制:
在内核对进程管理的 PCB信息块中有若干个字节,其中每个比特位用于表示某个信号是否发生。当需要向某个
进程发送一个特定的信号时,就将其 PCB 信息中对应的比特位置为 1。但是对信号的处理并不立刻发生,内核会在从
内核态返回用户态时对当前进程的 PCB 中表示信号的数据进行检查,如果有信号发生,则内核会修改当前进程栈中的
信息,使得返回用户态后首先执行与信号绑定的处理函数,然后再从当前进程被中断或进行系统调用的地方继续执行。
关于信号的一些概念:
◆ 发送信号
给某个进程发送信号实质上就是讲其 PCB 中的对应比特位置 1.当然,这个操作只能由内核来进行,但应用程
序可以通过系统调用间接的完成这个操作。
◆ 信号屏蔽
每个进程都有一个用来描述哪些信号将被屏蔽的信号集,成为信号掩码,如果某个信号在进程的信号掩码中,
则发生到进程的这种信号将会被屏蔽。屏蔽只是延迟信号的到达,信号会在解除屏蔽后继续传递。在信号处理函数和程
序的其他部分共享全局变量时,一般会在访问变量前屏蔽信号,以免在访问过程中发生信号而导致同步问题,访问完毕
后再解除对信号的屏蔽。
◆ 忽略信号
忽略信号就是指当进程收到某个信号时直接丢弃,对进程的执行没有影响。但有些信号是不能忽略的,如
SIGKILL 和 SIGSTOP 信号。
◆ 捕捉信号
对每个信号来说,系统都有默认的信号处理函数。如果程序中用自己定义的函数取代了默认的信号处理函数,
则称为捕捉了这个信号。
发送信号:
信号实际上是由一个正整数代表的,但为了使用方便,每个信号都定义了一个名称,代表一种特殊的含义。这
些信号的定义在系统头文件 signal.h 中。
向一个进程发送喜欢可以使用 kill 命令,默认情况下 kill 命令发送 SIGTERM 信号以试图终止一个进程。
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
◆ pid > 0:表示目标进程的 PID。
◆ pid = 0:表示与当前进程同组的所有进程。
◆ pid = -1:表示所有当前进程有发送信号权限的其他进程。
◆ pid < -1:表示所有处于进程组 -pid 中的进程。
sig 参数则是要发送的信号。函数返回 0 则表示信号发送成功,如果信号发送失败,则函数返回 -1。
注意的是,发送信号的进程需要有必要的权限,一般情况下,发送信号的进程与接收信号的进程应属于相同的
用户。root 可以发送信号到任何进程。
进程可以使用 raise 函数给自己发送信号:
#include <signal.h>
int raise(int sig);
使用 alarm 函数可以在过一段时间后向进程自己发送 SIGALRM 信号,其接口头文件及原型如下:
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
seconds 参数表示一个以秒为单位的超时时间。超过这段时间后,内核将自动向进程自己发送 SIGALRM 信号
。如果在指定时间超时前再次调用 alarm 函数,则新的时间值将覆盖旧的,并且从当前时间开始重新计算。如果指定
的时间值为 0,则表示取消信号的发送。
alarm 函数的返回值表示前一次对 alarm 函数的调用还剩多少秒就要超时,如果返回 0 则说明没有调用过
alarm 函数或前一次调用 alarm 函数后已超时。
利用 alarm 函数可以实现定时操作:
#include <unistd.h>
int main()
{
alarm(1);
for(; ;);
}
使用 alarm 函数设置了 1 秒后发送 SIGALRM 信号,而 SIGALRM 信号的默认操作是退出进程,所以尽管随后
是一个死循环,进程仍然能够退出
捕捉信号:
使用 signal 函数
signal 函数是 Linux 系统上传统的信号处理接口:
#include <signal.h>
sighandler_t signal(int signum, sighandler_t handler);
其中 sighandler_t 类型是一个函数指针类型,定义如下:
typedef void (*sighandler_t)(int);
这个类型表示一个信号处理函数。signal 函数的作用就是讲 handler 参数所指向的函数注册成为参数
signum 所代表的信号的处理函数,它的返回值是这个信号原来的处理函数,如果返回 SIG_ERR,则说明有错误发生,
注册失败。
注册成功后,所注册的函数就会在信号被处理时调用,代替了默认的行为,或者成为信号被捕捉。
使用 signal 函数时应注意以下两点:
◆ handler 参数的值可以是 SIG_IGN 或者 SIG_DFL,SIG_IGN 表示忽略这个信号,SIG_DFL 表示对信号的处
理重设为系统的默认方式。
◆ 有些信号是不可以忽略或捕获的,如 SIGKILL 和 SIGSTOP。
下面给出一个例程来说明信号的产生、忽略与捕获的编程,例程代码如下:
1 /* 文件名:sigtest.c */
2 /* 说明:信号处理例程 */
3
4 #include <stdio.h>
5 #include <stdlib.h>
6 #include <signal.h>
7 #include <unistd.h>
8 #include <sys/wait.h>
9
10 static pid_t pid;
11
12 /* 子进程 1 SIGALRM 信号处理函数 */
13 static void wakeup(int dummy)
14 {
15 printf("I (pid = %d) am up now
",pid);
16 }
17
18 /* 子进程 1 SIGINT 信号处理函数 */
19 static void handler(int dummy)
20 {
21 printf("I (pid = %d) got an interrupt, will exit
",pid);
22 exit(0);
23 }
24
25 /* 子进程 2 信号处理函数 */
26 static void trapper(int i)
27 {
28 if(i == SIGUSR1)
29 {
30 printf("I (pid = %d) got SIGUSR1,will exit
", pid);
31 exit(0);
32 }
33 else
34 {
35 printf("I (pid = %d) got signal %d, will continue
", pid, i);
36 }
37 }
38
39
40 /* 父进程信号处理函数 */
41 void parent(int sig)
42 {
43 printf("Signal (%d) received by parent (%d)
", sig, pid);
44 }
45
46 int main(int argc, char *argv[])
47 {
48 int i, cpid1, cpid2;
49
50 printf("Number of signal is %d
", NSIG); //输出系统中信号的个数
51
52 if(!(cpid1 = fork())) //创建第一个子进程
53 {
54 pid = cpid1 = getpid(); //获得子进程的进程号
55 printf("CPID1 = %d
", cpid1);
56
57 for(i=1; i<NSIG; i++)
58 {
59 signal(i, SIG_IGN); //忽略所以的信号
60 }
61
62 signal(SIGINT, handler); //捕获信号 SIGINT
63 signal(SIGALRM, wakeup); //捕获超时信号
64 alarm(2); //启动定时器,设置 2 秒后超时
65
66 for(; ;)
67 {
68 pause(); //等待信号
69 }
70
71 printf(" -- CPID1 (%d) terminates
", cpid1);
72
73 exit(0);
74 }
75 else if(!(cpid2 = fork())) //创建第二个子进程
76 {
77 pid = cpid2 = getpid();
78 printf("CPID2 = %d
", cpid2);
79
80 for(i=1; i<NSIG; i++)
81 {
82 signal(i, trapper); //捕获所有的信号
83 }
84
85 for(; ;)
86 {
87 pause(); //等待信号
88 }
89
90 printf(" -- CPID2 (%d) terminates
", cpid2);
91
92 exit(