Shell 重定向
Shell 重定向
2024年5月11日
摘要
曾经写过一篇关于命令拼接的文章,对如何连接使用多个命令进行了浅显的描述,例如管线,反引号,重定向等。然而仅仅只是重定向这一种方式,详细展开已经足够产生很多内容,其中一部分还并不是很好理解,因此本文将对 Shell 重定向的操作进行详尽的阐述,并且会包含一些拓展的高级用法。
Unix 输入输出流
在介绍重定向之前,需要知晓一个很重要的概念,即输入输出流。粗略的来讲,在计算机中数据的传输大致可以分为两种类型:块传输和流传输。
- 块传输:可以类比为现实世界中搬运纸箱,可以有一个明确的对物体整体的定义,要么就搬运完了一个,要么就没有搬运完,在数据传输中,许多压缩过后的数据例如图片和压缩文件就是这样的类型,必须整个传输完才能使用。
- 流传输:可以类比为现实世界中用水管输水,可以一遍传输一边把已经收到的部分进行处理。Shell 中许多文本的输入输出就是这种类型,一个程序不必完全执行完,一边运行就可以一边输出内容。
流传输是本文所需要使用的概念。在 Unix 中,最常见的输入输出流可以分为:
- 标准流:用于通过 Console 输入输出数据
- 标准输入(文件描述符 0):
/dev/stdin
(用于通过 Console 读取键盘输入的文本) - 标准输出(文件描述符 1):
/dev/stdout
(用于通过 Console 向屏幕或 SSH 输出文本) - 标准错误(文件描述符 2):
/dev/stderr
(用于通过 Console 向屏幕或 SSH 输出错误信息)
- 标准输入(文件描述符 0):
- 文件流(文件描述符 N):用于通过文件输入输出数据
由于 Linux 一切皆文件的设计哲学~~(不好评价,例如 Socket 就没法被抽象成文件)~~,标准流的三个种类也都通过内核映射成了文件,并且拥有了唯一的文件描述符,可以理解问一个编号。而其他文件则可以通过一定的方式获得其他值作为文件描述符。
后文所讨论的所有重定向都是基于输入输出流的。
重定向
输入重定向
输入重定向比输出重定向要简单很多,因为大体上只有 STDIN
和 FILE
两种形式因此先讨论。
假设有一个文件 sample.txt
:
This is a file
其中共有 4 个单词,我们可以使用 wc
命令对其进行字数统计:
wc -w sample.txt
得到输出:
4 sample.txt
此时,此命令是通过文件流进行的数据读取,我们也可以调用 <
将文件重定向至 STDIN
进行使用:
wc -w < sample.txt
得到输出:
4
还有一种类似输入重定向的操作,叫做 Heredoc,格式如下:
[COMMAND] <<[-] 'DELIMITER'
HERE-DOCUMENT
DELIMITER
例如:
wc -w << EOF
This is a file
EOF
其将会直接从 STDIN
中读取文本。
Heredoc 有几个规则:
[COMMAND] <<[-] 'DELIMITER'
后必须立刻换行,'DELIMITER'
可以不加引号<<
将会严格按照字符顺序读去后文,包括行首的缩进<<-
将会忽略行首缩进,Shell 是一个不依赖缩进进行语法分析的语言,因此此选项有时会有利于 Shell 脚本代码的美观性- 最后一行必须严格和第一行定义的
'DELIMITER'
一致,当使用<<
时,首尾都不能有空格,当使用<<-
时,开头可以有空格
依靠 Heredoc,我们可以在 Shell 中定义多行字符串,例如:
var=$(cat <<- EOF
this is line 1
this is line 2
EOF
)
输出重定向
输出重定向某些文章会讲的特别复杂,并且没有把语法的本质讲的非常清楚,本文将进行清晰的梳理。
和输入重定向的 <
类似,输出重定向也基本是基于 >
来衍生的。
一般重定向
常见的许多命令的输出结果都会输出至 STDOUT
,显示在 Console 中,我们可以使用 >
将其重定向至其他位置,例如某个文件或者 /dev/null
(当然 /dev/null
也是个文件),例如:
echo "Output to file" > temp.txt
此时 Console 上将不会显示任何输出,原本会显示在 Console 中的 "Output to a file"
将会覆盖存入 temp.txt
,此文件中原本的所有内容将会被抹去。
我们也可以把任何命令的输出重定向至 /dev/null
,从而实现静默执行,不显示任何结果(错误信息除外):
[COMMAND] > /dev/null
/dev/null
是一个特殊的文件,类似于一个黑洞,输入其中的任何内容都将被忽略,原地消失。
追加重定向
在一些场景下,我们想要将命令的输出结果重定向至文件,是为了留存备案方便日后查看,那么 >
会覆盖目标文件的特性就不太适合了,此时,我们可以使用 >>
对 STDOUT
进行追加重定向,例如:
echo "This is some log." >> log.txt
执行结果将会追加到 log.txt
的末尾,原有内容不会被修改。
错误重定向
在实际情况下,并不是所有命令都能完全成功执行,有时也会产生错误信息输出至 STDERR
,默认情况下,为了显示错误信息,STDERR
也会输出至 Console,看起来和 STDOUT
并无不同,但在系统内部二者是通过不同的线路进行处理的。要实现重定向错误信息,我们可以使用 2>
进行重定向,例如:
mkdir '' 2> error.txt
此时,错误信息将会被保存至 error.txt
。
许多文章在此处并没有讲的很明白,例如完全不解释明白这里的
2
是什么意义,其实2>
是一个整体,代表文件描述符为2
的STDERR
重定向,而 我们之前用的>
,也无非就是文件描述符为1
的STDOUT
的重定向符号1>
的简写,是一个语法糖而已。同样,
STDERR
也可以追加重定向,例如2>>
。重定向的指令在语法上,可以近似被理解为一种命令后的注解,并不会影响命令的执行,对前面的命令来说是一个黑盒,命令只管输出结果至某个流,由 Console 负责读取重定向指令并进行处理。
多个重定向
在一个命令后,可以使用多个重定向指令,假设一个场景,我们想要在当前目录下查看两个文件的详细信息,两个文件名为 exist.txt
和 not-exist.txt
,其中 exist.txt
存在,而 not-exist.txt
并不存在:
ls -l exist.txt not-exist.txt
此时的执行结果是:
ls: not-exist.txt: No such file or directory
-rw-r--r-- 1 psc wheel 0 5 11 21:30 exist.txt
表面上看起来,两排输出都是通过 Console 进行显示的,但其实第一排是一个错误信息,是通过 STDERR
进行输出的,而第二排是正常信息,是通过 STDOUT
进行输出的。我们可以讲两种类别的信息分别进行重定向:
ls -l exist.txt not-exist.txt >> log.txt 2>> error.txt
>> log.txt
和 2>> error.txt
两个是两条独立的重定向指令,但是是按照从右往左的顺序进行数据流动的(此处顺序还不影响结果,后面将详细介绍),此时,正常的输出 -rw-r--r-- 1 psc wheel 0 5 11 21:30 exist.txt
将被存入 log.txt
,错误信息 ls: not-exist.txt: No such file or directory
将被存入 error.txt
。
合并重定向
在某些情况下,我们会需要将两个流的输出结果进行合并,例如将 STDERR
合并至 STDOUT
然后一起处理,一般的文章会直接说我们使用 2>&1
即可,例如:
ls -l exist.txt not-exist.txt >> log.txt 2>&1
其中,2>&1
表示把文件标志符为 2
的 STDERR
融合进文件标志符为 1
的 STDOUT
,即把 STDERR
重定向至 STDOUT
。但此时,但凡是有思考的人就会疑惑:为什么突然出现了一个 &
符号?其实原因很简单,如果直接使用 2>1
,其中的 1
会被识别为一个文件名为 1
的文件(文件标志符是一个打开的文件的编号,而不是文件名),产生混淆,因此,&1
代表文件标志符为 1
的文件,有点类似于指针。
行为上,以上命令将会先把 STDERR
重定向至 STDOUT
,再将此时融合后的 STDOUT
重定向至 log.txt
,从而实现 STDERR
和 STDOUT
都被重定向至 log.txt
。
重定向指令顺序:此处极易引起混淆,为什么是
>> log.txt 2>&1
而不是2>&1 >> log.txt
,后者看似更符合语言顺序,把错误信息融合进标准输出,再一起输出给文本文件,而实际上正确的则是前者?其实,重定向规则更像是 C 语言中的
#define
指令,一条一条规则的完成替换,后续规则会根据前面的规则进行更新,生成最简规则,且每条规则都是终点,输出到哪里就到哪里了,输出不会被后续重定向当作输入:
>> log.txt 2>&1
:
#define STDOUT log.txt
#define STDERR STDOUT
(STDOUT
被上一条规则定义后等效于#define STDERR log.txt
)>> log.txt 2>&1
:
#define STDERR STDOUT
(此处STDOUT
还未被定义成log.txt
,此规则到此结束,通过此规则产生的输出不会再被下一条规则作为输入)#define STDOUT log.txt
按照 C 语言
#define
替换的思路即可理解多条重定向指令的顺序问题。另一种硬编的理解方式是(只是理解方式,不代表计算机的处理方式),为什么想要的数据流方向和从右往左的重定向指令相吻合?因为 Shell 命令的参数个数是可变的,而重定向指令永远在最后,不知道从第几个 Token 开始是重定向指令,因此 Shell 从右向左提取分析重定向指令得到数据流方向。例如:
>> log.txt 2>&1
,从右往左,先是2>&1
,先把STDERR
重定向至STDOUT
,然后是>> log.txt
,将此时融合后的STDOUT
重定向至log.txt
。其实之所以是从右往左,是因为
#define
是行为上是右结合的,比如#define STDERR STDOUT => #define STDERR (#define STDOUT log.txt)
,右侧的右值才是被前面的指令先定义的。
临时重定向
我们可以使用一个临时的文件操作符暂存某一个输出流,从而实现对不同的流进行不同的处理,例如:
(ls -l exist not-exist | sed 's/^/Out: /' >&9) 9>&2 2>&1 | sed 's/^/Err: /'
需要说明的是,括号内的部分将被外部的重定向指令视为一个黑盒指令,外部只会知晓括号内哪个流输出了什么东西。因此,此命令的处理流程如下:
- (括号内)
ls -l exist not-exist
产生STDOUT
输出a
与STDERR
结果b
- (括号内)
STDOUT
输出的a
通过|
管线输出至sed
,在开头被添加Out:
,得到Out: a
- (括号内)
STDERR
输出的b
无人处理,保持不变 - (括号内)
>&9
将STDOUT
定义为&9
- 括号作为子命令,最终通过
&9
输出Out: a
,通过STDERR
输出b
- (括号外)
9>&2
将&9
定义为STDERR
,使得Out: a
输出至STDERR
- (括号外)
2>&1
将STDERR
定义为STDOUT
,使得b
输出至STDOUT
- (括号外)
STDOUT
输出的b
通过|
管线输出至sed
,在开头被添加Err:
,得到Err: b
- 最终通过
STDERR
输出Out: a
,通过STDOUT
输出Err: b
此时,正常输出和错误输出得到了不同的处理,虽然输出通道发生了交换,但如果需要,我们可以再使用一次临时文件描述符暂存再交换一次。
再次解释易混淆点:步骤 6 到 7,为什么将
&9
定义为STDERR
,再将STDERR
定义为STDOUT
,并没有使得&9
变成STDOUT
,而是依然保持STDERR
?再举个例子:
#define &9 STDERR #define STDERR STDOUT
第二条规则出现之前,
STDERR
并没有被定义成别的东西,因此,两条规则都已经是最简形式,各自独立并行运行,不会级联。再如果,是
2>&1 9>&2
的话:#define STDERR STDOUT #define &9 STDERR
第二条规则出现之前,
STDERR
已经被定义成STDOUT
,因此第二条规则可以化简成:#define &9 STDOUT
则此时:
#define STDERR STDOUT #define &9 STDOUT
两条规则都已经是最简形式,各自独立并行运行,不会级联,都输出至
STDOUT
。
💡多条重定向分析方式总结:
- 后续规则根据前面的规则化简至最简
- 各自独立并行运行,不级联
本章节的命令另一种写法是:
((ls -l exist not-exist | sed 's/^/Out: /' >&9) 2>&1 | sed 's/^/Err: /') 9>&1
和
(ls -l exist not-exist | sed 's/^/Out: /' >&9) 9>&2 2>&1 | sed 's/^/Err: /'
完全相同,也比较好分析。
常用文本相关的命令都只接收文件输入或者上一个命令的标准输出。
一个语法糖
[COMMAND] |& ...
等效于 [COMMAND] 2&>1 | ...
进程替换
在 ZSH 以及 BASH >= 4.0 等新版本的 Shell 中,还有一个进程替换(Process Substitution)的功能,可以使用文件将命令进行封装,使得标准输入输出成为文件的形式递送给其他命令。
COMMAND_1 <(COMMAND_2)
:将COMMAND_2
的结果包装成匿名文件,由COMMAND_1
处理该匿名文件(COMMAND_1
需要能够接收文件输入),后面可以有多个匿名文件COMMAND_1 < <(COMMAND_2)
:将COMMAND_2
的结果包装成匿名文件,再将文件输入重定向至STDIN
输入给COMMAND_1
处理,此方式和COMMAND_2 | COMMAND_1
没什么区别COMMAND_1 >(COMMAND_2)
:>(COMMAND_2)
会被替换为ANONYMOUS_FILE_NAME
,如果COMMAND_1
能够向文件输出内容或处理文件,则该内容输出至ANONYMOUS_FILE_NAME
后被COMMAND_2
处理,可以echo >(COMMAND)
或者ls >(COMMAND)
查看文件路径,后面可以有多个匿名文件COMMAND_1 > >(COMMAND_2)
:COMMAND_1
输出重定向至COMMAND_2
包装成的ANONYMOUS_FILE_NAME
,COMMAND_2
再对文件进行处理,和COMMAND_1 | COMMAND_2
没什么区别