阶段四 · 管道与文本处理

标准输入输出与管道

1. 为什么需要理解流与管道

前几篇学了很多独立命令: ls, cat, find, chmod... 每个命令都能独立完成一件事. 但 Unix 的真正威力不在于单个命令有多强大,而在于把多个小命令组合起来,像搭乐高一样.

想象一个加工流水线: 第一台机器把原材料切割成形,传给第二台打磨,再传给第三台喷漆.
每台机器只做一件事,但连起来就能生产出复杂产品.
管道(pipe)和重定向(redirection)就是这条流水线上的传送带--它们决定数据如何在不同命令之间流转.

掌握流与管道之后,才真正开始 "用 Unix 的方式思考". 这也是接下来 grep / sed / awk 三篇的基础--这三个文本处理工具离开了管道就失去了大半武功.

2. 三个标准流: stdin / stdout / stderr

每个进程(包括 Shell 中运行的每个命令)启动时,操作系统会自动给它 "接通" 三条数据通道:

键盘 →── stdin (0) ──→ 进程(命令) →── stdout (1) ──→ 屏幕
→── stderr (2) ──→ 屏幕
名称 全称 文件描述符 默认流向 用途
stdin standard input 0 键盘 程序读取输入数据的来源
stdout standard output 1 屏幕 程序输出正常结果的地方
stderr standard error 2 屏幕 程序输出错误/诊断信息的地方

2.1 文件描述符(File Descriptor)

0, 1, 2 这三个数字就是文件描述符--操作系统用整数来追踪每个进程打开了哪些文件(在 Unix 中,"一切皆文件",键盘和屏幕也是 "文件"). 0/1/2 这三个号是每个进程的 "标配",由父进程(通常是 Shell)在 fork 时预先设置好.

为什么 stdout 和 stderr 要分开?

想象运行一个命令,正常输出想保存到文件,但错误信息想在屏幕上看到. 如果它们混在同一个通道,就无法分开处理. stderr 的存在让我们可以分别控制 "结果" 和 "诊断" 的去向--这是 Unix 设计中的精妙之处.

2.2 用最简单的例子验证

$ ls
file1.txt file2.txt # 正常输出 → stdout → 屏幕

$ ls nonexistent/
ls: nonexistent/: No such file or directory # 错误输出 → stderr → 屏幕

看起来都是 "打印到屏幕",但它们是两条不同的通道. 接下来学习如何分别控制它们的去向.

3. 输出重定向

重定向的本质是把某个流的默认目标(屏幕)替换成文件.

3.1 覆盖写入: >

$ echo "Hello World" > greeting.txt
$ cat greeting.txt
Hello World

# 再次重定向--会覆盖原有内容!
$ echo "Bonjour" > greeting.txt
$ cat greeting.txt
Bonjour

> 的完整写法是 1>(1 = stdout 的文件描述符),Shell 允许省略这个 1.

3.2 追加写入: >>

$ echo "第一行" >> log.txt
$ echo "第二行" >> log.txt
$ cat log.txt
第一行
第二行

>> 追加到文件末尾,不会覆盖已有内容. 完整写法是 1>>.

3.3 防覆盖保护: set -o noclobber

$ set -o noclobber          # 开启保护
$ echo "test" > greeting.txt
zsh: file exists: greeting.txt # Shell 拒绝覆盖

$ echo "test" >| greeting.txt # 使用 >| 强制覆盖
$ set +o noclobber # 关闭保护

建议在 .zshrc 中加上 set -o noclobber,防止误操作覆盖重要文件.

4. 错误重定向

stderr 的文件描述符是 2,所以错误重定向以 2 开头.

4.1 2>: 让错误单独走

$ ls existing.txt missing.txt > output.txt 2> error.log
$ cat output.txt
existing.txt
$ cat error.log
ls: missing.txt: No such file or directory

标准输出去了 output.txt,错误去了 error.log--互不干扰.

4.2 2>&1: 合并到标准输出

2>&1 的含义是 "让文件描述符 2(stderr)指向文件描述符 1(stdout)当前指向的地方". 这是最经典的合并写法:

# 把 stdout 和 stderr 都写入同一个文件
$ ls existing.txt missing.txt > all.log 2>&1

$ cat all.log
ls: missing.txt: No such file or directory
existing.txt

顺序很重要--2>&1 必须在 > 之后

ls > file 2>&1 -- 正确: 先把 stdout 指向 file,再把 stderr 指向 stdout(此时 stdout 已经指向 file 了).
ls 2>&1 > file -- 错误: 先把 stderr 指向 stdout(此时 stdout 还指向屏幕),再把 stdout 指向 file--结果是 stderr 打印到屏幕,stdout 写到 file,相当于没合并.

