【问题标题】:Strange behavior performing library functions on STDOUT and STDIN's file descriptors在 STDOUT 和 STDIN 的文件描述符上执行库函数的奇怪行为
【发布时间】:2018-01-14 03:46:54
【问题描述】:

在我作为 C 程序员的这些年里,我一直对标准流文件描述符感到困惑。有些地方,比如维基百科[1],说:

在 C 编程语言中,标准输入、输出和错误流分别附加到现有的 Unix 文件描述符 0、1 和 2。

这是由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],并且在文件描述符 1 上会更改标准输出。但是,在 0 或 1 上执行 termios functions 会更改标准输入[3][4]

我对文件描述符 1 和 0 的行为感到非常困惑。有谁知道原因:

  • write向标准输出写入 1 或 0?
  • 在 1 上执行 ioctl 修改标准输出,在 0 上修改标准输入,但在 1 或 0 上执行 tcsetattr/tcgetattr 对标准输入有效吗?

【问题讨论】:

  • 您到底为什么认为它正在向标准输出写入任何内容?它正在写入您的终端。您的进程的标准输出可能与您的终端相关联,但它们不是一回事。不要把两者混为一谈。在您的情况下,标准输入也与终端相关联,因此对标准输入的写入显示在终端上也就不足为奇了。

标签: c unix io file-descriptor


【解决方案1】:

让我们首先回顾一下所涉及的一些关键概念:

  • 文件说明

    在操作系统内核中,每个文件、管道端点、套接字端点、开放设备节点等等,都有一个文件描述。内核使用这些来跟踪文件中的位置、标志(读、写、追加、关闭执行)、记录锁等等。

    文件描述是内核内部的,不属于任何特定的进程(在典型的实现中)。
     

  • 文件描述符

    从进程的角度来看,文件描述符是标识打开文件、管道、套接字、FIFO 或设备的整数。

    操作系统内核为每个进程保存一个描述符表。进程使用的文件描述符只是该表的索引。

    文件描述符表中的条目引用内核文件描述。

当一个进程使用dup() or dup2()复制一个文件描述符时,内核只复制该进程的文件描述符表中的条目;它不会复制它自己保留的文件描述。

当一个进程分叉时,子进程得到它自己的文件描述符表,但条目仍然指向完全相同的内核文件描述。 (这本质上是一个shallow copy,所有文件描述符表条目都将是对文件描述的引用。引用被复制;引用的目标保持不变。)

当一个进程通过 Unix Domain 套接字辅助消息向另一个进程发送文件描述符时,内核实际上在接收者上分配了一个新的描述符,并复制了传输的描述符所引用的文件描述。

这一切都很好,虽然 “文件描述符”“文件描述” 如此相似有点令人困惑。

所有这些与 OP 所看到的效果有什么关系?

每当创建新进程时,通常会打开目标设备、管道或套接字,以及dup2() 标准输入、标准输出和标准错误的描述符。这导致所有三个标准描述符都引用相同的文件描述,因此任何使用一个文件描述符有效的操作,也适用于其他文件描述符。

这在控制台上运行程序时最常见,因为这三个描述符肯定都引用相同的文件描述;并且该文件描述描述了伪终端字符设备的从端。

考虑以下程序,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)

写入标准输入、读取标准输出和标准错误都可以工作——正如预期的那样,因为文件描述符引用了相同的、可读和可写的文件描述。

在玩了以上之后,结论是这里根本没有奇怪的行为。如果进程使用的文件描述符只是对操作系统内部文件描述的简单引用,并且标准输入、输出和错误描述符是duplicates 彼此。

【讨论】:

    【解决方案2】:

    我猜这是因为在我的 Linux 中,01 默认情况下都以 read/write 打开到 /dev/tty,这是进程的控制终端。所以确实可以从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 函数总是在实际的底层终端对象上工作,所以无论是使用0 还是1,只要它打开到一个 tty。

    【讨论】:

    • 如果我们深入研究细节,它实际上比这更有趣,甚至。每个 文件描述符 编号指的是在 Linux 和 Unixy 系统中称为 文件描述 的内核结构。 dup() 创建一个新的文件描述符(通过复制旧的); new 指的是完全相同的文件描述。在终端应用程序中,所有三个标准流都是来自伪终端的dup2()',并且所有三个的行为方式完全相同(即,您可以写入STDIN_FILENO,并从STDOUT_FILENOSTDERR_FILENO 读取) .然而,这不仅限于伪终端:[...]
    • [...] 只要标准输入和输出/错误源自相同的、可写的文件描述,它就可能/将会发生——是一个伪终端(tty),文件,甚至是套接字。如果有兴趣,我可以提供一个可用于测试和探索的 POSIX 便携式示例程序。
    • @NominalAnimal 你应该写一个答案。我从 我猜 开始,因为我没有任何权威来源来说明这是如何发生的,其中哪个是 POSIX,哪个只是 Linux,当然,除了 dup2
    猜你喜欢
    • 1970-01-01
    • 2014-08-25
    • 1970-01-01
    • 2018-05-10
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2017-08-22
    相关资源
    最近更新 更多