从文件描述符和打开文件之间的关系重新理解Shell重定向

本文缘起遇到的Shell重定向的先后顺序问题,下定决心搞懂linux下非常常用的重定向原理。

在使用Shell命令时,重定向是一种方便的将命令执行的结果输出到文件中或者将文件内容输入到命令中,其最常用的用法如下:

shell

# 输出
ls > output.txt             # 1. 将ls命令标准输出重定向到output.txt文件,文件已存在会清空
ls >> output.txt            # 2. 将ls命令标准输出重定向到output.txt文件,文件已存在会append
ls 2>&1                     # 3. 将ls命令输出中标准错误(文件描述符2)重定向到标准输出(文件描述符1)
ls &> output.txt            # 4. 将ls命令标准输出和标准错误输出到output.txt文件
ls > output.txt 2>&1        # 5. 和4语义上相同

# 输入
grep search-word <input.txt # 6. grep输入重定向到input.txt文件

我一直对重定向的理解都是很简单的,即:

  1. cmd 1> filename就是将命令的标准输出(fd为1,命令中可省略)输出的目的地从终端屏幕到文件
  2. cmd 2>&1就是将命令的标准错误2合并到标准输出1

当设置重定向时,比方说将标准输出1重定向到一个文件中,程序中有一条printf语句,其内部最终会调用write(fd=1, …)而重定向将fd=1替换成文件,就会使该语句从打印到屏幕改为打印到文件。

该简单的理解大部分情况已经够用了,但是难以理解下边重定向的顺序问题,在man bash文档中REDIRECTION小节对重定向的顺序问题的描述,即

Note that the order of redirections is significant. For example, the command

ls > dirlist 2>&1

directs both standard output and standard error to the file dirlist, while the command

ls 2>&1 > dirlist

directs only the standard output to file dirlist, because the standard error was duplicated from the standard output before the standard output was redirected to dirlist.

该语句想要实现的功能是将命令的标准输出和标准错误都输出到文件之中,想要实现该功能必须重定向标准输出到文件要在重定向标准错误到标准输出之前,即第一种写法才是正确的。当然更方便的写法是ls &> dirlist,可以理解其是对第一种写法的一种简写。

其对第二种写法错误的地方解释为:其标准错误从标准输出复制时,标准输出还未重定向到文件之中。该写法实际效果就是命令的标准输出重定向到文件之中,而标准错误则和重定向前的标准输出一样输出到屏幕上。

隐隐的感觉到需要了解文件描述符的复制才能真正理解重定向,从而对该问题有一个正确的解释。最近在《Linux/UNIX系统编程手册(上册)》中得到了答案,未防遗忘故记此文。文末对之前工作中遇到使用exec重定向Shell脚本打印日志的方法进行记录。

下边是对《Linux/UNIX系统编程手册(上册)》1第5章内容的总结归纳,从而解释上述问题。

众所周知,文件描述符fd是一个整数,其是内核维护的文件打开表中的index,通过该index在文件表中可以找到打开文件的相关信息,包含文件偏移量、对文件系统i-node引用等。这大概就是我之前所理解的fd,其益于理解,但是忽略了很多的细节,为了理解上述问题,需要填补这些细节。

在内核中为了实现文件描述符和打开文件之间的关系,维护了3个数据结构:

引用《Linux/UNIX系统编程手册(上册)》P78
fd与打开文件之间的关系

  1. 进程级的文件描述表

    针对每个进程,内核为其维护打开文件的描述符(open file descritor)表,其含有:

    • 进程对fd私有的设置flag(目前仅有close-on-exec标志)
    • 对系统级打开文件表中的打开文件句柄(open file handle)的引用
  2. 系统级的打开文件表

    内核会对所有打开的文件维护系统级的描述符表,表中的各个元素的名称为打开文件句柄(open file handle),其每一项含有:

    • 当前文件偏移量(调用read和write、lseek时更改)
    • 打开文件时所使用的状态标志
    • 文件访问模式(如open时所设置的只读模式、只写或读写模式)
    • 与信号驱动IO相关的设置
    • 对该文件i-node对象的引用
  3. 文件系统的i-node表

    文件系统会对所有文件建立一个i-node表,每个文件对应一个i-node表项,其含有:

    • 文件类型(例如,常规文件、套接字或FIFO和访问权限)
    • 一个执政,直线该文件所持有的锁列表
    • 文件的各种属性,包含文件大小以及不同类型操作相关的时间戳(没有文件名,文件名存储在目录文件中,其中含有该目录下所有文件名和inode对应关系)

