1. 进程创建
1.1 fork 函数
在 Linux 中,fork 函数是非常重要的系统调用,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
#include <unistd.h>
pid_t fork(void);
返回值:子进程中返回 0,父进程返回子进程 id,出错返回 -1。
进程调用 fork,当控制转移到内核中的 fork 代码后,内核做:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表当中
fork 返回,开始调度器调度。
当一个进程调用 fork 之后,就有两个二进制代码相同的进程。而且它们都运行到相同的地方。但每个进程都将可以开始它们自己的旅程。
int main(void) {
pid_t pid;
printf("Before: pid is %d\n", getpid());
if ((pid = fork()) == -1) {
perror("fork()");
exit(1);
}
printf("After: pid is %d, fork return %d\n", getpid(), pid);
sleep(1);
return 0;
}
运行结果示例:
[root@localhost linux]# ./a.out
Before: pid is 43676
After: pid is 43676, fork return 43677
After: pid is 43677, fork return 0
这里看到了三行输出,一行 before,两行 after。进程 43676 先打印 before 消息,然后它打印 after。另一个 after 消息由 43677 打印的。注意到进程 43677 没有打印 before,这是因为 fork 之前父进程独立执行,fork 之后,父子两个执行流分别执行。 注意:fork 之后,谁先执行完全由调度器决定。
1.2 写时拷贝
通常,父子代码共享,父子在不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。
上图显示父进程代码段在自己的页表中是只读的,包括我们以前定义的字符常量区,代码是不可写的。可是数据段为什么也是只读的?起始在我们的父进程还没创建子进程前,代码段是只读的没问题,但是数据段对应的映射关系,可能有一百个一千个映射地址,这些映射地址的权限实际上是读写的,但一旦创建了子进程,操作系统就会把数据段的权限也改成只读的。
然后后面的父子进程,比如说子进程尝试对它的数据进行写入,当它写入时,操作系统就会发现你要访问的数据,第一,数据是合法的,因为虚拟地址物理地址都有,而且它发现访问的区域是数据段。如果是代码段肯定在 start_code, end_code 这个区间里面,如果是数据段肯定在 start_data, end_data 这个区间里面。发现你是数据段,而且页表的映射关系是正确的,但是发现数据段怎么是只读的,所以这时候操作系统就会出错。这种出错不是真的错了,是操作系统检测到一个用户对一个只读的区域进行写入,但操作系统经过检查发现它是数据段,而且是子进程,这时候操作系统就会触发写时拷贝。
写时拷贝是通过设置页表的权限,让页表让操作系统出错的行为。让操作系统知道我们正在访问一个只读的区域,进而在错误的驱使之下让操作系统完成对应的写时拷贝这样的任务。
因为有写时拷贝技术的存在,所以父子进程得以彻底分离!完成了进程独立性的技术保证! 写时拷贝,是一种延时申请技术,可以提高整机内存的使用率。
为什么要写时拷贝?
- 减少子进程的创建时间
- 减少内存浪费
1.3 fork 调用失败的原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
2. 进程终止
进程终止的本质是释放系统资源,就是释放进程申请的相关内核数据结构和对应的数据和代码。
2.1 进程退出场景
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止(退出码无意义)
2.2 进程常见退出方式
正常终止(可以通过 echo $? 查看最近进程的退出码):
- 从 main 返回(main 函数结束表示进程结束,其他函数只表示自己函数调用完成)
- 调用 exit(status)(任何地方调用 exit 表示进程结束,并返回给父进程 bash 子进程的退出码)
- _exit:终止一个调用进程(相当于谁调用它,它把谁'弄死')
2.2.1 exit 函数
#include <unistd.h>
void exit(int status);
exit 最后也会调用_exit,但在调用_exit 之前,还做了其他工作:
- 执行用户通过 atexit 或 on_exit 定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写入
调用_exit。
2.2.2 _exit 函数
#include <unistd.h>
void _exit(int status);
参数:status 定义了进程的终止状态,父进程通过 wait 来获取该值。
说明:虽然 status 是 int,但是仅有低 8 位可以被父进程所用。所以_exit(-1) 时,在终端执行
$?发现返回值是 255。
2.2.3 exit 和_exit 的区别:
exit 是 c 语言提供的,_exit 是系统提供的。 进程如果 exit 退出的时候,exit() 会进行缓冲区的刷新。 进程如果 exit 退出的时候,_exit 不会进行缓冲区的刷新。
库函数和系统调用是上下层的关系,库函数没有进程终止能力,只能调用系统调用,操作系统给它提供的进程终止的接口它才能终止进程,所以 exit 的底层封装了_exit,所以我们之前谈论的缓冲区一定不在操作系统的内部,而是库缓冲区(c 语言提供的缓冲区)。
异常退出:
- ctrl + c,信号终止
2.2.4 退出码
退出码在 Linux 中通常用来表示命令执行后的结果,0 表示成功,非 0 表示不同的错误类型。 Linux Shell 中的主要退出码:
| 退出码 | 说明 |
|---|---|
| 0 | 成功(命令正常执行) |
| 1 | 一般性错误(如参数错误、文件未找到、权限不足等) |
| 2 | Shell 内置命令误用(如语法错误、未找到命令等) |
| 126 | 权限问题(命令不可执行,如缺少执行权限) |
| 127 | 命令未找到(Shell 找不到指定命令) |
| 130 | 进程被 Ctrl+C 终止(SIGINT 信号) |
| 141 | 进程被 SIGHUP 信号终止(如终端关闭) |
3. 进程等待
3.1 进程等待必要性
- 之前讲过,子进程退出,父进程如果不管不顾,就可能造成僵尸进程的问题,进而造成内存泄漏。
- 进程一旦变成僵尸状态,那就刀枪不入,'杀人不眨眼'的 kill-9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
- 最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
- 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。
3.2 进程等待的方法
3.2.1 wait 方法
如果等待子进程,子进程没有退出,父进程就会阻塞在 wait 调用处(相当于 scanf)。
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int* status);
返回值:成功返回被等待进程 pid,失败返回 -1。 参数:输出型参数,获取子进程退出状态,不关心则可以设置成为 NULL。
3.2.2 waitpid 方法
pid_t 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)((status>>8)&0xFF):若 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 比特位)。
3.2.4 阻塞与非阻塞等待
- 进程的阻塞等待方式:
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
int main(void) {
pid_t pid;
if ((pid = fork()) == -1) {
perror("fork");
exit(1);
}
if (pid == 0) {
sleep(20);
exit(10);
} else {
int st;
int ret = wait(&st);
if (ret > 0 && (st & 0X7F) == 0) {
// 正常退出
printf("child exit code:%d\n", (st >> 8) & 0XFF);
} else if (ret > 0) {
// 异常退出
printf("sig code : %d\n", st & 0X7F);
}
}
}
测试结果:
# ./a.out
# 等 20 秒退出
child exit code :10
# ./a.out
# 在其他终端 kill 掉
sig code :9
- 进程的非阻塞等待方式:
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#define MAX_HANDLERS 10
typedef void (*handler_t)();
void fun_one(){printf("这是一个临时任务 1\n");}
void fun_two(){printf("这是一个临时任务 2\n");}
void Load(handler_t handlers[], int *count) {
handlers[(*count)++] = fun_one;
handlers[(*count)++] = fun_two;
}
void handler(handler_t handlers[], int count) {
for (int i = 0; i < count; i++) {
handlers[i]();
}
}
int main() {
pid_t pid;
pid = fork();
if (pid < 0) {
printf("%s fork error\n", __FUNCTION__);
return 1;
} else if (pid == 0) {
// child
(, getpid());
sleep();
();
} {
status = ;
ret = ;
handlers[MAX_HANDLERS];
count = ;
Load(handlers, &count);
{
ret = waitpid(, &status, WNOHANG);
(ret == ) {
();
handler(handlers, count);
}
} (ret == );
(WIFEXITED(status) && ret == pid) {
(, WEXITSTATUS(status));
} {
();
;
}
}
;
}
4. 进程替换
我们先来看一段代码:
上面这种现象就叫做程序替换,也就是我自己的程序把系统当中的指令跑起来了。在程序替换的时候,并没有创建新的进程,只是把当前进程的代码和数据用新的进程的代码和数据覆盖式的进行替换。
- 问题一:'为什么我的程序运行结束了',这段话没有在显示器上打印出来? 答案是一旦程序替换成功就去执行新代码了,原始代码的后半部分已经不存在了。
有没有办法让后面的代码能继续执行? 答案是有的,创建一个子进程,让子进程去做替换工作,让父进程继续执行后面的代码。
效果演示:
Tips:程序替换也能替换我们自己写的程序,就相当于一种加载器,可以加载各种程序,包括编译型的解释型的,程序替换本质上不会创建新的进程。 为什么不会影响父进程呢? a. 进程具有独立性 b. 数据和代码发生写时拷贝
execl 的返回值: execl 函数只有失败返回值,没有成功返回值。 结论:exec*系列的函数,不用做返回值判定,只要返回,就是失败。
4.1 替换原理
4.2 替换函数
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
这里的 v 表示以数组的方式传进来,p 表示不用带路径,e 表示环境变量,如果非要传递环境变量列表,要求:被替换的子进程使用全新的 Env 列表(自己写的)。
若要以新增的方式传递环境列表呢? ① putenv 表示哪个进程调用它,就在谁的环境变量表里新增一个环境变量(B 是 A 的子进程,C 是 B 的子进程,如果 B 在它的环境列表里导入了一个新的环境变量,A 的环境列表里看不到,而 C 的环境列表里能看到) ②如果我们就行用 execvpe 的方式呢?environ
表示把新增的环境变量添加到环境变量表里面去,然后把环境变量表的起始地址传给 execvpe。
int execvp(const char *path, char *const argv[]);
有 p 所以不用带路径。
int execv(const char *path, char *const argv[]);
首先它没有带 p 所以它的参数是 path,所以同上上,这里的 v 就相当于 vector,所以第二个参数就以数组的形式呈现了,所以就必须提供一个命令行参数表,就是指针数组,就是把 ls -a -l 整体放在数组里,一次性传递,这个表也必须以 NULL 结尾。所以我们以前执行的所有命令行参数都是父进程通过 execv 传给子进程的。
int execlp(const char *file, const char *arg, ...);
execlp 当中的 p 表示 PATH,所以第一个参数只需传要执行的程序名就行了,因为 execlp 会自动的在环境变量(PATH)里查找对应的命令,所以 execlp 一般执行系统级的命令。后面参数的传递和上面的相同。
int execl(const char *path, const char *arg, ...);
第一个参数表示程序 + 路径名,第二个参数有个口诀:命令行怎么写,我们就怎么传(当然传 -al 也是可以的),而把参数一个一个的传进来我们称之为 list,类似于以链表的形式传给它,所以 execl 这个 l 就是 list 的意思,execl 函数的最后一个参数必须以 NULL 结尾,表明参数传递完成。
总结: 这些函数原型看起来很容易混,但只要掌握了规律就很好记。
- l(list): 表示参数采用列表
- v(vector): 参数用数组
- p(path): 有 p 自动搜索环境变量 PATH
- e(env): 表示自己维护环境变量
上面这些函数都是对系统调用进行了语言型的封装,最后都要调用系统调用 execve,为什么要做语言封装呢?因为程序替换时要面对各种各样上层替换的场景。所以 execve 在 man 手册第 2 节。
下图 exec 函数簇一个完整的例子:


