2198 words
11 minutes
Shell 心智模型·炼气篇(二):从 cat | grep | wc -l 严丝合缝说起,把管道 | 一次讲透

命令行为什么能这么灵活地”拼积木”?cat access.log | grep "ERROR" | wc -l 这样一行命令,三个程序严丝合缝地接在一起,没有一行胶水代码——这篇要讲透的,就是 | 这个符号到底是怎么把它们串起来的。

不过在讲管道本身之前,得先打好一层地基:每个程序天生自带的三根”数据管子”——stdin、stdout、stderr。管道说到底,操作的就是这三根管子,不先弄懂它们,管道的行为没法真正说清楚。

一、地基:标准输入/输出/错误是什么#

每个命令行程序默认连着三条”数据管道”:

  • 标准输入(stdin)——程序读取数据的地方,默认情况下是你的键盘。
  • 标准输出(stdout)——程序打印结果的地方,默认情况下是你的终端屏幕。
  • 标准错误(stderr)——专门用来打印错误信息的通道,默认也输出到屏幕。

举个例子,你在终端里敲 cat,然后开始打字,它会把你打的每一行原样重新打印出来——因为 cat 没收到文件参数时,就会从标准输入(也就是键盘)读取内容,再打印到标准输出(屏幕)。

有了这三根管子的概念,重定向和管道要做的事,其实就是”把这三根管子里的一根,改接到别处”——重定向接的是文件,管道接的是另一个程序。

二、重定向:改变数据的”去处”#

Terminal window
echo "hello" > file.txt

这里的 > 把标准输出从”打印到屏幕”改成了”写入 file.txt”——文件原有内容会被覆盖。

Terminal window
echo "world" >> file.txt

>> 是追加,不会覆盖,而是接在文件末尾。

Terminal window
sort < file.txt

< 是把标准输入从”读键盘”改成”读文件”——让 sort 去读 file.txt 里的内容,而不是等你手动输入。

三、主角登场:管道 | 到底在做什么#

地基打好了,现在看这篇真正的主角。管道做的事情是:把前一个命令的标准输出,直接接到后一个命令的标准输入。

拆开看开头那条命令链:

Terminal window
cat access.log | grep "ERROR" | wc -l
  1. cat access.log 把文件内容打印到标准输出
  2. 这个输出不是打印到屏幕,而是被 | 接到了 grep "ERROR" 的标准输入
  3. grep "ERROR" 从这些内容里筛出包含 ERROR 的行,打印到它的标准输出
  4. 这个输出又被 | 接到 wc -l 的标准输入
  5. wc -l 数一下总共有多少行,最后打印到屏幕——因为这是链条的最后一环,没有再被重定向

类比一下:可以把它想象成工厂流水线,每个命令是一个工位,| 就是传送带,把上一个工位加工完的”半成品”直接送到下一个工位,不需要你手动搬运(比如先存成文件,再手动读进下一个程序)。

四、一个真实的陷阱:管道接的具体是哪一条?#

上面说”管道把标准输出接到标准输入”——注意,是标准输出,不是”全部输出”。stderr 单独走它自己的路,管道压根不接它。拿一个会同时往两条流写内容的小脚本实测:

#!/bin/bash
echo "这是 stdout 的内容"
echo "这是 stderr 的内容" >&2

只重定向 stdout,stderr 照样冒到终端:

$ ./noisy.sh > out.txt
这是 stderr 的内容 ← 直接冒到屏幕,没被接管
$ cat out.txt
这是 stdout 的内容 ← 文件里只有这一条

很多人第一次这么写,以为”重定向了就该啥都不打印”,结果发现终端还在冒东西——那是因为 stderr 根本不知道 > 这回事,继续老老实实往屏幕写。

管道 | 同理,只连接 stdout,不连接 stderr:

$ ./noisy.sh | cat
这是 stderr 的内容 ← 直接漏到屏幕,没走管道
这是 stdout 的内容 ← 这条才是真的走了 cat

这是最常见的坑之一:很多人以为 | 会把程序的”全部输出”都接给下一个程序,其实管道只连接了 fd1(stdout)到下一个程序的 fd0(stdin),stderr 完全被晾在一边,原样冒到你的终端。所以有时候你写了 some_command | grep xxx,报错信息却还是直接甩在终端上、没被 grep 过滤掉——就是这个原因。

