1.进程创建
1.1 fork函数
在Linux中fork函数是非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
#include <unistd.h>
pid_t fork(void);
返回值:⼦进程中返回0,⽗进程返回⼦进程id,出错返回-1
进程调用fork,当控制转移到内核中的fork代码后,内核做:
- 分配新的内存块和内核数据给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表中
- fork返回,开始调度器调度

fork之前父进程独立执行,fork之后,父子两个执行流分别执行。(注意:fork之后,谁先执行完全由调度器决定)。

1.2 写时拷贝
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:

因为有写时拷贝技术的存在,所以父子进程得以彻底分离!完成了进程独立性的技术保证!写时拷贝是一种延时申请技术,可以提高整机内存的使用率。
1.3 fork常规用法
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
- 一个进程要执行一个不同的程序,例如:子进程从fork返回后,调用exec函数。
1.4 fork调用失败的原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
2.进程终止
进程终止的本质是释放系统资源,就是释放进程申请的相关内核数据结构和对应的数据和代码。
2.1 进程退出场景
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止。
由进程的退出码决定,不同的退出码表明不同的出错的原因。
2.2 进程常见退出方法
正常终止(可以通过echo$? 查看进程退出码):
- 从main返回
- 调用exit
- _exit
异常退出:
- ctrl + c,信号终止。
echo $?打印最近一个程序的退出时的退出码,将这个进程退出码写到进程的task_struct内部(exit_code)字段。
进程一旦出现异常,一般是进程收到了信号。
cpp
1 #include<stdio.h>
2
3 int main()
4 {
5 printf("main!\n");
6
7 return 0;
8 }

可以看到使用echo $?输出了最近一个程序的退出时的退出码
2.2.1 退出码
退出码(退出状态)可以告诉我们最后一次执行的命令的状态。在命令结束以后,我们可以知道命令是成功完成的还是以错误结束的。其基本思想是,程序返回退出代码0时表示执行成功,没有问题。代码1或0以外的任何代码都被视为不成功。
Linux Shell中的主要退出码:

- 退出码 0 表示命令执行无误,这是完成命令的理想状态。
- 退出码 1 我们也可以将其解释为"不被允许的操作"。例如在没有sudo权限的情况下使用yum;再例如除以 0 等操作也会返回错误码 1 。
- 130 (SIGINT 或 ^C)和143(SIGTERM)等终止信号时非常典型的,它们属于128 + n信号,其中n代表终止码。
- 可以使用strerror函数来获取退出码对应的描述。
strerror函数可以将错误码转为可读字符串,需要包含头文件<string.h>,我们不清楚strerror这个函数有多少个退出码的描述,我们使用for循环来打印看一下。发现有134个退出码的描述。
cpp
1 #include<stdio.h>
2 #include<string.h>
3
4 int main()
5 {
6 int i = 0;
7 for(; i < 200; i++)
8 {
9 printf("%d->%s\n", i, strerror(i));
10 }
11 return 0;
12 }

2.2.2 _exit函数
#include<unistd.h>
void _exit(int status);
参数:status定义了进程的终止状态,父进程通过wait来获得该值
- 说明:虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行$?发现返回值是255.
- 直接终止进程,不会刷新缓冲区、不会执行清理动作,直接进入内核终止进程。
- exit()库函数封装了_exit()系统调用,在调用_exit()前会先做用户层的资源清理。
2.2.3 exit函数
#include <unistd.h>
void exit(int status);
在代码的任意位置调用,都会直接终止当前进程。
exit最后也会调用_exit,但在调用_exit之前,还做了其他工作:
- 执行用户通过atexit或on_exit定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写入
- 调用_exit

