(本文主要讨论 BaSH,并且基于 Linux 系统讨论)

在 shell 编程里,说起重定向大家恐怕都用过 2>&1 或者 > log 这样的操作。在执行这些操作的时候,你是否偶尔也想过,/2/ 和 1 代表什么?/3/ 和 4 又能做什么的?是否可能困惑过,为什么 >/dev/null 2>&1=和 =>2>&1 /dev/null 的行为是完全不同的?如果你有这些问题,请听我讲述 shell 重定向里你不知道的那些故事。

基础知识

首先,先来介绍一些基础知识。这些东西基本在大学的计算机系课程中都有涉及,计算机专业的同学可以完全略过。

数字的含义

重定向里用到的数字被称作文件描述符(File Descriptor)。文件描述符与一个具体的文件相关联,它的作用就是给相应的文件操作函数提供一个目标即操作哪个文件。在 POSIX 标准中,文件描述符都是一个数字,并且有三个特殊的文件描述符他们是:

  • 0,Standard Input 也称 stdin 中文名称是标准输入
  • 1,Standard Output 也称 stdout 中文名称是标准输出
  • 2,Standard Error 也称 stderr 中文名称是标准错误

当你通过 open(2) 系统调用打开一个文件文件时, open(2) 会返回一个新建的文件描述符。描述符的数字在每次调用 open(2) 时递增。所以,一个程序第一次调用 open(2) 时得到的返回值就是 3 

什么是重定向

通常情况下, 0 号描述符会从键盘获取输入数据而 1 , 2 号描述符会把数据输出到显示设备上。重定向就是要改变数据来源或者目的的一种操作。比如,把一个程序输出的数据放到文件里就可以用,

date 1>readme.txt

特别注意,由于 1 、 2 号描述符都是输出到显示器上的。所以有时如果在重定向了 1 号描述符时仍有数据显示在屏幕上,则说明这些数据是标准错误里的信息。

对于输出重定向, date 1>readme.txt 和 date >readme.txt 是等价的。对于输入重定向 cat 0<readme.txt 和 cat <readme.txt 也是等价的。

特别地,当采用 >> 符号做输出重定向时,新的内容会追加在目标文件的尾部。

重定向小技巧

输出分类

虽然我们可以通过 STDOUT 和 STDERR 将程序输出分成一般输出和错误输出两类,但很多时候还是不能满足需求。比如,我们希望程序把 DEBUG,VERBOSE,INFO 三种类型的信息分别输出到三个文件。这个时候我们就需要自己创建新的描述符并把他们关联到对应的文件去了。举例来说,

# open three more fds for funnelling information of various levels
exec 3>/tmp/debug.log
exec 4>/tmp/verbose.log
exec 5>/tmp/info.log

echo >&3 I am a debug message
echo >&4 I am a verbose message
echo >&5 I am an info message

# close them after we finish
exec 5>&-
exec 4>&-
exec 3>&-

注:虽然程序退出后描述符会自动被关闭,但写程序的时候善始善终总是个好习惯

上面的程序引入了两个语法, exec n>filename 以及 exec n>&- 前者用来开启新的描述符并把目标指向对应的文件。后者用来关闭之前打开的描述符

进程代入 (Process Substitution)

说实话我不知道这东西应该怎么翻译,毕竟没见它过以中文名称出现过。这里我们就暂且叫它进程代入吧。

进程代入的意思就是把一个命令的输出直接重定向给另外一个命令作为输出,例如:

diff <(ssh server1 rpm -ql) <(ssh server2 rpm -ql)

这条命令的作用就是把命令 ssh server1 rpm -ql 的输出与 ssh server2 rpm -ql 的输出做 diff 比较。

进程代入的实现方式颇有意思,可以通过 echo 来一探究竟,

echo <(date)

这条命令会输出一个类似 /dev/fd/12 之类的东西。你可能已经想到了,进程代入的实现方式就是由 shell 创建一个临时文件。然后把被代入命令的标准输出定向到这个临时文件,再把这个文件重定向成代入进程的标准输入。

重定向的本质与实现

为什么 >/dev/null 2>&1 和 2>&1 /dev/null 的效果不同呢?为了解决这个问题,我们先要从内核里文件描述符和文件的关系说起。理解重定向的背后到底发生了什么。

