【Linux】进程控制深度解析:fork、exit、waitpid、exec 与简易 Shell 完整闭环

🔥 本文定位:面向已经理解 Linux 进程概念、准备进入系统编程的同学,围绕一条真实命令的生命周期打通进程创建、终止、等待与程序替换。
💡 学习目标:不仅会调用 fork()waitpid()execvp(),还要理解返回值为什么这样设计、status 为什么不是退出码、exec 前后哪些内容改变,以及 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 三类结束场景

进程结束可以分为:

  1. 正常结束,业务结果成功;
  2. 正常结束,但业务结果失败;
  3. 被信号终止或发生异常,未走正常返回路径。

“正常终止”描述的是结束方式,不等于业务成功。业务是否成功通常由退出码约定。

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() 通常会:

  1. 按规则执行通过 atexit() 等注册的清理函数;
  2. 刷新并关闭 C 标准 I/O 流;
  3. 进入最终进程终止步骤。

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() 的作用是:

  1. 取得子进程状态;
  2. 完成最终回收。

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) 为真后读取;
  • 停止与继续状态通常还需要配合 WUNTRACEDWCONTINUED 选项;
  • 不要依赖手写 status >> 8status & 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 可以分成五步:

  1. 打印提示符并读取命令行;
  2. 把输入解析成 argv[]
  3. 检查是否为内建命令;
  4. 外部命令走 fork + execvp
  5. 父 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 至少还要处理:

  • 词法分析与单双引号;
  • 反斜杠转义;
  • 变量展开与命令替换;
  • 通配符;
  • 输入、输出和追加重定向;
  • 管道;
  • 后台任务;
  • 进程组、会话与控制终端;
  • SIGINTSIGTSTPSIGCHLD 与作业控制。

所以 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:取得结果并完成最终回收

真正需要形成的思维模型是:

  1. 创建不等于替换fork 创建,exec 替换;
  2. 退出码不等于 wait status:先判断,再提取;
  3. 进程独立不等于所有底层对象都不共享:COW 与 open file description 要分开理解;
  4. Shell 不是简单地调用命令:它负责解析、内建状态、派生、替换、等待和下一次交互;
  5. 成功路径和失败路径同样重要:每个系统调用都要检查返回值与 errno

当你能从一条 ls -l 命令完整讲清 fork → execvp → exit → waitpid → $?,就真正打通了 Linux 进程控制主线。


参考手册

📌 一句话收尾:进程控制不是背 API,而是让一个进程可靠地创建工作、切换程序、交付结果并回收现场。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