文件描述符

Linux 操作系统中的文件 I/O 由内核通过文件描述符来完成. 当前进程所追踪的文件描述符以符号链接的形式存放在 /proc/self/fd/ 路径下, 其中最特殊的是编号为 0 1 2 的文件描述符, 分别代表 stdin , stdout , stderr . 此外, shell 还可以将 TCPUDP 端口作为文件描述符来对待.

> ls -l /dev/std*
lrwxrwxrwx 1 root root 15 Mar 12 18:02 /dev/stderr -> /proc/self/fd/2
lrwxrwxrwx 1 root root 15 Mar 12 18:02 /dev/stdin -> /proc/self/fd/0
lrwxrwxrwx 1 root root 15 Mar 12 18:02 /dev/stdout -> /proc/self/fd/1

> ls -gGl /proc/self/fd
total 0
lrwx------ 1 64 Mar 12 21:46 0 -> /dev/pts/1
lrwx------ 1 64 Mar 12 21:46 1 -> /dev/pts/1
lrwx------ 1 64 Mar 12 21:46 2 -> /dev/pts/1
lrwx------ 1 64 Mar 12 21:46 20 -> 'socket:[42311]'
lr-x------ 1 64 Mar 12 21:46 3 -> /proc/29324/fd

需要注意的是, 对每个进程来说 /proc/self/ 下的文件都是独立存在的, 该路径实际上是个符号链接, 指向 /proc/<pid>/ . 而从操作系统层面来看, proc 其实是一种特殊的文件系统, 用于给程序提供内核态数据结构的接口, 这个虚拟文件系统默认被挂载在 /proc 路径下, 通过 mount -t 可以将其挂载到其他地方:

# mount -t <types> <source> <target>
> mount -t proc proc /proc

对于 pipesocket , 文件描述符符号链接中会存储文件的类型和 inode , 例如上述例子中的 20 -> 'socket:[42311]' , 这说明 20 号文件描述符指向了一个 inode=42311 且已经被删除的 socket . 而对于通过 epoll_create , signalfd , eventfd 等特殊系统调用建立的文件描述符, 其符号链接中会存储 anon_inode:<file-type> 结构的信息, 以 epoll 为例, 其文件描述符中的内容如下:

anon_inode:[eventpoll]

对于通过计划任务如: at , cron 等执行的命令, 由于其不像普通命令一样能够继承父 shell 传递来的文件描述符, 因此这类命令的文件描述符一般指向管道或临时文件:

> at 1140
warning: commands will be executed using /bin/sh
at> ls -l /proc/self/fd/ > /tmp/test.at
at> <EOT>
job 6 at Fri Mar 13 11:40:00 2020
> cat /tmp/test.at 
total 0
lr-x------ 1 remilia remilia 64 Mar 13 11:40 0 -> /var/spool/cron/atjobs/a000060192d99c (deleted)
l-wx------ 1 remilia remilia 64 Mar 13 11:40 1 -> /tmp/test.at
lrwx------ 1 remilia remilia 64 Mar 13 11:40 2 -> /var/spool/cron/atspool/a000060192d99c
lr-x------ 1 remilia remilia 64 Mar 13 11:40 3 -> /proc/19462/fd

重定向执行顺序

shell 中, 我们经常会使用管道与重定向用于控制命令或脚本的输入输出, 这些操作的本质就是更改文件描述符符号链接所指向的文件. 在 shell 中执行一个命令之前会按照如下步骤进行文件描述符操作:

  • 如果上一个命令的输出通过管道 | 与当前命令的输入相连, 则把 /proc/<current_process_ID>/fd/0 的指向更新到与 /proc/<previous_process_ID/fd/1 相同指向的匿名管道.
  • 如果当前命令的输出通过管道 | 连接到下一个命令的输入, 则将 /proc/<current_process_ID>/fd/1 的指向更新到另一个匿名管道中.
  • 从左向右按顺序解析所有的重定向符号.
    • 如果命令后接N>&MN<&M 则将 /proc/self/fd/N 的输出或输入符号链接指向 /proc/self/fd/M 符号链接指向的目标.
    • 如果有 N > fileN < file 则将 /proc/self/fd/N 的输出或输入符号链接指向 file .
    • 如果出现 N>&- 则删除 /proc/self/fd/N 符号链接.
  • 执行命令.

重定向符号

shell 中支持的重定向符号如下表所示:

符号 说明
< file 将命令的输入进行重定向到文件
> file 或 1 > file 将命令的输出重定向到文件, 写入方式为覆盖
>> file 或 1 >> file 将命令的输出重定向到文件, 写入方式为追加
2 > file 将命令的标准错误重定向到文件 (覆盖)
2 >> file 将命令的标准错误重定向到文件 (追加)
&> file 或 >file 2>&1 将命令的标准输出和标准错误都重定向到一个文件中 (覆盖)
&>> file 或 >>file 2>&1 将命令的标准输出和标准错误都重定向到一个文件中 (追加)
<< tag 和 <<- tag 将开始标记 tag 和结束标记 tag 之间的内容作为输入

