创建(fork)、等待(wait)、替换(exec)------这是 Linux 进程控制的三大核心操作。本文从源码级讲解 fork 的写时拷贝、waitpid 的 status 位图编码、exec 系列函数的 l/v/p/e 命名规则,以及非阻塞轮询回收和批量创建多进程的工程实践。
目录
- 一、进程创建:fork 与写时拷贝
- 二、进程终止:退出码、信号与退出状态
- 三、进程等待:wait/waitpid 完全拆解
- 四、status 位图编码:退出信息如何被"打包"
- 五、阻塞等待 vs 非阻塞等待(WNOHANG)
- 六、创建多进程:批量 fork 的工程实践
- 七、进程程序替换:exec 家族完全指南
- 八、总结:进程控制全景图
一、进程创建:fork 与写时拷贝
1.1 fork 做了什么?
fork() 是从已存在的进程中创建一个新进程。新进程为子进程,原进程为父进程。
fork 的内部步骤:
① 分配新的内存块和内核数据结构(task_struct + mm_struct + 页表)给子进程
② 将父进程部分数据结构内容拷贝至子进程
③ 将子进程添加到系统进程列表中
④ fork 返回,开始调度器调度
1.2 fork 的两种常规用法
| 用法 | 说明 | 典型场景 |
|---|---|---|
| 父子各干各的 | 父进程复制自己,父子同时执行不同代码段 | 网络服务器:父进程监听,子进程处理连接 |
| 子进程换岗 | 子进程从 fork 返回后立即调用 exec() 执行另一个程序 |
Shell 执行命令:bash fork 子进程 → exec 替换成 ls |
1.3 写时拷贝的完整流程
fork 之后:
父进程的数据段权限 → 改为只读
子进程拷贝父进程的页表 → 指向同一块物理内存(浅拷贝)
任一进程尝试修改数据 →
→ 触发写时拷贝
→ OS 在物理内存中开辟新空间
→ 复制原值到新空间
→ 修改该进程的页表映射
→ 数据段权限恢复为读写
代码段永远是只读的 → 父子永不互相影响
1.4 调度顺序不确定
fork 了一个子进程后,父子谁先运行?不确定,调度器说了算。 不要依赖任何特定的执行顺序。
二、进程终止:退出码、信号与退出状态
2.1 进程终止的顺序(不可颠倒)
第一步:释放用户空间资源(代码段、数据段、堆、栈等)
第二步:释放内核数据结构(task_struct,即 PCB)
2.2 退出状态 = 两个数字
从操作系统角度看,进程退出的状态由两个数字共同描述:
int sig; // 信号编号(进程怎么死的)
int exit_code; // 退出码(进程死之前说了什么)
| 情况 | sig | exit_code | 示例 |
|---|---|---|---|
| 代码正常跑完,结果正确 | 0 | 0 | return 0 |
| 代码正常跑完,结果不对 | 0 | ≠ 0 | exit(1) |
| 代码没跑完,异常终止 | ≠ 0 | 无意义 | kill -9,段错误 |
2.3 echo $? 与 strerror
echo $? # 查看最近一次前台进程的退出码(0~255)
#include <string.h>
char *strerror(int errnum); // 把系统错误码翻译成人话
区分 :
echo $?看到的是进程退出码 (进程级别),strerror(errno)翻译的是库函数错误码(函数调用级别)。两者有关联但不是一回事。
2.4 exit vs _exit
exit() |
_exit() |
|
|---|---|---|
| 身份 | C 库函数 | 系统调用 |
| 头文件 | <stdlib.h> |
<unistd.h> |
| 刷新缓冲区 | ✅ 会强制刷新 | ❌ 不刷新 |
| 调用关系 | 最终调用 _exit |
就是自己 |
exit(status)
│
├── 1. 执行 atexit() 注册的回调
├── 2. 刷新所有 stdio 缓冲区(fflush)
├── 3. 清理临时文件
└── 4. 调用 _exit(status) → 陷入内核
缓冲区和刷新操作一定不在内核中! 它们是 C 标准库在用户空间做的事情。内核只管回收内存、关闭文件描述符。进程退出最佳实践:用
exit()。
三、进程等待:wait/waitpid 完全拆解
3.1 为什么需要进程等待?
- 子进程退出后父进程如果不管,子进程变成僵尸进程 → 内核内存泄漏
- 僵尸状态"刀枪不入"------
kill -9也杀不死一个已经死去的进程 - 父进程需要知道子进程的任务完成得怎么样------结果对不对、是否正常退出
3.2 wait() ------ 基础版
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
// 返回值:成功返回被等待子进程的 PID,失败返回 -1
// 参数:输出型参数,获取子进程退出状态,不关心可设为 NULL
行为 :父进程调用 wait() 后阻塞运行,直到任意一个子进程退出才返回。
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t id = fork();
if (id == 0) {
// 子进程
int cnt = 5;
while (cnt--) {
printf("子进程 pid:%d\n", getpid());
sleep(1);
}
exit(0);
} else {
// 父进程
printf("父进程等待子进程...\n");
pid_t rid = wait(NULL); // 阻塞等待
if (rid == id) {
printf("pid %d, wait success!\n", getpid());
}
}
}
如果父进程调了
wait()但子进程还没退出,父进程会阻塞在 wait 函数中,让出 CPU。
3.3 waitpid() ------ 进阶版
pid_t waitpid(pid_t pid, int *status, int options);
参数详解:
| 参数 | 值 | 含义 |
|---|---|---|
| pid | -1 |
等待任意一个子进程(与 wait 等效) |
> 0 |
等待进程 ID 等于 pid 的特定子进程 | |
| status | int* |
输出型参数,获取子进程退出状态 |
| options | 0 |
阻塞等待(默认) |
WNOHANG |
非阻塞等待:没有已退出的子进程则立即返回 0 |
返回值:
| 返回值 | 含义 |
|---|---|
> 0 |
成功回收,返回子进程 PID |
0 |
设置了 WNOHANG 且没有已退出的子进程 |
-1 |
出错,errno 被设置 |
3.4 waitpid 精准回收 vs 批量回收
// 精准回收:pid 传特定值,去子进程链表里精准查找
waitpid(child_pid, &status, 0);
// 批量回收:pid 传 -1,从父进程 PCB 的 children 链表逐个等待
waitpid(-1, &status, 0);
父进程 PCB 中有一个
struct list_head children链表。传-1时,内核在这个链表上逐个查找已退出的子进程------这是侵入式链表的又一次应用。
四、status 位图编码:退出信息如何被"打包"
4.1 status 是 32 位位图
status (32位):
├────── 高16位:不使用 ──────┤├── 低16位 ──────────────────┤
├── 高8位 ──┤├──── 低8位 ────┤
退出码 bit7 bit6~bit0
(exit_code) core 信号编号
dump (signal)
4.2 手动解码
int exit_code = ((status >> 8) & 0xFF); // 取低16位的高8位 → 退出码
int exit_sig = status & 0x7F; // 取低7位 → 信号编号
int core_dump = (status >> 7) & 0x1; // 取bit7 → core dump标志
4.3 使用宏解码(推荐)
#include <sys/wait.h>
if (WIFEXITED(status)) {
// 正常退出 → 取退出码
printf("退出码: %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
// 被信号杀死 → 取信号编号
printf("被信号 %d 杀死\n", WTERMSIG(status));
}
宏的实现(便于理解):
#define WIFEXITED(status) (!(status & 0x7F)) // 低7位全0 = 正常退出
#define WEXITSTATUS(status) (((status) >> 8) & 0xFF) // 取高8位退出码
4.4 完整示例
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t id = fork();
if (id == 0) {
int cnt = 5;
while (cnt--) {
printf("子进程 pid:%d\n", getpid());
sleep(1);
}
exit(10); // 退出码 10
} else {
int status = 0;
pid_t rid = waitpid(-1, &status, 0);
if (rid > 0) {
if (WIFEXITED(status)) {
printf("子进程 %d 正常退出, exit_code: %d\n",
rid, WEXITSTATUS(status));
} else {
printf("子进程异常退出!\n");
}
}
}
}
五、阻塞等待 vs 非阻塞等待(WNOHANG)
5.1 概念区分
| 阻塞等待(options=0) | 非阻塞等待(options=WNOHANG) | |
|---|---|---|
| 行为 | 子进程没退出,父进程卡在 waitpid 里 | 子进程没退出,waitpid 立即返回 0 |
| 类比 | 在银行排队,不叫到号不走 | 不时去银行门口看一眼,没叫到号先去办别的事 |
| 时间利用率 | 等待期间什么都没干 | 等待期间可以干其他事情 |
非阻塞是函数提供的能力,轮询是自己写代码不断检查的行为。 不存在"哪种等待更高效"------只能说非阻塞的时间利用率更高。
5.2 WNOHANG 的含义
W → wait
NO → no
HANG → 夯住了、宕机了
WNOHANG = "别夯在那,看一眼就回来"
5.3 非阻塞轮询回收
while (1) {
int status = 0;
pid_t rid = waitpid(-1, &status, WNOHANG); // 非阻塞检测+回收
if (rid > 0) {
// 子进程退出了
if (WIFEXITED(status)) {
printf("子进程 %d, exit_code: %d\n", rid, WEXITSTATUS(status));
break;
}
} else if (rid == 0) {
// 子进程还在运行
printf("子进程还在运行,父进程做点别的事...\n");
usleep(100000); // 100ms 后再查
} else {
// 出错
perror("waitpid");
break;
}
}
5.4 非阻塞等待期间"做别的事"------回调函数实践
#include <vector>
#include <functional>
typedef void (*callback_t)();
void PrintLog() { std::cout << "print Log" << std::endl; }
void SyncDisk() { std::cout << "write data to disk" << std::endl; }
void WriteToMysql(){ std::cout << "write data to mysql" << std::endl; }
int main() {
std::vector<callback_t> tasks;
tasks.push_back(PrintLog);
tasks.push_back(WriteToMysql);
tasks.push_back(SyncDisk);
pid_t id = fork();
if (id == 0) {
// 子进程干自己的活
sleep(5);
exit(10);
} else {
while (1) {
int status = 0;
pid_t rid = waitpid(-1, &status, WNOHANG);
if (rid > 0) {
printf("子进程 %d 退出\n", rid);
break;
} else if (rid == 0) {
// 子进程还在跑,父进程趁机处理其他任务!
for (auto &task : tasks) task();
}
}
}
}
非阻塞等待的精髓就在于此:父进程一边等子进程,一边把自己的活也干了,不浪费时间。
六、创建多进程:批量 fork 的工程实践
6.1 封装批量创建
#include <vector>
enum { OK, USAGE_ERR };
void Task() {
int cnt = 5;
while (cnt--) {
printf("子进程 pid:%d 执行 Task, cnt:%d\n", getpid(), cnt);
sleep(1);
}
}
void Hello() {
int cnt = 5;
while (cnt--) {
printf("子进程 pid:%d 执行 Hello, cnt:%d\n", getpid(), cnt);
sleep(1);
}
}
// 批量创建子进程
void CreateChildProcess(int num, std::vector<pid_t> *subs, callback_t cb) {
for (int i = 0; i < num; i++) {
pid_t id = fork();
if (id == 0) {
cb(); // 子进程执行回调任务
exit(0); // 执行完毕立即退出
}
subs->push_back(id); // 父进程记录子进程 PID
}
}
// 等待所有子进程
void WaitAllChild(const std::vector<pid_t> &subs) {
for (auto &pid : subs) {
int status = 0;
pid_t rid = waitpid(pid, &status, 0); // 逐个精准回收
if (rid > 0) {
printf("子进程 %d 退出, exit_code: %d\n",
rid, WEXITSTATUS(status));
}
}
}
调用示例:
./a.out 3 # 创建 3 个子进程执行 Hello 任务
6.2 为什么要精准回收?
如果使用
waitpid(-1, ...),可能回收到的子进程不是你先创建的那个。每个子进程的执行顺序不确定,所以必须用 PID 精准匹配。
七、进程程序替换:exec 家族完全指南
7.1 什么是程序替换?
程序替换的本质:把磁盘上的可执行程序的代码和数据加载到内存中,覆盖当前进程的代码段和数据段。
exec 之前: exec 之后:
┌──────────────┐ ┌──────────────┐
│ PCB (不变) │ │ PCB (不变) │
├──────────────┤ ├──────────────┤
│ 原程序代码 │ exec │ 新程序代码 │
│ 原程序数据 │ ───────→ │ 新程序数据 │
└──────────────┘ └──────────────┘
物理内存 物理内存(代码和数据被覆盖)
程序替换没有创建新进程。 PCB 和虚拟地址空间不变,被替换的只是物理内存中的代码和数据段。
7.2 父子进程中的 exec
fork 之后,父子进程的代码和数据共享(写时拷贝)。
子进程调用 exec() 进行程序替换
→ 子进程的数据被 exec 替换(触发写时拷贝)
→ 子进程的代码被 exec 替换
→ 从此父子进程的代码段和数据段彻底独立
→ 父子彻底分离!
7.3 exec 系列函数:l / v / p / e 命名规则
六个函数,底层都是同一个系统调用 execve。区别只在传参形式:
| 字母 | 全称 | 含义 |
|---|---|---|
| l | list | 参数采用列表形式,一个一个传 |
| v | vector | 参数采用数组形式,打包成 char*\[\] |
| p | path | 自动在环境变量 PATH 所指明的路径下搜索程序 |
| e | env | 自己维护环境变量表(可以传入全新环境变量) |
7.4 execl ------ 列表传参
#include <unistd.h>
int execl(const char *path, const char *arg, ...);
// path: 程序在哪(路径/文件名)
// arg : 命令行怎么写,这里就怎么填
// 最后一个参数必须是 NULL
// 示例:执行 ls -a -l
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
// 示例:执行 top -d 1 -n 3
execl("/usr/bin/top", "top", "-d", "1", "-n", "3", NULL);
第一个 arg(
"ls")表示你要执行谁 ,后面的("-a","-l")表示怎么执行。建议不要省略任何参数。
7.5 execlp ------ 自动 PATH 搜索
int execlp(const char *file, const char *arg, ...);
// file: 只需告诉程序名,execlp 会自动到 PATH 里找
execlp("ls", "ls", "-a", "-l", NULL);
// 不需要写 /usr/bin/ls,系统自动去 PATH 里搜
7.6 execv ------ 数组传参
int execv(const char *path, char *const argv[]);
// 构建参数表
char *argv[] = {(char*)"ls", (char*)"-a", (char*)"-l", NULL};
execv("/usr/bin/ls", argv);
// argv 就是传给新程序 main 函数的 char *argv[]!
// int main(int argc, char *argv[]) ← 这个 argv 就是 execv 传过来的
7.7 execvp ------ PATH + 数组
int execvp(const char *file, char *const argv[]);
char *argv[] = {(char*)"ls", (char*)"-a", (char*)"-l", NULL};
execvp("ls", argv); // PATH 搜索 + 数组传参
7.8 execvpe ------ 终极版:PATH + 数组 + 自定义环境变量
int execvpe(const char *file, char *const argv[], char *const envp[]);
// envp: 可以传入全新的环境变量表
// 构建自己的环境变量
char *myargv[] = {(char*)"othercmd", (char*)"-a", (char*)"-b", NULL};
char *myenv[] = {
(char*)"PATH=/home/maisui/code/exec/cmd",
NULL
};
// 使用全新的环境变量表(覆盖式的)
execvpe("./cmd/othercmd", myargv, myenv);
// 或者使用父进程的环境变量表
extern char **environ;
execvpe("./cmd/othercmd", myargv, environ);
7.9 在父进程环境变量基础上新增
#include <stdlib.h>
int putenv(char *string);
putenv((char*)"environmentself=./maisuifile");
// 在父进程环境变量的基础上新增一条!
execvpe("./cmd/othercmd", myargv, environ);
7.10 exec 系列函数关系图
execl ─┐
execlp ─┤
execv ─┤ 库函数(传参形式不同)
execvp ─┤
execle ─┤
execvpe─┘
│
▼
execve ← 唯一的系统调用!
7.11 exec 替换失败怎么办?
execl("/path/to/nonexistent", "cmd", NULL);
// 如果替换失败,程序会继续往下走!
// exec 成功没有返回值(因为代码已经被替换了),
// 只有失败才返回 -1
printf("这句话只在 exec 失败时才会打印\n");
exit(1);
7.12 什么程序都能替换
exec 不仅能替换系统命令(ls、top 等),还能替换:
- Shell 脚本(
.sh) - Python 程序(
.py) - 你自己写的 C/C++ 程序
- 凡是最终运行时形态是进程的,最后都可以替换。
7.13 核心结论
命令行参数表和环境变量表,都是父进程通过 exec* 传递给子进程的。 这就是为什么子进程能拿到
argc/argv和environ------不是魔术,是父进程调用execve时传的。
八、总结:进程控制全景图
┌──────────────────────────────────────────────────────┐
│ 进程控制三大操作
│
│ ① fork() 创建子进程
│ └── 拷贝 PCB + 页表 → 写时拷贝 → 父子独立
│
│ ② wait/waitpid 等待子进程
│ ├── 阻塞等待 (options=0) :卡在函数里直到子进程退出
│ ├── 非阻塞等待 (WNOHANG):看一眼就回来,可以轮询
│ └── status 位图:低16位编码退出码 + 信号 + core dump
│
│ ③ exec* 程序替换
│ ├── l (list) :execl, execlp
│ ├── v (vector):execv, execvp
│ ├── p (PATH) :execlp, execvp
│ ├── e (env) :execle, execvpe
│ └── 底层全是 execve 系统调用
└──────────────────────────────────────────────────────┘
核心概念速查
| 问题 | 答案 |
|---|---|
| fork 的子进程拷贝了什么? | 浅拷贝:拷贝自己的 PCB + mm_struct + 页表,代码数据共享 |
| 写时拷贝什么时候触发? | 任一进程尝试修改共享数据时 |
| 为什么要进程等待? | 回收僵尸、防止内存泄漏、获取子进程退出信息 |
| status 怎么解码? | 低 7 位=信号编号,bit7=core dump,次高 8 位=退出码 |
| 阻塞 vs 非阻塞? | 阻塞=卡在函数里等;非阻塞(WNOHANG)=立即返回,配合轮询 |
| exec 创建新进程了吗? | 没有。PCB 不变,只覆盖代码和数据段 |
| execlp 中的 p 是什么意思? | PATH------自动到环境变量 PATH 下搜索可执行文件 |
| execv 中的 v 是什么意思? | vector------参数通过 char*\[\] 数组传入 |
完整生命周期
bash (父进程)
│
├── fork() ───────────→ 子进程创建
│ │
├── waitpid(-1, &st, 0) ├── exec("/usr/bin/ls", ...)
│ (阻塞等待回收) │ 程序替换
│ │
│ ├── 执行 ls 的逻辑
│ │
│ ├── exit(0)
│ │ 进程退出,变为僵尸
│ │
├── waitpid 返回 ←─────────┘
│ (读取退出信息,回收 PCB)
│
└── 继续运行
本文基于 Linux 进程控制学习笔记整理,系统覆盖 fork 创建、wait/waitpid 等待回收、exec 程序替换三大核心机制,适合想深入理解 Linux 进程管理的开发者。
如果觉得有帮助,欢迎点赞收藏,评论区交流讨论!