欢迎光临
我们一直在努力

【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)
    未经允许不得转载:171主机测评 » 【Linux】进程控制深度解析:fork、exit、waitpid、exec 与简易 Shell 完整闭环
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址