/dev/null 是一个特殊的文件, 他会丢弃所有传给他的数据:

> ( echo stdout; echo stderr >&2 ) > /dev/null
stderr  # 标准输出被丢弃
> ( echo stdout; echo stderr >&2 ) 2> /dev/null
stdout  # 标准错误被丢弃
> ( echo stdout; echo stderr >&2 ) &> /dev/null
# 标准输出和标准错误都被丢弃

在分析带有重定向的命令时, 要时刻牢记重定向永远是相对命令产生的概念, 因此重定向的结果只与出现的顺序有关:

> > c > d echo "abcd" > e
# 从左到右解析重定向符号
# 先把 echo 的 FD1 更新为指向 c
# 再把 echo 的 FD1 更新为指向 d
# 最后把 echo 的 FD1 更新为指向 e
> cat e
abcd
> cat c d
# 没有输出 因为上个命令的输出没有最终指向它们

> < e grep . < d
# 没有输出因为 d 为空
> < d grep . < e
abcd  # 有输出因为最终是把 grep 的 FD0 更新为指向 e

再次强调, 重定向符号的顺序会影响最终重定向的结果:

# 只重定向标准输出
> ( echo stdout; echo stderr >&2 ) > log1
stderr
> cat log1
stdout

# 重定向标准输出和标准错误到同一文件
> ( echo stdout; echo stderr >&2 ) &> log2
# 等价于 ( echo stdout; echo stderr >&2 ) > log2 2>&1
# 等价于 ( echo stdout; echo stderr >&2 ) > log2 2>>log2
> cat log2
stdout
stderr

# 错误写法
> ( echo stdout; echo stderr >&2 ) > log2 2>log2
> cat log2
stderr  # 标准错误把标准输出给覆盖了

> ( echo stdout; echo stderr >&2 ) 2>log2 1>>log2
> cat log2
stderr  # 因为标准输出的写入顺序要优先于标准错误

> ( echo stdout; echo stderr >&2 ) > log2 1>&2
stdout  # 先把 FD1 指向 log2, 再把 FD1 指向标准错误
stderr
> cat log2  # 因此 log2 里什么也没有

管道符号

管道是进程间通讯的经典方法, 在 shell 中, 我们使用匿名管道符号 | 将前一个进程的 stdout 与后一个进程的 stdin 连接. 如下例, 我们先用 cat 将文件合并, 然后将其传递给后续命令进行处理, 这样就构建了处理流水线:

> cat $filename1 $filename2 | grep $search_word
> cat f{1,2,3,2,3,1,1} | sort -r | uniq  
3  
2  
1

shell 中支持的管道符号如下表所示: | 符号 | 说明 | | -- | -- | | cmd1 | cmd2 | 将 cmd1 的标准输出和 cmd2 的标准输入指向 pipe | | cmd1 |& cmd2 | 现将 cmd1 的标准输出和 cmd2 的标准输入指向 pipe , 再将 cmd1 的标准错误指向到当前标准输出的指向, 等价于 cmd1 2>&1 | cmd2 .|

管道默认是将左侧的标准输出和右侧的标准输入连接到同一个 pipe 文件上, 如果我们想把标准错误也传递给右侧可以按照如下方式操作:

# 传递 stdin 与 stderr
> ( echo stdout; echo stderr >&2 ) |& awk '{ printf "From pipe: %s\n", $0 }'
# 等价于( echo stdout; echo stderr >&2 ) 2>&1 | awk '{ printf "From pipe: %s\n", $0 }'
From pipe: stdout
From pipe: stderr

# 只传递 stderr
> ( echo stdout; echo stderr >&2 ) 2>&1 >/dev/null | awk '{ printf "From pipe: %s\n", $0 }'
From pipe: stderr

1. 首先处理管道, 将"command FD 1" 和 "grep FD 0"都指向管道
2. 将"command FD2"指向"command FD1"当前的指向 (也就是管道)
3. 将"command FD1"指向 /dev/null

# 错误写法
> ( echo stdout; echo stderr >&2 ) >/dev/null 2>&1 | awk '{ printf "From pipe: %s\n", $0 }'

1.  首先处理管道, 将"command FD 1" 和 "grep FD 0"都指向管道
2.  将"command FD 1"指向 /dev/null
3.  将"command FD 2"指向"command FD 1"当前的指向 (/dev/null)
4. 管道都没有输入端, 自然不会传递任何数据给 grep

[warning]注意: 要注意区分管道符号与重定向符号. | 左边的命令应该有标准输出, 其右边的命令应该接受标准输入; 但重定向符号的右侧只能是文件. 此外, 管道触发两个 sub shell 执行 | 两边的程序;而重定向是在一个进程内执行.

由于管道两侧的命令会放到 sub shell 中执行, 因此不能改变父 shell 中的变量:

> var="init_value"
> echo "new_value" | read var
> echo "var = $var"
var = init_value

命名管道

使用 | 建立的管道是匿名管道, 只在父进程与子进程之间起作用. 如果我们需要复杂的进程通信(如: 跨终端通信), 需要使用 mkfifo 创建命名管道:

