如果只能给刚接触命令行的人讲透一个核心概念,我会毫不犹豫地选标题里这三样:重定向、管道、以及标准输入标准输出。我见过不少人背熟了>和>>的区别,一遇到2>&1、| tee、<(command)就卡住;也见过有同事在脚本里写完管道链,调试一上午才发现变量没赋值成功,问题不在逻辑,而在管道子 Shell。这套机制不像面向对象的语法需要背类库,它更像一组底层管道阀门——搞懂 0、1、2 三个数字背后代表的数据通道,后面所有命令组合都一通百通。这篇内容适合刚入门的初学者打基础,也适合写过几周脚本但总是"哪里不对"的人查漏补缺,我会尽量按我们实际使用时的思路讲,而不是教科书的条目罗列。
1. 先把 0、1、2 三个文件描述符讲清楚:标准输入输出不只是"键盘和屏幕"
1.1 程序启动时自动拿到三根"管线"
在操作系统的视角里,任何进程跑起来,第一件事不是执行代码,而是先准备好三个默认打开的文件描述符(File Descriptor)。Shell 每执行一条命令,都会向内核申请启动一个新进程,同时给这个进程挂上通道:0 号通道是标准输入,默认接在键盘;1 号通道是标准输出,默认连到终端屏幕;2 号通道是标准错误,也默认连到终端屏幕。
可以这么理解:0、1、2 是程序出生时手里就攥着的三根吸管。0 是"往嘴里吸什么",1 是"正常内容往哪吐",2 是"报错内容往哪吐"。当你在终端里敲ls /tmp的时候,Shell 干了两件事:用 fork/exec 把ls这个进程拉起来,同时把默认的三根吸管插好。ls自己甚至不需要知道用户是谁、终端是哪个设备,它只管往 1 号吸管里写正常文件名列表,往 2 号吸管里写"没有权限"之类的报错信息,至于这些吸管的另一端通向哪里,那是 Shell 在启动它之前就安排好的。
理解这个模型的意义在于:重定向和管道做的事情,本质上都不是命令本身的功能。>、<、|这些符号是由 Shell 解释执行的,它们的作用是在命令真正启动之前,把某根吸管的另一端从默认位置换到文件、设备或者另一条命令的输入口。这个认知一旦建立,后面所有诡异的写法你都能自己推出来。
1.2 三条通道分道扬镳,不是多此一举
很多人第一次接触时会有疑问:既然 1 和 2 都默认显示到屏幕上,那分两个通道意义何在?直接都用一个通道不就行了吗?
答案是:正常输出和错误输出混在一起,会导致数据污染。设想一个场景,你执行find / -name "*.conf",这台机器上有些目录确实没有权限访问。如果所有提示都混在一根管道里,当你用管道把结果交给grep做筛选时,那些"权限不足"的提示也会混进结果里,后续处理的文本就脏了。分开之后,正常文件路径走 stdout,错误提示走 stderr,你可以只处理一边,也可以把两边合流后统一处理,主动权完全在自己手上。
随手在终端里试一下:先正常执行一个能列出的目录,再故意搭配一个不存在的路径,比如ls /tmp /nonexistent。你会看到,/tmp下的文件列表和/nonexistent的报错几乎同时出现在屏幕上,看起来完全一样。但如果用重定向把它们分别装进两个文件,就能看出区别:
ls /tmp /nonexistent > out.txt 2> err.txt cat out.txt # 只有正常列表 cat err.txt # 只有错误信息我当初学到这里时特意去/proc下看了一眼当前的进程描述符,原以为会看到很玄的东西,结果发现就是一串符号链接指向终端设备或文件。每个进程在/proc/<pid>/fd目录下都有一份这样的清单,你可以执行ls -l /proc/$$/fd看看当前这个 Shell 进程默认拿了哪些通道。看过真实文件结构之后,再回头看运算符就踏实得多。
1.3 被忽略的 /dev/null 和各路特殊节点
理解了 0、1、2 之后,终端世界里另一个高频出现的"黑洞"就顺理成章了——/dev/null。它本质上也是一个文件描述符可以指向的对象,只是写入它的内容会被直接丢弃。比如你只想看脚本有没有报错,而不想看正常日志,可以command 2> /tmp/error.log 1> /dev/null;如果你连错误都不想留存,就直接2> /dev/null。把某个通道丢进黑洞,并不是什么魔法,只是给吸管换了个终点。
与之类似的特殊设备还有很多:/dev/tty会直接打开当前进程所关联的终端,/dev/zero可以产生无限多的空字节,/dev/urandom是随机数源。在重定向的世界里,它们都和普通文件同等对待,这恰恰是 Unix 的经典哲学——一切皆文件。你在表达式里写2> /dev/null和写2> /tmp/log,机制完全一样,冲突只是终点不同。
2. 重定向的各种符号,本质是在重排文件描述符的指向
2.1 四类基础型号:覆盖、追加、输入、错误
现在有一个清晰结论:标准输出和标准错误是两根独立的管,重定向符号就是"拔管再插管"的操作。最基础的四个组合需要熟练掌握:
| 写法 | 含义 | 注意点 |
|---|---|---|
> file | 把命令的标准输出重定向到 file | 以覆盖模式打开,会清空原有内容 |
>> file | 追加输出到 file 尾部 | 不清空原内容,不存在则新建 |
< file | 把 file 的内容作为命令的标准输入 | 让命令从文件里读数据,而不是键盘 |
2> file | 把标准错误重定向到 file | 仅影响 2 号通道,stdout 不动 |
举个例子,wc -l < /var/log/syslog的意思是让wc从日志文件里读数据并统计行数,和wc -l /var/log/syslog的效果接近,但读取方式不同。前者是 shell 把文件以只读方式打开后接到 0 号口上,后者是wc自己打开文件;前者更符合"统一由 shell 管理数据源"的思路,在后续配合管道、循环时很有用。
很多教程平时写> file都省略了前面的数字 1,因为 1 是默认的。同理,2> file必须写数字,否则 shell 无法区分你想重定向哪根管。至于>file和2>file到底会不会互相覆盖,取决于你的写法:cmd > file 2> file在较新的 Bash 版本里会用两个独立打开的文件描述符分别打开 file,可能相互覆盖;而cmd &>file或者cmd >file 2>&1才会让两个通道真正指向同一个文件描述。
2.2 2>&1、&> 的细节,以及顺序为什么能改变结果
关于2>&1的说法网上很多,但值得单独拎出来讲透。2>&1不是"把 stderr 合并到 stdout 里",它的准确含义是:"把第 2 号文件描述符,指向当前第 1 号文件描述符所指向的目标"。这句话的重音应该落在"当前"两个字上,因为 shell 解析重定向参数是按照从左到右的顺序执行的,每一步都在改变某个描述符的指向。
来看一个经典对比:
command > file 2>&1 command 2>&1 > file第一行的执行顺序是:先把 stdout 指向 file,然后把 stderr 指向"当前 stdout 所指向的位置",于是 stderr 也到了 file。第二行则完全相反:先把 stderr 指向"当前 stdout 所指向的终端",再把 stdout 指向 file,结果 stderr 依然留在终端,只有 stdout 去了 file。
我后来养成了一个习惯:凡是需要同时捕获正常输出和错误输出的场景,优先写&> file,因为它一步到位,根本不给顺序出错的机会。需要兼容旧脚本时再写> file 2>&1。至于2>&1的顺序错误,是面试题里常考、实际调试中也特别容易出现的一个坑,后面专门讲。
除了这两个,还有一对容易被忽略的写法:>&file和&>file在 Bash 里都表示同时重定向 stdout 和 stderr,但写法略有差异。>& file传统上表示把 stdout 重定向到 file 后再复制给 stderr,&> file是 Bash 提供的简写。这两种情况都比写一堆2>&1直观,但如果需要更精细地控制通道,还是老老实实分开写。
2.3 输入重定向的高级形态:heredoc 和 here-string
< file够用了,但有些时候数据源不是文件,而是你要在命令行里直接写的一大段文本,或者一个变量里的字符串。这时就该 heredoc 出场了。我用得最多的场景是给某段配置内容批量写入文件:
cat > /tmp/某服务的配置.conf <<EOF server { port 8080 name "某应用" } EOF这段命令里,<<EOF告诉 shell:接下来从这里到下一个单独成行的EOF为止,所有内容都作为 stdin 输入给cat。cat再把 stdin 原样写入> /tmp/某服务的配置.conf。比起手写echo拼字符串,这种写法在生成多行配置时干净得多。
heredoc 里还有一个容易出细节的坑:如果定界符没加引号,内容中的变量和命令替换会先被 shell 展开。假如你想把配置里的$PATH原样写入文件而不被解析成环境变量,就需要写成<<'EOF'。只差一对引号,结果差异经常让新手摸不着头脑。
bin here-string 的三元胞瓶盖<<<则更轻量,它把一个字符串或变量的值作为标准输入传给命令。example:grep "error" <<< "$log"。这比echo "$log" | grep "error"省了一条管道,更重要的是它不会触发管道子 shell 的变量丢失问题,还在后面排坑篇里继续展开。
2.4 重定向的解析时机:命令之前、之中、之后都有讲究
还有一个经常被忽略的事实:重定向不是命令执行过程中的操作,而是 shell 启动命令之前的初始化。换句话说,> file这段表达式在命令的 argv 传参之前就已经被执行了。这一点直接导致了一个大坑:command > file一旦敲下去,file 会立刻被创建并截断为空文件,哪怕 command 执行了一毫秒就报错退出,file 也已经被清空了。
这也解释了为什么sort file > file这种"就地修改"看起来有道理,实际跑完 file 会变成空文件。Shell 先把输出文件截断成 0 字节,然后sort才打开同一个路径读取数据,这时候它读到的已经是被清空的文件了。正确的就地修改姿势是输出到临时文件再覆盖:
sort file > file.tmp && mv file.tmp file稳妥的做法是把临时文件写在同目录下,确保 mv 是在同一文件系统内办事情。很多命令自己提供的-o选项会在这个思路上进一步封装,内部处理好临时文件和替换,这就是另一个话题了。
3. 管道把命令的输出接进另一条命令的输入,但没接管错误输出
3.1 管道传输的到底是什么
管道符|是命令行里最具生产力的设计之一。它的底层机制是:shell 创建一段内核匿名管道(pipe),把左侧命令的 1 号文件描述符连接到管道写端,把右侧命令的 0 号文件描述符连接到管道读端。两边都是普通命令,它们只需要按照"读到 stdin、写出 stdout"的契约工作,不需要知道对方存在。
和人们第一直觉不同的是,管道两侧的命令是同时运行、互相同步的,不是左边跑完了再启动右边。拿最朴素的例子验证:tail -f /var/log/某应用/app.log | grep "ERROR"。tail -f会持续等待新日志产生,只要有一行新内容写入文件,它就送给管道,grep立刻处理一次。如果按"左边的命令执行完再交给右边"的理解,这个命令永远不会输出任何内容,因为左侧根本没有执行完的那一天。
管道两端的同步本质上是一种反压机制:读取方消费速度慢了,写入方写到一定程度就会阻塞等待,不会无限制往内存里塞数据。管道缓冲区通常几十 KB 量级,一旦写满,写端进程挂起,等读端消费了一些才继续。这种设计保证整个系统不会被一次大数据爆发打死。反过来,如果读端进程提前退出(比如yes | head -n 1中的head只取一行就结束),写入方继续写时会收到 SIGPIPE 信号,默认直接终止退出,这也解释了为什么运行某些管道命令时终端里偶尔会冒出一句" Broken pipe"或者"No such pipe"之类的提示。
3.2 管道与重定向的本质差异
既然提了管道和重定向常被相提并论,就得把它们之间的关系讲清楚。粗看两者都改变了数据的流向,但抽象的层级不同:重定向的终点是"文件描述符指向的对象",通常是一个文件、设备节点或者另一个文件描述符的拷贝;管道的终点是"另一个进程的输入口"。换个说法:>把数据流收进一个文件,文件不会运行;|把数据流直接喂给另一个程序去处理,程序会继续产生新的输出。
实际使用中二者不是对立关系,而是经常嵌套。cmd1 | cmd2 > out.txt中 cmd1 的输出经管道给 cmd2,cmd2 的输出再重定向到 out.txt;cmd1 2>&1 | cmd2中把 cmd1 的 stdout 和 stderr 都喂给 cmd2。写命令行的时候,心里始终要有一张"数据流地图":从哪个命令的哪个通道出发,经过什么符号,到达哪个目标。一旦能画出这张地图,管道与重定向怎么组合都不会乱。
3.3 多段管道与工具链:一次典型的数据流追踪
学习管道最好的方法不是背语法,而是拆解一条真实用到多个命令的管道链。我常用的一条统计命令是:
history | awk '{print $2}' | sort | uniq -c | sort -nr | head -n 10第一段history输出全部历史记录;awk '{print $2}'取出每条记录的命令部分,比如ls、cd;sort把命令名相同的排在一起;uniq -c统计每类命令出现的次数;再sort -nr按出现次数倒序排;最后head -n 10只展示前十条。整条链路上每一步都只做一件小而确定的事,前一步的输出交给下一步的输入,中间不需要任何临时文件。
管道链参与调试时还有一个特别重要的隐藏问题:整条链的退出状态默认只看最后一条命令。也就是说,grep "somepattern" file | sort如果 grep 没能匹配到任何行返回状态 1,但 sort 正常运行返回状态 0,这个管道的整体退出状态仍然是 0。在自动化脚本里,这可能导致你误以为匹配成功。要解决这个问题,可以在脚本开头set -o pipefail,让管道中任何一条命令失败都导致整条管道失败;或者用echo ${PIPESTATUS[@]}在不同阶段逐个检查每段命令的返回值。这两个选项的价值在定时任务脚本里体现得尤其明显,避免"明明 grep 失败了脚本却继续往下走"的隐性问题。
3.4 管道侧身而过的 stderr 与管道子 Shell
管道只管连接左侧的 stdout 到右侧的 stdin,对于左侧的 stderr 是视而不见的。这个特性本身不是 bug,但实际使用中经常造成困惑。例如某条命令权威的输出是正常的文件内容,错误信息却单独走 stderr 显示到屏幕上;你用command | grep "error"去捕获错误文本时,发现 grep 什么也没有等到,因为错误文本根本没走管道。此刻必须明确地command 2>&1 | grep "error"把 stderr 并进 stdout 才能捞到。
另外一个比 stderr 更难察觉的是管道子 shell。Bash 中,管道里的命令默认运行在当前的子 shell 进程里。这意味着在管道左侧或右侧给变量赋的值,在管道结束后不会保留到当前 shell。最典型的例子:
cat file | while read line; do count=$((count + 1)) done echo "$count" # 打印 0,因为 while 里的 count 在子 shell 里改了这不是逻辑问题,而是进程边界问题:$count 的修改发生在子 shell 里,父 shell 看到的是自己的那份 count。这个问题我后面专门用一个小节展开讲,因为它的解法会牵扯到进程替换和重定向的变通,属于管道部分最值得掌握的实用技能。
4. 实测最易翻车的四种组合场景,完整排查过程与解法
4.1 场景一:2>&1 写错位置,错误日志神秘消失
第一次踩这个坑的人不在少数。设想你写了这样一行命令:
cmd 2>&1 > /tmp/out.log你本意是想把 stdout 和 stderr 都收集到日志里,但运行完之后,打开/tmp/out.log,发现只有正常输出,错误信息依然战在终端上。我当时排查这个问题花了大约二十分钟,还以为是命令没把错误写到 stderr 而是直接输出到了终端设备,回头查看 shell 的解析过程才反应过来。
上面已经解释过原因:shell 从左到右执行重定向,2>&1把 stderr 指向了当时 stdout 指向的位置——也就是默认终端屏幕;随后才执行> /tmp/out.log,把 stdout 换到了日志文件。于是 stderr 始终停留屏幕,stdout 去了文件。修正方法只有两个:把2>&1写在最后,或者直接用&> /tmp/out.log。我后来再写这类命令,都习惯在使用任何通道之前先决定 stdout 的去向,然后把 stderr 复制过去。
4.2 场景二:管道后变量值消失,绕不开子 Shell 进程边界
管道子 shell 的变量丢失问题非常隐蔽,因为代码看起来完全没毛病。某个脚本里,我需要统计某应用日志中出现 ERROR 主某个关键字的模块分别是多少个模块。一开始我这样写:
grep "ERROR" 某应用.log | while read -r line; do if [[ "$line" == *"模块A"* ]]; then ((a_count++)) elif [[ "$line" == *"模块B"* ]]; then ((b_count++)) fi done echo "moduleA=$a_count moduleB=$b_count"结果不管日志里有几万行,echo输出的两个计数始终是 0。当时第一反应是grep的匹配模式有问题,单独跑了grep "ERROR" 某应用.log确认字符确实有 —— 这下更诡异了。index 到 Bash 文档才想起:管道中的while循环运行在子 shell 里,循环里修改的a_count和b_count在父 shell 中根本不存在,父 shell 打印的还是自己那份初始值 0。
解法有三种,大家可以根据情况选择:
- 把
cat file | while改成while ... done < file,让重定向而不是管道把文件内容喂给循环。因为重定向在当前 shell 执行,不引入新的子 shell,循环内的变量改动就能保留。 - 如果左侧是某个命令的输出而不是文件,可以用进程替换:
while ... done < <(grep "ERROR" 某应用.log),这个写法后面专门讲,它同样不会生成子 shell。 - 实在必须用管道的时候,把计算结果写入临时文件,循环结束后再读回变量。
我最后的常规做法是,任何涉及变量累积的循环都尽量避免管道,而用输入重定向或进程替换。慢慢地这就变成了习惯,也让脚本被 bash -x 调试时更顺畅。
4.3 场景三:重定向把原文件清空,代码没问题但数据没了
我遇到过一次挺惨不过也很有教学意义的案例:某个同事手动整理数据时,在临时服务器上执行了grep "error" 某大文件.log > 同一个大文件.log。他本意是把不包含 error 的行筛出来,覆盖写回原文件。结果命令一运行,大文件立刻变成空文件,所有数据全部清零。
原因就是重定向发生的时机早于命令本身。>打开输出文件时会先截断它,而这条命令的输入文件恰好也是同一个路径,导致 grep 打开输入时文件已经被清空,什么都读不到了。这类错误的特征是:命令的反馈一切正常,但是文件内容没了。
从这个案例延伸出的通用原则就是:永远不要对同一个文件做"读它写它"的操作,哪怕命令本身看起来支持也不行。除非像sort -o file file那样,命令自行内部处理了临时文件的替换。手动操作时,更稳的做法是先输出到临时文件,然后单独用mv覆盖回去。数据无价,这一行习惯值得养成。
4.4 场景四:管道里的命令有缓冲,输出迟迟不出现
有个常见的困惑:实时查看某服务的日志时,敲了tail -f 某服务.log | grep "ERROR",但是屏幕上很长一段时间什么都看不到,直到积累了一大堆然后一次性刷出来。我当时以为是 tail 的输出有问题,后来定位到是 grep 自身做了缓冲。
标准库的缓冲策略和连接目标有关系:当 stdout 连接终端时,多以行缓冲方式工作——每输出一个换行就把缓冲刷新一次;当 stdout 连接的不是终端(比如管道)时,就变成全缓冲——攒够 4KB 或 8KB 才刷新一次。grep "ERROR"中 grep 的输出进入了下一个管道,而他又可能被管道喂给下一个命令,于是它选择全缓冲,在数据量不足时不会驱动输出。实时监控时,数据可能每小时只有几百字节,于是你就只能干等很久才能看到一批结果。
解决办法是按照数据量选择命令。GNU grep 支持--line-buffered;awk 可以用stdbuf -oL awk或者写成awk '{print; fflush()}'。其他像 sort、gzip 这类不可能做行缓冲的命令,最好就避免实时场景。我写实时监控管道时,一般会在所有应该"拿到一行就输出一行"的命令后面补上对应的缓冲参数,能少踩很多坑。
5. 再看两个能跟重定向、管道协同的进阶机制:进程替换与FIFO
5.1 进程替换:把命令输出伪装成"文件"
进程替换是把命令的 stdout 包装成一个虚拟文件的写法,形式是<(...)或>(...)。在 Bash 中处理这种替换时,shell 会创建一个文件描述符对应的路径(通常在/dev/fd/N下),并把进程的输出送到这个"伪文件"上。外围命令读取这个路径时,实际就是读取该进程的输出。
比较典型的应用场景是 diff 两份命令输出:
diff <(cat 某应用1.log | sort) <(cat 某应用2.log | sort)如果不用进程替换,你得先把两个排序结果写进两个临时文件,再比较,最后清理。进程替换让整件事一步到位,逻辑清晰得很。而且这个写法的本质是重定向:它避免了你手动创建文件、管理生命周期这一整套繁琐操作。
进程替换还有一个高技术价值的用途是解决上面提到的管道子 shell 变量问题。用while ... done < <(command)以后,循环体不需要进入管道,而是在当前 shell 执行,变量修改自然可以保留。这和while ... done < file是同样的原理,只是数据源从文件换成了另一个命令的输出。
5.2 FIFO:跨终端共享数据流的匿名管道扩展版
FIFO 也叫命名管道,文件系统里有一个类似文件名的节点,但它本身不存数据,数据依然只在内核的管道缓冲区里流动。创建方式用一个命令:
mkfifo /tmp/某数据管道之后,一端写入、一端读取,两边才能真正让数据流过。比如在终端 A 执行cat /tmp/某数据管道,终端 B 执行echo "hello" > /tmp/某数据管道。你会看到终端 A 打印出 hello,终端 B 的命令也顺利结束。这让人直观感受到匿名管道和命名管道的差别:匿名管道必须由同一个 shell 创建,连接权限绑定在创建进程里,其他终端很难介入;FIFO 则给数据流起了一个文件名,谁都可以参与。
FIFO 与重定向天然兼容,因为> /tmp/某数据管道本质上也是把 stdout 指向这个管道节点。例如我想把某服务的滚动日志实时递给另一个终端上的命令处理,可以在终端 A 执行mkfifo /tmp/某数据管道,然后tail -f 某服务.log > /tmp/某数据管道;终端 B 执行grep "ERROR" < /tmp/某数据管道。两边只要同时打开这个 FIFO,数据就会持续流动。
5.3 FIFO 的阻塞行为与清理细节
FIFO 有一个典型的行为特征容易被忽略:打开方式是阻塞式同步的。写端在没有任何读端打开的情况下向 FIFO 写入,会一直阻塞,直到有读端出现;读端也一样,如果没有写端,open 的时候可能直接挂起等待。这个行为让 FIFO 很像"两个进程之间的握手协议",方法放在自动脚本里要注意避免因为缺少一端而导致整个命令卡死。
我自己使用 FIFO 时的另一个习惯是及时清理节点。FIFO 在文件系统里是一个正常文件,如果忘了删,重新运行时通常会遇到"文件已存在"的提示,而且它并不像真实文件那样保存历史数据,打开后读到的只有后续写入的内容。所以每次用完,我会顺手rm /tmp/某数据管道,再配合 trap 在脚本退出时做清理,避免留下零字节的孤儿节点。
6. 一个日志实时处理组合的完整拆解与我的使用习惯
针对这三者组合的练习题,我喜欢用下面的例子:部署完某应用后,想实时监控错误日志,同时把错误行完整保存下来,还希望顺带统计每种错误出现的高频。场景很典型,组合起来的命令是这样的:
tail -F /var/log/某应用/app.log 2>&1 | \ grep --line-buffered -iE "error|exception|失败" | \ tee /var/log/某应用/error_trace.log | \ awk '{print $0; freq[$NF]++} END {for (k in freq) print "count["k"]="freq[k]}'我拆开讲讲每段为什么这么写:
tail -F会持续追踪文件,即使文件被轮转也能重新打开。2>&1放在这里是为了把 tail 自身可能产生的提示(例如文件被轮转)也并入管道,避免它们在终端上随意冒出来。grep --line-buffered前面才讲过的关键 buffering 设置,没有它实时性会很差,随时卡上半天。tee把 grep 筛选后的行同时写入 error_trace.log,并在 stdout 上继续传递,实现一对多分流,既存档又不打断数据处理。- 最后的
awk一边把完整行原样打印,一边用每行最后一个字段统计错误类型出现频率,结束时汇总输出。
运行时你会看到终端同时滚动着匹配出的错误行,等信号中断后还能看到 awk 汇总的频率结果。如果你想进一步把统计结果保存下来,可以在末尾再加> /tmp/频率.txt 2>&1;如果不需要同步存档,直接去掉tee即可。这整条命令没有使用任何复杂工具,但把重定向、管道、标准输入输出全部串在了一起。
我自己的使用习惯是,凡是超过两三个环节的数据流处理,都会先在头脑里画一张"数据流地图",才敢放心执行。脚本里则统一开启set -o pipefail,因为默认的管道退出状态太容易掩盖中间环节的失败。若是在排查问题,我会用bash -x打开脚本的逐行跟踪,再结合echo "${PIPESTATUS[@]}"检查每个阶段的返回码,而不是盲目改代码。
整套机制学完之后,最大的感受是:重定向不是背命令,管道也不是靠经验堆起来的。只要把 0、1、2 三个文件描述符想清楚,把"谁在往哪根通道里写、谁在读哪根通道"这件事画明白,终端里大多数神秘行为都变得可预期了。遇到奇怪的 shell 问题,倒退回这一层做推理,通常很快能找到真相。