彭某的技术折腾笔记

彭某的技术折腾笔记

Shell 重定向

2024-05-12

Shell 重定向

2024年5月11日

摘要

曾经写过一篇关于命令拼接的文章,对如何连接使用多个命令进行了浅显的描述,例如管线,反引号,重定向等。然而仅仅只是重定向这一种方式,详细展开已经足够产生很多内容,其中一部分还并不是很好理解,因此本文将对 Shell 重定向的操作进行详尽的阐述,并且会包含一些拓展的高级用法。

Unix 输入输出流

在介绍重定向之前,需要知晓一个很重要的概念,即输入输出流。粗略的来讲,在计算机中数据的传输大致可以分为两种类型:块传输和流传输。

  • 块传输:可以类比为现实世界中搬运纸箱,可以有一个明确的对物体整体的定义,要么就搬运完了一个,要么就没有搬运完,在数据传输中,许多压缩过后的数据例如图片和压缩文件就是这样的类型,必须整个传输完才能使用。
  • 流传输:可以类比为现实世界中用水管输水,可以一遍传输一边把已经收到的部分进行处理。Shell 中许多文本的输入输出就是这种类型,一个程序不必完全执行完,一边运行就可以一边输出内容。

流传输是本文所需要使用的概念。在 Unix 中,最常见的输入输出流可以分为:

  • 标准流:用于通过 Console 输入输出数据
    • 标准输入(文件描述符 0):/dev/stdin(用于通过 Console 读取键盘输入的文本)
    • 标准输出(文件描述符 1):/dev/stdout(用于通过 Console 向屏幕或 SSH 输出文本)
    • 标准错误(文件描述符 2):/dev/stderr(用于通过 Console 向屏幕或 SSH 输出错误信息)
  • 文件流(文件描述符 N):用于通过文件输入输出数据

由于 Linux 一切皆文件的设计哲学~~(不好评价,例如 Socket 就没法被抽象成文件)~~,标准流的三个种类也都通过内核映射成了文件,并且拥有了唯一的文件描述符,可以理解问一个编号。而其他文件则可以通过一定的方式获得其他值作为文件描述符。

后文所讨论的所有重定向都是基于输入输出流的。

重定向

输入重定向

输入重定向比输出重定向要简单很多,因为大体上只有 STDINFILE 两种形式因此先讨论。

假设有一个文件 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> 是一个整体,代表文件描述符为 2STDERR 重定向,而 我们之前用的 >,也无非就是文件描述符为 1STDOUT 的重定向符号 1> 的简写,是一个语法糖而已。

同样,STDERR 也可以追加重定向,例如 2>>

重定向的指令在语法上,可以近似被理解为一种命令后的注解,并不会影响命令的执行,对前面的命令来说是一个黑盒,命令只管输出结果至某个流,由 Console 负责读取重定向指令并进行处理。

多个重定向

在一个命令后,可以使用多个重定向指令,假设一个场景,我们想要在当前目录下查看两个文件的详细信息,两个文件名为 exist.txtnot-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.txt2>> 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 表示把文件标志符为 2STDERR 融合进文件标志符为 1STDOUT,即把 STDERR 重定向至 STDOUT。但此时,但凡是有思考的人就会疑惑:为什么突然出现了一个 & 符号?其实原因很简单,如果直接使用 2>1,其中的 1 会被识别为一个文件名为 1 的文件(文件标志符是一个打开的文件的编号,而不是文件名),产生混淆,因此,&1 代表文件标志符为 1 的文件,有点类似于指针。

行为上,以上命令将会先把 STDERR 重定向至 STDOUT,再将此时融合后的 STDOUT 重定向至 log.txt,从而实现 STDERRSTDOUT 都被重定向至 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: /'

需要说明的是,括号内的部分将被外部的重定向指令视为一个黑盒指令,外部只会知晓括号内哪个流输出了什么东西。因此,此命令的处理流程如下:

  1. (括号内)ls -l exist not-exist 产生 STDOUT 输出 aSTDERR 结果 b
  2. (括号内)STDOUT 输出的 a 通过 | 管线输出至 sed,在开头被添加 Out: ,得到 Out: a
  3. (括号内)STDERR 输出的 b 无人处理,保持不变
  4. (括号内)>&9STDOUT 定义为 &9
  5. 括号作为子命令,最终通过 &9 输出 Out: a,通过 STDERR 输出 b
  6. (括号外)9>&2&9 定义为 STDERR,使得 Out: a 输出至 STDERR
  7. (括号外)2>&1STDERR 定义为 STDOUT,使得 b 输出至 STDOUT
  8. (括号外)STDOUT 输出的 b 通过 | 管线输出至 sed,在开头被添加 Err: ,得到 Err: b
  9. 最终通过 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

💡多条重定向分析方式总结:

  1. 后续规则根据前面的规则化简至最简
  2. 各自独立并行运行,不级联

本章节的命令另一种写法是:

((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_NAMECOMMAND_2 再对文件进行处理,和 COMMAND_1 | COMMAND_2 没什么区别
  • 0