【Linux】进程控制深度解析:fork、exit、waitpid、exec 与简易 Shell 完整闭环
【Linux】进程控制深度解析:fork、exit、waitpid、exec 与简易 Shell 完整闭环
🔥 本文定位:面向已经理解 Linux 进程概念、准备进入系统编程的同学,围绕一条真实命令的生命周期打通进程创建、终止、等待与程序替换。
💡 学习目标:不仅会调用fork()、waitpid()和execvp(),还要理解返回值为什么这样设计、status为什么不是退出码、exec前后哪些内容改变,以及 Shell 为什么必须区分内建命令与外部命令。

文章目录
- 前言
- 一、先建立进程控制的完整闭环
- 二、fork:从一个执行流到两个进程
- 三、fork 之后复制了什么、共享了什么
- 四、进程终止:return、exit 与 _exit
- 五、进程等待:wait 与 waitpid
- 六、阻塞等待与 WNOHANG
- 七、exec:不创建新进程,只替换程序映像
- 八、exec 函数族怎样记
- 九、fork、exec、wait 如何组成一个启动器
- 十、实现一个教学版 mini shell
- 十一、常见问题与避坑指南
- 总结
前言
刚开始学习进程控制时,很容易把下面几个接口分别记忆:
fork() 创建子进程
exit() 结束进程
waitpid() 等待子进程
execvp() 执行新程序
这些定义都对,但如果只停留在函数说明层面,仍然解释不了很多真实问题:
fork()明明只写了一次,为什么父子进程会收到两个返回值?- 父子各有一份文件描述符表,为什么又可能共享文件偏移?
- 子进程
return 7之后,父进程为什么不能直接把status当作7? exec已经“换了程序”,为什么 PID 没变?- 为什么
cd不能像ls一样交给普通子进程执行? - 子进程调用
execvp()失败后,为什么通常要_exit(127)?
真正需要掌握的是一条闭环:
父进程 fork 创建子进程
↓
子进程 exec 替换为目标程序
↓
目标程序 exit / _exit / 信号终止
↓
父进程 wait / waitpid 取得状态并完成回收
这条闭环就是 Shell 启动外部命令、服务器派生工作进程、测试框架启动用例和任务调度器执行作业的共同骨架。
⚠️ 文中的程序请在普通用户的独立实验目录中运行。不要在生产服务器上批量创建进程,也不要把故意制造僵尸或高频轮询的代码放入长期服务。
一、先建立进程控制的完整闭环
1.1 进程控制不是严格串行执行
从接口顺序上看,我们经常写成:
fork → exec → exit → waitpid
但真实执行不是简单的单线程流水线。
fork() 成功后,父进程与子进程成为两个可以独立调度的执行流:
- 父进程可以立即调用
waitpid(),此时它可能阻塞; - 子进程可以继续运行,随后调用
exec(); - 新程序执行完毕后产生退出状态;
- 父进程的
waitpid()才取得结果并返回。
因此更准确的模型是:
父进程:fork ───────────────→ waitpid ─────→ 解析状态
\ ↑
子进程: └→ exec → 新程序 → exit
1.2 四个动作分别解决什么问题
| 动作 | 解决的问题 | 关键结论 |
|---|---|---|
fork() |
谁来执行新任务 | 创建子进程,父子从同一位置继续 |
exec*() |
子进程执行哪个程序 | 替换当前程序映像,不创建新 PID |
exit/_exit |
怎样结束并留下结果 | 结束执行,向父进程保留终止信息 |
wait/waitpid |
父进程怎样取得结果并回收 | 解析等待状态,释放僵尸表项 |
只要始终围绕这四个问题组织代码,进程控制就不会变成一堆零散 API。
二、fork:从一个执行流到两个进程
2.1 基本接口
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
返回值有三种情况:
| 执行位置 | 返回值 | 含义 |
|---|---|---|
| 父进程 | > 0 |
新创建子进程的 PID |
| 子进程 | 0 |
当前执行流位于子进程 |
| 原进程 | -1 |
创建失败,没有子进程产生,并设置 errno |
2.2 第一个 fork 程序
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
printf("before: pid=%ld\n", (long)getpid());
fflush(stdout);
pid_t child = fork();
if (child == -1) {
perror("fork");
return 1;
}
if (child == 0) {
printf("child : pid=%ld, ppid=%ld, fork_ret=0\n",
(long)getpid(), (long)getppid());
} else {
printf("parent: pid=%ld, child=%ld\n",
(long)getpid(), (long)child);
}
return 0;
}
编译运行:
gcc -Wall -Wextra -std=c11 fork_demo.c -o fork_demo
./fork_demo
before 位于 fork() 之前,因此由原进程执行一次。fork() 成功后,父子进程都从调用点之后继续,分别进入不同分支。
父子谁先打印没有固定保证。调度顺序不属于这段程序的正确性条件。
2.3 为什么要给父子不同返回值
父进程可能创建多个子进程,所以它需要知道“刚创建的是哪一个 PID”,才能进行定向等待和管理。
子进程如果想知道自己的 PID,可以调用 getpid(),没必要让 fork() 再返回一次重复信息;因此用 0 作为简单、无歧义的分支标记。
这不是“同一个变量同时等于 0 和大于 0”,而是两个进程各自拥有自己的变量副本,并分别接收返回值。
2.4 fork 为什么会失败
常见原因包括:
- 实际用户达到
RLIMIT_NPROC限制; - 系统线程/进程数量达到上限;
- PID 空间耗尽;
- cgroup 的
pids.max限制被触发; - 内核无法分配所需管理结构或页表资源。
因此不要忽略失败分支:
pid_t child = fork();
if (child == -1) {
perror("fork");
return 1;
}
2.5 vfork 为什么要格外谨慎
vfork() 也创建子进程,但在 Linux 语义下,父调用线程会暂停,子进程在成功 exec 或调用 _exit() 前共享父进程地址空间,甚至共享栈。
这意味着子进程不能像普通 fork() 子进程那样自由修改数据、从当前函数返回或调用 exit()。错误使用可能破坏父进程状态。
教学阶段和大多数普通程序优先使用 fork();除非已经明确性能目标、约束条件和平台语义,否则不要把 vfork() 当作“更快的 fork”随意替换。
三、fork 之后复制了什么、共享了什么

