最近工作,生活中有一堆事情要处理,博客好久没更新了,请见谅。

拖了好久才翻译好这篇文章,这篇翻译下来好累,主要是原文废话太多了,后来还是决定尽量省略部分内容,建议有时间的还是去看下原文。这一篇太长了,先翻译部分,我会继续不断的更新。

这是 Bash One-Liners Explained 系列的第三篇文章。在这一篇里,我会给大家介绍重定向相关的内容。我会选择用最合适的 Bash 方法,各种常见的语法和技巧,向各位阐明如何用 Bash 内置的命令和 Bash 编程语言来完成各式各样的任务。

重定向其实是通过操作文件描述符来完成的,这样会更容易理解。当 Bash 启动时,会自动创建三个标准的文件描述符,它们分别是 stdin(标准输入,文件描述符为0),stdout(标准输出,文件描述符为1)和 stderr(标准错误输出,文件描述符为2)。你也可以创建更多的文件描述符,例如3,4,5等等,或者关闭它们,又或者拷贝它们。你可以从对应的文件中读取或者写入内容。

文件描述符指向某个文件(除非它们被关闭)。通常情况下,Bash 启动的三个文件描述符 —— stdin,stdout 和 stderr 都是指向你的终端,从终端输入中读取内容,并且把标准输出和标准错误都送到终端上。

假设你的终端对应的设备文件是 /dev/tty0,下面的截图解释了 Bash 启动时文件描述符表的样子:

initial-fd-table

当 Bash 执行一个命令时,他会 fork 一个子进程(查看man 2 fork)。子进程会从父进程继承所有的文件描述符,设置好指定的重定向,最后执行该命令(查看man 3 exec)。

可以尝试用图表来可视化展现,重定向发生时文件描述符表的变化过程,这种方法可以帮助你更好的理解重定向功能。

1. 重定向命令的 stdout 到文件

$ command >file

>是输出重定向操作符。Bash 首先会打开文件准备写入,如果文件打开成功,则将命令command的 stdout 指向之前打开的文件。如果打开失败,则不会继续执行命令。command >file的写法和command 1>file的写法是一样的,1是 stdout 对应的文件描述符。

下面的图片描述了上述命令执行时文件描述符表的变化过程:

redirect-stdout

1. 重定向命令的 stderr 到文件

$ command 2> file

Bash 将这行命令的 stderr(文件描述符为2)重定向到文件file中。

下面是重定向后的文件描述符表:

redirect-stderr

3. 重定向命令的 stdout 和 stder 到同一个文件中

$ command &>file

这一行命令使用了&>操作符,它将命令command的 stdout 和 stderr 都重定向到文件file中。

除此之外,还有几种方法可以将 stdout 和 stderr 同时重定向到同一个文件中。你可以依次重定向每个输出。

$ command >file 2>&1

上面是一种更加常见的方法,首先重定向 stdout 到文件file,然后将 stderr 重定向到和 stdout 同样的文件中。

当 Bash 在命令中遇到多个重定向操作时,它会从左到右依次处理。我们通过图表来依次推导这整个过程。初始时文件描述符表的样子:

initial-fd-table

现在 Bash 处理第一组重定向>file,我们之前已经解释过,它将使得 stdout 指向文件file

redirect-stdout

接下来,Bash 开始处理第二组重定向2>&1,它会把 stderr 重定向到 stdout 所指向的文件:

redirect-stdout-stderr

这里要注意不要错误的写成:

$ command 2>&1 >file 

重定向的顺序是很重要的,这行命令只会把 stdout 重定向到文件,而 stderr 会继续输出到终端屏幕上。为了理解原因,我们同样来推导依次整个处理过程。

当 Bash 遇到2>&1时,它会把 stderr 指向 stdout 对应的文件(这里是终端):

duplicate-stderr-stdout

紧接着,Bash 看到>file,按照之前我们解释的,它会把 stdout 重定向到文件file

duplicate-stderr-stdout-stdout-file

从上面的图片中可以看出,stdout 指向了文件file,但是 stderr 依然指向终端。所以,一定要注意重定向的书写顺序。

4. 丢弃命令的 stdout 输出

$ command > /dev/null

/dev/null是一个特殊的文件,任何写入到该文件的内容都会被丢弃。所以,我们需要做的就是把 stdout 重定向到文件/dev/null

redirect-stdout-dev-null

类似的,基于前一条命令,我们可以做到把输出到 stdout 和 stderr 的内容都丢弃:

$ command >/dev/null 2>&1

或者简单的写成:

$ command &>/dev/null

此时的文件描述符表为:

redirect-stdout-stderr-dev-null

5. 重定向文件到命令的 stdin

$ command <file

Bash 在执行命令之前,打开文件file准备读入。如果打开文件出错,Bash 会直接返错,不会继续执行命令。相反如果打开成功,Bash 会使用打开的文件的文件描述符作为命令的标准输入。此时,文件描述符表的样子为:

