第 2 章 · 04 fork + exec:执行外部命令


文档摘要

第 2 章 · 04 fork + exec:执行外部命令 本节摘要:这一节是造 Shell 的「主轴」——你要让用户敲的 、 真正跑起来,靠的就是 Unix 的两大进程原语: (复制当前进程)和 (在进程里替换程序映像)。本节导读它的设计动机(为什么要把「创建进程」和「加载程序」拆成两步)、子系统拆解(fork 之后、exec 之前那段「黄金窗口」是 Shell 做手脚的地方)、以及最难也最关键的「为什么是两步分离」——这个设计决定了 Shell 能在子进程里做重定向、管道、改环境变量等一切「接线」工作。理解 fork+exec,你就理解了 Unix 进程模型的灵魂,它也会在第 7 章(造 Web 服务器)和第 8 章(造数据库)里反复出现。

第 2 章 · 04 fork + exec:执行外部命令

本节摘要:这一节是造 Shell 的「主轴」——你要让用户敲的 lsgrep 真正跑起来,靠的就是 Unix 的两大进程原语:fork(复制当前进程)和 exec(在进程里替换程序映像)。本节导读它的设计动机(为什么要把「创建进程」和「加载程序」拆成两步)、子系统拆解(fork 之后、exec 之前那段「黄金窗口」是 Shell 做手脚的地方)、以及最难也最关键的「为什么是两步分离」——这个设计决定了 Shell 能在子进程里做重定向、管道、改环境变量等一切「接线」工作。理解 fork+exec,你就理解了 Unix 进程模型的灵魂,它也会在第 7 章(造 Web 服务器)和第 8 章(造数据库)里反复出现。

内容来源:基于原索引「Build your own X」Shell 域相关条目整理的导读,原始教程为外部资源(如「Tutorial - Write a Shell in C」「Writing a UNIX Shell」等)。

学习目标

阅读完本节,你应当能够:

  1. 说清 fork 干的事:复制当前进程,得到一个几乎一模一样的子进程(代码、数据、文件描述符表)。
  2. 说清 exec 干的事:把当前进程的程序映像替换成另一个可执行文件,从它的 main 开始执行。
  3. 解释「为什么 fork 与 exec 要分离」:为了在 fork 之后、exec 之前这段窗口里,能在子进程做重定向、管道、环境变量等改造。
  4. 写出执行一条外部命令的最小骨架:fork → 在子进程 exec → 在父进程 wait
  5. 理解 wait 的作用:父进程回收子进程的退出状态,避免产生僵尸进程(呼应《Linux 命令》第 6 章)。
  6. 处理 fork 失败与 exec 失败两种错误,理解「exec 成功就不返回」的含义。

一、学习价值:为什么这一节是造 Shell 的主轴

Shell 要执行一条外部命令(比如 ls),本质问题是:Shell 自己是一个正在运行的进程,它不能「变成」ls 程序(那就没法回到提示符等下一行了)。所以它需要一个办法:「另起一个进程去跑 ls,我自己等着它跑完」。

Unix 给出的答案就是 fork + exec 这个两步组合:

  • fork:Shell 自己 fork 出一个副本(子进程),这个子进程此刻和 Shell 几乎一模一样。
  • exec:子进程调用 exec,把自己的程序映像替换成 ls 的可执行文件,从 ls 的入口开始跑。
  • wait:父进程(Shell)调用 wait,等子进程跑完,拿到它的退出码。

这个三步骨架,就是 Shell 执行外部命令的全部核心。造完它,你的 Shell 就「能跑外部命令」了——这是从「空壳」到「能用」的第一次飞跃。

更重要的是,fork+exec 不只是造 Shell 用到——它是 Unix 进程模型的通用原语:任何需要「启动另一个程序」的场景都用这套(systemd 启动服务、你的终端启动 Shell、Shell 启动脚本、Web 服务器启动 CGI……)。学透它,你就拿到了 Unix 系统编程的一把万能钥匙。

二、子系统拆解:fork、exec、wait 三段

fork:复制进程

fork() 被调用一次,却会返回两次:在父进程里返回子进程的 PID(一个正数),在子进程里返回 0。这就是你区分「我现在是父还是子」的方法。

调用 fork 前: [Shell 进程, PID=100] 调用 fork 后: [Shell 父进程, PID=100] ── fork 返回 101(子PID) [Shell 子进程, PID=101] ── fork 返回 0 (两者代码、数据、文件描述符表几乎相同)

⚠️ 难点预警:「fork 返回两次」是新手最绊脚的地方。很多人写 if (pid == 0) { 子进程逻辑 } else { 父进程逻辑 } 时会困惑「为什么 if 两个分支都执行了」——答案是同一个 fork 调用在两个进程里分别返回,各自走进了对应的分支。

exec:替换程序映像

子进程 fork 出来后,它还是 Shell 的副本。要让子进程去跑 ls,得调用 exec 家族(execl/execv/execvp 等)。exec 会把当前进程的代码段、数据段、堆栈全部替换成新程序,从新程序的 main 开始执行——进程的 PID 不变,但「灵魂」已经换成了另一个程序。

