第 2 章 · 05 管道与重定向:文件描述符的接线 本节摘要:这一节是造 Shell 里最巧妙、也最有「顿悟感」的部分。很多新手觉得 和 是某种「Shell 魔法」,但当你理解了 这个系统调用,会发现它们的本质出奇地简单:Shell 在 fork 出子进程后、exec 之前,把子进程的 stdin/stdout(文件描述符 0/1)用 dup2 换成某个文件或管道的句柄。就这么一改,exec 后的程序完全不知道发生了什么,它照常往 stdout 写——但写的已经不再是屏幕,而是文件或管道。本节导读重定向( / / )与管道( )的实现机制,以及它背后那个深刻的思想:文件描述符是「可替换的插头」,程序只管读写插头,插头接的是什么由 Shell 决定。
本节摘要:这一节是造 Shell 里最巧妙、也最有「顿悟感」的部分。很多新手觉得
ls | grep foo和ls > out.txt是某种「Shell 魔法」,但当你理解了dup2这个系统调用,会发现它们的本质出奇地简单:Shell 在 fork 出子进程后、exec 之前,把子进程的 stdin/stdout(文件描述符 0/1)用 dup2 换成某个文件或管道的句柄。就这么一改,exec 后的程序完全不知道发生了什么,它照常往 stdout 写——但写的已经不再是屏幕,而是文件或管道。本节导读重定向(>/</>>)与管道(|)的实现机制,以及它背后那个深刻的思想:文件描述符是「可替换的插头」,程序只管读写插头,插头接的是什么由 Shell 决定。这个思想不只造 Shell 用得到——它是整个 Unix 「一切皆文件描述符」哲学的实现基石。
内容来源:基于原索引「Build your own X」Shell 域相关条目整理的导读,原始教程为外部资源。
阅读完本节,你应当能够:
dup2(oldfd, newfd) 干的事:把 newfd 这个位置换成 oldfd 指向的同一个文件/管道。> 重定向:fork 后,在子进程用 dup2 把 fd 1 换成打开的目标文件,再 exec。< 重定向:同理,把 fd 0 换成输入文件。A | B:用 pipe() 建一对 fd,fork 两个子进程,A 的 stdout 接管道写端、B 的 stdin 接管道读端。重定向和管道表面上是 Shell 的两个功能,但它们揭示的,是 Unix 最深刻的一个设计哲学:程序不需要知道它的输入输出接在哪里。
一个程序被写出来时,它的代码是「从 fd 0 读、往 fd 1 写」——它根本不关心 fd 0 接的是键盘、是文件、还是另一个程序的输出管道。「接的是什么」这件事,由启动它的 Shell 决定。Shell 在 fork 之后、exec 之前,把 fd 0/1 换成合适的句柄,程序 exec 启动后就「天然地」从新句柄读写。
这就是 Unix 的「机制与策略分离」:dup2 与 pipe 提供了「替换 fd」的机制(策略无关),至于「换成文件还是管道」是 Shell 的策略选择。程序、Shell、内核三者解耦,使得同一个程序(grep、sort、wc...)能灵活地接在流水线的任何位置——这正是《Linux 命令》第 3 章讲的「管道组合的工程美学」得以成立的根基。
理解这点,你不仅会造 Shell 的管道和重定向,更会理解「为什么 Unix 工具能组合出如此强大的能力」——因为每个工具都被设计成「只管读写 fd,不管接的是什么」,组合的自由度由此而来。
每个进程都有一张「文件描述符表」,记录它打开了哪些文件/管道/套接字。表的下标就是 fd 整数。约定俗成的前三项是:
程序里的「往 stdout 写」就是「往 fd 1 写」,它根本不知道 fd 1 实际指向什么(屏幕?文件?管道?)。这正是可替换性的根源。
dup2(oldfd, newfd) 的作用是:把 newfd 这个位置,换成 oldfd 指向的同一个对象。之后,newfd 和 oldfd 指向同一个文件/管道,写 newfd 等于写 oldfd。
调用前: fd 1 -> [屏幕] fd 3 -> [文件 out.txt] (刚 open 出来的) 调用 dup2(3, 1): fd 1 -> [文件 out.txt] (1 被换成指向 out.txt) fd 3 -> [文件 out.txt] (两个 fd 指向同一文件) 此后程序往 fd 1 写, 实际写进了 out.txt
💡 学习建议:dup2 的语义用一句话记:「把
newfd改成oldfd的别名」。记住这点,重定向的实现就豁然开朗。
> 的实现实现 ls > out.txt:
1. 解析: 命令是 ls, stdout 要重定向到 out.txt 2. open("out.txt", 写模式) -> fd 3 3. fork() -> 子进程 4. 在子进程: dup2(3, 1) // 把 fd 1 换成 fd 3(指向 out.txt) close(3) // 关掉多余的 3, 程序只需用 1 exec("ls") // ls 启动后, 它的 stdout(fd 1) 已经指向 out.txt 5. 在父进程: close(3), wait(子进程)
ls 启动后,它照常往 stdout(它以为是屏幕)写——但 fd 1 已经被换成 out.txt,所以输出进了文件。ls 自己完全不知道发生了重定向。
<(输入重定向)同理,只是把 fd 0 换成输入文件;>> 同理,只是 open 时用「追加」模式。
A | B 的实现管道稍复杂,因为它要协调两个子进程。核心是 pipe() 系统调用:它创建一对 fd,一端写、一端读,写到写端的数据能从读端读出来。
实现 A | B(比如 ls | grep foo):
1. pipe(fds) -> 得到 fds[0](读端)、fds[1](写端) 2. fork() -> 子进程 1(跑 A) 3. 在子进程 1: dup2(fds[1], 1) // A 的 stdout 指向管道写端 close(fds[0]); close(fds[1]) exec("A") // A 启动后, 往 stdout 写就是往管道写 4. fork() -> 子进程 2(跑 B) 5. 在子进程 2: dup2(fds[0], 0) // B 的 stdin 指向管道读端 close(fds[0]); close(fds[1]) exec("B") // B 启动后, 从 stdin 读就是从管道读 6. 在父进程: close(fds[0]); close(fds[1]) // 父进程必须关掉两端! wait(子进程 2) // 等最后一个进程结束
⚠️ 难点预警:父进程必须 close 掉管道两端,这点极易遗漏,后果是「B 永远读不到 EOF」。原因:管道的「读到 EOF」是「所有写端都关闭」;如果父进程还持有写端 fd,B 读到管道空时不会收到 EOF(因为它知道还有写端开着),于是阻塞等待——死锁。这是管道实现最常见的坑。
A | B | C理解了两级,多级就是「每个命令 fork 一个子进程,相邻两个用一对 pipe 连」,形成一条链。这是一条共享缓冲区的流水线:A 写、B 读 A 写的、B 再写、C 读——所有进程并发跑,靠管道的缓冲与阻塞同步。
本节的实现可以分三步递进,每一步都有清晰的里程碑:
第一步:实现 > 重定向。这是最简单的,只需 fork + dup2 + exec。验证:ls > out.txt 能把 ls 输出写进文件。这步约二十行代码,是对「dup2 接线」的最小验证。
第二步:实现 < 重定向。把 fd 0 换成输入文件。验证:wc < in.txt 能从文件读。这步与第一步几乎对称,巩固 dup2 的理解。
第三步:实现 A | B 两级管道。引入 pipe(),fork 两个子进程,各自 dup2 接管道的一端。验证:ls | grep foo、cat file | wc -l。关键记得父进程 close 管道两端,否则死锁。
第四步(进阶):多级管道 A | B | C。把第三步泛化成「解析出 N 个管道段,每段一个子进程,相邻段用一对 pipe 连」。这步让你的 Shell 真正能跑《Linux 命令》第 3 章那些「五连管道」。
完成这四步,你的 Shell 就具备了核心能力:能跑外部命令、能重定向、能管道。这已经是一个「真正能用」的小 Shell 了——剩下的作业控制(后台/挂起)和脚本能力是锦上添花。
dup2(oldfd, newfd) 把 newfd 换成 oldfd 的别名——重定向与管道都靠它实现。> 的实现:fork 后,子进程用 dup2 把 fd 1 换成目标文件,再 exec;程序对重定向毫无感知。A | B 的实现:pipe() 建一对 fd,fork 两个子进程,A 的 stdout 接写端、B 的 stdin 接读端。dup2/pipe 提供机制,Shell 决定策略,程序只管读写 fd。这是「工具能灵活组合」的根基。> 重定向(最简单):fork + open + dup2 + exec,验证 ls > out.txt。< 重定向(对称练习):巩固 dup2 理解。>> 追加重定向:open 用追加模式,理解 > 与 >> 的差别只在 open 标志。A | B:引入 pipe(),关键是父进程要 close 管道两端。A | B | C:解析出多个管道段,链式连接。cat log | grep error | wc -l,这是本节的「毕业考试」。下一节(作业控制)讲 Shell 如何用信号管理后台进程(&)与挂起(Ctrl+Z)——它依赖本节和上一节的进程模型,把「前台阻塞等一条命令」扩展为「能同时管多个进程」。这是造 Shell 从「能用」迈向「好用」的关键一步。