想让 stderr 也走管道,得先用 2>&1 把它并入 stdout:

$ ./noisy.sh 2>&1 | cat
这是 stdout 的内容
这是 stderr 的内容 ← 现在两条都乖乖走了 cat

fd 编号:0、1、2 到底是什么#

标准输入/输出/错误还有官方编号,叫文件描述符(file descriptor,简称 fd):

  • 0 = 标准输入(stdin)
  • 1 = 标准输出(stdout)
  • 2 = 标准错误(stderr)

前面写的 >file 其实是 1>file 的简写——因为重定向默认操作的就是 fd 1(stdout)。2>file 就是显式指定操作 fd 2(stderr)。2>&1 读作”把 fd 2 重定向到 fd 1 当前指向的地方”。

这也回答了一个常见顾虑:管道会不会污染数据?#

比如这行:

Terminal window
grep pattern huge_log.txt | sort | uniq -c

你可能会担心:如果 grep 中途报了个警告(比如某行编码有问题),这条警告会不会混进 stdout,被 sort 当成”数据”处理,把统计结果搅乱?前面已经验证过答案——不会,因为管道天生只接 stdout,警告走的是 stderr,sort 根本看不到它。

那如果你确实想对 stderr 做点什么,具体想要什么效果,分三种情况:

  • 情况 A:只是想让警告照常显示,统计结果也照常输出——什么都不用做,默认行为就是这样。

  • 情况 B:想把 stderr 存下来,方便以后查:

    Terminal window
    grep pattern huge_log.txt 2> errors.log | sort | uniq -c

    警告被存进 errors.log,统计结果照常走管道。

  • 情况 C:想让 stderr 也流进管道,和 stdout 合并成一条(比较少见,调试或者想把所有输出存进单一日志文件时才用):

    Terminal window
    grep pattern huge_log.txt 2>&1 | sort | uniq -c

    这时警告确实会被 sort 当成数据处理——回到了最初担心的那个”污染”问题,所以这个写法通常只在故意想合并两条流时才用,比如 command > all_output.log 2>&1

为什么常常和 > 一起用#

比如这样一行:

Terminal window
cargo test my_test > "$LOGFILE" 2>&1

这行按从左到右执行:

  • > "$LOGFILE" —— 先把标准输出(1)指向 $LOGFILE 这个文件
  • 2>&1 —— 再把标准错误(2)指向”标准输出现在指向的地方”,也就是同一个文件

结果就是:正常输出和报错信息全部一起写进同一个日志文件,事后看日志时能同时看到程序打印了什么、又报了什么错,顺序也是真实执行顺序,不会错乱。

顺序很重要:一个常见坑#

Terminal window
command 2>&1 > file.txt # ❌ 不是你想要的
command > file.txt 2>&1 # ✅ 这才对

为什么反过来就错了?重定向是按从左到右顺序生效的,可以理解成”赋值”:

  • 第一种写法:先执行 2>&1(此时 stdout 还指向屏幕,stderr 也被指向了屏幕),然后才 > file.txt(只把 stdout 改指向文件)。结果 stdout 进了文件,stderr 还是打印在屏幕上——两者没有汇合。
  • 第二种写法:先把 stdout 指向文件,这时 stderr 依然对着屏幕;接着 2>&1 才把 stderr 也指向”stdout 现在指向的地方”,也就是文件。这样两者才真正汇合进同一个文件。

记忆技巧:把 2>&1 想象成”复制当前指向”,而不是”绑定关系”——它只是在那一刻把 2 的指向复制成了 1 当时的指向,之后 1 再变,2 也不会跟着变。

为什么这套设计这么好用#

catgrepwc 这些工具,谁也不用为谁专门写兼容代码,却能随便组合——因为大多数命令行工具都遵循同一个约定:读 stdin、写 stdout。正是这个共同的约定,让工具之间可以自由拼接。这就是 Unix 哲学里”每个程序只做一件事,做好它,然后用管道组合”的核心。

一句话总结#

管道能随便拼,靠的是”读 stdin、写 stdout”这一个共同约定;> >> < 改变的是数据的”去处”,| 改变的是”谁接谁的输出”。stdin/stdout/stderr 背后对应着 fd 0/1/2,> 就是 1> 的简写;管道接的具体是 stdout,不是全部——stderr 单独走自己的路,想让它也进管道得显式 2>&1,而且顺序不能反。