0%

Linux 重定向与管道

Linux 重定向与管道

进程读写文件、终端或管道时,使用的不是文件名,而是内核分配的编号,称为文件描述符(file descriptor,fd)。从终端启动的程序,会从 shell 继承三个已经打开的 fd:fd 0 用于读取输入(stdin),fd 1 用于输出结果(stdout),fd 2 用于输出诊断信息(stderr),三者都指向终端。

程序只按编号读写,不需要关心 fd 背后是终端、文件还是管道。因此,shell 只要在程序启动前修改 fd 的指向,就能改变程序的输入来源和输出去向,而无需改动程序本身:

把 fd 指向文件,就是重定向;

用 | 把一个程序的 fd 1 通过管道连接到另一个程序的 fd 0,就是管道。

以下常见误区,都可以由这一机制解释:

  1. “重定向写在命令后面,所以命令先执行,再把输出写入文件”。 无论重定向写在命令的左边还是右边,shell 都会先处理完所有重定向,再启动程序;”从左到右”仅指多个重定向之间的执行顺序。
  2. “cmd > file 会把命令的所有输出写入文件”。 > 是 1> 的省略,只重定向 fd 1;错误信息写入 fd 2,而 fd 2 仍指向终端。
  3. “stderr 中都是错误信息”。 stderr 用于输出诊断信息,进度条、统计结果也属于诊断信息,例如 curl 的进度条、git clone 的进度都写入 stderr。
  4. “2>&1 > file 与 > file 2>&1 等价”。 2>&1 只在执行的那一刻,把 fd 1 此刻的指向复制给 fd 2,之后 fd 1 再变,fd 2 不会跟着变。写在 > file 之前时,fd 1 此刻还指向终端,fd 2 复制到的就是终端,错误信息不会写入 file。
  5. “终端上看到的输出,会全部通过管道(管道符|)传给下一个命令”。 终端上的输出来自 fd 1 和 fd 2 两个通道,管道只连接前一个命令的 fd 1,fd 2 的内容仍直接显示在终端上。

1. 文件描述符与命令的启动过程

1.1 默认的三个 fd

之所以称为”文件”描述符,是因为在 Linux 中,终端、管道、网络连接都和普通文件一样,通过 read、write 等同一组系统调用读写。

fd 名称 方向 默认指向 约定用途
0 stdin(标准输入) 输入 终端 读取输入
1 stdout(标准输出) 输出 终端 结果数据
2 stderr(标准错误) 输出 终端 错误、警告、进度等诊断信息

程序读写时只指定 fd,例如 write(1, …) 写入 fd 1,read(0, …) 从 fd 0 读取。

查看当前 shell 的 fd:

ls -l /proc/$$/fd 列出当前 shell 打开的每个 fd 及其指向的对象($$ 是当前 shell 的进程号)。

三个 fd 都指向同一个终端 /dev/pts/0,因此在终端上无法区分某行输出来自 fd 1 还是 fd 2。

1.2 shell 如何启动一条命令

以 ls > out.txt 为例,shell 执行外部命令分为三步:

  1. 创建子进程(fork)。 子进程是 shell 的副本,继承 shell 的全部 fd。shell 自身的 fd 0、1、2 指向终端,所以子进程的 fd 也指向终端,这就是”默认指向终端”的由来。
  2. 在子进程中处理重定向。 此时子进程运行的仍是 shell 的代码。它按书写顺序逐条处理重定向:先以截断方式打开 out.txt,得到一个新的 fd(如 fd 3),再用系统调用 dup2 把它复制到 fd 1,使 fd 1 指向 out.txt。重定向在子进程中进行,因此只影响这一条命令,shell 自身的 fd 不变。
  3. 加载目标程序(exec)。 进程号不变,运行的程序由 shell 换成 ls,fd 保持不变。ls 开始运行时,fd 1 已经指向 out.txt。

由此可以得出三个结论:

  • 重定向在程序运行之前就已完成,与书写位置无关。 程序启动时,fd 已经设置好;wc -l < /etc/passwd 与 < /etc/passwd wc -l 完全等价。
  • 重定向由 shell 以当前用户身份执行。 打开文件时的权限检查与之后运行的程序无关。
  • 多个重定向按书写顺序执行,后一条看到的是前一条修改后的结果。 例如 cmd > out.txt 2>&1,先把 fd 1 指向 out.txt,再让 fd 2 复制 fd 1 此时的指向,因此 fd 2 也指向 out.txt;顺序颠倒,结果就不同。

内置命令(如 echo、cd)不创建子进程,shell 会临时修改自身的 fd,执行完毕后再恢复,效果相同。

重定向,就是 shell 在 fork 之后、exec 之前,修改子进程的 fd。

2. > 即 1>,2> 是同一种操作

重定向的完整写法是 n>文件,即把 fd n 重定向到文件。

n 省略时,> 默认为 fd 1(stdout,标准输出),< 默认为 fd 0(stdin,标准输入)。

