在程序中重定向标准输入和标准输出的多种方法及原理

如何在程序中重定向标准输入和标准输出呢?本文将记录多种方法并介绍背后的原理。

前一阵子在看一段代码时,虽然猜到那段代码的作用大概是重定向了stdout,但是一时没看明白是怎么完成的重定向。后来一阵学习才发现对stdin和stdout的重定向还有种种方法,而且对这些方法的完全理解需要了解操作系统中的文件描述符的一些实现。

如果对linux上的重定向很熟悉的话,那么这篇文章的内容就不需要看了。

一段重定向标准输出的代码

当初看到的代码做的事情大致是先fork产生了一个子进程,然后在子进程中用open打开了一个管道,然后直接向标准输出输出就将字节流输出到了管道中,抽象出重定向相关的最核心的代码就是下面的样子。

1
2
3
4
5
6
7
8
9
10
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main() {
const char *filename = "./output.txt";
close(1);
open(filename, O_WRONLY | O_CREAT, 0666);
printf("Hello, World!\n");
}

当编译并执行这段代码后,会发现向stdout的输出都写在了文件中,如下面的过程。

1
2
3
4
5
$ g++ -o test test1.cpp
$ ./test
$ cat output.txt
Hello, World!
$

可以看到,标准输出被重定向到了文件中,那么,这一重定向过程是怎么完成的呢? 要完整地解释,就需要了解Unix/Linux的文件实现。

Unix/Linux的文件模型

Figure 1展示了Unix系统的文件实现,每一个进程有一个File Descriptor Table,File Descriptor Table中都是该进程的fd(File Descriptor),fd从0开始,都是很小的整数;每一个File Descriptor都是一个index,File Descritpor T able中的每一项会指向一个系统全局的File Table中的File Table Entry,这个File Table Entry记录了一些信息包括文件的打开模式、当前seek位置(cursor)、指向该entry的File Descriptor数量,每个File table Entry还会指向一个Vnode Table(Inode Table in Linux)中的Vnode(Inode in Linux), vnode则是真正的关于文件的抽象。

Figure 1: File table and inode table, Qwertyus, via Wikimedia Commons

上面描述了Unix系统中文件的三层结构File Descriptor Table <-> File Table <-> Vnode Table,需要注意的是,多个File Descriptor(不管是不是在同一进程中)可以指向同一个File Table Entry,多个File Table Entry可以指向同一vnode,因此,多个File Descriptor代表同一文件会有两种情况:

  1. 多个File Descriptor指向同一File Table Entry,该File Table Entry指向一个vnode,例如同一进程的fd3和fd4都指向File Table Entry 3,File Table Entry3 指向/tmp/output的vnode
  2. 多个File Descriptor指向不同的File Table Entry,这些entries指向同一vnode,例如同一进程的fd3指向Entry 4,fd4 指向Entry 4,Entry3和Entry4都指向/tmp/output的vnode

那么,这两种情况会有什么区别呢?区别就在于情形1共享相同的File Table Entry,意味着他们共享相同的cursor,可以一起相继地写入,但是情形2是不同的File Table Entry,两个cursor互相不同步,例如写入可能互相覆盖。

什么情况下会出现情形1呢?例如,同一进程中,使用dupdup2函数复制一个fd的File Table Entry到另一个fd,也就是产生了两个fd指向同一File Table Entry。还有可能是,使用fork产生了一个子进程,子进程会复制父进程的File Descriptor Table。

什么情况会出现情形2呢?同一进程可以用open打开两次同一文件,这会产生两个File Table Entry,两个fd分别指向两个entry,而对两个fd的写入是互相不知道对方seek的cursor位置的。

下面的代码演示了使用dup的情形1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <fcntl.h>
#include <unistd.h>
#include <assert.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>

int main() {
const char filename[] = "./text.txt";
const char info1[] = "123\n456\n";
const char info2[] = "789\nabcde\n";
int fd3 = open(filename, O_WRONLY | O_CREAT, 0666);
int fd4 = dup(fd3);
printf("fd3 is %d, fd4 is %d\n", fd3, fd4);
assert(write(fd4, info2, strlen(info2)) > 0);
assert(write(fd3, info1, strlen(info1)) > 0);

}

下面是运行结果,可以看到在对fd4写入内容后,对fd3写入是接在fd4写入内容后面的,说明fd3和fd4指向了同一File Table Entry,用的同一cursor。

