Linux 进程控制完全指南:fork、wait、exec 三部曲 创建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 替换成 ls1.3 写时拷贝的完整流程fork 之后 父进程的数据段权限 → 改为只读 子进程拷贝父进程的页表 → 指向同一块物理内存浅拷贝 任一进程尝试修改数据 → → 触发写时拷贝 → OS 在物理内存中开辟新空间 → 复制原值到新空间 → 修改该进程的页表映射 → 数据段权限恢复为读写 代码段永远是只读的 → 父子永不互相影响1.4 调度顺序不确定fork 了一个子进程后父子谁先运行不确定调度器说了算。不要依赖任何特定的执行顺序。二、进程终止退出码、信号与退出状态2.1 进程终止的顺序不可颠倒第一步释放用户空间资源代码段、数据段、堆、栈等 第二步释放内核数据结构task_struct即 PCB2.2 退出状态 两个数字从操作系统角度看进程退出的状态由两个数字共同描述int sig; // 信号编号进程怎么死的 int exit_code; // 退出码进程死之前说了什么情况sigexit_code示例代码正常跑完结果正确00return 0代码正常跑完结果不对0≠ 0exit(1)代码没跑完异常终止≠ 0无意义kill -9段错误2.3 echo $? 与 strerrorecho $? # 查看最近一次前台进程的退出码0~255#include string.h char *strerror(int errnum); // 把系统错误码翻译成人话区分echo $?看到的是进程退出码进程级别strerror(errno)翻译的是库函数错误码函数调用级别。两者有关联但不是一回事。2.4 exit vs _exitexit()_exit()身份C 库函数系统调用头文件stdlib.hunistd.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 的特定子进程statusint*输出型参数获取子进程退出状态options0阻塞等待默认WNOHANG非阻塞等待没有已退出的子进程则立即返回 0返回值返回值含义 0成功回收返回子进程 PID0设置了 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 非阻塞等待WNOHANG5.1 概念区分阻塞等待options0非阻塞等待optionsWNOHANG行为子进程没退出父进程卡在 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::vectorcallback_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::vectorpid_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::vectorpid_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 父子进程中的 execfork 之后父子进程的代码和数据共享写时拷贝。 子进程调用 exec() 进行程序替换 → 子进程的数据被 exec 替换触发写时拷贝 → 子进程的代码被 exec 替换 → 从此父子进程的代码段和数据段彻底独立 → 父子彻底分离7.3 exec 系列函数l / v / p / e 命名规则六个函数底层都是同一个系统调用execve。区别只在传参形式字母全称含义llist参数采用列表形式一个一个传vvector参数采用数组形式打包成 char*[]ppath自动在环境变量 PATH所指明的路径下搜索程序eenv自己维护环境变量表可以传入全新环境变量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);第一个 argls表示你要执行谁后面的-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 脚本.shPython 程序.py你自己写的 C/C 程序凡是最终运行时形态是进程的最后都可以替换。7.13 核心结论命令行参数表和环境变量表都是父进程通过 exec* 传递给子进程的。这就是为什么子进程能拿到argc/argv和environ——不是魔术是父进程调用execve时传的。八、总结进程控制全景图┌──────────────────────────────────────────────────────┐ │ 进程控制三大操作 │ │ ① fork() 创建子进程 │ └── 拷贝 PCB 页表 → 写时拷贝 → 父子独立 │ │ ② wait/waitpid 等待子进程 │ ├── 阻塞等待 (options0) 卡在函数里直到子进程退出 │ ├── 非阻塞等待 (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 位信号编号bit7core 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 进程管理的开发者。如果觉得有帮助欢迎点赞收藏评论区交流讨论