我们之前谈论的缓冲区是C语言提供的缓冲区,不是操作系统内部的缓冲区!!!
|-------|-------------------|-------------------|
| 特性 | exit() | _exit() |
| 类型 | C标准库函数 | 操作系统系统调用 |
| 缓冲区处理 | 退出前刷新I/O缓冲区 | 不处理缓冲区,直接终止 |
| 清理动作 | 执行终止函数、关闭文件流等清理工作 | 无用户层清理,直接进入内核终止进程 |
| 层级 | 用户层封装 | 内核级终止接口 |
2.2.4 return退出
return是一种更常见的退出进程方法。执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返回值当做exit的参数。
只有main函数的return会触发进程退出,其他普通函数的return仅代表函数调用结束,返回调用方。
3. 进程等待
3.1 进程等待必要性
- 之前讲过,子进程退出,父进程如果不管不顾,就可能造成"僵尸进程"的问题,PCB会保留,进而造成内存泄露。
- 另外,进程一旦变成僵尸状态,那就刀枪不入,"杀人不眨眼"的kill -9 也无能为力。因为谁也没有办法杀死一个已经死去的进程。
- 最后,父进程派给子进程任务完成的如何,我们需要知道,如,子进程运行完成,结果对还是不对,或者是否正常退出。
- 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。
3.2 进程等待的方法
3.2.1 wait方法
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int * status);
返回值:成功返回被等待进程pid,失败返回-1
参数:status输出型参数,获取子进程退出状态,不关心则可以设置为NULL
核心特性:阻塞等待任意一个子进程,子进程退出后,回收其僵尸资源。
3.2.2 waitpid方法
pid_waitpid(pid_t pid, int *status, int options);
**返回值:**当正常返回的时候waitpid返回收集到的子进程的进程ID;
如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0。
如果调用中出错,则返回-1,这时errno会被设置成相对应的值以指示错误所在;
参数:
pid:pid=-1,等待任一个子进程。与wait等效。
pid>0,等待其进程ID与pid相等的子进程。
status:输出型参数
WIFEXITED(status):若为正常终止子进程返回的状态,则为真。(查看进程是否正常退出)
WEXITSTATUS(status):若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
options:默认为0,表示阻塞等待
WNOHANG:若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,返回该子进程的ID。
- 如果子进程已经退出,调用wait/waitpid,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。
- 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。
- 如果不存在该子进程,则立即出错返回。

3.2.3 获取子进程status
- wait和waitpid,都有一个参数status,该参数是一个输出型参数,由操作系统填充。
- 如果传递NULL,表示不关心子进程的退出状态信息。
- 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
- status不能简单的当做整形来看待,可以当作位图来看待,具体细节如下图(status仅使用低16比特位):

