第 2 章 · 04 fork + exec:执行外部命令 本节摘要:这一节是造 Shell 的「主轴」——你要让用户敲的 、 真正跑起来,靠的就是 Unix 的两大进程原语: (复制当前进程)和 (在进程里替换程序映像)。本节导读它的设计动机(为什么要把「创建进程」和「加载程序」拆成两步)、子系统拆解(fork 之后、exec 之前那段「黄金窗口」是 Shell 做手脚的地方)、以及最难也最关键的「为什么是两步分离」——这个设计决定了 Shell 能在子进程里做重定向、管道、改环境变量等一切「接线」工作。理解 fork+exec,你就理解了 Unix 进程模型的灵魂,它也会在第 7 章(造 Web 服务器)和第 8 章(造数据库)里反复出现。
本节摘要:这一节是造 Shell 的「主轴」——你要让用户敲的
ls、grep真正跑起来,靠的就是 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」等)。
阅读完本节,你应当能够:
fork 干的事:复制当前进程,得到一个几乎一模一样的子进程(代码、数据、文件描述符表)。exec 干的事:把当前进程的程序映像替换成另一个可执行文件,从它的 main 开始执行。fork → 在子进程 exec → 在父进程 wait。wait 的作用:父进程回收子进程的退出状态,避免产生僵尸进程(呼应《Linux 命令》第 6 章)。Shell 要执行一条外部命令(比如 ls),本质问题是:Shell 自己是一个正在运行的进程,它不能「变成」ls 程序(那就没法回到提示符等下一行了)。所以它需要一个办法:「另起一个进程去跑 ls,我自己等着它跑完」。
Unix 给出的答案就是 fork + exec 这个两步组合:
ls 的可执行文件,从 ls 的入口开始跑。这个三步骨架,就是 Shell 执行外部命令的全部核心。造完它,你的 Shell 就「能跑外部命令」了——这是从「空壳」到「能用」的第一次飞跃。
更重要的是,fork+exec 不只是造 Shell 用到——它是 Unix 进程模型的通用原语:任何需要「启动另一个程序」的场景都用这套(systemd 启动服务、你的终端启动 Shell、Shell 启动脚本、Web 服务器启动 CGI……)。学透它,你就拿到了 Unix 系统编程的一把万能钥匙。
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 调用在两个进程里分别返回,各自走进了对应的分支。
子进程 fork 出来后,它还是 Shell 的副本。要让子进程去跑 ls,得调用 exec 家族(execl/execv/execvp 等)。exec 会把当前进程的代码段、数据段、堆栈全部替换成新程序,从新程序的 main 开始执行——进程的 PID 不变,但「灵魂」已经换成了另一个程序。
子进程 [Shell 副本, PID=101] ── exec("ls") ──> [ls 程序, PID=101] (PID 没变, 但代码换了)
exec 的关键特性:成功就不返回(因为原来的代码已经被替换,没有「原来的地方」可回)。只有失败才返回 -1(比如文件不存在、没权限)——所以 exec 之后的代码,通常是错误处理。
子进程跑完 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 怎么执行一条命令」。
这一节最深的洞见,是回答「为什么 Unix 要把 fork 和 exec 拆成两步,而不是合成一个 create_process_and_run(ls)」。
答案藏在那段「黄金窗口」里:fork 之后、exec 之前的子进程,仍然是 Shell 的副本,可以随意改造。Shell 正是利用这段窗口,在子进程里做这些事:
dup2 把 stdout(文件描述符 1)改成某个文件,然后再 exec。这样 ls > out.txt 就生效了——ls 一启动,它的 stdout 已经指向了 out.txt。PATH、设环境变量,exec 后的程序继承这个环境。setuid 切换用户身份。如果 fork 和 exec 是一个原子操作,这些改造就没地方做——你没法在「创建进程」和「跑程序」之间插手。Unix 的设计者把这两步分离,留出了这段窗口,使得 Shell 能用统一的机制(文件描述符操作)实现重定向、管道、环境等所有「命令修饰」功能。这是 Unix 设计哲学「提供机制而非策略」的经典体现。
理解了这点,你就明白为什么第 05 节(管道与重定向)要放在 fork+exec 之后——它们正是利用这段窗口实现的。
fork 复制进程、exec 在子进程替换程序、wait 父进程收退出码。if (pid==0) 区分父子。ls、pwd、echo 能在你的 Shell 里跑起来。这是第一个里程碑——约五十行代码就能达成。ls 时,你的 exec 要去 PATH 列出的目录里找 ls 可执行文件(用 execvp 会自动帮你做这步)。$?,为第 07 节的 &&/|| 做铺垫。下一节是本章最巧妙的部分:管道与重定向。它正是利用本节讲的「fork 后、exec 前的黄金窗口」,用 dup2 系统调用做文件描述符的接线——一旦你理解了 dup2,ls | grep | wc 和 ls > out.txt 的神秘感就彻底消失了。