下面两行完全等价:

1
2
ls /etc > out.txt
ls /etc 1> out.txt

同理,2> 把 fd 2 重定向到文件:

1
2
3
ls /etc/hostname /nope 2> err.txt
# 终端只显示:/etc/hostname
# err.txt 中:ls: cannot access '/nope': No such file or directory

需要注意的细节:

  • 数字和 > 之间不能有空格。 shell 只把紧贴 > 的数字识别为 fd。ls 2 > f 中的 2 会被当作 ls 的参数,被重定向的是 fd 1。
  • > 在命令运行前就会清空文件。 shell 处理重定向时以截断方式打开文件,此时命令尚未运行。因此即使命令执行失败,文件也已被清空;sort file > file 也因此得到空文件。
  • >> 表示追加,同样可以指定 fd:2>> err.log。

<:输入重定向

< 即 0<,把 fd 0 重定向到文件。对比下面两条命令:

1
2
3
4
$ wc -l /etc/passwd
57 /etc/passwd
$ wc -l < /etc/passwd
57

第一条命令中,文件名是 wc 的参数,wc 自己打开文件,因此能输出文件名。

第二条命令中,由 shell 打开文件,并在启动 wc 之前把它设置为 wc 的 fd 0。wc 没有收到任何参数,只从 fd 0 读取数据,无从得知文件名,因此只输出行数。这说明数据来源由 shell 设置,程序只负责读写 fd。

无论重定向写在命令的左边还是右边,shell 都会先处理完所有重定向,再启动程序;”从左到右”仅指多个重定向之间的执行顺序。

>、2>、< 是同一种操作,区别仅在于重定向的是哪个 fd。

3. stdout 与 stderr 的区别

3.1 为什么需要两个输出通道

如果程序只有一个输出通道,结果数据和错误信息会混在一起:把输出写入文件或交给下一个命令处理时,错误信息会混入数据,用户在终端上也看不到出错。

因此标准 I/O 把输出分为两个通道:fd 1 输出结果数据,fd 2 输出供人阅读的诊断信息。

执行 cmd > result.txt 或 cmd | grep x 时,结果数据被重定向,错误、警告等诊断信息仍显示在终端上。

3.2 内容没有区别,写入哪个 fd 由程序决定

系统不会检查写入的是不是错误信息:程序把一行 Error 写入 fd 1,它就是 stdout;把一行 OK 写入 fd 2,它就是 stderr。各种语言的输出函数最终都调用系统调用 write(fd, buf, len),写入哪个 fd 由程序的作者选择。

既然 stderr 的用途是诊断信息,而不只是错误,那么以下输出写入 stderr 都符合约定:

  • curl 的进度条、git clone 的进度、ffmpeg 的运行日志。
  • time 的统计结果。

约定也并非所有程序都遵守,有些程序会把错误信息写入 stdout。因此判断一行输出来自哪个 fd,应通过实验,而不是看内容:

1
2
cmd 2>/dev/null   # 终端上剩下的都是 stdout
cmd >/dev/null # 终端上剩下的都是 stderr

3.3 用途不同,缓冲方式也不同

两个通道的用途不同,C 标准库为它们设置了不同的默认缓冲策略:

  • stdout 输出数据,注重效率。 指向文件或管道时采用全缓冲,缓冲区满后一次写出,以减少系统调用;指向终端时采用行缓冲,遇到换行即写出,以便交互时及时显示。
  • stderr 输出诊断信息,注重及时。 始终不缓冲,写入后立即输出,即使程序随后崩溃,诊断信息也不会丢失。
指向终端时 指向文件或管道时
stdout 行缓冲 全缓冲
stderr 无缓冲 无缓冲

这导致重定向后的输出顺序可能与终端上不一致:执行 cmd > log 2>&1 时,stderr 立即写入,stdout 仍在缓冲区中,日志里错误信息会出现在前面。解决方法:在程序中调用 fflush;Python 使用 python -u 或设置 PYTHONUNBUFFERED=1;其他命令可使用 stdbuf -oL cmd。

stdout 和 stderr 在内容上没有区别,区别在于用途:fd 1 输出数据,注重效率;fd 2 输出诊断信息,注重及时。两者默认都指向终端,因此在终端上无法区分。

4. 2>&1 的含义与顺序

2>&1 的含义是:把 fd 2 指向 fd 1 此刻的指向。 这是一次性的复制,而不是绑定:复制发生在执行 2>&1 的那一刻,之后 fd 1 再改变,fd 2 不会跟着改变。底层对应系统调用 dup2(1, 2)。

  • & 表示其后是 fd 编号,而不是文件名。写成 2>1 会创建一个名为 1 的文件。
  • >&2 即 1>&2,把 fd 1 重定向到 fd 2 当前指向的位置,常用于在脚本中输出错误信息:echo "出错了" >&2。

4.1 顺序:从左到右依次执行