|------------------|--------------------------------------------|
| 位区间 | 含义 |
| 低8位(bit0~bit7) | 终止信号:进程异常退出时,保存触发终止的信号编号;正常退出时为0 |
| bit7 | core dump标志:进程异常时是否生成核心转储文件 |
| 次8位(bit8~bit15) | 退出码:进程正常退出时,保存main函数/exit()设置的退出码;异常退出时无意义 |
手动位运算解析
1.获取退出码:次8位右移8位,按位与0xFF(00000000 00000000 00000000 11111111)保留低8位
int exit_code = (status >> 8) & 0xFF
2.获取终止信号:低7位,按位与0x7F(00000000 00000000 00000000 01111111)
int signal_code = status & 0x7F
系统提供的解析宏
- WIFEXITED(status):判断进程是否正常退出,正常退出返回真。
- WEXITSTATUS(status):获取正常退出进程的退出码,仅当WIFEXITED为真时有效。
- WIFSIGNALED(status):判断进程是否被信号终止(异常退出),异常退出返回真。
- WTERMSIG(status):获取终止进程的信号编号,仅当WIFSIGNALED为真时有效。
下面我们写一段代码验证进程等待
cpp
1 #include<stdio.h>
2 #include<string.h>
3 #include<sys/types.h>
4 #include<sys/wait.h>
5 #include<errno.h>
6 #include<stdlib.h>
7 #include<unistd.h>
8
9 int main()
10 {
11 pid_t id = fork();
12 if(id == 0)
13 {
14 //子进程
15 int cnt = 5;
16 while(cnt)
17 {
18 sleep(1);
19 printf("我是一个子进程,pid:%d, ppid:%d\n",getpid(), getppid());
20 cnt--;
21 }
22 }
23 //父进程
sleep(10)
24 int status = 0;
25 pid_t rid = wait(NULL);
26 if(rid > 0)
27 printf("wait success, rif:%d, exit_code :%d,exit_signal:%d\n",rid,(status>>8)&0xFF,status&0x7F);
28 else
29 printf("wait failed: %d: %s\n",errno,strerror(errno));
30 return 0;
31 }
32
3.2.4 阻塞与非阻塞等待
阻塞等待:父进程调用wait/waitpid后,会一直卡住(hang住),直到目标子进程退出,才会继续执行后续代码。waitpid(pid, &status,0);
非阻塞等待+轮询:父进程调用waitpid(pid, &status,WNOHANG),如果子进程未退出,函数立即返回0,不会卡住;父进程可以在轮询间隙执行自己的任务。
核心逻辑:通过循环反复调用waitpid,检测子进程状态,直到子进程退出回收完成。
特点:父进程在等待期间可以执行其他任务,资源利用率高。
cpp
1 #include<stdio.h>
2 #include<string.h>
3 #include<sys/types.h>
4 #include<sys/wait.h>
5 #include<errno.h>
6 #include<stdlib.h>
7 #include<unistd.h>
8
9 //bin函数指针类型
10 typedef void (*func_t)();
11
12 #define NUM 5
13 func_t handlers[NUM+1];
14
15 //下面几个函数模拟任务
16 void DownLoad()
17 {
18 printf("我是一个下载任务...\n");
19 }
20 void Flush()
21 {
22 printf("我是一个刷新任务...\n");
23 }
24 void Log()
25 {
26 printf("我是一个记录日志的任务\n");
27 }
28
29 //注册
30 void RegisterHandler(func_t h[],func_t f)
31 {
32 int i = 0;
33 for(; i < NUM; i++)
34 {
35 if(h[i] == NULL) break;
36 }
37 if(i == NUM) return;
38 h[i] = f;
39 h[i+1] = NULL;
40 }
41
42 int main()
43 {
44
45 RegisterHandler(handlers,DownLoad);
46 RegisterHandler(handlers,Flush);
47 RegisterHandler(handlers,Log);
48
49 pid_t id = fork();
50 if(id == 0)
51 {
52 //子进程
53 int cnt = 5;
54 while(cnt)
55 {
56 sleep(1);
57 printf("我是一个子进程,pid:%d, ppid:%d\n",getpid(),getppid());
58 cnt--;
59 }
60 exit(10);
61 }
62 while(1)
63 {
64 int status = 0;
65 pid_t rid = waitpid(id, &status, WNOHANG);//非阻塞
66 if(rid > 0)
67 {
68 printf("wait success, rid: %d, exit_code :%d ,exit_signal:%d\n",getpid(),WEXITSTATUS(status),status&0x7F);
69 break;
70 }
71 else if(rid == 0)//子进程还在运行,这个时候父进程可以做别的事情
72 {
73 //函数指针进行回调处理
74 int i = 0;
75 for(; handlers[i]; i++)
76 {
77 handlers[i]();
78 }
79 printf("本轮调用结束,子进程没有退出\n");
80 sleep(1);
81 }
82 else
83 {
84 printf("等待失败\n");
85 break;
86 }
87 }
88
89 }
上面代码是非阻塞等待,父进程可以做其他的事情。

4 进程程序替换
fork()之后,父子个自执行父进程代码的一部分如果子进程就想执行一个全新的程序呢?进程的程序替换来完成这个功能!
程序替换是通过特定的接口,加载磁盘上的一个全新的程序(代码和数据),加载到调用进程的地址空间中!
4.1替换原理
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不是创建新进程,所以调用exec前后该进程的id并未改变。

进程调用exec系列函数后,会将磁盘上的一个新的可执行程序覆盖式替换当前进程的代码段和数据段,重新初始化堆栈、虚拟空间地址。关键特性:进程的PCB、PID、地址空间等内核结构完全不变,仅替换用户层的代码和数据。
exec系列函数执行成功后,后续的代码完全不会被执行(因为代码段已经被覆盖);只有调用失败时,才会返回继续执行后续代码。
cpp
1 #include<stdio.h>
2 #include<unistd.h>
3
4 int main()
5 {
6 printf("我的程序要运行了!\n");
7 execl("/usr/bin/ls","ls","-l","-a", NULL);
8 printf("我的程序运行完毕了\n");
9 return 0;
10 }

