Linux系统篇(十六)——进程(五):Linux 进程控制全解:fork、exec、wait 核心原理与实战
从原理到实战完整梳理进程生命周期:fork 创建与写时拷贝、exit 终止与退出码、wait/waitpid 回收、exec 系列程序替换,附全套代码案例。
项目背景
进程是 Linux 操作系统调度资源的基本单位,而进程控制则是 Linux 系统编程中最核心的知识点之一。无论是日常开发、底层学习还是面试考察,fork 创建子进程、exec 系列函数实现程序替换、wait/waitpid 完成进程等待与资源回收,都是绕不开的重点。
很多初学者对这三者的理解停留在"会用"层面:知道 fork 会复制进程,却不理解它的返回值机制和写时拷贝;知道 exec 能执行新程序,却搞不清六个函数变体的区别;知道要 wait,却不明白僵尸进程的危害和退出状态的解析方法。本文从原理入手,结合完整代码案例,梳理进程从创建、运行、替换到退出、回收的全流程。
技术方案
1. fork():一个调用,两个返回值
fork() 是创建新进程的核心系统调用,定义在 <unistd.h> 中:
pid_t fork(void);
返回值规则:
- 父进程:返回新创建子进程的 PID(正数);
- 子进程:返回 0;
- 调用失败:返回 -1。
这是 fork 最特殊的地方:一次函数调用,会在两个进程中分别返回两次。fork 之后,系统中出现两个二进制代码完全相同的进程,它们都会从 fork 调用之后的位置开始执行。
2. fork 的内核工作流程
进程调用 fork 时,内核完成以下关键操作:
- 为子进程分配新的内存块和内核数据结构(PCB);
- 将父进程的部分数据结构内容拷贝至子进程;
- 将子进程添加到系统进程列表中;
- 调度器开始调度子进程。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
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;
}
3. 写时拷贝(COW)
默认情况下,父子进程的代码段是共享的,数据段在子进程未写入时也共享。当任意一方尝试修改数据时,内核才以写时拷贝的方式为修改方生成一份数据副本,从而保证进程独立性。写时拷贝是一种"延迟申请"技术,避免了 fork 时直接拷贝整个进程地址空间,大幅提升了内存利用率和进程创建速度。
4. fork 的常规用法与失败原因
常见用法:
- 父进程创建子进程,让父子进程同时执行不同代码段(如父进程等待客户端请求、子进程处理请求的服务器模型);
- 子进程调用 exec 系列函数,执行一个全新的程序。
失败原因:
- 系统中进程数量过多,达到系统上限;
- 实际用户的进程数超过了系统限制。
系统架构
1. 进程终止的三种方式
进程终止的本质是释放系统资源,包括内核数据结构、代码和数据。进程退出的场景分三类:
- 代码运行完毕且结果正确(如 main 函数 return 0);
- 代码运行完毕但结果不正确(如 main 函数返回非 0 值);
- 代码异常终止(收到信号被杀死,如段错误、Ctrl+C 中断等)。一旦异常终止,退出码将无意义,操作系统会提供一个"信号终止状态码"说明进程是怎么死的:
#include <unistd.h>
#include <signal.h>
int main(void)
{
kill(getpid(), SIGKILL); // 主动用 SIGKILL 杀死自己
return 0;
}
2. 正常终止:exit 与 _exit 的区别
正常终止有三种途径:
- 从 main 函数 return:
return n等价于调用exit(n); - 调用
exit():C 标准库函数,会执行清理函数、刷新缓冲区,再调用_exit(); - 调用
_exit():系统调用,直接终止进程,不刷新缓冲区。
区别体现在代码上:
// exit():会刷新缓冲区,输出 hello
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
printf("hello"); // 未加 \n,数据暂存在缓冲区
sleep(1);
printf("\n");
exit(0); // 刷新缓冲区,hello 被输出
}
// _exit():不刷新缓冲区,无输出
#include <stdio.h>
#include <unistd.h>
int main() {
printf("hello");
_exit(0); // 直接终止,缓冲区内容丢失
}
3. 退出码约定
退出码反映命令执行结果,Linux 约定:
| 退出码 | 解释 |
|---|---|
| 0 | 命令成功执行 |
| 1 | 通用错误代码 |
| 2 | 命令(或参数)使用不当 |
| 126 | 权限被拒绝,无法执行 |
| 127 | 未找到命令或 PATH 错误 |
| 130 | 通过 Ctrl+C(SIGINT)终止 |
实施过程
1. 进程等待:避免僵尸进程
如果子进程退出后父进程没有回收它的资源,子进程就会变成僵尸进程,占用系统资源且无法被 kill -9 杀死。进程等待的核心目的:回收子进程资源,避免资源泄漏;获取子进程退出状态,判断任务是否正常完成。
#include <stdio.h>
#include <unistd.h>
// 没有 wait,子进程结束后会变成僵尸状态
int main()
{
pid_t t = fork();
if (t == 0)
{
printf("我是一个子进程:%d\n", getpid());
sleep(5);
}
else
{
while (1)
{
sleep(1);
printf("我是一个父进程:%d\n", getpid());
}
}
return 0;
}
wait():等待任意子进程
pid_t wait(int *wstatus);
返回值:成功返回被等待进程的 PID,失败返回 -1;参数 wstatus 是输出型参数,用于获取子进程退出状态,不关心时可传 NULL。
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程:可改为 exit(42) 或触发段错误测试
printf("我是子进程,pid=%d\n", getpid());
sleep(2);
exit(42); // 测试1:正常退出
// int *p = NULL; *p = 1; // 测试2:段错误
} else {
int status;
wait(&status); // 等子进程,同时把状态存入 status
if (WIFEXITED(status)) {
printf("子进程正常退出,退出码:%d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("子进程被信号杀死,信号号:%d\n", WTERMSIG(status));
}
}
return 0;
}
waitpid():更灵活的等待
pid_t waitpid(pid_t pid, int *wstatus, int options);
- 参数 pid:
pid > 0等待指定 PID 的子进程;pid = -1等待任意子进程(与 wait 等价); - 参数 options:
0为阻塞等待(子进程未退出时父进程一直阻塞);WNOHANG为非阻塞等待(子进程未退出时直接返回 0,父进程可继续执行其他任务)。
退出状态解析:status 是一个位图,仅低 16 位有效——正常终止时高 8 位为退出码、低 7 位为 0;异常终止时低 7 位为终止信号、第 8 位为 core dump 标志。常用宏:WIFEXITED(status) 判断是否正常退出,WEXITSTATUS(status) 提取退出码(仅正常退出时有效)。
阻塞等待示例:
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
printf("child is run, pid is: %d\n", getpid());
sleep(5);
exit(257);
} else {
printf("我是父进程:%d\n", getpid());
int status = 0;
pid_t ret = waitpid(pid, &status, 0); // 阻塞等待
if (ret > 0 && WIFEXITED(status)) {
printf("wait success, child return code is: %d\n", WEXITSTATUS(status));
}
}
return 0;
}
非阻塞等待示例:
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
printf("child is run, pid is: %d\n", getpid());
sleep(5);
exit(257);
} else {
int status = 0;
pid_t ret = waitpid(pid, &status, WNOHANG); // 非阻塞等待
while (1) {
sleep(1);
printf("我是父进程:%d\n", getpid());
ret = waitpid(pid, &status, WNOHANG); // 轮询检查子进程
if (ret > 0 && WIFEXITED(status)) {
printf("wait success, child return code is: %d\n", WEXITSTATUS(status));
break;
}
}
}
return 0;
}
2. 进程程序替换:exec 系列函数
fork 创建的子进程会与父进程执行相同代码,而程序替换可以让子进程加载并执行一个全新的程序,且不改变进程的 PID。原理:进程调用 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[]);
命名规律:l(list)参数以列表形式传递、必须以 NULL 结尾;v(vector)参数以字符串数组形式传递、数组末尾必须为 NULL;p(path)自动搜索 PATH 环境变量、无需写全路径;e(env)可自定义传入环境变量数组。
函数对比:
| 函数名 | 参数格式 | 是否自动用 PATH | 是否自定义环境变量 |
|---|---|---|---|
| execl | 列表 | 否 | 否(使用当前环境) |
| execlp | 列表 | 是 | 否(使用当前环境) |
| execle | 列表 | 否 | 是 |
| execv | 数组 | 否 | 否(使用当前环境) |
| execvp | 数组 | 是 | 否(使用当前环境) |
| execve | 数组 | 否 | 是(真正的系统调用) |
先准备一个被替换执行的程序 other.cc:
// other.cc:编译为 other 可执行文件
#include <iostream>
#include <unistd.h>
int main(int argc, char *argv[], char *env[])
{
std::cout << "I am other program, pid: " << getpid() << std::endl;
for (int i = 0; i < argc; i++)
std::cout << "argv[" << i << "] = " << argv[i] << std::endl;
return 0;
}
execl:完整路径 + 列表参数
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
// 参数列表必须以 NULL 结尾
execl("/bin/ls", "ls", "-l", NULL);
// execl 成功则下面代码不会执行
perror("execl failed");
exit(EXIT_FAILURE);
} else {
wait(NULL);
printf("execl 测试完成\n");
}
return 0;
}
execlp:自动搜索 PATH + 列表参数
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
// 无需写 /bin/ls,自动从 PATH 查找
execlp("ls", "ls", "-a", NULL);
perror("execlp failed");
exit(EXIT_FAILURE);
} else {
wait(NULL);
printf("execlp 测试完成\n");
}
return 0;
}
execle:完整路径 + 列表参数 + 自定义环境变量
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
// 自定义环境变量列表,必须以 NULL 结尾
char *const env[] = {
"MY_NAME=Tom",
"MY_AGE=18",
NULL
};
pid_t pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
// 用 execle 执行 env 命令查看环境变量
execle("/usr/bin/env", "env", NULL, env);
perror("execle failed");
exit(EXIT_FAILURE);
} else {
wait(NULL);
printf("execle 测试完成\n");
}
return 0;
}
execv:完整路径 + 数组参数
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
char *const argv[] = {"ls", "-lh", NULL}; // 数组以 NULL 结尾
execv("/bin/ls", argv);
perror("execv failed");
exit(EXIT_FAILURE);
} else {
wait(NULL);
printf("execv 测试完成\n");
}
return 0;
}
execvp:自动搜索 PATH + 数组参数
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main()
{
printf("我的程序要运行了!\n");
if (fork() == 0)
{
char *const argv[] = {"other", "-a", "-b", "-c", "-d", NULL};
execvp("./other", argv); // 执行当前目录的 other 程序
exit(1);
}
waitpid(-1, NULL, 0);
printf("我的程序运行完毕了\n");
}
execve:真正的系统调用
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main()
{
printf("我的程序要运行了!\n");
if (fork() == 0)
{
char *const argv[] = {"other", "-a", "-b", "-c", "-d", NULL};
extern char **environ;
execve("/home/user/project/other", argv, environ); // 完整路径 + 环境数组
exit(1);
}
waitpid(-1, NULL, 0);
printf("我的程序运行完毕了\n");
}
注意:如果传入自己的环境变量数组,就不会继承父进程的环境变量,即程序替换会覆盖之前进程的环境变量。想保留原环境并追加新变量,可以用 putenv 先注入再调用不带 e 的函数:
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
char *const addenv[] = {
(char *const)"MYVAL=123456789",
(char *const)"MYVAL1=123456789",
(char *const)"MYVAL2=123456789",
NULL
};
int main()
{
printf("我的程序要运行了!\n");
if (fork() == 0)
{
// 先把新变量注入当前环境
for (int i = 0; addenv[i]; i++)
putenv(addenv[i]);
char *const argv[] = {"other", "-a", "-b", "-c", "-d", NULL};
execvp("./other", argv); // 非 e 结尾:继承当前(已扩展的)环境
exit(1);
}
waitpid(-1, NULL, 0);
printf("我的程序运行完毕了\n");
}
应用价值
掌握 fork、exec、wait 这四大核心能力,就真正握住了 Linux 系统编程的骨架。无论是以后深入研究 Shell 解释器的模拟实现、多进程网络服务器(如早期的 Apache 模型),还是理解容器(Container)底层的隔离机制,进程控制都是最坚实的底座。建议在理解原理的基础上,亲手跑一遍文中所有示例代码,用 ps 观察进程状态变化,用 kill 制造异常退出再通过 wait 解析信号,把"概念"变成"手感"。
SEO关键词
Linux, 进程控制, fork, exec, wait, waitpid, 僵尸进程, 写时拷贝