为什么不是引用语义?--理解 dup2 的拷贝机制

直觉上可能认为 2>&1 是把 fd2 设成一个 "指向 fd1 的引用"--以后 fd1 变,fd2 也自动跟着变. 但实际并非如此.

重定向的本质是 dup2 系统调用,语义是值拷贝(快照)而非引用绑定:

dup2(oldfd, newfd) → 让 newfd 指向 oldfd 此刻所指目标的 "副本"
→ 调用完成后,两者各自独立,互不影响

这意味着 2>&1 所做的就是 "把 fd1 此刻指向的目标拷贝给 fd2",之后 fd1 再怎么变都与 fd2 无关. 因此:

错误写法: ls 2>&1 > file
2>&1 → fd2 拷贝 fd1 此刻的值 → fd2 → 终端(fd1 还没被改)
> file → fd1 → file
结果: fd1 → file, fd2 → 终端 → 等于没合并

正确写法: ls > file 2>&1
> file → fd1 → file(先把 fd1 改好)
2>&1 → fd2 拷贝 fd1 此刻的值 → fd2 → file
结果: fd1 → file, fd2 → file

一句话概括: 2>&1 不是 "以后跟着 fd1 走",而是 "复制 fd1 此刻的目标". 时机不同,复制到的就是不同的值.

4.3 &>: 现代 Shell 的快捷方式

# bash / zsh 都支持这个简写,等价于 > file 2>&1
$ ls existing.txt missing.txt &> all.log

# 追加版本
$ ls existing.txt missing.txt &>> all.log

&>2>&1 更简洁,但注意它是 bash/zsh 的扩展语法,POSIX sh 不支持. 写脚本时如果需要兼容性,用 > file 2>&1.

5. 输入重定向

5.1 <: 从文件读取

默认情况下命令从键盘读取输入(stdin = 键盘). < 让命令从文件读取:

$ wc -l < data.txt
42 # data.txt 有 42 行

# 对比:如果传文件名参数,输出包含文件名
$ wc -l data.txt
42 data.txt

重定向输入 vs 文件名参数--很多命令同时支持两种方式,但行为略有不同. 上面的 wc 例子中,< 版本只输出数字(wc 不知道数据来自哪个文件),而传文件名时 wc 会带上文件名.

5.2 <<: Here Document(内嵌文本)

Here Document 让在命令行中直接写多行文本,以一个自定义的结束标记(通常是 EOF)结尾:

$ cat << EOF
这是一个多行文本
第一行
第二行
第三行
EOF

# 输出以上三行

最常用的场景: 往配置文件追加一段多行内容,或者在脚本中给命令提供输入:

# 往配置文件追加多行
$ cat >> ~/.zshrc << 'EOF'

# === 自定义别名 ===
alias ll='ls -lh'
alias la='ls -lAh'
alias gs='git status'
EOF

# 注意 'EOF' 加引号 → 禁止变量替换和命令替换

带引号的 EOF vs 不带引号的 EOF

不带引号的 EOF(默认)             带引号的 'EOF'
────────────────────── ─────────────────
$ cat << EOF $ cat << 'EOF'
当前用户是 $USER 当前用户是 $USER
EOF EOF
→ 当前用户是 yutong → 当前用户是 $USER
(变量被替换) (变量原样保留)

如果文本内容中含 $ 或反引号且不想被 Shell 解释,记得给 EOF 加引号.

6. 管道 |: Unix 的核心发明

6.1 直觉理解

管道 |左边命令的 stdout 直接连到右边命令的 stdin,中间不经过磁盘,没有临时文件:

$ ls -l | grep "html"
-rw-r--r-- 1 yutong staff 18844 Jun 10 10:21 index.html
-rw-r--r-- 1 yutong staff 24271 Jun 12 17:04 note.html

ls -l 的输出没有打印到屏幕,而是直接被送进了 grep 的输入. grep 从中筛选出含 "html" 的行再打印.

管道就像工厂流水线--前一台机器的出料口直接对接后一台机器的进料口.
数据不是整批搬运的,而是边生产边消费: ls 写出一行,grep 就立即处理一行.
这就是流式处理(streaming)--不积攒,不停顿.

6.2 管道 vs 重定向

管道 | 重定向 > / <
连接对象 命令 ↔ 命令 命令 ↔ 文件
数据形态 流式的,不落盘 持久的,写入磁盘
典型用法 cmd1 | cmd2 | cmd3 cmd > filecmd < file
比喻 传送带 仓库入库/出库