可以看到execl做了ls -a -l,并且最后一句没有打印。
程序替换与Shell执行命令的关联
Shell(bash)执行命令的完整流程,就是进程创建+等待+程序替换的完整闭环:
- bash收到用户输入的命令,调用fork()创建子进程
- 子进程调用exec函数,做程序替换,执行用户输出的命令程序
- bash父进程调用wait/waitpid,阻塞等待子进程命令执行完毕,回收资源。
- 子进程退出后,bash继续接受下一条用户命令。
4.2 替换函数
有六种exec开头的函数,统称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[]);
int execve(const char *path, char *const argv[], char *const envp[]);
4.2.1 函数解释
- 这些函数如果调用成功则加载新的程序从启动代码开始执行,不在返回。
- 如果调用出错则返回-1
- 所有exec函数只有出错的返回值而没有成功的返回值
进场独立性保障:子进程执行程序替换,完全不会影响父进程。因为替换时会触发代码和数据的写时拷贝、子进程的地址空间、页表会与父进程彻底分离,保障进程间的独立性。
4.2.2 命名理解
|-----------------|------------------------------------------|
| 后缀 | 核心含义 |
| l (list) | 以列表形式逐个传递命令行参数,可变参数列表,最终以NULL结尾 |
| v (vector) | 以指针数组(argv形式)传递命令行参数,数组最终以NULL结尾 |
| p (path) | 自动在系统PATH环境变量中查找目标程序,第一个参数只需传程序名,无需带完整路径 |
| e (environment) | 支持自定义传递环境变量数组,传入后会覆盖子进程原有的环境变量,而非增量添加 |
- l (list):表示参数采用列表
- v(vector):采用数组
- p(path):自动搜索环境变量PATH
- e(env):表示自己维护环境变量

1.execl
函数原型:int execl(const char *path, const char *arg, ...);
传参规则:第一个参数为程序的完整路径+名称,后续为可变参数列表,对应命令行的每个参数,最终必须以NULL结尾。
核心语义:第一个参数说明"要执行谁",后续参数说明"要怎么执行它",命令行怎么写,参数就怎么按逗号分隔传递。
cpp
1 #include<stdio.h>
2 #include<unistd.h>
3
4 int main()
5 {
6 printf("我的程序要运行了\n");
7 if(fork() == 0)
8 {
9 sleep(1);
10 execl("/usr/bin/ls", "ls","-a","-l", NULL);
11 exit(1);
12 }
13 waitpid(-1, NULL, 0);
14 printf("我的程序运行完毕\n");
15 return 0;
16 }

2.excelp
函数原型:int excelp(const char* file, const char *arg, ...);
与execl的核心区别:带p后缀,第一个参数只需传程序名,无须带路径,函数会自动在PATH环境变量中查找目标程序,其余传参规则与execl一致。
cpp
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<sys/types.h>
4 #include<sys/wait.h>
5 #include<stdlib.h>
6
7 int main()
8 {
9 printf("我的程序要运行了\n");
10 if(fork() == 0)
11 {
12 sleep(1);
13 execlp("ls", "ls","-a","-l", NULL);
14 exit(1);
15 }
16 waitpid(-1, NULL, 0);
17 printf("我的程序运行完毕\n");
18 return 0;
19 }

3.execv
函数原型:int execv(const char* path, char *const argv[]);
与execl的核心区别:带V后缀,不再使用可变参数模版,而是通过指针数组传递所有命令行参数,数组必须以NULL结尾;第一个参数仍需传递程序的完整路径+名称
cpp
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<sys/types.h>
4 #include<sys/wait.h>
5 #include<stdlib.h>
6
7 int main()
8 {
//命令行参数表
9 char *const argv[] = {
10 (char*const) "ls",
11 (char*const) "-l",
12 (char*const) "-a",
13 NULL
14 };
15
16 printf("我的程序要运行了\n");
17 if(fork() == 0)
18 {
19 sleep(1);
20 execv("/usr/bin/ls", argv);
21 exit(1);
22 }
23 waitpid(-1, NULL, 0);
24 printf("我的程序运行完毕\n");
25 return 0;
26 }

4.execvp
函数原型:int execvp(const char* file, char *const argv[]);
核心特征:结合v+p的能力,数组形式传参+自动在PATH环境变量中查找程序,第一个只需传程序名,第二个参数为命令行参数数组。
cpp
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<sys/types.h>
4 #include<sys/wait.h>
5 #include<stdlib.h>
6
7 int main()
8 {
9 char *const argv[] = {
10 (char*const) "ls",
11 (char*const) "-l",
12 (char*const) "-a",
13 NULL
14 };
15
16 printf("我的程序要运行了\n");
17 if(fork() == 0)
18 {
19 sleep(1);
20 execv("ls", argv);
21 exit(1);
22 }
23 waitpid(-1, NULL, 0);
24 printf("我的程序运行完毕\n");
25 return 0;
26 }

