在 STDOUT 和 STDIN 的文件描述符上执行库函数的奇怪行为
Strange behavior performing library functions on STDOUT and STDIN's file descriptors
在我作为 C 程序员的这些年里,我一直对标准流文件描述符感到困惑。有些地方,比如维基百科[1],说:
In the C programming language, the standard input, output, and error streams are attached to the existing Unix file descriptors 0, 1 and 2 respectively.
这是由 unistd.h
支持的:
/* Standard file descriptors. */
#define STDIN_FILENO 0 /* Standard input. */
#define STDOUT_FILENO 1 /* Standard output. */
#define STDERR_FILENO 2 /* Standard error output. */
但是,这段代码(在任何系统上):
write(0, "Hello, World!\n", 14);
将 Hello, World!
(和一个换行符)打印到 STDOUT
。这很奇怪,因为 STDOUT
的文件描述符应该是 1。write
-ing 到文件描述符 1
也打印到 STDOUT
.
对文件描述符 0 执行 ioctl
更改标准输入[2], and on file descriptor 1 changes standard output. However, performing termios
functions on either 0 or 1 changes standard input[3][4].
我对文件描述符 1 和 0 的行为很困惑。有谁知道为什么:
write
将 1 或 0 写入标准输出?
- 对 1 执行
ioctl
修改标准输出,对 0 修改标准输入,但是对 1 或 0 执行 tcsetattr
/tcgetattr
对标准输入有效?
我猜是因为在我的Linux中,0
和1
默认都是用read/write打开的到 /dev/tty
这是进程的控制终端。所以确实有可能 read from stdout
.
然而,一旦你管道进出东西,这就会中断:
#include <unistd.h>
#include <errno.h>
#include <stdio.h>
int main() {
errno = 0;
write(0, "Hello world!\n", 14);
perror("write");
}
和运行以及
% ./a.out
Hello world!
write: Success
% echo | ./a.out
write: Bad file descriptor
termios
函数总是在实际的底层终端对象上工作,所以只要对 tty 打开,使用 0
还是 1
都没有关系.
让我们首先回顾一下涉及的一些关键概念:
文件描述
在操作系统内核中,每个文件、管道端点、套接字端点、打开的设备节点等等,都有一个文件描述。内核使用这些来跟踪文件中的位置、标志(读、写、追加、执行时关闭)、记录锁等。
文件描述是内核内部的,不属于任何特定进程(在典型实现中)。
文件描述符
从进程的角度来看,文件描述符是标识打开文件、管道、套接字、FIFO 或设备的整数。
操作系统内核为每个进程保留 table 个描述符。进程使用的文件描述符只是对此 table.
的索引
文件描述符中的条目 table 指的是内核文件描述。
每当进程使用 dup()
or dup2()
复制文件描述符时,内核只会为该进程复制文件描述符 table 中的条目;它不会复制它保留给自己的文件描述。
当进程分叉时,子进程会获得自己的文件描述符 table,但条目仍指向完全相同的内核文件描述。 (这本质上是一个 shallow copy,所有文件描述符 table 条目都是对文件描述的引用。引用被复制;引用的目标保持不变。)
当一个进程通过 Unix Domain 套接字辅助消息向另一个进程发送文件描述符时,内核实际上在接收方上分配一个新的描述符,并复制传输的描述符所指的文件描述符。
一切都很好,虽然有点令人困惑 "file descriptor" 和 "file description"如此相似。
这与 OP 看到的效果有什么关系?
每当创建新进程时,通常会打开目标设备、管道或套接字,并dup2()
标准输入、标准输出和标准错误的描述符。这导致所有三个标准描述符都引用相同的 文件描述 ,因此无论使用一个文件描述符有效的操作,使用其他文件描述符也有效。
这在控制台上 运行ning 程序时最常见,因为这三个描述符肯定都引用相同的文件描述;并且该文件描述描述了伪终端字符设备的从端。
考虑以下程序,run.c:
#define _POSIX_C_SOURCE 200809L
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
static void wrerrp(const char *p, const char *q)
{
while (p < q) {
ssize_t n = write(STDERR_FILENO, p, (size_t)(q - p));
if (n > 0)
p += n;
else
return;
}
}
static inline void wrerr(const char *s)
{
if (s)
wrerrp(s, s + strlen(s));
}
int main(int argc, char *argv[])
{
int fd;
if (argc < 3) {
wrerr("\nUsage: ");
wrerr(argv[0]);
wrerr(" FILE-OR-DEVICE COMMAND [ ARGS ... ]\n\n");
return 127;
}
fd = open(argv[1], O_RDWR | O_CREAT, 0666);
if (fd == -1) {
const char *msg = strerror(errno);
wrerr(argv[1]);
wrerr(": Cannot open file: ");
wrerr(msg);
wrerr(".\n");
return 127;
}
if (dup2(fd, STDIN_FILENO) != STDIN_FILENO ||
dup2(fd, STDOUT_FILENO) != STDOUT_FILENO) {
const char *msg = strerror(errno);
wrerr("Cannot duplicate file descriptors: ");
wrerr(msg);
wrerr(".\n");
return 126;
}
if (dup2(fd, STDERR_FILENO) != STDERR_FILENO) {
/* We might not have standard error anymore.. */
return 126;
}
/* Close fd, since it is no longer needed. */
if (fd != STDIN_FILENO && fd != STDOUT_FILENO && fd != STDERR_FILENO)
close(fd);
/* Execute the command. */
if (strchr(argv[2], '/'))
execv(argv[2], argv + 2); /* Command has /, so it is a path */
else
execvp(argv[2], argv + 2); /* command has no /, so it is a filename */
/* Whoops; failed. But we have no stderr left.. */
return 125;
}
它需要两个或更多参数。第一个参数是文件或设备,第二个是命令,其余参数提供给命令。该命令是 运行,所有三个标准描述符都重定向到第一个参数中指定的文件或设备。您可以使用 gcc 编译以上内容,例如
gcc -Wall -O2 run.c -o run
让我们写一个小的测试工具,report.c:
#define _POSIX_C_SOURCE 200809L
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>
int main(int argc, char *argv[])
{
char buffer[16] = { "\n" };
ssize_t result;
FILE *out;
if (argc != 2) {
fprintf(stderr, "\nUsage: %s FILENAME\n\n", argv[0]);
return EXIT_FAILURE;
}
out = fopen(argv[1], "w");
if (!out)
return EXIT_FAILURE;
result = write(STDIN_FILENO, buffer, 1);
if (result == -1) {
const int err = errno;
fprintf(out, "write(STDIN_FILENO, buffer, 1) = -1, errno = %d (%s).\n", err, strerror(err));
} else {
fprintf(out, "write(STDIN_FILENO, buffer, 1) = %zd%s\n", result, (result == 1) ? ", success" : "");
}
result = read(STDOUT_FILENO, buffer, 1);
if (result == -1) {
const int err = errno;
fprintf(out, "read(STDOUT_FILENO, buffer, 1) = -1, errno = %d (%s).\n", err, strerror(err));
} else {
fprintf(out, "read(STDOUT_FILENO, buffer, 1) = %zd%s\n", result, (result == 1) ? ", success" : "");
}
result = read(STDERR_FILENO, buffer, 1);
if (result == -1) {
const int err = errno;
fprintf(out, "read(STDERR_FILENO, buffer, 1) = -1, errno = %d (%s).\n", err, strerror(err));
} else {
fprintf(out, "read(STDERR_FILENO, buffer, 1) = %zd%s\n", result, (result == 1) ? ", success" : "");
}
if (ferror(out))
return EXIT_FAILURE;
if (fclose(out))
return EXIT_FAILURE;
return EXIT_SUCCESS;
}
它只需要一个参数,一个文件或设备来写入,报告是否写入标准输入,是否从标准输出读取和错误工作。 (我们通常可以在 Bash 和 POSIX shell 中使用 $(tty)
来引用实际的终端设备,以便报告在终端上可见。)使用例如
gcc -Wall -O2 report.c -o report
现在,我们可以检查一些设备:
./run /dev/null ./report $(tty)
./run /dev/zero ./report $(tty)
./run /dev/urandom ./report $(tty)
或任何我们想要的。在我的机器上,当我在文件上 运行 时,说
./run some-file ./report $(tty)
写入标准输入,以及从标准输出和标准错误中读取都有效——这是预期的,因为文件描述符指的是相同的、可读的和 writable、文件描述。
结论,在玩过上面的之后,这里根本没有奇怪的行为。如果进程使用的 文件描述符 只是对操作系统内部 文件描述 和标准输入的引用,那么它的所有行为都完全符合预期,输出和错误描述符 dup
相互关联。
在我作为 C 程序员的这些年里,我一直对标准流文件描述符感到困惑。有些地方,比如维基百科[1],说:
In the C programming language, the standard input, output, and error streams are attached to the existing Unix file descriptors 0, 1 and 2 respectively.
这是由 unistd.h
支持的:
/* Standard file descriptors. */
#define STDIN_FILENO 0 /* Standard input. */
#define STDOUT_FILENO 1 /* Standard output. */
#define STDERR_FILENO 2 /* Standard error output. */
但是,这段代码(在任何系统上):
write(0, "Hello, World!\n", 14);
将 Hello, World!
(和一个换行符)打印到 STDOUT
。这很奇怪,因为 STDOUT
的文件描述符应该是 1。write
-ing 到文件描述符 1
也打印到 STDOUT
.
对文件描述符 0 执行 ioctl
更改标准输入[2], and on file descriptor 1 changes standard output. However, performing termios
functions on either 0 or 1 changes standard input[3][4].
我对文件描述符 1 和 0 的行为很困惑。有谁知道为什么:
write
将 1 或 0 写入标准输出?- 对 1 执行
ioctl
修改标准输出,对 0 修改标准输入,但是对 1 或 0 执行tcsetattr
/tcgetattr
对标准输入有效?
我猜是因为在我的Linux中,0
和1
默认都是用read/write打开的到 /dev/tty
这是进程的控制终端。所以确实有可能 read from stdout
.
然而,一旦你管道进出东西,这就会中断:
#include <unistd.h>
#include <errno.h>
#include <stdio.h>
int main() {
errno = 0;
write(0, "Hello world!\n", 14);
perror("write");
}
和运行以及
% ./a.out
Hello world!
write: Success
% echo | ./a.out
write: Bad file descriptor
termios
函数总是在实际的底层终端对象上工作,所以只要对 tty 打开,使用 0
还是 1
都没有关系.
让我们首先回顾一下涉及的一些关键概念:
文件描述
在操作系统内核中,每个文件、管道端点、套接字端点、打开的设备节点等等,都有一个文件描述。内核使用这些来跟踪文件中的位置、标志(读、写、追加、执行时关闭)、记录锁等。
文件描述是内核内部的,不属于任何特定进程(在典型实现中)。
文件描述符
从进程的角度来看,文件描述符是标识打开文件、管道、套接字、FIFO 或设备的整数。
操作系统内核为每个进程保留 table 个描述符。进程使用的文件描述符只是对此 table.
的索引文件描述符中的条目 table 指的是内核文件描述。
每当进程使用 dup()
or dup2()
复制文件描述符时,内核只会为该进程复制文件描述符 table 中的条目;它不会复制它保留给自己的文件描述。
当进程分叉时,子进程会获得自己的文件描述符 table,但条目仍指向完全相同的内核文件描述。 (这本质上是一个 shallow copy,所有文件描述符 table 条目都是对文件描述的引用。引用被复制;引用的目标保持不变。)
当一个进程通过 Unix Domain 套接字辅助消息向另一个进程发送文件描述符时,内核实际上在接收方上分配一个新的描述符,并复制传输的描述符所指的文件描述符。
一切都很好,虽然有点令人困惑 "file descriptor" 和 "file description"如此相似。
这与 OP 看到的效果有什么关系?
每当创建新进程时,通常会打开目标设备、管道或套接字,并dup2()
标准输入、标准输出和标准错误的描述符。这导致所有三个标准描述符都引用相同的 文件描述 ,因此无论使用一个文件描述符有效的操作,使用其他文件描述符也有效。
这在控制台上 运行ning 程序时最常见,因为这三个描述符肯定都引用相同的文件描述;并且该文件描述描述了伪终端字符设备的从端。
考虑以下程序,run.c:
#define _POSIX_C_SOURCE 200809L
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
static void wrerrp(const char *p, const char *q)
{
while (p < q) {
ssize_t n = write(STDERR_FILENO, p, (size_t)(q - p));
if (n > 0)
p += n;
else
return;
}
}
static inline void wrerr(const char *s)
{
if (s)
wrerrp(s, s + strlen(s));
}
int main(int argc, char *argv[])
{
int fd;
if (argc < 3) {
wrerr("\nUsage: ");
wrerr(argv[0]);
wrerr(" FILE-OR-DEVICE COMMAND [ ARGS ... ]\n\n");
return 127;
}
fd = open(argv[1], O_RDWR | O_CREAT, 0666);
if (fd == -1) {
const char *msg = strerror(errno);
wrerr(argv[1]);
wrerr(": Cannot open file: ");
wrerr(msg);
wrerr(".\n");
return 127;
}
if (dup2(fd, STDIN_FILENO) != STDIN_FILENO ||
dup2(fd, STDOUT_FILENO) != STDOUT_FILENO) {
const char *msg = strerror(errno);
wrerr("Cannot duplicate file descriptors: ");
wrerr(msg);
wrerr(".\n");
return 126;
}
if (dup2(fd, STDERR_FILENO) != STDERR_FILENO) {
/* We might not have standard error anymore.. */
return 126;
}
/* Close fd, since it is no longer needed. */
if (fd != STDIN_FILENO && fd != STDOUT_FILENO && fd != STDERR_FILENO)
close(fd);
/* Execute the command. */
if (strchr(argv[2], '/'))
execv(argv[2], argv + 2); /* Command has /, so it is a path */
else
execvp(argv[2], argv + 2); /* command has no /, so it is a filename */
/* Whoops; failed. But we have no stderr left.. */
return 125;
}
它需要两个或更多参数。第一个参数是文件或设备,第二个是命令,其余参数提供给命令。该命令是 运行,所有三个标准描述符都重定向到第一个参数中指定的文件或设备。您可以使用 gcc 编译以上内容,例如
gcc -Wall -O2 run.c -o run
让我们写一个小的测试工具,report.c:
#define _POSIX_C_SOURCE 200809L
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>
int main(int argc, char *argv[])
{
char buffer[16] = { "\n" };
ssize_t result;
FILE *out;
if (argc != 2) {
fprintf(stderr, "\nUsage: %s FILENAME\n\n", argv[0]);
return EXIT_FAILURE;
}
out = fopen(argv[1], "w");
if (!out)
return EXIT_FAILURE;
result = write(STDIN_FILENO, buffer, 1);
if (result == -1) {
const int err = errno;
fprintf(out, "write(STDIN_FILENO, buffer, 1) = -1, errno = %d (%s).\n", err, strerror(err));
} else {
fprintf(out, "write(STDIN_FILENO, buffer, 1) = %zd%s\n", result, (result == 1) ? ", success" : "");
}
result = read(STDOUT_FILENO, buffer, 1);
if (result == -1) {
const int err = errno;
fprintf(out, "read(STDOUT_FILENO, buffer, 1) = -1, errno = %d (%s).\n", err, strerror(err));
} else {
fprintf(out, "read(STDOUT_FILENO, buffer, 1) = %zd%s\n", result, (result == 1) ? ", success" : "");
}
result = read(STDERR_FILENO, buffer, 1);
if (result == -1) {
const int err = errno;
fprintf(out, "read(STDERR_FILENO, buffer, 1) = -1, errno = %d (%s).\n", err, strerror(err));
} else {
fprintf(out, "read(STDERR_FILENO, buffer, 1) = %zd%s\n", result, (result == 1) ? ", success" : "");
}
if (ferror(out))
return EXIT_FAILURE;
if (fclose(out))
return EXIT_FAILURE;
return EXIT_SUCCESS;
}
它只需要一个参数,一个文件或设备来写入,报告是否写入标准输入,是否从标准输出读取和错误工作。 (我们通常可以在 Bash 和 POSIX shell 中使用 $(tty)
来引用实际的终端设备,以便报告在终端上可见。)使用例如
gcc -Wall -O2 report.c -o report
现在,我们可以检查一些设备:
./run /dev/null ./report $(tty)
./run /dev/zero ./report $(tty)
./run /dev/urandom ./report $(tty)
或任何我们想要的。在我的机器上,当我在文件上 运行 时,说
./run some-file ./report $(tty)
写入标准输入,以及从标准输出和标准错误中读取都有效——这是预期的,因为文件描述符指的是相同的、可读的和 writable、文件描述。
结论,在玩过上面的之后,这里根本没有奇怪的行为。如果进程使用的 文件描述符 只是对操作系统内部 文件描述 和标准输入的引用,那么它的所有行为都完全符合预期,输出和错误描述符 dup
相互关联。