popen()函数能否像pipe()+fork()一样生成双向管道?

25

我正在使用C++(主要是C)在模拟文件系统上实现管道。它需要在宿主shell中运行命令,但在模拟文件系统上执行管道操作。

我可以使用pipe(), fork(), 和 system()系统调用来实现这一点,但我更喜欢使用popen()(它处理创建管道、fork进程和将命令传递给shell)。这可能是不可能的,因为(我认为)我需要能够从管道的父进程中写入数据,从子进程中读取数据,将子进程的输出写回父进程,最后从父进程中读取输出。我的系统上popen()的man page表示双向管道是可行的,但我的代码需要在一个只支持单向管道的旧版本系统上运行。

使用上述分离的调用,我可以打开/关闭管道以实现此目的。那么用popen()是否也可能呢?

对于一个简单的例子,要运行ls -l | grep .txt | grep cmds,我需要:

  • 打开管道和进程,在主机上运行ls -l;然后读取它的输出
  • ls -l的输出放回模拟器
  • 打开管道和进程,在主机上运行grep .txt,并将其应用于ls -l的输出
  • 将此输出返回到模拟器(卡在这里)
  • 打开管道和进程,在主机上运行grep cmds,并将其应用于grep .txt的输出
  • 将此输出返回到模拟器并打印出来

man popen

来自Mac OS X:

popen()函数通过创建双向管道、fork进程和调用shell来'打开'一个进程。

任何由父进程之前的popen()调用打开的流都会在新的子进程中关闭。历史上,popen()是使用单向管道实现的,因此许多 popen() 的实现仅允许模式参数指定读取或写入,而不是同时具备两者。由于popen()现在采用双向管道实现,因此模式参数可以请求双向数据流。模式参数是一个指向以空字符结尾的字符串的指针,必须为'r'表示读取,'w'表示写入,或'r+'表示读和写。


3
请忽略这份糟糕的手册,它误导人们认为管道的单向性是一种过时行为。您实现的行为是非标准的,不大可能得到其他环境的支持。只需自己设置管道并进行分叉和执行即可,一切都会很顺利。 - R.. GitHub STOP HELPING ICE
2
整个线程都是赢家,所有答案和问题都值得+1。 - Matt Joiner
2
我刚在Ubuntu 13.04上执行了man popen,它指出:由于管道的定义是单向的,类型参数只能指定读取或写入,而不能同时;因此,生成的流相应地是只读或只写的。 我很惊讶这是“旧版”的popen... - sager89
6个回答

21

我建议编写自己的函数来处理管道/分叉/系统调用。您可以使该函数生成一个进程并返回读/写文件描述符,就像这样...

typedef void pfunc_t (int rfd, int wfd);

pid_t pcreate(int fds[2], pfunc_t pfunc) {
    /* Spawn a process from pfunc, returning it's pid. The fds array passed will
     * be filled with two descriptors: fds[0] will read from the child process,
     * and fds[1] will write to it.
     * Similarly, the child process will receive a reading/writing fd set (in
     * that same order) as arguments.
    */
    pid_t pid;
    int pipes[4];

    /* Warning: I'm not handling possible errors in pipe/fork */

    pipe(&pipes[0]); /* Parent read/child write pipe */
    pipe(&pipes[2]); /* Child read/parent write pipe */

    if ((pid = fork()) > 0) {
        /* Parent process */
        fds[0] = pipes[0];
        fds[1] = pipes[3];

        close(pipes[1]);
        close(pipes[2]);

        return pid;

    } else {
        close(pipes[0]);
        close(pipes[3]);

        pfunc(pipes[2], pipes[1]);

        exit(0);
    }

    return -1; /* ? */
}

你可以在其中添加任何需要的功能。


11

POSIX规定,popen()调用并不是为双向通信而设计的:

popen()的mode参数是一个指定I/O模式的字符串:

  1. 如果mode是r,在启动子进程时,其文件描述符STDOUT_FILENO应该是管道的可写端,而在调用进程中,stream是由popen()返回的流指针,其文件描述符fileno(stream)应该是管道的可读端。
  2. 如果mode是w,在启动子进程时,其文件描述符STDIN_FILENO应该是管道的可读端,而在调用进程中,stream是由popen()返回的流指针,其文件描述符fileno(stream)应该是管道的可写端。
  3. 如果mode是其他任何值,则结果未指定。

任何可移植的代码都不会做出其他假设。BSD的popen()与您的问题描述类似。

此外,管道与套接字不同,每个管道文件描述符都是单向的。您需要创建两个管道,一个用于每个方向。


11

看起来你已经回答了自己的问题。如果你的代码需要在不支持双向管道开启popen的旧系统上运行,那么你将无法使用popen(至少不能使用提供的那个)。