5.execvpe
函数原型:int execvpe(const char* file, char *const argv[], char* const envp[]);
核心特征:结合v+p+e的能力,数组传参+PATH查找+自定义环境变量;第三个参数为自定义的环境变量数组,传入后子进程会使用该数组完全覆盖原有环境变量,而非增量添加。
先创建一个other.cc,编译后产生other
cpp
1 #include<iostream>
2 #include<cstdio>
3 #include<unistd.h>
4
5 int main(int argc, char* argv[],char *env[])
6 {
7 for(int i = 0; i < argc; i++)
8 {
9 printf("argv[%d]:%s\n",i,argv[i]);
10 }
11 printf("\n");
12 for(int i = 0; env[i];i++)
13 {
14 printf("env[%d]:%s\n",i,env[i]);
15 }
16 return 0;
17 }
proc.c调用other这个函数
cpp
7 int main()
8 {
9 //命令行参数表
10 char *const argv[]= {
11 (char* const)"other",
12 (char* const)"-a",
13 (char* const)"-b",
14 (char* const)"-c",
15 (char* const)"-d",
16 NULL
17 };
18 //env环境变量表
19 char* const env[]={
20 (char* const)"MYENV=123456789",
21 NULL
22 };
23 if(fork() == 0)
24 {
25 printf("我是子进程,我的pid:%d\n",getpid());
26 sleep(1);
27 execvpe("./other",argv,env);
28 exit(1);
29 }
30 waitpid(-1, NULL, 0);
31 printf("我的程序运行完毕了\n");
32 return 0;
33 }

可以发现这里的环境变量被覆盖了。
如果不想覆盖环境变量,我们需要使用putenv,用新增的方式交给子进程!
cpp
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<sys/types.h>
4 #include<sys/wait.h>
5 #include<stdlib.h>
6 //env环境变量表
7 char* const env[]={
8 (char* const)"MYENV=123456789",
9 (char* const)"ADD1=111111",
10 (char* const)"ADD2=222222",
11 (char* const)"ADD3=333333",
12 NULL
13 };
14 int main()
15 {
16 //命令行参数表
17 char *const argv[]= {
18 (char* const)"other",
19 (char* const)"-a",
20 (char* const)"-b",
21 (char* const)"-c",
22 (char* const)"-d",
23 NULL
24 };
25
26 extern char** environ;
27 for(int i = 0; env[i]; i++)
28 {
29 putenv(env[i]);
30 }
31 if(fork() == 0)
32 {
33 printf("我是子进程,我的pid:%d\n",getpid());
34 sleep(1);
35 execvpe("./other",argv,environ);
36 exit(1);
37 }
38 waitpid(-1, NULL, 0);
39 printf("我的程序运行完毕了\n");
40 return 0;
41 }

可以看到这样就是全部的环境变量了。
4.2.3 底层系统调用与封装逻辑
- 上述六个接口均为C语言级别的库函数封装,Linux系统中真正的程序替换系统调用只有一个:execve(对应2号系统手册)
- 所有上层exec系列库函数,最终都会调用execve这个系统调用;该系统调用必须传递3个核心参数:目标程序路径、命令行参数数组、环境变量数组。
- 若上层接口未传递环境变量,函数内部会默认传递当前进程的全局环境变量指针environ,这也是子进程默认能继承父进程环境变量的核心原因。
事实上,只有execve是真正的系统调用,其他五个函数最终都调用execve,所以execve在man手册第二节,其他函数在man手册的第三节。这些函数之间的关系如下图所示。

程序替换的本质是加载器:exec系列接口就是程序加载器的底层核心,任何程序加载到内存以进程形式运行,本质都是先创建进程,再通过exec接口替换程序的代码和数据。
全类型程序支持:程序替换可以执行任何能以进程形式运行的程序,包括C/C++编译的二进制程序、Python/Shell/PHP等解释型脚本(本质是替换对应解释器的二进制程序,再由解释器执行脚本内容)