6.3 管道的局限: stderr 不走管道

管道只传递 stdout. stderr 仍然打印到屏幕:

$ ls existing.txt missing.txt | grep "txt"
ls: missing.txt: No such file or directory # ← 这是 stderr,穿过了管道直接到屏幕
existing.txt # ← 这是 stdout,被管道传给了 grep

如果需要 stderr 也进管道,先用 2>&1 合并:

$ ls existing.txt missing.txt 2>&1 | grep "No such"
ls: missing.txt: No such file or directory

7. 组合实战

管道和重定向可以灵活组合. 以下是几个常用模式:

7.1 管道 + 重定向

# 管道处理 + 结果保存到文件
$ ls -l | grep "html" > html_files.txt

# 管道处理 + 追加到文件
$ find . -name "*.log" | wc -l >> report.txt

7.2 多级管道

# 三连管道:找最大的 5 个文件
$ du -sh * | sort -hr | head -5
2.1G Videos
856M Downloads
342M node_modules
120M .cache
45M Documents

# 管道链的阅读方式:从左到右,数据逐步 "精炼"
# du -sh * → 列出每个文件/目录的大小
# | sort -hr → 按人类可读的格式降序排列
# | head -5 → 只取前 5 行

7.3 过滤与统计范式

# 统计某种文件的数量
$ ls *.txt | wc -l

# 统计某类进程数
$ ps aux | grep -c "node"

# 查看最近修改的 N 个文件
$ ls -t | head -10

# 去重计数
$ cat access.log | cut -d' ' -f1 | sort | uniq -c | sort -rn | head -10
# 解释:取日志 → 切出第一列(IP) → 排序 → 去重计数 → 降序 → 取前10

Unix 哲学: 小工具组合干大事

上面的最后一个例子用了 6 个命令串联,每个命令只做一件小事--切列, 排序, 去重, 取前 N. 但连在一起就能完成 "找出访问量最高的 10 个 IP" 这样复杂的分析.

这就是 Unix 哲学的精髓: 写一个能做所有事的程序很难,但把多个只做一件事的小工具串起来就容易得多.

8. tee: T 型分流器

管道把数据从 A 传到 B,数据就 "消失" 在管道里了--没法同时看到中间结果. tee 解决的就是这个问题: 一边把数据传下去,一边写入文件(或打印到屏幕).

cmd → tee file.log → 下一级管道

file.log(同时写入)
# 基本用法:处理同时记录
$ ls -l | tee listing.txt | grep "html"
-rw-r--r-- 1 yutong staff 18844 Jun 10 10:21 index.html
# grep 筛选出的行打印到屏幕,完整的 ls -l 输出存入了 listing.txt

# -a 追加而非覆盖
$ ls -l | tee -a full_listing.txt | wc -l

# tee 到多个文件
$ echo "important" | tee file1.txt file2.txt

典型场景: 运行一条耗时很长的命令(比如编译),想同时在屏幕上看到进度,又把完整日志保存下来排查问题--make 2>&1 | tee build.log.

9. xargs: stdin → 命令行参数

这是一个重要的概念转折: 管道传递的是 stdin 数据流,不是命令行参数. 有些命令不接受 stdin,只接受参数--这时就需要 xargs 来架桥.

管道方式(stdin 数据流)                  参数方式(命令行参数)
────────────────────── ──────────────────────
echo "a.txt b.txt" | rm ← rm 不会从 stdin 读参数
echo "a.txt b.txt" | xargs rm ← xargs 把 stdin 转为参数传给 rm
# 经典的 "找出来然后删掉" 模式
$ find . -name "*.tmp" | xargs rm

# 等价于 rm $(find . -name "*.tmp"),但 xargs 更安全--它会处理文件名中的空格

# 配合 -I {} 控制参数位置
$ find . -name "*.jpg" | xargs -I {} cp {} backup/

# 配合 -p 交互确认(每条都问)
$ find . -name "*.tmp" | xargs -p rm
rm ./cache/a.tmp ?...y
rm ./cache/b.tmp ?...y

# 配合 -n 控制每批参数数量
$ echo "1 2 3 4 5 6" | xargs -n 2 echo
1 2
3 4
5 6

xargs 的文件名安全

文件名中的空格会导致传统 rm $(find ...) 出错: My Document.txt 会被当成两个文件. 解决方法是 find -print0 | xargs -0:

