C高级:有关匿名管道与命名管道的学习记录

管道(Pipe)是 Linux 系统中最古老、最常用的进程间通信(IPC)方式之一。它基于"先进先出"的数据流模型,为进程之间提供了一条单向或双向的数据传输通道。本文将从概念到代码,系统讲解匿名管道和命名管道的原理、区别,以及 popen()、pclose()、pipe()、mkfifo() 四个核心函数的用法。以下是我在职坐标的学习记录:

一、管道的分类

Linux 中的管道分为两种:匿名管道(Anonymous Pipe)命名管道(Named Pipe / FIFO)

1.1 匿名管道

匿名管道是一种半双工的通信机制,数据只能沿一个方向流动:从写端流向读端。它没有名字,不存在于文件系统中,只能在具有亲缘关系的进程之间使用——通常是父子进程或兄弟进程。

核心特点:

  • 半双工通信:数据只能单向流动,一端写、另一端读。
  • 无名字:不占用文件系统路径,通过文件描述符(fd)引用。
  • 亲缘关系限制:只能用于有血缘关系的进程间(通过 fork() 创建的子进程继承了父进程的管道描述符)。
  • 基于内存:管道内部维护一个内核缓冲区(Linux 默认大小为 64KB),数据写入后存储在缓冲区中,读取后自动清除。
  • 生命周期:随进程消亡而自动释放,不需要手动清理。

典型使用场景: 父进程通过管道向子进程传递数据,或者在 shell 中使用 | 符号将两个命令串联起来(如 ls | grep .c),本质上就是匿名管道。

1.2 命名管道(FIFO)

命名管道也叫 FIFO(First In, First Out),它在文件系统中拥有一个路径名,任何知道该路径的进程都可以打开它进行通信,因此不要求进程之间存在亲缘关系

核心特点:

  • 存在于文件系统:通过 mkfifo() 创建后,可以用 ls 看到它,文件类型为 p(pipe)。
  • 无亲缘限制:任意两个进程只要知道 FIFO 的路径,就可以通过它进行通信。
  • 半双工或全双工:传统用法是半双工的(一个进程读、另一个写),但如果双方都以读写模式(O_RDWR)打开,某些系统上也支持全双工。
  • 阻塞行为:默认以阻塞方式打开——读端会阻塞直到有写端打开,写端也会阻塞直到有读端打开。可以设置 O_NONBLOCK 标志改变这一行为。
  • 持久性:FIFO 文件存在于文件系统中,不会随进程退出自动消失,需要手动删除(unlink()rm)。

1.3 两者的对比

对比维度 匿名管道(pipe) 命名管道(FIFO)
文件系统可见 不可见,仅通过 fd 访问 可见,有文件路径
使用范围 仅限有亲缘关系的进程 任意进程之间
创建方式 pipe() mkfifo()
生命周期 随进程自动销毁 持久存在,需手动删除
通信方向 半双工(单向) 半双工(也可全双工,取决于实现)
阻塞行为 通常不阻塞(除非缓冲区满或空) 打开时默认阻塞,直到对端也打开
典型场景 父子进程通信、shell 管道符 无关系进程间的协调通信

二、popen() 函数

2.1 函数原型

#include <stdio.h>

FILE *popen(const char *command, const char *type);

2.2 函数作用

popen() 会创建一个管道,然后 fork() 出一个子进程,在子进程中通过 exec() 执行 command 所指定的 shell 命令。父进程可以通过返回的 FILE* 流来读取命令的输出,或者向命令写入数据。

简单来说,popen() 封装了 创建管道 + fork + exec 这一整套流程,让开发者可以用一行代码就实现"启动一个命令并读取其输出"或"向一个命令写入数据"的功能。

2.3 参数详解

参数 类型 说明
command const char * 要执行的 shell 命令字符串。它会作为参数传递给 /bin/sh -c,因此支持 shell 的通配符、管道符等语法。例如 "ls -l""cat /etc/passwd | grep root"
type const char * 管道的工作模式,只能取 "r""w"

type 参数深入解释:

  • "r"(read 模式):父进程以方式打开管道,子进程执行 command 后的标准输出会连接到管道的写端。父进程通过返回的 FILE* 可以读取命令的输出结果。

    FILE *fp = popen("ls -l", "r");
    // 父进程可以通过 fp 读取 ls -l 的输出
    
  • "w"(write 模式):父进程以方式打开管道,父进程写入的数据会成为子进程执行 command 时的标准输入

    FILE *fp = popen("grep error > result.txt", "w");
    // 父进程可以向 fp 写入数据,这些数据会作为 grep 的标准输入
    

2.4 返回值

  • 成功:返回一个指向 FILE 的指针(标准 I/O 流),可以用 fgets()fread()fprintf()fwrite() 等标准库函数来操作。
  • 失败:返回 NULL,可能的原因包括无法 fork 子进程、无法创建管道等。