1
2
3
4
5
6
7
8
9
$ g++ -o test testdup.cpp
$ ./test
fd3 is 3, fd4 is 4
$ cat text.txt
789
abcde
123
456
$

下面的代码演示了open两次同一文件的情形

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <fcntl.h>
#include <unistd.h>
#include <assert.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>

int main() {
const char filename[] = "./text2.txt";
const char info1[] = "123\n456\n";
const char info2[] = "789\nabcde\n";
int fd3 = open(filename, O_WRONLY | O_CREAT, 0666);
int fd4 = open(filename, O_WRONLY | O_CREAT, 0666);
assert(write(fd4, info2, strlen(info2)) > 0);
assert(write(fd3, info1, strlen(info1)) > 0);
}

下面是运行结果,可以看到我们先对fd4进行写入,再对fd3进行写入,fd3的写入覆盖了fd4写入的部分内容,两个fd指向的File Table Entry中的cursor都是0,因此都是从文件头开始写入的。

1
2
3
4
5
6
7
$ g++ -o test test34.cpp
$ ./test
$ cat text2.txt
123
456
e
$

另外,Unix系统有一个十分重要的特性,就是File Descriptor都是从0开始的非负整数,而当系统新分配一个File Descriptor给进程时,内核会选取最小的未分配的非负整数作为新fd。

重定向标准输入和标准输出的原理

在Unix/Linux系统设计中,每一个有3个预分配的File Descriptor,分别是0(标准输入),1(标准输出),2(标准错误输出),例如在shell中运行一个程序,这三个File Descriptor默认都会最终指向终端的vnode。

Figure 2演示了3个shell运行的进程的文件模型,他们的0,1,2 File Descriptor都指向相同的File Table Entry,并且这些entries都指向终端vnode

Figure 2: 三个进程与三层文件模型,三个进程的0,1,2fd指向相同的File Table Entry, source: CS110: Principles of Computer Systems

那么,什么是对标准输入输出进行重定向呢?我们可以先想一想,哪些程序会用到标准输入输出呢?在Unix系统函数的层面,perror()函数会将错误信息输出到标准错误输出,也就是值为2的fd,那么如果我们将fd2对应到一个新的文件,标准错误输出就被重定向了。

因此,在Unix系统层面对标准输入输出的重定向可以总结为转移fd0,1,2对应的文件。

那么怎么实现这一点呢,最简单的对标准输入的重定向如下,先close(0);断开标准输入fd 0和File Table Entry的连接,然后建立fd 0和新的指向文件vnode的Entry的连接,之后从标准输入的读入就是从文件读入了。那么重定向标准输出和标准错误输出也是类似的。下面的代码演示的是最简单直接的方法。

1
2
3
4
5
6
7
8
9
10
#include <fcntl.h>
#include <unistd.h>
#include <assert.h>

int main() {
const char filename[] = "./text.txt";
close(0);
assert(open(filename, O_RDONLY) == 0);
//...
}

在展示其他重定向的方法之前,我们还需要讲明白c library中的stream和file descriptor的关系,https://www.gnu.org/software/libc/manual/html_node/Streams-and-File-Descriptors.html简单地介绍了GNU中的File Descriptor和C中的Stream的区别,File Descriptor就是我们上面提到的概念,而Stream就是FILE *这样的结构,这种结构是对File Descriptor的封装,加入了缓存buffer,和一些格式化输入输出之类的功能。我们可以从File Descriptor创建一个Stream,也可以获取一个已有的FILE*的fd。C library中的stdin,stdout,stderr都是Stream

fopenfclose的操作和open,close是类似的,Stream的开关除了会对FILE*结构的buffer进行处理外,也会做File Descriptor和File Table Entry、vnode相关的处理。

下面的代码则是演示了fclose,freopen stdin对fd 0的影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>

int main() {
fclose(stdin);
if (fcntl(0, F_GETFD) < 0) {
fprintf(stderr, "%s\n", strerror(errno));
}
freopen("text.txt", "r", stdin);
int flags = 0;
if (( flags = fcntl(0, F_GETFD) ) < 0) {
fprintf(stderr, "%s\n", strerror(errno));
}
else {
fprintf(stderr, "fd 0 valid, flags: %d\n", flags);
}
}

