0%

Bash 的输入输出重定向详解

Linux 中每个进程维护了一个文件描述符表,如 shell 终端,默认有三个文件已打开,他们的文件描述符和文件对应关系为:

1
2
3
fd 0: /dev/stdin -> /proc/self/fd/0(前者是指向后者的软链接,下同)
fd 1: /dev/stdout -> /proc/self/fd/1
fd 2: /dev/stderr -> /proc/self/fd/2

所以在该 shell 中创建新的的文件的描述符就从3开始。

用于输入输出重定向使用的文件描述符大于9,所以安全可用的自定义文件描述符范围就是:3-9

因为 Bash 文档 里有这样一句话:

Redirections using file descriptors greater than 9 should be used with care, as they may conflict with file descriptors the shell uses internally.

下面的几个例子会分别演示

1)输入输出重定向
2)复制输入输出文件描述符
3)移动输入输出文件描述符
4)关闭文件描述符和同时读写文件
5)将已重定向的输入/输出恢复到标准输入/标准输出

脚本例子1

这个例子讲的是 输出重定向复制输出文件描述符

脚本内容:

1
2
3
4
5
6
7
8
9
10
#!/bin/bash
# test.sh
echo "write to fd3..."
echo "fd3" >&3
echo "write to fd4..."
echo "fd4" >&4
echo "write to fd5..."
echo "fd5" >&5
echo "write to fd6..."
echo "fd6" >&6

输出:

1
2
3
4
5
6
7
8
9
10
11
[root@jz ~]# sh test.sh 3>file3 4>file4 5>file5 6>file6
write to fd3...
write to fd4...
write to fd5...
write to fd6...
[root@jz ~]# cat file*
fd3
fd4
fd5
fd6
[root@jz ~]#

说明:

创建自定义的文件描述符(fd)从 3 开始。test.sh 脚本中 >&n(n 表示文件描述符,为 3, 4, 5, 6)表示:
复制 fd n 作为 fd 1(1 即标准输出的文件描述符)的值(这里的复制可以理解为复制 fd n 指向的文件指针,fd n 的值 n 实际上是数组下标,数组元素才是指向文件的指针)
也就是说,fd 1 复制了 fd n 的值之后,fd 1 原本指向文件 /dev/stdout,而现在与 fd n 指向相同的文件,n 必须与实际文件关联,这个文件描述符 n 才是有效的
在上文的例子中,通过执行脚本 test.sh,fd 3 关联(重定向)到了实际文件 file3,fd 4 关联到了实际文件 file4 等等
fd 3, 4, 5, 6 关联了实际文件之后,脚本中的 echo “fdN” >&N 就可以依次把标准输出的内容保存到不同的文件了

脚本例子2

这个例子讲的是 输入重定向复制输入文件描述符

脚本内容:

1
2
3
4
5
6
#!/bin/bash
# test.sh
echo "read from fd3..."
cat <&3
echo "read from fd4..."
cat <&4

输出:

1
2
3
4
5
6
[root@jz ~]# sh test.sh 3<file3 4<file4
read from fd3...
fd3
read from fd4...
fd4
[root@jz ~]#

说明:

和输出重定向差不多,例子2脚本中标准输入分别复制了:输入文件描述符3、输入文件描述符4,使得标准输入分别指向 file3、file4
执行脚本时,fd 3、fd 4 分别重定向到了 file3、file4,所以脚本中 cat <&N 就会依次从 file3、file4 读取内容(file3、file 4 是例子1脚本执行后创建的)
上面两个例子讲的是“复制文件描述符”和“输入输出重定向”,bash shell 也支持“移动文件描述符”,下面的脚本将实验移动文件描述符。

脚本例子1

这个例子讲的是 移动输出文件描述符

脚本:

1
2
3
4
5
6
7
8
#!/bin/bash
# move_fd.sh
echo "1: write to fd3..."
echo "fd3" >&3
echo "2: write to fd3..."
echo "fd3" >&3-
echo "3: write to fd3..."
echo "fd3" >&3

执行:

1
2
3
4
5
6
7
8
9
10
11
12
[root@jz ~]# cat file3
fd3
[root@jz ~]#
[root@jz ~]# sh move_fd.sh 3>file3
1: write to fd3...
2: write to fd3...
3: write to fd3...
move_fn.sh: line 8: 3: Bad file descriptor
[root@jz ~]# cat file3
fd3
fd3
[root@jz ~]#

说明:

1)与复制文件描述符不同的是,脚本中 >&3- 表示在 fd 1 复制了 fd 3 的文件指针以后,fd 3 就会关闭,就好像 fd 3 移动到了 fd 1 一样
注意脚本执行结果,第1次写入内容到 fd 3 是正常的,第2次写入时也正常,但之后 fd 3 就被关闭,即 fd 3 不再重定向到文件 file3
所以第3次写入内容时,就会报错,显示坏掉的文件描述符

2)还有,一开始 file3 只有一行,最后变成了二行,因为脚本中的 echo 操作的文件从始至终都是 file3,脚本执行时,因为输出文件 fd 3 重定向到 file3
脚本开始执行,file3 被打开,直到脚本结束,file3 文件才被关闭,所以脚本中每次执行完 echo “fd3” >&3[-] 都是向 file3 新增一行
而不会像终端下直接执行:echo “fd3” > file3,每次都是覆盖文件,因为终端下每次执行 echo 都会单独打开/关闭文件 file3