3.1 独立地址空间与写时拷贝
从应用视角看,父子进程拥有独立地址空间。父子刚创建时内容相同,但一方修改普通变量不会改变另一方看到的值。
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
int value = 10;
pid_t child = fork();
if (child == -1) {
perror("fork");
return 1;
}
if (child == 0) {
value = 99;
printf("child : addr=%p, value=%d\n", (void *)&value, value);
fflush(stdout);
_exit(0);
}
waitpid(child, NULL, 0);
printf("parent: addr=%p, value=%d\n", (void *)&value, value);
return 0;
}
父子打印的虚拟地址数值可能相同,但它们属于不同虚拟地址空间。子进程首次写入对应页时,内核通过写时拷贝(Copy-on-Write,COW)为写入方准备独立物理页。
fork 后、尚未写入:父子页表可暂时映射同一物理页
某一方首次写入:触发保护异常
内核复制受影响页面:修改方映射新页
结果:父子继续看到各自的数据
3.2 文件描述符表是副本,底层打开对象可能共享
fork() 后,子进程得到父进程文件描述符表的一份副本。但父子中对应的文件描述符,通常引用同一个 open file description。
因此它们可能共享:
- 当前文件偏移;
- 文件状态标志;
- 某些信号驱动 I/O 属性。
这解释了一个看似矛盾的现象:
父子进程是独立进程
但
父 fd=3 与子 fd=3 可以引用同一个内核打开文件对象
关闭父进程的 fd=3 只是删除父进程的一条引用。只要子进程仍持有引用,底层对象就不一定立即销毁。
3.3 fork 前的 stdio 缓冲为什么可能打印两次
stdio 缓冲位于用户态地址空间。若 fork() 前缓冲中已有未刷新数据,父子进程会各自继承一份缓冲状态;之后双方都通过 exit() 正常结束时,同一段内容可能被刷新两次。
printf("hello"); /* 没有换行,可能仍在缓冲区 */
fork();
exit(0);
避免方法包括:
- 在
fork()前调用fflush(NULL); - 关键输出及时换行或显式刷新;
exec失败的子进程使用_exit(),避免再次刷新继承缓冲。
四、进程终止:return、exit 与 _exit