2.5 示例代码

#include <stdio.h>
#include <stdlib.h>

int main() {
    FILE *fp;
    char buf[1024];

    // 以读模式打开管道,执行 ls -l 命令
    fp = popen("ls -l", "r");
    if (fp == NULL) {
        perror("popen failed");
        exit(EXIT_FAILURE);
    }

    // 逐行读取命令输出
    while (fgets(buf, sizeof(buf), fp) != NULL) {
        printf("%s", buf);
    }

    pclose(fp);
    return 0;
}

三、pclose() 函数

3.1 函数原型

#include <stdio.h>

int pclose(FILE *stream);

3.2 函数作用

pclose() 用于关闭由 popen() 创建的管道流。它不仅会关闭底层的管道文件描述符,还会调用 waitpid() 等待子进程终止,并回收子进程的资源(避免产生僵尸进程)。

这一点非常重要:如果 popen() 之后不调用 pclose(),子进程退出后会变成僵尸进程,占用系统资源。

3.3 参数详解

参数 类型 说明
stream FILE * popen() 返回的文件流指针。必须是 popen() 的返回值,不能传入其他来源的 FILE*

3.4 返回值

  • 成功:返回子进程的终止状态(与 waitpid() 的返回格式相同)。可以使用 WIFEXITED()WEXITSTATUS() 等宏来解析退出码。
  • 失败:返回 -1,并设置 errno。常见错误包括传入无效的 stream 指针。

3.5 获取子进程退出码

#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>

int main() {
    FILE *fp = popen("ls /nonexistent_dir", "r");
    if (fp == NULL) {
        perror("popen failed");
        exit(EXIT_FAILURE);
    }

    int status = pclose(fp);

    if (WIFEXITED(status)) {
        printf("子进程正常退出,退出码: %d\n", WEXITSTATUS(status));
    } else {
        printf("子进程异常终止\n");
    }

    return 0;
}

四、pipe() 函数

4.1 函数原型

#include <unistd.h>

int pipe(int pipefd[2]);

4.2 函数作用

pipe() 创建一个匿名管道,并通过 pipefd 数组返回两个文件描述符:pipefd[0]读端pipefd[1]写端。数据从写端流入,从读端流出。

pipe() 通常与 fork() 配合使用:父进程创建管道后调用 fork(),子进程继承管道的文件描述符,父子进程之间就可以通过管道通信了。

4.3 参数详解

参数 类型 说明
pipefd int[2] 一个长度为 2 的整型数组,用于接收返回的两个文件描述符。pipefd[0] 为读端 fd,pipefd[1] 为写端 fd。

4.4 返回值

  • 成功:返回 0pipefd[0]pipefd[1] 被填充为有效的文件描述符。
  • 失败:返回 -1,并设置 errno。常见原因包括进程打开的文件描述符数量已达上限(EMFILE)、系统内存不足(ENOMEM)等。

4.5 使用模式

创建管道后,通常的做法是:

  1. 父进程调用 pipe() 创建管道。
  2. 父进程调用 fork() 创建子进程。
  3. 父进程关闭不需要的端口(例如关闭读端),子进程关闭不需要的端口(例如关闭写端)。
  4. 父子进程分别通过 write() / read() 进行通信。
  5. 通信结束后,双方各自 close() 自己持有的 fd。

为什么要关闭不需要的端口? 如果写端没有全部关闭,读端的 read() 永远不会收到 EOF(返回 0),会一直阻塞等待数据;如果读端没有全部关闭,可能会导致数据被错误的进程读走。

4.6 示例代码

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>

int main() {
    int pipefd[2];
    pid_t pid;
    char buf[1024];

    // 1. 创建管道
    if (pipe(pipefd) == -1) {
        perror("pipe failed");
        exit(EXIT_FAILURE);
    }

    // 2. fork 子进程
    pid = fork();
    if (pid == -1) {
        perror("fork failed");
        exit(EXIT_FAILURE);
    }

    if (pid == 0) {
        // ---- 子进程:作为写端 ----
        close(pipefd[0]);  // 关闭读端
        const char *msg = "Hello from child!";
        write(pipefd[1], msg, strlen(msg));
        close(pipefd[1]);  // 写完关闭
        exit(EXIT_SUCCESS);
    } else {
        // ---- 父进程:作为读端 ----
        close(pipefd[1]);  // 关闭写端
        int n = read(pipefd[0], buf, sizeof(buf) - 1);
        if (n > 0) {
            buf[n] = '\0';
            printf("父进程收到: %s\n", buf);
        }
        close(pipefd[0]);  // 读完关闭
        waitpid(pid, NULL, 0);  // 等待子进程结束
    }

    return 0;
}