真正的问题是有关旧系统的确切能力。特别是它们的pipe是否支持创建双向管道?如果它们有一个可以创建双向管道的pipe,但是popen没有,则我会编写主流程代码来使用带有双向管道的popen,并提供一个可以使用双向管道的popen实现,在需要时进行编译并使用。

如果你需要支持的系统太旧,以至于pipe仅支持单向管道,那么你基本上只能自己使用pipeforkdup2等函数。我可能仍然会将其封装在一个函数中,该函数几乎像现代版本的popen一样工作,但是不是返回一个文件句柄,而是填充一个小结构体,其中包含两个文件句柄,一个用于子进程的stdin,另一个用于子进程的stdout


1
我认为你帮助我意识到我的问题实际上是“双向能力是否‘常见’?”,而似乎并不是。感谢您的出色回答。 - Taylor D. Edmiston
对于一个方向,使用popen +1,另一个方向使用pipe()。这样可以避免重写大部分的cout语句,只需要改变cin即可。 - Cardin

5

netresolve 的一个后端中,我正在与一个脚本进行通信,因此需要编写其 stdin 并从其 stdout 读取。下面的函数执行带有 stdin 和 stdout 重定向到管道的命令。您可以使用它并根据自己的需求进行调整。

static bool
start_subprocess(char *const command[], int *pid, int *infd, int *outfd)
{
    int p1[2], p2[2];

    if (!pid || !infd || !outfd)
        return false;

    if (pipe(p1) == -1)
        goto err_pipe1;
    if (pipe(p2) == -1)
        goto err_pipe2;
    if ((*pid = fork()) == -1)
        goto err_fork;

    if (*pid) {
        /* Parent process. */
        *infd = p1[1];
        *outfd = p2[0];
        close(p1[0]);
        close(p2[1]);
        return true;
    } else {
        /* Child process. */
        dup2(p1[0], 0);
        dup2(p2[1], 1);
        close(p1[0]);
        close(p1[1]);
        close(p2[0]);
        close(p2[1]);
        execvp(*command, command);
        /* Error occured. */
        fprintf(stderr, "error running %s: %s", *command, strerror(errno));
        abort();
    }

err_fork:
    close(p2[1]);
    close(p2[0]);
err_pipe2:
    close(p1[1]);
    close(p1[0]);
err_pipe1:
    return false;
}

https://github.com/crossdistro/netresolve/blob/master/backends/exec.c#L46

(我在 popen simultaneous read and write 中使用了相同的代码)


2
这里是代码(C++编写,但可以轻松转换为C语言):
#include <unistd.h>

#include <cstdlib>
#include <cstdio>
#include <cstring>
#include <utility>

// Like popen(), but returns two FILE*: child's stdin and stdout, respectively.
std::pair<FILE *, FILE *> popen2(const char *__command)
{
    // pipes[0]: parent writes, child reads (child's stdin)
    // pipes[1]: child writes, parent reads (child's stdout)
    int pipes[2][2];

    pipe(pipes[0]);
    pipe(pipes[1]);

    if (fork() > 0)
    {
        // parent
        close(pipes[0][0]);
        close(pipes[1][1]);

        return {fdopen(pipes[0][1], "w"), fdopen(pipes[1][0], "r")};
    }
    else
    {
        // child
        close(pipes[0][1]);
        close(pipes[1][0]);

        dup2(pipes[0][0], STDIN_FILENO);
        dup2(pipes[1][1], STDOUT_FILENO);

        execl("/bin/sh", "/bin/sh", "-c", __command, NULL);

        exit(1);
    }
}

使用方法:

int main()
{
    auto [p_stdin, p_stdout] = popen2("cat -n");

    if (p_stdin == NULL || p_stdout == NULL)
    {
        printf("popen2() failed\n");
        return 1;
    }

    const char msg[] = "Hello there!";
    char buf[32];

    printf("I say \"%s\"\n", msg);

    fwrite(msg, 1, sizeof(msg), p_stdin);
    fclose(p_stdin);

    fread(buf, 1, sizeof(buf), p_stdout);
    fclose(p_stdout);

    printf("child says \"%s\"\n", buf);

    return 0;
}


可能的输出:

可能的输出:

I say "Hello there!"
child says "     1      Hello there!"

1

1
请问您能否添加更多细节,说明为什么每个进程的文件描述符会是一种“浪费”?我的意思是它会带来什么成本?使用域套接字会更便宜吗? - Martin Meeser
我也不确定在这种情况下是否需要套接字。但如果它确实比管道更好,我想知道如何以及为什么。 - Pavel Šimerda
1
套接字描述符在UNIX系统中实现为文件描述符。 - Nikolay Dimitrov
你需要更多的细节吗?你需要两个管道来支持双向通信,每个管道使用2个文件描述符。你只需要一个套接字来支持双向通信,因此总共只使用2个文件描述符。如果你正在编写一个将处理大量客户端连接的服务器,那么描述符的成本会累加。 - hyc

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接