> mkfifo /tmp/fifo1
# mknod /tmp/fifo1 p
> file /tmp/fifo1
/tmp/fifo1: fifo (named pipe)
> ls -lF /tmp/fifo1  # -F 选项会在结尾加管道符号
prw-rw-r-- 1 remilia remilia 0 Mar 15 19:18 /tmp/fifo1|

管道的读写遵照先进先出原则, 其内容驻留在内存中而不是被写到硬盘上, 数据内容只有在输入输出端都打开时才会传送. FIFO 具有以下特点:

  1. 当写进程向管道中写数据的时候,如果没有进程读取这些数据,写进程会堵塞
  2. 当读取管道中的数据的时候,如果没有数据,读取进程会被堵塞
  3. 当写进程堵塞的时候,有读进程读取数据,那么写进程恢复正常
  4. 当读进程堵塞的时候,如果写进程写了数据,那么读进程会读取数据,然后正常执行后面的代码

下面是一个循环读取 FIFO 内容的脚本:

#!/usr/bin/bash

pipe=/tmp/fifo1

trap "rm -f $pipe" EXIT

if [[ ! -p $pipe ]]; then
    mkfifo $pipe
fi

# 不加外面这层则读到 end-of-stream 就退出
while true
do
    while read line
    do
        if [[ "$line" == 'quit' ]]; then
            break
        else
            echo $line
        fi
    done < $pipe
done

echo "Stop reader...."

自定义文件描述符

我们可以通过 exec 命令自定义文件描述符:

# exec fdN> file
> exec 6> file  # 把文件描述符 6 分配给 file 只写
> echo "line1" >& 4
> cat file
line1

# exec fdN< file
> exec 7< file # 把文件描述符 7 分配给 file 只读
> echo "line2" > file
> cat <& 7
line2

# exec fdN<> file
> exec 8<> file # 读写 多用于fifo

# read -u 表示从文件描述符读取
> read -u 7 line; echo $line
line2

我们可以在脚本中将标准输入描述符暂存, 从而实现让脚本能够自由切换输入为文件或终端:

CONFIG=/var/tmp/sysconfig.out

# 暂存标准输入描述符
exec 7<&0

# 切换输入为文件
exec < /etc/passwd

read rootpasswd
echo $rootpasswd >> "$CONFIG"

# 恢复标准输入 并删除暂存描述符
exec 0<&7 7<&-

我们可以通过 exec fd<&- 关闭一个文件描述符, 例如我们可以将标准输出打印到屏幕的同时将标准错误传递到管道右侧:

> cat listdirs.sh
#!/usr/bin/bash

INPUTDIR="$1"

# 文件描述符 6 指向标准输出
exec 6>&1

# ls 的 FD1 和 awk 的 FD0 都指向管道
# 把 ls 的 FD2 指向 FD1 当前的指向, 也就是管道
# 把 ls 的 FD1 指向 FD6 当前的指向, 也就是标准输出
# 管道两侧的 subshell 会继承父 shell 的文件描述符
# 关闭 ls 的 FD6
# 关闭 awk 的 FD6
ls "$INPUTDIR"/* 2>&1 >&6 6>&- | awk -F":" '{print "ERROR: " $2}' 6>&-

# 关闭当前 shell 的文件描述符
exec 6>&-

> bash listdirs.sh /root
ERROR:  cannot access '/root/*'

如果在文件描述符未被释放的情况下删除原文件, 可以通过拷贝描述符的方式恢复文件数据(拷贝后的文件与原文件 inode 不同):

> touch file1
> exec 6> file1
> ll /proc/$/fd/6
lrwx------ 1 remilia remilia 64 Mar 15 18:58 6 -> /home/remilia/file1
> echo "abcd" >& 6
> cat file1
abcd
> cat /proc/$/fd/6
abcd
> rm file1
> lrwx------ 1 remilia remilia 64 Mar 15 18:58 6 -> '/home/remilia/file1 (deleted)'
> cp /proc/$/fd/6 ~/file1
> cat file1
abcd

嵌入文档

嵌入文档 (Here Document) 是一种特殊的重定向方式,它的基本的形式如下:

command << delimiter
    document
delimiter

# 加个 - 也可以
command <<- delimiter
    document
delimiter

它的作用是将两个 delimiter 之间的内容 document 作为输入传递给 command . 注意:

  • 结尾的delimiter 一定要顶格写,前面不能有任何字符,后面也不能有任何字符,包括空格和 tab 缩进。
  • 开始的delimiter前后的空格会被忽略掉。

下面的例子,通过 wc -l 命令计算 document 的行数:

> wc -l << EOF
> This is a simple lookup program
> for good (and bad) restaurants
> in Cape Town.
> EOF
3

也可以 将 Here Document 用在脚本中,例如:

#!/bin/bash

wc -w <<EOF
This is a test.
Apple juice.
100% fruit juice and no added sugar, colour or preservative.
EOF

results matching ""

    No results matching ""