由于 shell 按书写顺序逐条处理重定向,可以把 fd 看作变量,2>&1 相当于赋值语句 fd2 = fd1:赋值时取 fd 1 的当前值,之后 fd 1 再变化,fd 2 不受影响。

写法 A:cmd > out.txt 2>&1(正确)

步骤 操作 fd 1 fd 2
初始 — 终端 终端
1 > out.txt out.txt 终端
2 2>&1 out.txt out.txt

结果:stdout 和 stderr 都写入 out.txt。

写法 B:cmd 2>&1 > out.txt(常见错误)

步骤 操作 fd 1 fd 2
初始 — 终端 终端
1 2>&1 终端 终端(复制了 fd 1 此时的指向)
2 > out.txt out.txt 终端

结果:只有 stdout 写入文件,stderr 仍输出到终端。

4.2 为什么不能写成 > out.txt 2> out.txt

这种写法会两次独立打开同一个文件,两个 fd 各自维护写入位置,相互覆盖,内容会错乱。2>&1 使两个 fd 共享同一次打开的文件,写入位置也共享,因此能正确地交替写入。

2>&1 复制的是 fd 1 当前的指向,因此必须先设置好 fd 1,再复制给 fd 2。

5. 管道 | 只传递 stdout

5.1 管道的建立过程

执行 A | B 时,shell 按以下顺序操作:

  1. 创建一个管道,包含写入端和读取端。
  2. 为 A 和 B 分别创建子进程。在 A 的子进程中,把 fd 1 指向写入端;在 B 的子进程中,把 fd 0 指向读取端。
  3. 在各自的子进程中处理 A、B 自己的重定向。
  4. 分别加载 A、B 程序。

A 的 fd 2 在整个过程中没有改变,仍指向终端,因此 stderr 不经过管道,直接输出到终端:

1
2
3
4
5
6
$ ls /etc/hostname /nope | wc -l
ls: cannot access '/nope': No such file or directory # 未进入管道
1

$ ls /etc/hostname /nope 2>&1 | wc -l
2

5.2 管道先于重定向建立

从上述顺序可以看出,管道在第 2 步就已连接,A 自己的重定向在第 3 步才处理。

所以在 A 2>&1 | B 中,执行 2>&1 时 fd 1 已指向管道,fd 2 复制到的正是管道。

同理,A 2>&1 >/dev/null | B 可以只让 stderr 进入管道:先把 fd 2 指向 fd 1 当前指向的管道,再把 fd 1 重定向到 /dev/null,结果只有 stderr 进入管道。这也说明 2>&1 写在前面并非总是错误,关键在于执行时 fd 1 指向哪里。

6. 补充知识

6.1 &> 与 >&:bash 的简写

cmd &> file 等价于 cmd > file 2>&1,&>> file 表示追加(bash 4+)。它们不是 POSIX 语法。在 #!/bin/sh 脚本中(如 Debian 的 dash),cmd &> file 会被解析为”后台运行 cmd,再执行 > file“,且不会报错。需要可移植的脚本应使用 > file 2>&1。

cmd >& file 是来自 csh 的旧写法,效果相同;但文件名为数字时会被当作 fd 编号,bash 官方推荐使用 &>。

6.2 /dev/null:空设备

写入的数据被直接丢弃,读取时立即返回 EOF。常见用法:cmd >/dev/null 2>&1(丢弃全部输出),cmd 2>/dev/null(只丢弃错误信息)。

6.3 tee:同时输出到终端和文件

cmd 2>&1 | tee build.log 在终端显示输出的同时写入文件;tee -a 表示追加。

向需要 root 权限的文件写入时,sudo echo x > /etc/f 会失败。原因是重定向由 shell 在启动 sudo 之前、以当前用户身份执行,打开 /etc/f 时就因权限不足而失败,sudo 根本不会运行。正确的做法是让以 root 身份运行的程序去打开文件:echo x | sudo tee /etc/f >/dev/null,或 sudo sh -c 'echo x > /etc/f'。

6.4 exec:对整个脚本生效的重定向

不带命令的 exec 不会加载新程序,而是直接修改当前 shell 自身的 fd。之后启动的每个命令都从 shell 继承 fd,因此该重定向对之后的所有命令生效:

1
2
3
4
exec > >(tee -a run.log) 2>&1   # 此后的全部输出同时写入日志
exec 3< input.txt # 打开 fd 3 用于读取
while read -r line <&3; do …; done
exec 3<&- # 关闭 fd 3

6.5 Here Document 与 Here String

两者都用于向 fd 0 提供输入:

1
2
3
4
5
cat <<EOF          # 多行输入,变量会展开;写作 <<'EOF' 则不展开
hello $USER
EOF

grep x <<< "$var" # bash 的 here string

6.6 重定向的位置

重定向不必写在命令末尾:> out.txt echo hi 与 echo hi > out.txt 等价。对 for、while、{ … } 等复合命令,重定向作用于整体:{ cmd1; cmd2; } > all.log 2>&1 可一次收集多条命令的输出。