脚本例子2

这个例子讲的是 移动输入文件描述符

脚本:

1
2
3
4
5
6
7
8
#!/bin/bash
# move_fd.sh
echo "1: read from fd3..."
read ln <&3; echo $ln; ln=
echo "2: read from fd3..."
read ln <&3-; echo $ln; ln=
echo "3: read from fd3..."
read ln <&3; echo $ln; ln=

执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
[root@jz ~]# cat file3
fd3
fd3
fd3
[root@jz ~]# sh move_fd.sh 3<file3
1: read from fd3...
fd3
2: read from fd3...
fd3
3: read from fd3...
move_fd.sh: line 8: 3: Bad file descriptor

[root@jz ~]#

说明:

脚本中使用 read 命令而不是 cat,因为 read 可以逐行读取文件,而 cat 会一次把文件所有行都读取完
类似脚本例子1,这里 <&3- 把输入文件描述符3关闭之后,下次执行 read 命令就会报错

如果脚本例子2内容如下:

1
2
3
4
5
6
7
8
#!/bin/bash
# move_fd.sh
echo "1: read from fd3..."
cat <&3
echo "2: read from fd3..."
cat <&3-
echo "3: read from fd3..."
cat <&3

输出结果是:

1
2
3
4
5
6
7
8
[root@jz ~]# sh move_fd.sh 3<file3
1: read from fd3...
fd3
fd3
fd3
2: read from fd3...
3: read from fd3...
[root@jz ~]#

可以看到,cat 并不会报错,可能是在 cat 一次读完文件后,cat 先判断文件是否读完,再判断文件描述符3是否可用(因为已读完就无需判断是否可用),所以不会报错

创建文件描述符来同时读写文件

语法:n<>filename

n 为文件描述符
filename 为实际文件名
n 不写时表示 fd 0

举例脚本:

1
2
3
4
5
6
7
8
9
10
#!/bin/bash
# test.sh
# 如下命令行表示把输入/输出同时重定向到 inputfile 文件,而文件描述符是3
# 然后让标准输入指向 inputfile 文件,标准输出也指向 inputfile 文件
# 执行 exec 后,下面的命令标准输出都保存到 inputfile 文件,标准输入都读取自 inputfile
# 只有标准错误输出依旧是屏幕
exec 3<>inputfile <&3 >&3
echo -n "1234"
ls xxxx
cat

结果:

1
2
3
4
5
6
7
8
9
10
11
[root@jz ~]# cat inputfile
abcde
dsfsf
[root@jz ~]# sh test.sh
ls: cannot access xxxx: No such file or directory
[root@jz ~]# cat inputfile
1234e
dsfsf
e
dsfsf
[root@jz ~]#

说明:

以上输出结果很复杂,涉及到文件读写操作的实现细节,猜测如下:
脚本执行后,执行到 exec 命令行,文件 inputfile 被打开,文件读写指针应该是指向文件开头的
此时运行 echo 命令行,把 1234 写入到文件开头位置,覆盖掉 abcde 前4个字符变成 1234e,读写指针在字符 e 的位置
执行 ls 时因为文件不存在,信息输出到标准错误,所以文件 inputfile 读写指针位置不变
执行 cat 从字符 e 开始读取,直到文件结尾,读到的内容是:
e
dsfsf

因为文件被 exec 打开,cat 并不会关闭文件,cat 读取出来的内容会再次写入 inputfile 文件,此时读写指针在文件结尾
cat 读出的内容添加到文件结尾之后,变成:
1234e
dsfsf
e
dsfsf

关闭文件描述符

1
2
3
4
<&- 关闭标准输入
>&- 关闭标准输出
n<&- 表示将 n 号输入关闭
n>&- 表示将 n 号输出关闭

举例脚本:

1
2
3
4
5
6
7
[root@jz ~]# cat <&-           # 关闭标准输入
cat: -: Bad file descriptor
cat: closing standard input: Bad file descriptor
[root@jz ~]# ls >&- # 关闭标准输出
ls: write error: Bad file descriptor
[root@jz ~]# ls xxxx 2>&- # xxxx 文件不存在,关闭标准错误后,错误信息不会输出
[root@jz ~]#

最后,说说怎么恢复到标准输入/标准输出

其实很简单,上文已经讲了复制文件描述符,只需要把标准输入输出,或标准错误先复制保存,之后恢复即可。

举例脚本:

1
2
3
#!/bin/bash
# test.sh
read -p "Enter to continue ..."

执行:

1
2
3
4
5
6
7
8
[root@jz ~]# cat file3
fd3
fd3
fd3
[root@jz ~]# sh test.sh
Enter to continue ...
[root@jz ~]# sh test.sh <file3 # 脚本的输入被重定向到 file3 后,read 命令直接读取了 file3 的行,不再显示 Enter ...
[root@jz ~]#

那么,怎么把 read 的输入恢复到标准输入,再次询问用户按回车继续呢?

把脚本改成:

1
2
3
#!/bin/bash
# test.sh
read -p "Enter to continue ..." <&3

然后用如下方式执行脚本:

1
sh test.sh 3<&0 <file3

执行脚本时,它的含义是先创建一个新的文件描述符3,fd 3 复制 fd 0 的值,就把 fd 0 保存到 fd 3 了,然后在脚本中 read 命令的标准输入文件描述符从 fd 3 复制回来即可。

参考:

https://www.gnu.org/software/bash/manual/html_node/Redirections.html