redirect-stdin

下面是一个例子,假如你想把文件的第一行读入到变量中:

$ read -r line < file

6. 重定向一堆字符串到命令的 stdin

$ command <<EOL
your
multi-line
text
goes
here
EOL

这里用到了 here document 的语法<<MARKER。当 Bash 遇到该操作符是,它会从标准输入读取每一行,直到遇到一行以MARKER开头为止。这个例子中,Bash 读取到所有内容并传给command的 stdin。

假设你想去除一堆 URL 地址中的http://部分,可以用下面的一行命令:

$ sed 's|http://||' <<EOF
http://url1.com
http://url2.com
http://url3.com
EOF

输出结果为:

url1.com
url2.com
url3.com

7. 重定向一行文本到命令的 stdin

$ command <<< "foo bar baz"

等价于:

$ echo "foo bar baz" | command

8. 重定向所有命令的 stderr 到文件中

$ exec 2>file
$ command1
$ command2
$ ...

这一行命令中使用了 Bash 的内置命令exec。如果你在它之后指定重定向操作,重定向的效果为一直持续到显示改变或者脚本退出为止。

在这个例子中,2>file处理之后,随后所有命令的 stderr 都会重定向到文件file中。通过这种方法,你可以很方便的把脚本中所有命令的 stderr 都汇总到一个文件,同时又不用每一个命令之后都指定2>file

9. 打开文件并通过特定文件描述符读

$ exec 3<file

上面我们再次用到了exec命令,3<file告诉它以只读方式打开文件 file,并将文件描述符 3 指向打开的文件:

custom-fd

随后你可以通过描述符 3 来读取文件内容:

$ read -u 3 line

一些常规的命令,例如 grep,还可以这么用:

$ grep "foo" <&3

执行了上面的命令后,grep 命令的 stdin 指向了之前打开的文件,看起来好像将文件描述符 3 复制成了 0。

当你使用完成后,通过下面的方法关闭该文件:

$ exec 3>&-

这里文件描述符 3 指向&-,就意味着关闭改文件描述符。

10. 打开文件并通过特定文件描述符写

$ exec 4>file

同上面一条类似,这里我们将文件描述符 4 指向以写方式打开的文件:

custom-fd-writing

你可以看到,你并不需要按顺序使用文件描述符,可以任意挑选从 0 到 255 之内的所有未被使用的描述符。

接下来,我们可以很方便的通过描述符 4 来写文件:

$ echo "foo" >&4

或者关闭描述符:

$ exec 4>&-

11. 打开文件并通过特定文件描述符读写

$ exec 3<>file

这里我们用到了菱形操作符(diamond operator) <>,该操作符表示打开的文件既可以用于读也可以用于写。例如:

$ echo "foo bar" > file   # write string "foo bar" to file "file".
$ exec 5<> file           # open "file" for rw and assign it fd 5.
$ read -n 3 var <&5       # read the first 3 characters from fd 5.
$ echo $var

结果会输出foo。然后,我们可以往里写些内容:

$ echo -n + >&5           # write "+" at 4th position.
$ exec 5>&-               # close fd 5.
$ cat file

结果输出foo+bar

12. 重定向一组命令的 stdout 到文件中

$ (command1; command2) >file

这一行命令使用(commands)语法,commands 会在一个子 shell 中执行。所以在这里,command1command2会在子 shell 中运行,然后 Bash 将子 shell 的 stdout 重定向到文件中。

13. 在 Shell 中通过文件中转执行的命令

打开两个 shell,在第一个中执行以下命令:

mkfifo fifo
exec < fifo

而在第二个中,执行:

exec 3> fifo;
echo 'echo test' >&3

回头你会发现在第一个 shell 中会输出 test,你可以继续不断地往文件 fifo 中输入命令,第一个 shell 中 会一直执行这些命令。

我们来解释下这里的原理。

在第一个 shell 中,我们使用 mkfifo 命令创建了一个命名管道fifo。命名管道(也可以叫做 FIFO)类似之前提到的管道(匿名管道),除了前者是以文件系统上的文件的方式存在(标识一条特殊的进程通信的内核通道)。命名管道可以被多个进程打开同时读写,当多个进程通过 FIFO 交换数据时,内核并没有写到文件系统中,而是自己私下里传递了这些数据。所以,FIFO 这中特殊的文件,它在文件系统中是没有存放数据块的。文件系统只是通过文件名的形式提供标识,以便进程间可以利用这个标识来访问管道。

接下来,我们通过exec < fifo命令,使用 fifo 作为当前 shell 的标准输入。

现在,我们在第二个 shell 中以写的方式打开命名管道,并将文件描述符 3 指向改它。接下来,我们只要简单地把echo test 文件描述描述符 3,最总会写到管道 fifo 中。因为第一个 shell 的标准输入连接到管道的读的一段,它会接受到传递过来的内容并执行。