$ find . -name "*.tmp" -print0 | xargs -0 rm
# -print0 用 null 字符(\0)分隔文件名,-0 让 xargs 识别此分隔符

文件名不可能含 null 字符,所以这是完美的分隔方案.

什么时候需要 xargs? 判断标准很简单: 要传给目标命令的数据是参数(如文件名列表)还是内容(如要搜索/处理的文本)? 参数用 xargs,内容直接用管道.

场景 写法 说明
内容处理 cat file | grep "error" grep 从 stdin 读文本内容
参数传递 find . -name "*.log" | xargs rm rm 需要文件名作参数
参数替换到中间 ... | xargs -I {} mv {} dest/{} {} 占位

10. /dev/null: 终极黑洞

/dev/null 是一个特殊的设备文件--写入它的数据会被内核直接丢弃,读取它则立即返回 EOF. 本质上是一个 "黑洞".

# 丢弃输出,只看错误
$ some_command > /dev/null

# 丢弃错误,只看正常输出
$ some_command 2> /dev/null

# 全部丢弃(静默运行)
$ some_command &> /dev/null

# 测试一个命令是否成功,但不看输出
$ if grep "pattern" file > /dev/null 2>&1; then
echo "找到了"
fi

常见用途: cron 定时任务中在命令末尾加 > /dev/null 2>&1 可以防止输出触发邮件通知. 或者只关心命令的退出码($?),不在乎它打印了什么.

复习: 为什么是 > /dev/null 2>&1 而不是反过来?

可能直觉觉得应该 "先把两条流绑在一起(2>&1),再一起搬进黑洞(> /dev/null)"--听起来很合理,但这正是 4.2 节讲过的拷贝语义陷阱.

2>&1 不是 "以后跟着 fd1 走" 的引用绑定,而是 "复制 fd1 此刻目标的快照". 因此:

先 2>&1 再 >/dev/null:
① 2>&1 → fd2 拷贝 fd1 此刻目标 → fd2 → 终端
② > /dev/null → fd1 → /dev/null
结果: fd1 进黑洞,fd2 还在屏幕 → stderr 仍然打印

先 >/dev/null 再 2>&1:
① > /dev/null → fd1 → /dev/null
② 2>&1 → fd2 拷贝 fd1 此刻目标 → fd2 → /dev/null
结果: 两条都进黑洞

记住: 先让 fd1 到位,再让 fd2 效仿. 这和 4.2 节 > file 2>&1 的顺序是完全一致的道理.

快速回顾

  • 每个进程有三个标准流: stdin(0) 读入, stdout(1) 输出正常结果, stderr(2) 输出错误信息
  • 重定向改变流的默认去向: > 覆盖写, >> 追加写, < 从文件读
  • 错误重定向: 2> 单独控制 stderr, 2>&1 合并到 stdout, &> 一次性全重定向
  • 管道 | 把左边命令的 stdout 连到右边命令的 stdin,实现流式传递,不落盘
  • 管道只传 stdout--stderr 需要 2>&1 合并后才能进管道
  • tee 在管道中插入一个 "分支",一边往下传数据,一边写入文件
  • xargs 把 stdin 数据流转成命令行参数--当目标命令不接受 stdin 时用它架桥
  • /dev/null 是黑洞--写入即消失,用来抑制不关心的输出
  • Unix 哲学: 小命令通过管道组合,完成远超单个命令能力的复杂任务

动手练习

  1. 分离 stdout 和 stderr: 运行 ls / /nonexistent > out.txt 2> err.txt,检查两个文件的内容--确认 stdout 和 stderr 是否被正确分开了
  2. 覆盖 vs 追加: 创建一个文件,用 echo "hi" > test.txt,然后 echo "bye" >> test.txt,cat test.txt 验证追加 vs 覆盖的区别
  3. 管道数据流: 运行 ls -la / | wc -l,理解数据如何从 ls 流向 wc
  4. 多级管道: 用多级管道找出当前目录下占用空间最大的 3 个文件或目录: du -sh * 2>/dev/null | sort -hr | head -3
  5. tee 分流: 用 tee 运行 ls -l | tee listing.txt | grep "^d",验证 listing.txt 中保存了完整输出,而屏幕只显示目录行
  6. xargs 处理空格: 创建几个带空格名字的文件: touch "my file.txt" "your file.txt",然后用 find . -name "*.txt" -print0 | xargs -0 ls -l 列出它们
  7. 重定向与 /dev/null: 运行一条耗时命令(如 sleep 3; echo done),用 time 包裹,同时把 stderr 重定向到 /dev/null: (time sleep 3) 2>/dev/null