云计算百科
云计算领域专业知识百科平台

从0开始的操作系统(4)

【操作系统】程序与进程:从状态机到 fork、execve 与进程生命周期

头像

🔥 星恒随风:
个人主页

❄️ 个人专栏:
《指针合集》

《C语言基础》

《数据结构》

《机器学习导论》

《前端基础》

《python基础》

《C++从入门到入土》

✨ 数据即知识,压缩即智能


文章目录

  • 【操作系统】程序与进程:从状态机到 fork、execve 与进程生命周期
    • 声明
    • 一、从虚拟化开始:为什么每个程序都像独占了一台计算机?
    • 二、程序和进程到底有什么区别?
      • 1. 一个进程包含什么?
      • 2. 进程不是程序文件的“另一个名字”
    • 三、进程为什么会有不同状态?
    • 四、在 Linux 中观察一个真实进程
    • 五、UNIX 如何管理进程:复制、重置和销毁状态机
    • 六、fork:复制一个正在运行的进程
      • 1. “复制整个内存”会不会很慢?
      • 2. fork 到底复制和继承了什么?
      • 3. fork 之后为什么会形成进程树?
      • 4. 为什么 fork 会带来指数增长?
      • 5. fork 的实际用途
    • 七、一道看似简单,却很容易答错的 fork 题
      • 1. 关键不在 fork,而在 stdio 缓冲
    • 八、execve:不是创建进程,而是替换当前进程
      • 1. execve 的三个参数
        • path:可执行文件路径
        • argv:命令行参数
        • envp:环境变量
      • 2. 哪些状态被替换,哪些状态会保留?
      • 3. execve 与 execl、execvp 有什么关系?
      • 4. PATH 为什么会影响程序执行?
    • 九、wait 和 waitpid:父进程如何等待并回收子进程?
    • 十、exit 与 _exit:看起来相似,行为并不相同
    • 十一、一个完整的 fork + execve + waitpid
    • 十二、为什么 UNIX 要把 fork 和 exec 分开?
    • 十三、用 strace 观察进程生命周期
    • 十四、理解状态机

声明

本文根据南京大学 JYY《操作系统》2026 春季课程 Lecture 5“程序和进程”整理,并参考 Operating Systems: Three Easy Pieces(OSTEP)第 4、5 章及 Linux 官方手册进行补充。各位感兴趣的话可以上官网看看

课程网站 https://jyywiki.cn


一、从虚拟化开始:为什么每个程序都像独占了一台计算机?

站在应用程序的角度看,操作系统可以理解成“对象 + API”:

  • 文件是对象,可以通过 open、read、write 等接口访问;
  • 进程是对象,可以通过 fork、execve、waitpid、_exit 等接口管理;
  • 虚拟内存、网络套接字也都可以用类似的方式理解。

站在硬件的角度看,操作系统本身又只是一个程序。它从机器启动后的某个入口开始执行,管理 CPU、内存和各种外部设备。

这两种视角最终在“虚拟化”上汇合:

操作系统把一台真实的物理计算机,抽象成许多台彼此隔离的“虚拟计算机”。

我们明明只有有限数量的 CPU 核心,但浏览器、编辑器、终端、音乐播放器却好像都在同时运行。原因并不是每个程序真的拥有一颗 CPU,而是操作系统不断让不同进程轮流运行:

while (1) {
Process *p = choose_one_ready_process();
p->run_for_a_while();
handle_event_or_syscall(p);
}

这段代码当然只是概念模型。真实操作系统通常不会逐条解释应用程序指令,而是让程序直接在 CPU 上执行,再借助时钟中断、异常、系统调用和上下文切换重新获得控制权。