子进程 [Shell 副本, PID=101] ── exec("ls") ──> [ls 程序, PID=101] (PID 没变, 但代码换了)

exec 的关键特性:成功就不返回(因为原来的代码已经被替换,没有「原来的地方」可回)。只有失败才返回 -1(比如文件不存在、没权限)——所以 exec 之后的代码,通常是错误处理。

wait:父进程收尸

子进程跑完 ls 后会退出,但内核不会立即清理它的进程信息——它变成「僵尸进程」(Z 状态),保留着退出码,等父进程来取。父进程用 wait/waitpid 取走这个退出码,内核才彻底清理子进程。

父进程 [Shell] ── wait(&status) ── 阻塞, 等子进程 101 结束 │ 子进程 [ls] 跑完, exit(0) ──────────────┘ 父进程拿到 status, 解析退出码, 存入 $?

如果父进程不 wait,子进程就会一直保持僵尸状态——这正是《Linux 命令》第 6 章讲的「僵尸进程」的产生原因。造 Shell 时,你必须 wait,否则僵尸会堆积。

三段合起来:执行一条命令的最小骨架

把上面三段合起来,执行一条外部命令的最小骨架(C 风格伪代码):

parse(line) // 把用户输入解析成 命令 + 参数 pid = fork() if pid == 0: // 子进程 exec(命令, 参数) // 替换程序映像 exit(127) // 只有 exec 失败才走到这里(127 约定为"命令找不到") else if pid > 0: // 父进程 wait(pid, &status) // 等子进程结束, 收退出码 $exit_code = status // 存入 $? else: // fork 失败(很少见, 资源耗尽时) error("fork failed")

这段骨架是几乎所有 Shell 教程的核心。看懂它,你就看懂了「Shell 怎么执行一条命令」。

三、上手第一步:为什么 fork 与 exec 要分离

这一节最深的洞见,是回答「为什么 Unix 要把 fork 和 exec 拆成两步,而不是合成一个 create_process_and_run(ls)」。

答案藏在那段「黄金窗口」里:fork 之后、exec 之前的子进程,仍然是 Shell 的副本,可以随意改造。Shell 正是利用这段窗口,在子进程里做这些事:

  • 重定向:在子进程里用 dup2 把 stdout(文件描述符 1)改成某个文件,然后再 exec。这样 ls > out.txt 就生效了——ls 一启动,它的 stdout 已经指向了 out.txt。
  • 管道:在子进程里把 stdout 接到管道的一端(详见下一节),exec 后的程序写 stdout 就是写管道。
  • 环境变量:在子进程里改 PATH、设环境变量,exec 后的程序继承这个环境。
  • 权限切换:在子进程里用 setuid 切换用户身份。

如果 fork 和 exec 是一个原子操作,这些改造就没地方做——你没法在「创建进程」和「跑程序」之间插手。Unix 的设计者把这两步分离,留出了这段窗口,使得 Shell 能用统一的机制(文件描述符操作)实现重定向、管道、环境等所有「命令修饰」功能。这是 Unix 设计哲学「提供机制而非策略」的经典体现

理解了这点,你就明白为什么第 05 节(管道与重定向)要放在 fork+exec 之后——它们正是利用这段窗口实现的。

本节要点回顾

  1. fork + exec + wait 是执行外部命令的三段骨架:fork 复制进程、exec 在子进程替换程序、wait 父进程收退出码。
  2. fork 返回两次:父进程返回子 PID,子进程返回 0——用 if (pid==0) 区分父子。
  3. exec 成功不返回:替换了程序映像就没有「原处」可回;只有失败(命令找不到等)才返回 -1。
  4. wait 防止僵尸:不 wait,子进程退出后变僵尸堆积——这是《Linux 命令》第 6 章僵尸进程的根因。
  5. fork 与 exec 分离的意义:fork 之后、exec 之前的「黄金窗口」,是 Shell 做重定向/管道/环境改造的地方。
  6. exec 失败的退出码:约定 127 表示「命令找不到」,可在你的 Shell 里沿用。

推荐上手顺序

  1. 先实现单条命令的执行:fork + exec + wait 三段骨架,让 lspwdecho 能在你的 Shell 里跑起来。这是第一个里程碑——约五十行代码就能达成。
  2. 处理 exec 失败:命令不存在时打印「command not found」,退出码设为 127。
  3. 接入 PATH 查找:用户敲 ls 时,你的 exec 要去 PATH 列出的目录里找 ls 可执行文件(用 execvp 会自动帮你做这步)。
  4. 存住退出码:把 wait 取到的退出状态存入 $?,为第 07 节的 &&/|| 做铺垫。
  5. 进入下一节(管道重定向):利用本节讲的「黄金窗口」,在 fork 后、exec 前用 dup2 做接线。

下一节是本章最巧妙的部分:管道与重定向。它正是利用本节讲的「fork 后、exec 前的黄金窗口」,用 dup2 系统调用做文件描述符的接线——一旦你理解了 dup2,ls | grep | wcls > out.txt 的神秘感就彻底消失了。


发布者: 作者: 灏天文库 转发
评论区 (0)
U