14. 通过 Bash 访问 Web 站点

$ exec 3<>/dev/tcp/www.google.com/80
$ echo -e "GET / HTTP/1.1\n\n" >&3
$ cat <&3

Bash 将/dev/tcp/host/port当作一种特殊的文件,它并不需要实际存在于系统中,这种类型的特殊文件是给 Bash 建立 tcp 连接用的。

在这个例子中,我们首先以读写的方式打开文件描述符 3,并把它指向/dev/tcp/www.google.com/80,后者是一个连接,表示连接到 www.google.com 的 80端口。

接下来,我们往文件描述符 3 写GET / HTTP/1.1\n\n。完成之后,我们使用cat命令从同样的地方读取返回内容。

类似的,你也可以通过/dev/udp/host/port 来创建一个 UDP 连接。

使用/dev/tcp/host/port,你甚至可以使用 Bash 写一个端口扫描程序。

15. 重定向输出时防止覆盖已有的文件

$ set -o noclobber

这行命令将当前 shell 的noclobber选项打开,这个选项的作用是,防止>重定向操作符覆盖已有的文件内容。

这时如果你重定向写入到一个文件,会返回一个错误:

$ program > file
bash: file: cannot overwrite existing file

如果你100%确定你要覆盖一个文件,可以使用>|重定向操作符:

$ program >| file

上面的命令会正确的执行,因为它覆盖了noclobber选项。

16. 重定向标准输入到文件,同时打印到标准输出

$ command | tee file

tee是一个很方便的命令,它并不是 Bash 的一部分,但是你会经常用到这个命令。它将接收到的输入,同时打印到标准输出和一个文件中。

下面的图片描述了上面命令执行的过程:

tee

17. 重定向进程的标准输出到另外一个进程的标准输入

$ command1 | command2

这是一个大家非常熟悉的管道用法:

pipe

18. 重定向进程的标准输出和标准错误到另外一个进程的标准输入

$ command1 |& command2

以上用法只在 Bash 4.0 以后的版本才能使用,对于老的版本,比较通用的做法是:

$ command1 2>&1 | command2

下面的图片描述了上面命令执行的过程:

pipe-stdout-stderr

19. Give file descriptors names

略,Bash 4以上新增功能。

20. 重定向顺序

你可以将重定向放到命令的任意位置,一下三个命令的执行结果都是一样的:

$ echo hello >/tmp/example
$ echo >/tmp/example hello
$ >/tmp/example echo hello

21. 交换标准输出与标准错误输出

$ command 3>&1 1>&2 2>&3

在这里,我们首先让文件描述符3指向 stdout,然后将 stdout(文件描述符1)指向 stderr(文件描述符2)。最后有把 stderr(文件描述符2)指向文件描述符3,即 stdout。最终,我们交换了 stdout 与 stderr。

下面我们通过图来展示以上过程,初始的时候是这样的:

首先,执行了3>&1之后,文件描述符3指向 stdout:

接下来,执行1>&2,文件描述符1指向了 stderr:

最后,执行2>&3,文件描述符2执向了 stdout:

如果你是一个追求完美的人,可以将文件描述符3关闭:

$ command 3>&1 1>&2 2>&3 3>&-

最终的文件描述符图会是这样的:

22. 重定向标准输出和标注错误输出给不同的进程

$ command > >(stdout_cmd) 2> >(stderr_cmd)

这一行命令用到了进程替换(Process Substitution)语法。>(...)操作符的执行过程是,运行里面的命令,同时将命令的标准输入连接到一个命名管道的读段。Bash 随后会用命名管道的实际文件名替换这个操作符。

例如,假设第一个替换操作>(stdout_cmd)返回/dev/fd/60,而后一个返回/dev/fd/61。替换后,最初的命令变成以下形式:

$ command > /dev/fd/60 2> /dev/fd/61

从上面可以看出,标准输出重定向到了/dev/fd/60,而标准错误输出则重定向到了/dev/fd/61

当命令执行是输出内容到 stdout,则管道/dev/fd/60后面的进程(stdout_cmd)会从另外一侧读取到数据。同样的,进程stderr_cmd也能从命令的 stderr 输出中读取。

23. 获取管道流中的所有命令执行退出码

假设你用管道流执行多个命令:

$ cmd1 | cmd2 | cmd3 | cmd4

然后你想获取所有命令的退出码,但是这里并没有一种简单的做法可以实现,因为 Bash 只会返回最后一个命令的退出码。

Bash 的开发者同样思考了这个问题,他们添加了PIPESTATUS数组,这个数组中存放了管道流中所有命令的退出码。

下面是一个简单的例子:

$ echo 'pants are cool' | grep 'moo' | sed 's/o/x/' | awk '{ print $1 }'
$ echo ${PIPESTATUS[@]}
0 1 0 0