4.1 三类结束场景
进程结束可以分为:
- 正常结束,业务结果成功;
- 正常结束,但业务结果失败;
- 被信号终止或发生异常,未走正常返回路径。
“正常终止”描述的是结束方式,不等于业务成功。业务是否成功通常由退出码约定。
4.2 Shell 中的 $?
Shell 用 $? 展示上一条前台命令的状态:
./app
echo $?
通常:
0表示成功;- 非
0表示不同类型的失败; - Shell 常把信号终止显示为
128 + 信号编号,例如 SIGINT 常见为130。
最后一条说明是 Shell 层的表示惯例。父进程通过 waitpid() 得到的是编码后的等待状态,不能直接把它当作 $?。
4.3 return 与 exit
从 main 返回:
return 7;
会把返回值交给 C 运行时,进入正常终止流程。对 main 而言,可以近似理解为:
exit(7);
exit() 通常会:
- 按规则执行通过
atexit()等注册的清理函数; - 刷新并关闭 C 标准 I/O 流;
- 进入最终进程终止步骤。
4.4 _exit 不做哪些用户态工作
#include <unistd.h>
_exit(7);
_exit() 不执行 atexit 清理,也不刷新 stdio 流。但它并不是“什么都不清理”:进程拥有的文件描述符仍会在终止过程中关闭,父进程也能通过等待接口取得 status & 0xFF 对应的退出状态。
对比:
printf("hello");
exit(0); /* 正常运行时会刷新 stdout */
printf("hello");
_exit(0); /* 未刷新的 stdio 内容可能丢失 */
4.5 为什么 exec 失败的子进程常用 _exit(127)
典型写法:
execvp(argv[0], argv);
perror("execvp");
_exit(127);
原因有两个:
- 避免重复刷新从父进程继承的用户态
stdio缓冲; - 避免执行父进程注册的清理函数。
127 是 Shell 中常用的“命令未找到”状态;如果文件存在但无法执行,常使用 126。这是命令解释器约定,不是 execvp() 自动替你返回的值。
4.6 为什么父进程最终只读取到低 8 位退出状态
正常退出码通过等待状态向父进程传递时,只保留退出参数的低 8 位。因此:
_exit(257) → 父进程通过 WEXITSTATUS 读到 1
_exit(-1) → 常见结果是 255
应用退出码应设计在 0~255 范围内。复杂错误信息应该写日志、标准错误或通过 IPC 返回,而不是全部塞进退出码。
五、进程等待:wait 与 waitpid
5.1 为什么必须等待
子进程终止后,大部分资源已经释放,但内核通常还要保留一小部分信息:
- 退出原因;
- 正常退出码或终止信号;
- 少量资源统计;
- 供父进程识别的进程表项。
父进程尚未读取时,终止的子进程可能处于僵尸状态。wait() / waitpid() 的作用是:
- 取得子进程状态;
- 完成最终回收。
kill -9 不能“杀掉僵尸”,因为僵尸已经结束执行;真正需要修复的是父进程的等待逻辑。
5.2 wait
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
wait() 等待任意一个子进程。它等价于常见的:
waitpid(-1, status, 0);
成功时返回被回收子进程 PID;失败时返回 -1。
5.3 waitpid
pid_t waitpid(pid_t pid, int *status, int options);
pid 的常见取值:
pid |
等待对象 |
|---|---|
> 0 |
等待 PID 等于该值的子进程 |
-1 |
等待任意子进程 |
0 |
等待与调用者处于同一进程组的任意子进程 |
< -1 |
等待进程组 ID 等于 abs(pid) 的任意子进程 |
教学中最常见的是:
waitpid(child, &status, 0);
waitpid(-1, &status, 0);
waitpid(child, &status, WNOHANG);
5.4 status 不是退出码