运行结果如下,可见在fclose(stdin)后,fd 0对entry的指向也被取消了,0不再是一个valid的file descriptor,而在使用freopen后,fd 0又被再次分配。也就是说,对Stream的重定向,也就会导致对fd的重定向。

1
2
3
4
5
$ g++ -o test testfclose.cpp
$ ./test
Bad file descriptor
fd 0 valid, flags: 0
$

当我们说明了Stream和File Descriptor的区别后,我们除了可以通过对Stream的操作影响fd,也可以通过对fd的操作影响Stream。我们之前提到了Stream是对fd的封装,那么,如果我们改变一个File Descriptor的指向,FILE*中的fd值并没有改变,但是fd指向的entry和vnode却变了,因此Stream实际指向的文件就发生了变化。那么就是说,我们对fd的重定向,也会导致对Stream的重定向。

到这里可以总结,fd是Unix系统层面的API,Stream是C library的API,但是对fd的重定向会导致Stream的重定向,对Stream的重定向也会通过对fd的重定向实现。

重定向标准输入标准输出的各种方法

我们在第一节就已经展示了最简单的重定向标准输出的方法,这里就简单地再作介绍,下面的介绍为了简便,都用重定向标准输出来代表

1. 先 close(1) 再 open

1
2
3
4
5
6
7
8
9
10
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main() {
const char *filename = "./output.txt";
close(1);
open(filename, O_WRONLY | O_CREAT, 0666);
printf("Hello, World!\n");
}

原理已经说过,就是先取消fd 1和文件的连接,再重新改变fd 1的指向,open会分配最小的可用fd,也就是1;而printf是向Stream stdout中输出,由于stdout中的fd就是1,因此实际上就会向open的文件中输出。完成了对标准输出的重定向。

2. 使用dup

1
2
3
4
5
6
7
8
9
10
11
12
#include <fcntl.h>
#include <unistd.h>
#include <assert.h>
#include <stdio.h>

int main() {
const char filename[] = "./output.txt";
int fd3 = open(filename, O_WRONLY | O_CREAT, 0666);
close(1);
assert(dup(fd3) == 1);
printf("Hello, World!\n");
}

dup(fd1)会将新分配一个fd2,fd2指向fd1指向的File Table Entry;当close(1)后,dup就会新分配1,并将fd 1指向fd3指向的entry,而该entry指向一个文件的vnode。

3. 使用dup2

1
2
3
4
5
6
7
8
9
10
11
12
#include <fcntl.h>
#include <unistd.h>
#include <assert.h>
#include <stdio.h>

int main() {
const char filename[] = "./output.txt";
int fd3 = open(filename, O_WRONLY | O_CREAT, 0666);
close(1);
assert(dup2(fd3, 1) == 1);
printf("Hello, World!\n");
}

方法3和方法2很相似,dup2(fd1,fd2)会新分配fd2,并将fd2指向fd1指向的File Table Entry,剩下的就和方法2一样了。

4. 使用freopen

1
2
3
4
5
6
7
#include <stdio.h>

int main() {
if (freopen("output.txt", "w", stdout)) {
printf("Hello, World!\n");
}
}

该方法形式上最为简单,需要注意的是,freopen会自动close原来的fd,不需要先手动fclose

题外话,如何回到重定向前的标准输入输出

上面的方法基本都取消了原来的fd和File Table Entry的连接,那么如果我们想再将标准输出重定向回去该如何呢?如果需要保留原来的fd的话,可以用dup/dup2先复制一下原来fd指向的File Table Entry,然后再重定向,这样即使想回到最开始的标准输出文件(例如终端),也可以完成。

下面的代码演示了这一过程,先将fd 4指向fd 1指向的File Table Entry,which指向了终端的vnode,想要恢复stdout到终端时,只要先flush stdout的buffer,将fd 1再指向fd 4保留的File Table Entry,然后Stream stdout实际上就是指向终端的Stream

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <unistd.h>
#include <string.h>

int main() {
dup2(1, 4);
if (freopen("output.txt", "w", stdout)) {
printf("Hello, World!\n");
fflush(stdout);
dup2(4, 1);
printf("output to shell\n");
}
}

总结

这是一个很常见的问题,可以用于子进程的重定向输入输出等场景,但是对它的理解需要深入到对Unix文件模型实现的了解。我之前在这方面只会用freopen或者shell调用时redirect,但是并不知其所以然,所以记录下来。