对图中进程A中fd=2和进程B中fd=2都指向同一个打开文件句柄73的情况可能在调用fork后出现(即进程A与进程B为父子关系),同时也需要注意到fork之后打开的文件会共享偏移量、状态标志等。或者当某进程通过UNIX域套接字至将一个打开的文件描述符传递给另一个进程时,也会发生。

对图中进程A中fd=0和进程B中fd=3指向不同的打开文件句柄,但这两个句柄指向同一个inode项1976,这种情况就是两个进程open了同一个文件,其偏移量并不共享,所以两个进程都write时会相互覆盖。在用一个进程打开同一个文件两次也类似。

ps: 所有的系统调用都是原子操作,两个进程同时往一个文件里write,不会出现交织一起的情况,但是涉及到偏移量的问题,可能会互相覆盖。

通过打开文件时加上O_APPEND参数,内核会保证向文件末尾追加时,移动偏移量到文件末尾和写入两个操作是原子的。在多进程往同一个文件追加写日志的时候很有用。

对图中进程A中fd=1和fd=20都指向同一个打开的文件句柄23,就可能是通过复制文件描述符dup()、dup2()、dup3()或fcntl()形成的,在下边一节中着重介绍。

这里想到一个问题,假如对fd=20调用close会影响到fd=1吗?从man close会得到答案,即close只会关闭进程级的文件描述符表,在所有对打开文件句柄的引用文件描述符都关闭时,才会释放打开文件句柄。

The close() call deletes a descriptor from the per-process object reference table. If this is the last reference to the underlying object, the object will be deactivated.

上述的分层设计让我想到了那句名言

Any problem in computer science can be solved by another layer of indirection.

计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。

复制文件描述的系统调用可以实现复制一个fd生成一个副本,而副本的fd值可以由调用者设置。共有dup()、dup2()、dup3()和fcntl()系统调用实现该功能,简单来说:

  1. int dup(int oldfd)仅仅是复制oldfd返回副本fd,如果需要设定副本fd值,需要利用系统保证新描述符一定是编号最低的未用文件描述符来设置,并不是十分可靠的。
  2. newfd = fcntl(oldfd, F_DUPFD, startfd)复制oldfd并且选择大于等于startfd的最小未用值作为newfd的值,通过先close(startfd)基本可以保证设置newfd的值为startfd。
  3. int dup2(int oldfd, int newfd)复制oldfd并且设置副本fd值为newfd,如果newfd参数所指的文件描述符之前已打开,那么dup2会将其先关闭close,并且忽略关闭期间的任何错误。故,更好的做法是在调用dup2之前若newfd已经打开,先手动close处理可能发生错误。
  4. int dup3(int oldfd, int newfd, int flags)dup2的区别便是增加了附加参数flags,目前仅支持O_CLOEXEC为复制的newfd设置close_on_exec标志。为linux 2.6.27之后的版本所特有。

所以对于重定向

  1. cmd 1> filename

    可以理解为bash(父进程)exec cmd命令之前打开filename文件得到fd设为N,然后调用dup2(N, 1)将fd=1原本指向的文件(终端屏幕)关闭,复制成N的副本。最后调用exec cmd执行命令,此时cmd命令的打印都会重定向到filename文件中。

    1. 重定向cmd > ./filename
    1. 重定向cmd > ./filename

  2. cmd 2>&1

    可以理解为bash(父进程)exec cmd命令之前执行dup2(1, 2),关闭fd2,将fd2设置为fd的副本,此时fd1/fd2共享同一个打开文件表。

    2. 重定向cmd > ./filename
    2. 重定向cmd > ./filename

综上所述,就可以回答ls > dirlist 2>&1ls 2>&1 > dirlist的区别了。第一种先将stdout重定向到文件,再将stderr重定向到stdout,此时stdout和stderr再系统文件表中指向了文件。而第二种情况如图,stderr并没有重定向到文件,所以默认还会输出到屏幕上。