等待接口写入的 status 是编码后的状态容器。正确顺序是“先判断类型,再提取内容”:
if (WIFEXITED(status)) {
printf("exit code=%d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("terminated by signal=%d\n", WTERMSIG(status));
} else if (WIFSTOPPED(status)) {
printf("stopped by signal=%d\n", WSTOPSIG(status));
} else if (WIFCONTINUED(status)) {
printf("continued\n");
}
注意:
WEXITSTATUS(status)只能在WIFEXITED(status)为真后读取;WTERMSIG(status)只能在WIFSIGNALED(status)为真后读取;- 停止与继续状态通常还需要配合
WUNTRACED、WCONTINUED选项; - 不要依赖手写
status >> 8或status & 0x7F,等待宏更清楚也更可移植。
5.5 一个完整的阻塞等待示例
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
pid_t child = fork();
if (child == -1) {
perror("fork");
return 1;
}
if (child == 0) {
sleep(1);
_exit(7);
}
int status = 0;
pid_t result;
do {
result = waitpid(child, &status, 0);
} while (result == -1 && errno == EINTR);
if (result == -1) {
perror("waitpid");
return 1;
}
if (WIFEXITED(status)) {
printf("child %ld exited, code=%d\n",
(long)result, WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("child %ld killed by signal %d\n",
(long)result, WTERMSIG(status));
}
return 0;
}
循环处理 EINTR,是因为阻塞中的系统调用可能被信号处理过程打断。是否自动重启还与信号安装方式有关,稳妥的教学代码应显式处理。
六、阻塞等待与 WNOHANG

6.1 options=0:阻塞等待
pid_t result = waitpid(child, &status, 0);
若目标子进程还没有可报告状态,调用线程会阻塞。子进程已经退出时,调用可以立即返回。
阻塞并不等于浪费 CPU。等待期间,调用线程不会持续占用 CPU 空转,调度器可以运行其他任务。
6.2 WNOHANG:当前无状态就立即返回
pid_t result = waitpid(child, &status, WNOHANG);
返回值:
| 返回值 | 含义 |
|---|---|
child PID |
已取得一个可报告状态,status 可解析 |
0 |
目标子进程当前没有可报告状态,没有发生回收 |
-1 |
调用失败,检查 errno |
最容易犯的错误是把 0 当成“子进程成功退出”。它真正表示的是:
WNOHANG 要求立即返回
且
当前没有可交付给调用者的目标状态
6.3 非阻塞不等于忙轮询
错误思路:
while (waitpid(child, &status, WNOHANG) == 0) {
/* 什么也不做 */
}
这个循环可能持续占用 CPU。
教学示例可以加入退避:
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
int main(void)
{
pid_t child = fork();
if (child == -1) {
perror("fork");
return 1;
}
if (child == 0) {
sleep(2);
_exit(9);
}
int status = 0;
const struct timespec pause_time = {0, 100 * 1000 * 1000};
for (;;) {
pid_t result = waitpid(child, &status, WNOHANG);
if (result == child) {
break;
}
if (result == -1) {
if (errno == EINTR) {
continue;
}
perror("waitpid");
return 1;
}
puts("child is still running; parent can do other work");
nanosleep(&pause_time, NULL);
}
if (WIFEXITED(status)) {
printf("exit code=%d\n", WEXITSTATUS(status));
}
return 0;
}
生产程序通常把子进程状态纳入信号处理、事件循环、任务队列或专门的进程管理框架,而不是固定频率轮询。
七、exec:不创建新进程,只替换程序映像

7.1 exec 的本质
exec 系列把磁盘上的新程序加载到调用进程中,替换其用户态程序映像:
旧程序 text/data/bss/heap/stack
↓ execve
新程序 text/data/bss/heap/stack + argv + envp
成功后,从新程序入口开始执行,原调用点后面的代码不会继续。
7.2 PID 为什么不变
exec 没有创建第二个进程,只是让当前进程开始执行另一份程序映像,所以 PID 继续保留。
可以把它理解为:
进程身份与关系仍在
运行的“程序内容”被整体换掉
这就是 Shell 常先 fork() 再让子进程 exec() 的原因:
- 如果 Shell 自己直接
exec,Shell 就被目标程序替换了; - 先
fork,可以让子进程被替换,同时保留父 Shell 继续管理终端与下一条命令。
7.3 exec 成功没有返回值
execvp(argv[0], argv);
perror("execvp");
_exit(127);
只有失败时才会走到 perror()。因此“exec 返回了”本身就是错误分支。
常见失败原因包括:
- 文件不存在;
- 没有搜索或执行权限;
- 文件系统以
noexec挂载; - 可执行格式或解释器无效;
- 参数与环境总大小超过限制。
7.4 exec 前后哪些属性保留
不能简单地说“exec 后什么都变了”或“除了代码什么都不变”。
通常继续保留的内容包括:
- PID、PPID 与进程关系;
- 当前工作目录;
umask;- 真实 UID/GID 等身份信息。
被替换或重置的内容包括:
- 原用户态地址空间映像;
- 已注册的
atexit清理函数; - 被捕获信号的处理方式通常重置为默认;
- 多线程进程中,除调用线程外的其他线程不再保留。
7.5 文件描述符与 FD_CLOEXEC
默认情况下,打开的文件描述符可以跨 exec 保留。这正是 Shell 实现重定向和管道的基础:
子进程先 dup2 配置 stdin/stdout
↓
exec 新程序
↓
新程序天然使用已准备好的 0/1/2
不希望新程序继承的描述符,应设置 close-on-exec:
#include <fcntl.h>
int flags = fcntl(fd, F_GETFD);
if (flags != -1) {
fcntl(fd, F_SETFD, flags | FD_CLOEXEC);
}
创建新描述符时,若接口支持,优先使用原子设置方式,例如 O_CLOEXEC,可以减少多线程程序中的竞态窗口。
八、exec 函数族怎样记

8.1 六个常见接口
#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[]);
8.2 后缀记忆法
| 后缀 | 含义 | 关注点 |
|---|---|---|
l |
list | 参数以可变参数列表给出 |
v |
vector | 参数放在 argv[] 数组中 |
p |
PATH | 按 PATH 搜索命令名 |
e |
environment | 显式传递 envp[] |
8.3 execl 示例
execl("/bin/ls", "ls", "-l", "/tmp", (char *)NULL);
perror("execl");
_exit(127);
可变参数列表必须以空指针结束。显式写 (char *)NULL 可以避免在可变参数中因整型 0 宽度而产生可移植性问题。
8.4 execvp 示例
char *const argv[] = {"ls", "-l", "/tmp", NULL};
execvp(argv[0], argv);
perror("execvp");
_exit(127);
argv[] 也必须以 NULL 结尾。约定上,argv[0] 应放程序名。
8.5 execle 最容易写错的位置
char *const envp[] = {
"PATH=/usr/bin:/bin",
"LANG=C",
NULL
};
execle("/usr/bin/env", "env", (char *)NULL, envp);
perror("execle");
_exit(127);
注意:execle 的普通参数列表先以 NULL 结束,之后才是 envp。
8.6 execve 与其他接口的关系
在 Linux 上,execve() 是核心系统接口;其他常用 exec 形式主要由 C 库提供不同的参数组织、环境处理和 PATH 搜索能力。
这并不意味着应用必须只写 execve()。选择最能清楚表达需求的包装接口,通常比手工实现 PATH 搜索更稳妥。
九、fork、exec、wait 如何组成一个启动器
下面的程序启动 ls -l /tmp,并把目标程序的结果转换为自身返回值:
#include <errno.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
pid_t child = fork();
if (child == -1) {
perror("fork");
return 1;
}
if (child == 0) {
char *const argv[] = {"ls", "-l", "/tmp", NULL};
execvp(argv[0], argv);
int error = errno;
perror("execvp");
_exit(error == ENOENT ? 127 : 126);
}
int status = 0;
pid_t result;
do {
result = waitpid(child, &status, 0);
} while (result == -1 && errno == EINTR);
if (result == -1) {
perror("waitpid");
return 1;
}
if (WIFEXITED(status)) {
return WEXITSTATUS(status);
}
if (WIFSIGNALED(status)) {
return 128 + WTERMSIG(status);
}
return 1;
}
核心过程:
父进程 fork
├─ 子进程:组织 argv → execvp → 失败才 _exit
└─ 父进程:waitpid → 判断状态类型 → 提取结果
这已经非常接近 Shell 执行外部命令的核心骨架。
十、实现一个教学版 mini shell