我们先来看看文件描述符与文件的关系。内核通过三张表来对把一个文件描述符对应到具体的文件,

file_sharing_linux2.png

首先,通过最左边的文件描述符表(File Descriptor Table)将一个文件描述符数字对应到一个文件表(File Table)中的文件项。再通过文件表将一个文件项对应到磁盘上具体的 inode 。

所谓“重定向”本质上就是通过修改文件描述符表中的文件指针(file table descriptor)将对一个描述符的操作落到其他文件上去。

file_dup.png

比如, 3>&1 就会导致上图的结果。也就是将描述符 3 的文件指针指向和描述符 0 相同的地方。这个操作在 Linux 系统是通过一个叫 dup2(2) 的系统调用完成的。举例来说: 2>&1 在程序实现上就是

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
    dup2(1, 2);
    fprintf(stderr, "hello, worldn");
    return 0;
}

理解了 dup2(2) 做的工作后,我们再来看看 2>&1 >/dev/null 和 >/dev/null 2>&1 的实现,

int fd = open("/dev/null", "w+");
dup2(fd, 1);
dup2(1, 2);

以上就是 >/dev/null 2>&1 的实现。我们先把描述符 1 指向和 /dev/null 的位置,然后再把描述符 2 指向描述符 1 对应文件的位置。由于之前描述符 1 的对应文件已经被 dup2(2) 修改为了 /dev/null 因此描述符 2 现在也指向 /dev/null 了。后续的所有标准输出与标准错误的数据都被定向到了 /dev/null 。

再来看 2>&1 >/dev/null 的实现,

int fd = open("/dev/null", "w+");
dup2(1, 2);
dup2(fd, 1);

这里仅仅就是把两条 dup2(2) 语句的顺序调换了一下。但是却产生了完全不同的结果。首先,我们把描述符 2 指向描述符 1 对应的文件,也就是标准输出,然后再把描述符 1 指向 /dev/null 。结果是描述符 1 指向了 /dev/null 而描述符 2 仍然指向标准输出。如果还不是理解得很清楚,可以画一下类似上面的描述符指向图,来模拟一下每一步文件描述符和文件的关系。

理解了重定向的原理后,就很容易理解在 shell 中重定向的顺序是至关重要的。

STDIN 偷盗

在理解了上面的内容之后,我们就可以解释并解决一个 shell 编程中的难题了,这就是“被盗窃的标准输入”(好吧,这名字好矬)。

先来看一个有意思的程序,为了说明重点问题这个程序被简化到了无聊的地步,

while read filename; do
  rm -iv $filename
done < <(ls)

从外观上看,这是想把 ls 命令的输出也就是当前目录的文件列表逐行读取出来,然后使用 rm -iv 在获得你许可的情况下,优雅地删除他们。假设我们的测试目录下存在名为:1、2、3的三个文件。那么运行这个程序后你会发现,虽然 rm 命令显示了询问是否删除的信息,但是 rm 完全没有关心你的回答。并且整个程序也没有删除一个文件。这是为什么呢?

答案是 rm -iv 偷了 read 的数据!因为 rm -iv 期待用户从标准输入给出一个 y 或 n 的答案以确认是否删除,但标准输入被 < <(ls) 重定向了。于是 rm -iv 开始在 < <(ls) 里寻找答案。如果找不到 y 或者 n 就一直寻找下去,直到把 < <(ls) 的内容消耗完。这时在下一轮的循环中由于数据没有了, read 读不出数据,程序也就退出了。

如何应对这个情况呢?这就需要我们利用之前讲到的知识了。我们先把标准输入复制一份出来,然后让 rm -iv 使用复制出来的标准输入。再把原来的标准输入重定向给 < <(ls) 。请看修改后的程序:

exec 3<&0
while read filename; do
  rm -iv $filename <&3
done < <(ls)

由于在 exec 3<&0 放在了 < <(ls) 之前,描述符 3 很好地保留了原来的标准输入(也就是键盘输入)。之后再运行 rm 时,我们把通过 <&3 把 rm 自身的标准输入再重定向回键盘输入。