3. 重定向顺序区别
3. 重定向顺序区别

在工作中曾经遇到过这样的一个需求:当时需要实现个车端应用层总的启动脚本,有不同的模块需要启动多个程序,启动程序是通过调用各个模块的启动脚本来实现。因为该脚本不光会人为手动调用还会在总的状态管理程序中调用,为了便于排查问题(甩锅),将各模块启动时各模块的日志的输出不仅要打印在屏幕上,也需要保存在日志文件之中。实现该需求就用到了在脚本中使用exec重定向当前脚本的功能,一行代码优雅实现。

man bash中介绍exec的一节中提到在调用exec没有指定命令,此时进行重定向会应用到当前环境之中:

If no command is specified, redirections may be used to affect the current shell environment. If there are no redirection errors, the return status is zero; otherwise the return status is non-zero.

按照上边的需求,需要将stdout和stderr打印屏幕的同时输出到日志文件之中,在脚本开始前加上如下一行即可。

shell

exec &> >(tee -a "$LOG_FILE")

对于exec重定向很好理解,如果将当前脚本的stdout和stderr重定向到文件之中执行exec &> $LOG_FILE即可,而上边的代码为了同时输出到文件和屏幕使用了>(tee -a "$LOG_FILE"),该命令涉及到目录/dev/fd进程替换(Process Substitution)

在《Linux/Unix系统编程手册(上册)》5.11节对该目录的用途进行了阐释,对每个进程,内核都会提供一个虚拟目录/dev/fd,链接到linux专有的/proc/self/fd目录。该目录中包含"/dev/fd/n"形式的文件名,n是与进程中打开的文件描述符对应的编号。

而打开open该文件就相当于复制了相应的文件描述符,下列两行代码是等价的:

shell

fd = open("/dev/fd/1", O_WRONLY);
fd = dup(1);

显而易见open调用设置的flag访问模式需要和原文件描述符一致。程序中当然用不太到该目录下文件,直接调用dup即可,其主要用途在Shell中,有一些命令只支持从文件中读取内容,而不支持从标准输入中读取内容,此时想使用管道将上个命令的输出作为输入就有点困难了。使用/dev/fd目录就可以解决该问题,通过

shell

ls | cmd_only_read_file /dev/fd/0

管道会将上个命令ls的标准输出重定向到下个命令的标准输入中,所以对与cmd_only_read_file读取/dev/fd/0就相当于读取ls的输出。

为了方便起见,系统还提供了3个符号链接: /dev/stdin、/dev/stdout和/dev/stderr,分别链接了/dev/fd/{0,1,2}。

man bash中对进程替换有如下阐述:

man bash

Process substitution is supported on systems that support named pipes (FIFOs) or the /dev/fd method of naming open files. It takes the form of <(list) or >(list). The process list is run with its input or output connected to a FIFO or some file in /dev/fd. The name of this file is passed as an argument to the current command as the result of the expansion. If the >(list) form is used, writing to the file will provide input for list. If the <(list) form is used, the file passed as an argument should be read to obtain the output of list.

When available, process substitution is performed simultaneously with parameter and variable expansion, command substitution, and arithmetic expansion.

我理解为在Shell命令中有进程替换Shell会首先根据不同类型进行如下操作:

  1. >(cmd list)类似管道会将(cmd list)的stdin替换成fd=n的文件描述符。根据上边的介绍/dev/fd目录,此时对文件/dev/fd/n进行写入时,会如同输入到(cmd list)的stdin中。
  2. <(cmd list)类似管道会将(cmd list)的stdout替换成fd=n的文件描述符。同样的,此时对文件/dev/fd/n进行读取时,就相当于读取(cmd list)的stdout。

之后,文本替换命令中的>(cmd list)、<(cmd list)为/dev/fd/n,此时/dev/fd/n作为一个文件,同时也作为(cmd list)的标准输入/标准输出。这也就解释了>(tee -a "$LOG_FILE")的用法,将tee -a "$LOG_FILE"命令看成一个可以输入的文件,对该文件输入时会同时输出到屏幕和LOG_FILE中。