但这个看似简单的循环,已经揭示了 CPU 虚拟化的核心:

  • 操作系统保存多个程序的运行状态;
  • 每次选择一个可以运行的程序;
  • 让它执行一段时间;
  • 保存它的新状态,再切换到另一个程序。
  • OSTEP 将这种方法称为 time sharing(时间共享)。操作系统通过在时间上切分 CPU,让多个进程都产生“自己拥有 CPU”的错觉。


    二、程序和进程到底有什么区别?

    这是本节课最先需要说清楚的问题。

    假设磁盘中有下面这样一个可执行文件:

    #include <unistd.h>

    int main(void) {
    while (1) {
    write(STDOUT_FILENO, "Hello, World!\\n", 14);
    }
    }

    当它静静地躺在磁盘中时,它是一个程序(program);当操作系统把它加载到内存并开始执行后,它就成为一个进程(process)。

    可以从状态机的角度进一步理解:

    • 程序描述了初始状态以及每一步如何迁移;
    • 进程是这个状态机正在运行的一个实例;
    • 同一个程序可以同时运行多次,从而产生多个彼此独立的进程。

    例如同时打开三个终端,并在其中分别运行同一个程序,磁盘上仍然只有一份可执行文件,但系统里会出现三个进程。它们拥有不同的 PID,也可能执行到不同的位置、保存不同的数据。

    1. 一个进程包含什么?

    进程绝不只是“正在执行的代码”。按照 OSTEP 的描述,一个进程在某个时刻的状态至少包括:

    组成部分典型内容
    地址空间 代码段、全局数据、堆、栈、动态库和内存映射
    CPU 上下文 通用寄存器、程序计数器 PC、栈指针 SP、状态寄存器
    I/O 状态 打开的文件描述符、当前文件偏移、管道、套接字
    身份信息 PID、PPID、用户 ID、组 ID、进程组、会话
    内核管理信息 调度状态、优先级、信号状态、资源使用量等

    因此可以写出一个更完整的关系:

    进程 = 程序的运行时状态 + 操作系统维护的管理状态。

    操作系统通常会用某种内核数据结构记录这些信息。教材中经常把它概括为 PCB(Process Control Block,进程控制块)。不同内核的实际结构并不完全相同,但思想是一致的:想暂停一个进程并在未来恢复它,就必须保存足够多的现场。

    2. 进程不是程序文件的“另一个名字”

    可以用下面的对比加深印象:

    程序进程
    静态的 动态的
    通常存放在磁盘中 正在内存和 CPU 上演化
    描述“应该怎样执行” 表示“已经执行到了哪里”
    一份程序可以长期存在 进程有创建、运行和退出的生命周期
    同一程序通常只有一份代码文件 同一程序可以对应多个进程

    如果把程序比作一份菜谱,那么进程不是菜谱本身,而是“某位厨师按照菜谱做菜的整个现场”:做到哪一步、手中有什么材料、炉火是否打开,这些都属于运行状态。


    三、进程为什么会有不同状态?

    多个进程共享有限的 CPU,但并不是每个进程都随时能够运行。OSTEP 使用三个基本状态帮助我们建立模型:

    状态含义
    Running(运行) 正在某个 CPU 核心上执行
    Ready(就绪) 已经具备运行条件,只是在等待 CPU
    Blocked(阻塞) 正在等待某个事件,例如磁盘 I/O、网络数据或锁

    常见的状态迁移如下:

    • Ready → Running:调度器选中了该进程;
    • Running → Ready:时间片耗尽,或者进程被更高优先级任务抢占;
    • Running → Blocked:进程发起 I/O,暂时无法继续;
    • Blocked → Ready:等待的事件完成,进程重新获得运行资格。

    这里最容易混淆的是“就绪”和“阻塞”:

    • 就绪进程什么都不缺,只缺 CPU;
    • 阻塞进程即使立刻分配 CPU,也无法继续完成当前工作。

    例如,一个进程调用 read 等待网络数据时,它可以进入阻塞状态。操作系统不会让 CPU 陪着它空等,而会切换到其他就绪进程。等网卡收到数据并产生中断后,内核再把它转回就绪状态。

    真实系统还会有创建中、停止、僵尸等更细的状态,但 Running、Ready、Blocked 已经足以解释调度的基本逻辑。


    四、在 Linux 中观察一个真实进程

    抽象最终要落到真实系统上。在 Linux 中,最适合观察进程的入口之一是 procfs。

    /proc 看起来像一个普通目录,实际上是内核动态提供的虚拟文件系统。对于 PID 为 1234 的进程,/proc/1234/ 中保存了大量与它相关的信息。

    可以先在终端中完成下面这组实验:

    # 当前 shell 的 PID
    echo $$

    # 查看进程的可读状态信息
    cat /proc/$$/status

    # 查看它正在执行的程序
    readlink /proc/$$/exe

    # 查看命令行参数;其中原本使用 '\\0' 分隔
    tr '\\0' ' ' < /proc/$$/cmdline
    echo

    # 查看环境变量
    tr '\\0' '\\n' < /proc/$$/environ | head

    # 查看打开的文件描述符
    ls -l /proc/$$/fd

    # 查看内存映射
    head /proc/$$/maps

    几个常用文件的含义如下:

    路径内容
    /proc/[pid]/status PID、PPID、内存、线程数、信号等可读信息
    /proc/[pid]/stat 面向程序解析的进程状态
    /proc/[pid]/cmdline 启动该程序时的参数
    /proc/[pid]/environ 环境变量
    /proc/[pid]/fd/ 已打开文件描述符对应的符号链接
    /proc/[pid]/maps 虚拟地址空间中的内存映射
    /proc/[pid]/exe 当前可执行文件
    /proc/[pid]/cwd 当前工作目录

    还有一个很有意思的路径:

    cat /proc/self/status

    /proc/self 会自动指向“正在访问 /proc 的那个进程”。所以上述命令显示的通常是 cat 自己,而不是启动 cat 的 shell。

    除了 procfs,程序还可以通过系统调用获取自己的身份:

    getpid(); // 当前进程 PID
    getppid(); // 父进程 PID
    getpgrp(); // 进程组
    getsid(0); // 会话 ID
    getuid(); // 真实用户 ID
    geteuid(); // 有效用户 ID
    getgid(); // 真实组 ID
    getegid(); // 有效组 ID

    需要注意,/proc 是 Linux 特有的接口,并不是所有 UNIX 系统都提供完全相同的目录和字段。写可移植程序时,应优先使用标准 API;调试和探索 Linux 时,/proc 则是一座宝库。


    五、UNIX 如何管理进程:复制、重置和销毁状态机

    如果让我们自己设计进程 API,最直观的方案可能是:

    spawn(path, argv) 创建并运行一个新程序
    exit(status) 结束当前进程

    Windows 的 CreateProcess 在接口形态上更接近这种思路。

    UNIX 却选择了一个看起来有些奇怪的组合:

    目标UNIX 接口状态机视角
    创建 fork() 复制当前状态机
    执行另一程序 execve() 用新程序重置当前状态机
    等待子进程 wait() / waitpid() 等待子状态机发生特定变化并回收结果
    退出 _exit() 销毁当前状态机并留下退出状态

    所以,UNIX 中“启动一个新程序”的经典流程并不是一个调用,而是:

    fork + execve

    父进程先复制出一个子进程,再由子进程把自己替换成目标程序。这个设计看似绕了一步,却给 shell 的重定向、管道、权限调整和文件描述符安排留下了非常灵活的操作空间。


    六、fork:复制一个正在运行的进程

    fork 的函数原型很简单:

    #include <unistd.h>

    pid_t fork(void);

    它最特殊的地方在于:调用一次,却可能返回两次。

    成功调用后,父进程和子进程都会从 fork() 返回的位置继续执行,只是返回值不同:

    所在进程fork() 返回值
    父进程 子进程的 PID,大于 0
    子进程 0
    调用失败 父进程得到 -1,且不会创建子进程

    下面是最基本的例子:

    #include <stdio.h>
    #include <stdlib.h>
    #include <sys/types.h>
    #include <sys/wait.h>
    #include <unistd.h>

    int main(void) {
    int value = 100;
    pid_t pid = fork();

    if (pid < 0) {
    perror("fork");
    return EXIT_FAILURE;
    }

    if (pid == 0) {
    value++;
    printf("[child] pid=%ld, ppid=%ld, value=%d\\n",
    (long)getpid(), (long)getppid(), value);
    return 0;
    }

    value += 10;
    printf("[parent] pid=%ld, child=%ld, value=%d\\n",
    (long)getpid(), (long)pid, value);

    waitpid(pid, NULL, 0);
    return 0;
    }

    编译并运行:

    gcc -Wall -Wextra -O2 fork_basic.c -o fork_basic
    ./fork_basic

    你可能看到父进程先输出,也可能看到子进程先输出。两者都正确,因为 fork 之后谁先被调度并不确定。

    但 value 一定分别是:

    • 子进程中的 101;
    • 父进程中的 110。

    原因是父子进程拥有彼此独立的虚拟地址空间。fork 时二者内容相同,之后一方对内存的修改不会改变另一方。

    1. “复制整个内存”会不会很慢?

    从语义上看,fork 像是复制了父进程的整个地址空间;但现代 Linux 通常使用 Copy-on-Write(写时复制,COW):

  • fork 刚完成时,父子进程的页表可以暂时指向相同的物理页;
  • 这些页面会被保护起来;
  • 当某一方尝试写入时,CPU 触发缺页异常;
  • 内核再为即将写入的一方复制对应页面。
  • 因此,fork 的主要初始开销通常是复制页表、创建内核任务结构等,而不是立刻复制父进程每一个物理内存页。

    这也解释了一个经典用法:程序先完成耗时的共享数据初始化,再 fork 出多个工作进程。只读数据可以继续共享物理页,真正发生修改的部分才需要复制。Android 的 Zygote 进程也利用了“先预加载,再派生应用进程”的思路。

    不过 COW 并不等于“没有开销”。父进程页表很大、子进程大量写内存,或者频繁创建进程时,成本仍然可能很高。

    2. fork 到底复制和继承了什么?

    这就要讲到:

    父子进程在 fork 返回的瞬间看起来几乎一样,但它们不是同一个进程。

    几个最重要的细节是:

    • 子进程有自己的 PID;
    • 子进程的 PPID 指向父进程;
    • 父子进程拥有独立的虚拟地址空间;
    • 子进程继承父进程打开的文件描述符副本;
    • 这些文件描述符通常指向相同的内核 open file description,因此可能共享文件偏移;
    • 某些信号、锁、计时器等状态有特殊继承规则,不能简单理解成“所有东西逐位复制”。

    最后一点非常重要。课堂中的“复制状态机”是理解语义的好模型,但真正写系统程序时,仍然需要查看 fork(2) 手册确认具体资源的继承行为。

    3. fork 之后为什么会形成进程树?

    每次 fork 都会建立父子关系,于是整个系统中的进程自然形成一棵树。

    假设关系为:

    A 创建 B,B 创建 C

    如果 B 先退出,C 的父进程并不一定简单地变成 A。在 Linux 中,仍在运行的孤儿进程会被重新托管给最近的 subreaper;如果不存在合适的 subreaper,则通常由所在 PID 命名空间中的 init 进程接管。

    这就是孤儿进程(orphan process) 问题:父进程先退出,但子进程仍在运行。

    另一个容易混淆的概念是僵尸进程(zombie process):

    • 子进程已经结束运行;
    • 内核仍需保留它的 PID、退出状态和少量统计信息;
    • 父进程尚未调用 wait 或 waitpid 取走这些信息。

    孤儿进程还活着,僵尸进程已经死了。两者完全不是一回事。

    4. 为什么 fork 会带来指数增长?

    看下面的循环:

    for (int i = 0; i < n; i++) {
    fork();
    }

    如果每次 fork 都成功,而且所有新进程都会继续执行剩余循环,那么进程数量会这样增长:

    1 → 2 → 4 → 8 → … → 2^n

    连续执行 n 次无条件 fork 后:

    • 最终进程总数是 2^n;
    • 新创建的进程数是 2^n – 1。

    这也是 fork bomb 危险的根源:它会快速耗尽 PID、进程数限制、内存和调度资源。现代 Linux 可以使用 RLIMIT_NPROC、cgroup 的 pids.max 等机制限制进程数量,但 fork bomb 依然可能严重影响系统。

    不要在真实环境中尝试 fork bomb。理解指数增长即可,没有必要用机器稳定性做实验。

    5. fork 的实际用途

    尽管接口看起来古怪,fork 具有很强的表达能力:

    • shell 创建子进程执行命令;
    • Web 服务器或守护进程派生工作进程;
    • 完成公共数据预处理后创建多个只读工作进程;
    • 为搜索算法的不同分支保存状态快照;
    • 通过进程边界实现故障隔离;

    当然,并行程序不应看到分支就无条件 fork。进程创建、调度和通信都有成本,任务规模过小时,开销可能大于并行收益。


    七、一道看似简单,却很容易答错的 fork 题

    课堂中给出了下面的程序:

    for (int i = 0; i < 2; i++) {
    fork();
    printf("Hello\\n");
    }

    先暂时忽略缓冲,数一数 printf 被执行多少次:

    • 第 1 轮 fork 后有 2 个进程,因此输出 2 次;
    • 第 2 轮开始时有 2 个进程,每个再复制一次,于是有 4 个进程,输出 4 次;
    • 总计 2 + 4 = 6 次。

    一般地,如果循环执行 n 轮,并在每轮 fork 后输出一次,输出语句的执行次数是:

    2 + 4 + … + 2^n = 2^(n+1) – 2

    但实验时可能出现一个反常现象:

    ./a.out
    ./a.out | wc -l

    前者通常看到 6 行,而后者可能统计出 8 行。为什么?

    1. 关键不在 fork,而在 stdio 缓冲

    连接终端时,stdout 通常采用行缓冲,遇到换行符就会刷新。因此第一轮产生的两行文本在第二轮 fork 前大概率已经通过 write 交给内核,不会再次复制。

    连接管道时,stdout 通常采用全缓冲。第一轮的两个 printf("Hello\\n") 只是把文本放进各自进程的用户态缓冲区,还没有真正执行 write。

    接下来第二轮 fork 复制进程地址空间,也就复制了缓冲区:

  • 第一轮后有 2 个进程,每个缓冲区中有一行 Hello;
  • 第二轮 fork 后有 4 个进程,每个都带着被复制的一行;
  • 4 个进程又各自追加一行;
  • 正常退出时,每个进程刷新两行,共得到 4 × 2 = 8 行。
  • 所以,真正被复制的不是已经写入终端或管道的数据,而是还留在用户空间内存中的 stdio 缓冲区。

    可以用下面几种方法验证:

    // 方法一:关闭 stdout 缓冲
    setbuf(stdout, NULL);

    // 方法二:在 fork 前主动刷新
    fflush(stdout);

    // 方法三:对比底层 write 系统调用
    write(STDOUT_FILENO, "Hello\\n", 6);

    还可以用 strace 观察实际发生的系统调用:

    strace -f -e trace=process,write ./a.out
    strace -f -e trace=process,write ./a.out 2>&1 | less


    八、execve:不是创建进程,而是替换当前进程

    execve 的原型如下:

    #include <unistd.h>

    int execve(const char *path,
    char *const argv[],
    char *const envp[]);

    如果说 fork 是“复制状态机”,那么 execve 就是“重置状态机”:

    它把当前进程正在运行的程序替换成 path 指定的新程序。

    成功调用后:

    • 原程序的代码、数据、堆和栈被新程序替换;
    • CPU 从新程序入口开始执行;
    • 新程序获得新的参数和环境变量;
    • 调用成功时 execve 不会返回。

    最后一条尤其重要。下面的输出只会在 execve 失败时出现:

    execve(path, argv, envp);
    perror("execve"); // 能执行到这里,说明 execve 失败

    1. execve 的三个参数

    path:可执行文件路径

    例如:

    "/bin/echo"

    底层 execve 需要明确的路径,它本身不会像 shell 那样自动搜索 PATH。

    argv:命令行参数

    argv 是以空指针结尾的字符串指针数组:

    char *argv[] = {
    "echo",
    "Hello from execve",
    NULL
    };

    按照约定,argv[0] 通常是程序名。新程序的 main(int argc, char *argv[]) 参数正是由这里建立起来的。

    envp:环境变量

    环境变量同样使用以空指针结尾的字符串数组:

    char *envp[] = {
    "LANG=C",
    "MY_KEY=hello",
    NULL
    };

    每一项通常采用 KEY=VALUE 的形式。PATH、HOME、PWD、LANG 等都属于环境变量。

    Shell 中的:

    export MY_KEY=hello

    本质上是在修改 shell 自己的环境,使它之后创建并执行的子进程能够继承或获得相应变量。

    2. 哪些状态被替换,哪些状态会保留?

    execve 并没有创建新的进程,所以调用前后 PID 不变。可以把常见状态分成两类:

    被新程序替换或重新初始化通常继续保留
    代码段、数据段、堆、栈 PID、PPID
    程序计数器和用户态执行现场 当前工作目录
    原程序的内存映像 大部分进程身份信息
    新的 argv 和 envp 由调用者提供 未设置 close-on-exec 的文件描述符

    这张表只列出最核心的行为。信号处理方式、凭据、内存映射、定时器等都有更细的规则,系统编程时应查阅 execve(2)。

    打开的文件描述符默认可以跨越 execve 保留,这一点极其重要:shell 正是借助它实现重定向和管道。

    如果某个文件描述符不应该泄漏给新程序,应设置 FD_CLOEXEC,或者在创建文件描述符时使用 O_CLOEXEC 等原子选项。

    3. execve 与 execl、execvp 有什么关系?

    在 Linux 上,真正进入内核执行新程序的基础接口是 execve。libc 又提供了一组便于使用的 exec* 函数:

    函数参数形式是否搜索 PATH是否显式传入环境
    execl 参数列表 list
    execv 参数数组 vector
    execlp 参数列表
    execvp 参数数组
    execle 参数列表
    execve 参数数组

    可以用后缀帮助记忆:

    • l:list,参数逐个写出;
    • v:vector,参数放在数组中;
    • p:按照 PATH 搜索可执行文件;
    • e:显式提供 environment。

    因此:

    char *args[] = {"ls", "-l", NULL};
    execvp(args[0], args);

    会根据 PATH 查找 ls;而:

    execve("/bin/ls", args, envp);

    则直接执行给定路径。

    4. PATH 为什么会影响程序执行?

    PATH 保存了一组用冒号分隔的目录:

    echo "$PATH"

    当 shell 或 execvp 需要执行一个不带 / 的程序名时,会按照 PATH 中的目录顺序尝试查找。

    例如 gcc 在编译过程中还需要调用汇编器、链接器等其他程序。如果把 PATH 清空:

    PATH="" /usr/bin/gcc a.c

    即使 /usr/bin/gcc 本身能够启动,它也可能找不到后续需要执行的工具。

    这里要区分两层:

    • execve 负责执行一个明确路径;
    • shell 或带 p 后缀的 libc 包装函数负责搜索 PATH,最终仍会落到类似 execve 的底层执行操作上。

    九、wait 和 waitpid:父进程如何等待并回收子进程?

    fork 之后父子进程并发执行,父进程有时需要等子进程结束再继续。最常用的接口是:

    #include <sys/wait.h>

    pid_t wait(int *status);
    pid_t waitpid(pid_t pid, int *status, int options);

    wait(NULL) 等待任意一个子进程;waitpid 可以指定等待哪个子进程,并提供更多选项。

    一个典型写法是:

    int status;
    pid_t result = waitpid(pid, &status, 0);

    if (result == 1) {
    perror("waitpid");
    } else if (WIFEXITED(status)) {
    printf("child exited with status %d\\n",
    WEXITSTATUS(status));
    } else if (WIFSIGNALED(status)) {
    printf("child killed by signal %d\\n",
    WTERMSIG(status));
    }

    几个常见宏的含义:

    宏含义
    WIFEXITED(status) 子进程是否正常退出
    WEXITSTATUS(status) 正常退出时的退出码
    WIFSIGNALED(status) 是否被信号终止
    WTERMSIG(status) 导致终止的信号编号
    WIFSTOPPED(status) 是否因信号而停止
    WNOHANG 作为 waitpid 选项时,不阻塞等待

    wait 不只是“让父进程暂停”,还承担了回收子进程退出信息的责任。如果父进程一直不等待已经退出的子进程,子进程就可能以僵尸状态留在进程表中。


    十、exit 与 _exit:看起来相似,行为并不相同

    课程中用 _exit 表示立即销毁进程:

    #include <unistd.h>

    void _exit(int status);

    但 C 程序中还经常见到:

    #include <stdlib.h>

    void exit(int status);

    二者最关键的差异是:

    exit_exit
    libc 函数 POSIX 进程终止接口
    刷新并关闭 stdio 流 不刷新 stdio 流
    调用 atexit 注册的函数 不调用 atexit 处理函数
    完成用户态清理后终止进程 更直接地终止进程

    这也是为什么 fork 后的子进程在 execve 失败时,通常写成:

    execve(path, argv, envp);
    perror("execve");
    _exit(127);

    而不是直接调用 exit。如果父进程在 fork 前存在尚未刷新的 stdio 缓冲,子进程调用 exit 可能再次刷新被复制的缓冲,造成重复输出。

    在 Linux 的实现细节中,进程与线程还会带来 exit 和 exit_group 的区别。现代 glibc 的 _exit 包装函数会终止整个进程中的线程组,而原始内核系统调用层面的行为需要结合具体接口理解。初学阶段先掌握“exit 会做 libc 清理,_exit 不会刷新 stdio”即可。


    十一、一个完整的 fork + execve + waitpid

    下面的程序展示了 UNIX 创建新程序的标准骨架:

    #include <stdio.h>
    #include <stdlib.h>
    #include <sys/types.h>
    #include <sys/wait.h>
    #include <unistd.h>

    extern char **environ;

    int main(void) {
    pid_t pid = fork();

    if (pid == 1) {
    perror("fork");
    return EXIT_FAILURE;
    }

    if (pid == 0) {
    char *argv[] = {
    "echo",
    "Hello from the new program!",
    NULL
    };

    execve("/bin/echo", argv, environ);

    // 只有 execve 失败时才会到达这里
    perror("execve");
    _exit(127);
    }

    int status;
    if (waitpid(pid, &status, 0) == 1) {
    perror("waitpid");
    return EXIT_FAILURE;
    }

    if (WIFEXITED(status)) {
    printf("child %ld exited with status %d\\n",
    (long)pid, WEXITSTATUS(status));
    } else if (WIFSIGNALED(status)) {
    printf("child %ld was killed by signal %d\\n",
    (long)pid, WTERMSIG(status));
    }

    return 0;
    }

    编译运行:

    gcc -Wall -Wextra -O2 spawn_demo.c -o spawn_demo
    ./spawn_demo

    整个过程可以按时间顺序理解:

  • 父进程调用 fork;
  • 操作系统创建子进程;
  • 子进程调用 execve,把自身替换成 /bin/echo;
  • 父进程在 waitpid 中等待;
  • echo 输出并退出;
  • 父进程获得退出状态,回收子进程信息;
  • 父进程继续执行并退出。
  • 这就是一个最小版 shell 的核心流程。


    十二、为什么 UNIX 要把 fork 和 exec 分开?

    乍看之下,先复制自己,再立即把子进程替换掉,似乎有些多余。OSTEP 给出的关键答案是:

    fork 与 exec 之间的空隙,让父进程或子进程有机会修改新程序即将继承的运行环境。

    例如执行:

    wc -l input.txt > result.txt

    shell 可以在子进程中先完成这些操作:

  • fork 创建子进程;
  • 子进程打开 result.txt;
  • 使用 dup2 让标准输出 STDOUT_FILENO 指向该文件;
  • 调用 execvp 执行 wc;
  • wc 并不知道自己被重定向,仍然只向标准输出写数据;
  • 由于文件描述符跨 exec 保留,输出最终进入 result.txt。
  • 管道的思路也类似:

    cat input.txt | grep hello

    shell 先创建管道,再通过 fork 建立子进程,并在 exec 前把不同进程的标准输入、标准输出接到管道两端。

    因此,分离 fork 和 exec 并不是历史上随意留下的怪接口,而是 UNIX 组合式设计的重要基础:

    • fork 决定“由哪个进程来执行”;
    • 中间步骤决定“以怎样的环境执行”;
    • exec 决定“最终执行哪个程序”。

    十三、用 strace 观察进程生命周期

    只看代码容易把库函数、系统调用和 shell 行为混在一起。strace 可以记录程序与内核交互的过程。

    观察进程相关系统调用:

    strace -f -e trace=process ./spawn_demo

    其中:

    • -f 表示继续跟踪 fork 创建的子进程;
    • -e trace=process 只显示进程创建、执行、等待和退出相关调用。

    观察 execve 和文件描述符:

    strace -f -e trace=execve,openat,close,dup2 ./your_program

    观察输出究竟何时进入内核:

    strace -f -e trace=write ./a.out

    在现代 Linux/glibc 上,你还可能看到:

    • 源代码调用的是 fork,跟踪结果中出现 clone 或 clone3;
    • 源代码调用 _exit,底层出现 exit_group;
    • printf 并不立刻对应 write,而是在缓冲区刷新时集中写出。

    这并不矛盾。应用程序调用的 libc 接口可以在内部选择合适的底层系统调用。学习操作系统时,要有意识地区分:

  • 源代码中的库函数;
  • 用户态运行库的实现;
  • 真正进入内核的系统调用;
  • 内核内部完成工作的机制。

  • 十四、理解状态机

    把整节课压缩成一条主线,可以得到:

  • 程序描述状态机的初始状态和迁移规则;
  • 进程是状态机正在运行的实例;
  • 操作系统保存进程状态,并在多个进程之间分配 CPU;
  • fork 复制当前进程;
  • execve 用新程序替换当前进程;
  • _exit 终止进程并留下退出状态;
  • wait / waitpid 让父进程等待并回收子进程;
  • shell 通过组合这些接口实现命令执行、重定向和管道。
  • 因此,下面这段代码不只是固定模板:

    pid_t pid = fork();

    if (pid == 1) {
    // 创建失败
    } else if (pid == 0) {
    // 子进程:设置环境、重定向、exec
    } else {
    // 父进程:继续工作或等待子进程
    }

    它实际上表达了一套完整的状态机管理思想:

    复制状态 → 调整环境 → 装入新程序 → 等待结果 → 回收状态

    理解到这里,进程就不再是课本里一句“正在运行的程序”,而是一个可以被观察、复制、替换、调度和回收的真实对象。


    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 从0开始的操作系统(4)
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!