4.7 pipe2() 扩展(Linux 特有)

Linux 还提供了一个扩展版本 pipe2(),可以在创建管道时直接设置标志位:

#define _GNU_SOURCE
#include <fcntl.h>
#include <unistd.h>

int pipe2(int pipefd[2], int flags);
flags 值 说明
O_NONBLOCK 将管道的两个 fd 设置为非阻塞模式
O_CLOEXEC 设置 close-on-exec 标志,exec 时自动关闭这两个 fd

五、mkfifo() 函数

5.1 函数原型

#include <sys/types.h>
#include <sys/stat.h>

int mkfifo(const char *pathname, mode_t mode);

5.2 函数作用

mkfifo() 在文件系统中创建一个命名管道(FIFO 文件)。创建完成后,任何进程都可以通过 open()read()write()close() 等系统调用来操作这个 FIFO,实现无亲缘关系的进程间通信。

5.3 参数详解

参数 类型 说明
pathname const char * FIFO 文件的路径名。可以是绝对路径(如 "/tmp/myfifo"),也可以是相对路径。如果该路径已经存在一个同名文件,mkfifo() 会失败并设置 errnoEEXIST
mode mode_t FIFO 文件的权限位,与 open()mode 参数含义相同,如 0666(所有用户可读写)。注意:最终权限还会受到进程的 umask 值影响。例如 umask022 时,0666 实际生效的权限为 0644

常见 mode 值说明:

mode 值 含义
0666 所有用户可读可写
0644 所有者可读写,其他人只读
0600 仅所有者可读写

5.4 返回值

  • 成功:返回 0
  • 失败:返回 -1,并设置 errno。常见错误包括:
    • EEXIST:路径已存在同名文件。
    • ENOENT:路径中某个目录不存在。
    • EACCES:对路径中的某个目录没有权限。

5.5 FIFO 的阻塞行为

FIFO 的默认(阻塞)打开方式有一个重要的行为特征:

  • 以只读方式打开(O_RDONLY:会阻塞,直到有进程以写方式打开该 FIFO。
  • 以只写方式打开(O_WRONLY:会阻塞,直到有进程以读方式打开该 FIFO。

这意味着读端和写端必须配对打开,否则会一直等待。可以使用 O_NONBLOCK 标志来避免阻塞,但需要注意处理错误(如写入时没有读端会返回 ENXIO)。

5.6 示例代码

以下展示两个独立的程序——一个读端、一个写端,通过 FIFO 进行通信:

写端(fifo_writer.c):

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <string.h>

#define FIFO_PATH "/tmp/myfifo"

int main() {
    // 创建 FIFO(如果已存在则忽略错误)
    if (mkfifo(FIFO_PATH, 0666) == -1) {
        perror("mkfifo");
        // 如果已存在,不退出,继续打开使用
    }

    // 以写方式打开 FIFO(会阻塞,直到有读端打开)
    int fd = open(FIFO_PATH, O_WRONLY);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }

    const char *msg = "Hello from FIFO writer!";
    write(fd, msg, strlen(msg));
    printf("已写入: %s\n", msg);

    close(fd);
    // 注意:FIFO 文件不会自动删除,如需清理可手动 unlink
    // unlink(FIFO_PATH);
    return 0;
}

读端(fifo_reader.c):

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>

#define FIFO_PATH "/tmp/myfifo"

int main() {
    char buf[1024];

    // 以读方式打开 FIFO(会阻塞,直到有写端打开)
    int fd = open(FIFO_PATH, O_RDONLY);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }

    int n = read(fd, buf, sizeof(buf) - 1);
    if (n > 0) {
        buf[n] = '\0';
        printf("读取到: %s\n", buf);
    }

    close(fd);
    return 0;
}

编译运行:

gcc fifo_writer.c -o fifo_writer
gcc fifo_reader.c -o fifo_reader

# 先运行写端(会阻塞等待读端)
./fifo_writer &

# 再运行读端
./fifo_reader

六、总结与选型建议

需求场景 推荐方案
父子进程间快速传递数据 pipe() + fork()
执行一个 shell 命令并读取/写入数据 popen() + pclose()
无亲缘关系的进程间通信 mkfifo() 创建命名管道
需要在 shell 脚本或命令行中使用 直接使用 |(匿名管道)或 mkfifo 命令(命名管道)

管道是 Linux IPC 的基础设施,理解它的原理和行为对于编写健壮的多进程程序至关重要。实际开发中,还需要注意管道的容量限制(默认 64KB)、SIGPIPE 信号的处理(向已关闭读端的管道写入时触发)以及多读者/多写者场景下的数据交错问题。

posted @ 2026-07-19 14:36  Solitude0420  阅读(38)  评论(4)    收藏  举报