10.1 最小工作循环
一个教学版 Shell 可以分成五步:
- 打印提示符并读取命令行;
- 把输入解析成
argv[]; - 检查是否为内建命令;
- 外部命令走
fork + execvp; - 父 Shell 用
waitpid取得结果,再打印下一次提示符。
read → parse → built-in ? → 在父 Shell 内执行
\ no
fork → child exec → parent wait → next prompt
10.2 为什么 cd 必须是内建命令
当前工作目录是进程自身属性。
如果 Shell 创建子进程,并让子进程执行:
chdir("/tmp");
改变的只是子进程工作目录。子进程退出后,父 Shell 仍停留在原目录。
因此下面这些命令通常需要在父 Shell 内执行:
cd:修改 Shell 的当前工作目录;export:修改 Shell 要传给后续子进程的环境;exit:结束 Shell 自身;- 一些作业控制命令:需要操作 Shell 维护的进程组与任务表。
10.3 可运行教学版
下面的版本支持:
- 普通外部命令;
cd [dir];exit [code];echo $?;EINTR重试和常见 Shell 状态约定。
它故意不实现引号、转义、管道、重定向、变量展开、后台任务与作业控制,这些应在理解本节闭环后逐步添加。
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#define LINE_SIZE 1024
#define ARG_SIZE 64
static int split_line(char *line, char *argv[], int capacity)
{
int argc = 0;
char *save = NULL;
char *token = strtok_r(line, " \t\r\n", &save);
while (token != NULL && argc < capacity - 1) {
argv[argc++] = token;
token = strtok_r(NULL, " \t\r\n", &save);
}
argv[argc] = NULL;
return argc;
}
static int decode_status(int status)
{
if (WIFEXITED(status)) {
return WEXITSTATUS(status);
}
if (WIFSIGNALED(status)) {
return 128 + WTERMSIG(status);
}
return 1;
}
static int run_external(char *argv[])
{
pid_t child = fork();
if (child == -1) {
perror("fork");
return 1;
}
if (child == 0) {
execvp(argv[0], argv);
int error = errno;
perror(argv[0]);
_exit(error == ENOENT ? 127 : 126);
}
int status = 0;
pid_t result;
do {
result = waitpid(child, &status, 0);
} while (result == -1 && errno == EINTR);
if (result == -1) {
perror("waitpid");
return 1;
}
return decode_status(status);
}
int main(void)
{
char line[LINE_SIZE];
char *argv[ARG_SIZE];
int last_status = 0;
for (;;) {
printf("mini-shell$ ");
fflush(stdout);
if (fgets(line, sizeof(line), stdin) == NULL) {
if (ferror(stdin) && errno == EINTR) {
clearerr(stdin);
putchar('\n');
continue;
}
putchar('\n');
break;
}
int argc = split_line(line, argv, ARG_SIZE);
if (argc == 0) {
continue;
}
if (strcmp(argv[0], "exit") == 0) {
int code = last_status;
if (argc > 1) {
code = (int)strtol(argv[1], NULL, 10);
}
return code & 0xFF;
}
if (strcmp(argv[0], "cd") == 0) {
const char *path = argc > 1 ? argv[1] : getenv("HOME");
if (path == NULL || chdir(path) == -1) {
perror("cd");
last_status = 1;
} else {
last_status = 0;
}
continue;
}
if (argc == 2 &&
strcmp(argv[0], "echo") == 0 &&
strcmp(argv[1], "$?") == 0) {
printf("%d\n", last_status);
last_status = 0;
continue;
}
last_status = run_external(argv);
}
return last_status;
}
编译运行:
gcc -Wall -Wextra -std=c11 mini_shell.c -o mini_shell
./mini_shell
测试:
mini-shell$ pwd
mini-shell$ cd /tmp
mini-shell$ pwd
mini-shell$ command_that_does_not_exist
mini-shell$ echo $?
mini-shell$ exit 0
10.4 这个 Shell 还缺什么
真正的 Shell 至少还要处理:
- 词法分析与单双引号;
- 反斜杠转义;
- 变量展开与命令替换;
- 通配符;
- 输入、输出和追加重定向;
- 管道;
- 后台任务;
- 进程组、会话与控制终端;
SIGINT、SIGTSTP、SIGCHLD与作业控制。
所以 strtok_r() 版本只用于理解进程控制主线,不能当作完整 Shell 解析器。
十一、常见问题与避坑指南
11.1 fork 之后父进程一定先运行吗
不一定。父子执行顺序由调度器决定。需要固定顺序时,应使用进程间同步,而不是依赖某次实验输出。
11.2 fork 返回两个值是不是同一个变量同时有两个值
不是。父子进程拥有各自的变量副本和执行上下文,分别接收不同返回值。
11.3 写时拷贝意味着父子共享普通变量吗
不意味着。COW 是内核的延迟复制优化,应用视角仍是独立地址空间。真正共享数据要使用共享内存、管道、套接字等 IPC。
11.4 status 为什么不能直接打印
它编码了正常退出、信号终止、停止和继续等多种结果。必须先使用 WIF* 宏判断类型,再用匹配的宏提取内容。
11.5 WNOHANG 返回 0 是否代表子进程退出码为 0
不是。它表示当前没有可报告状态,且调用按照非阻塞要求立即返回。
11.6 exec 会创建新 PID 吗
不会。它替换当前程序映像,进程 PID 继续保留。新 PID 通常来自前一步 fork()。
11.7 exec 成功以后为什么不执行下一行
因为调用进程已经开始执行新程序。只有 exec 失败才会返回 -1,所以后续代码就是失败处理路径。
11.8 为什么 exec 后文件描述符还在
默认情况下,文件描述符跨 exec 保留;设置了 FD_CLOEXEC 的描述符才会关闭。Shell 正是利用保留语义实现重定向与管道。
11.9 为什么子进程 exec 失败后不能 return
return 可能进入从父进程复制来的 C 运行时清理路径,重复刷新缓冲或执行清理函数。子进程的失败路径通常应报告错误后 _exit(126/127)。
11.10 cd 为什么不能通过 execvp 解决
即使能启动一个名为 cd 的外部程序,它也只能修改自己的工作目录,无法修改父 Shell。改变 Shell 状态的命令必须在 Shell 进程内实现。
11.11 多线程程序 fork 后要注意什么
多线程进程调用 fork() 后,子进程中只有调用 fork() 的线程存在,但地址空间里可能仍保留互斥量等对象的旧状态。在执行 exec 前,只能安全调用 async-signal-safe 接口。复杂多线程程序应慎重设计 fork 路径,并评估 posix_spawn() 等方案。
总结
Linux 进程控制可以浓缩为四个动作:
fork:创建独立子进程与新的执行流
exec:在现有进程身份中加载新程序
exit:结束执行并产生终止信息
wait:取得结果并完成最终回收
真正需要形成的思维模型是:
- 创建不等于替换:
fork创建,exec替换; - 退出码不等于 wait status:先判断,再提取;
- 进程独立不等于所有底层对象都不共享:COW 与 open file description 要分开理解;
- Shell 不是简单地调用命令:它负责解析、内建状态、派生、替换、等待和下一次交互;
- 成功路径和失败路径同样重要:每个系统调用都要检查返回值与
errno。
当你能从一条 ls -l 命令完整讲清 fork → execvp → exit → waitpid → $?,就真正打通了 Linux 进程控制主线。
参考手册
- Linux man-pages:fork(2)
- Linux man-pages:wait(2)
- Linux man-pages:execve(2)
- Linux man-pages:exit(3)
- Linux man-pages:_exit(2)
- Linux man-pages:vfork(2)
📌 一句话收尾:进程控制不是背 API,而是让一个进程可靠地创建工作、切换程序、交付结果并回收现场。
更多推荐



所有评论(0)