21、标准流与重定向:stdin/stdout/stderr、freopen重定向、管道与文件描述符概念

说到标准流,这其实是每个C程序员每天都在打交道的东西。你可能没意识到,你写的每个printf、scanf、fprintf,背后都在操作这三个默认打开的流。我个人习惯把它们叫做「三剑客」——stdin、stdout、stderr。

21.1 标准流到底是什么?

程序启动时,操作系统会自动打开三个文件流。说白了,就是三个默认的通信管道:

  • stdin(标准输入)——默认从键盘读取数据
  • stdout(标准输出)——默认往屏幕写数据
  • stderr(标准错误)——默认也往屏幕写,但独立于stdout

你想想看,为什么printf能直接往屏幕打印?因为它内部用的就是stdout。为什么scanf能从键盘读?因为它读的是stdin。这三个流在stdio.h里被声明为FILE*类型的全局变量。

核心要点:stdin、stdout、stderr是程序启动时自动打开的,不需要你手动fopen。它们对应的文件描述符分别是0、1、2。

21.2 文件描述符——内核视角的「编号」

嗯,这里要注意。文件描述符是操作系统层面的概念,不是C语言标准库独有的。每个进程都有一个文件描述符表,说白了就是一个整数数组。0、1、2这三个位置被预留给标准流了。

// 文件描述符与标准流的对应关系
// 0 → stdin   → 标准输入
// 1 → stdout  → 标准输出
// 2 → stderr  → 标准错误

// 你可以直接用文件描述符来读写
#include <unistd.h>

char buffer[100];
read(0, buffer, sizeof(buffer));   // 从stdin读,等价于fgets
write(1, buffer, strlen(buffer));  // 写到stdout,等价于printf
write(2, "error\n", 6);            // 写到stderr

我在项目中遇到过一个问题:有人用fprintf(stdout, ...)和fprintf(stderr, ...)混合输出,结果在重定向时发现错误信息和正常输出混在一起了。原因就是stdout和stderr虽然都指向屏幕,但它们是两个不同的流。

21.3 freopen重定向——把流「拐」到文件里

freopen这个函数很有意思。它可以把一个已经打开的流重新绑定到另一个文件。我经常用它来做日志重定向或者测试数据注入。

#include <stdio.h>

int main() {
    // 把stdout重定向到文件
    if (freopen("output.log", "w", stdout) == NULL) {
        perror("freopen failed");
        return 1;
    }
    
    // 现在printf不会打印到屏幕,而是写入output.log
    printf("这条信息写入了文件,而不是屏幕\n");
    printf("标准输出已经被重定向了\n");
    
    // 恢复stdout到终端(需要保存原始fd)
    // 注意:freopen后原来的流被关闭了
    return 0;
}

我的经验:freopen有个坑——它会关闭原来的流。如果你之后想恢复,最好先dup保存原始文件描述符。我曾经在写一个命令行工具时,重定向后忘了恢复,结果后续所有输出都丢了。

再看一个stderr重定向的例子:

// 把错误信息单独记录到文件
freopen("error.log", "a", stderr);
fprintf(stderr, "这条错误写入了error.log\n");

// 标准输出仍然正常打印
printf("屏幕上看得到我\n");

21.4 管道——进程间的「数据水管」

管道这个概念,说白了就是让一个进程的输出变成另一个进程的输入。你在shell里用的|符号,背后就是管道机制。

// 在shell中:
// ls -l | grep ".c" | wc -l
// 这条命令创建了两个管道,连接了三个进程

// C语言中创建管道:
#include <unistd.h>

int main() {
    int pipefd[2];
    if (pipe(pipefd) == -1) {
        perror("pipe");
        return 1;
    }
    
    // pipefd[0] → 读端
    // pipefd[1] → 写端
    
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:关闭读端,重定向stdout到写端
        close(pipefd[0]);
        dup2(pipefd[1], STDOUT_FILENO);
        close(pipefd[1]);
        execlp("ls", "ls", "-l", NULL);
    } else {
        // 父进程:关闭写端,从读端读取
        close(pipefd[1]);
        char buffer[1024];
        read(pipefd[0], buffer, sizeof(buffer));
        close(pipefd[0]);
        printf("从子进程读到:\n%s", buffer);
    }
    return 0;
}

注意:管道是单向的。如果你想双向通信,需要创建两个管道。另外,管道有缓冲区大小限制(通常65536字节),写满时会阻塞。

21.5 重定向的几种常见场景

我整理了一张表,把日常开发中常见的重定向操作列出来:

操作 Shell写法 C语言实现 说明
输出重定向 ./a.out > out.txt freopen("out.txt","w",stdout) 覆盖写入
追加输出 ./a.out >> out.txt freopen("out.txt","a",stdout) 追加到文件末尾
输入重定向 ./a.out < in.txt freopen("in.txt","r",stdin) 从文件读取输入
错误重定向 ./a.out 2> err.txt freopen("err.txt","w",stderr) 单独记录错误信息
合并输出 ./a.out > all.txt 2>&1 dup2(fd, STDERR_FILENO) stdout和stderr写入同一文件

21.6 知识体系图

下面这张图展示了标准流、文件描述符、重定向和管道之间的关系:

标准流与重定向知识体系 进程 (Process) 文件描述符表 0 → stdin 1 → stdout 2 → stderr 标准流 (FILE*) stdin (读) stdout (写) stderr (写) freopen 重定向 • 将流绑定到文件 • 支持 "r" "w" "a" 模式 • 会关闭原流 管道 (pipe) • 进程间单向通信 • 配合 dup2 重定向 fd • 有缓冲区大小限制 核心关系:文件描述符 → 标准流 → 重定向/管道

21.7 避坑指南

我曾经在一个线上服务中踩过一个坑:程序用freopen把stdout重定向到了日志文件,但后来fork了子进程。子进程继承了重定向后的stdout,结果父子进程同时写同一个日志文件,导致日志内容交错混乱。

解决方案其实很简单:fork之后,子进程应该先关闭继承来的文件描述符,再重新打开。或者用dup2配合O_CLOEXEC标志,让子进程执行exec时自动关闭。

几个实用建议:

  • 调试时多用stderr输出错误信息,因为它不受stdout重定向影响
  • 重定向后记得检查返回值,freopen失败会返回NULL
  • 管道通信时,记得关闭不需要的端,否则可能造成死锁
  • 文件描述符是有限资源,用完要close

嗯,标准流和重定向这块内容,说白了就是理解「数据从哪里来,到哪里去」。你掌握了freopen和管道,就能灵活控制程序的输入输出方向。这在写命令行工具、日志系统、进程间通信时特别有用。


公众号:蓝海资料掘金营,微信deep3321