从学 C 语言的第一天起,你大概就写过这样两行代码:fopen("myfile", "w") 打开一个文件,然后 fwrite 往里面写数据。你可能会想:这不就是"读写文件"吗?有什么好讲的?

但当你深入 Linux 之后,一个问题会逐渐变得扎眼——**到底是谁在真正地"打开文件"?**是 C 语言的库函数吗?还是某个藏在操作系统内核里你从没见过的家伙?

答案会让你重新审视前面学的所有 IO 代码:fopen、fwrite 这些库函数,只是"跑腿的中介",真正打开文件、负责搬运数据的是 Linux 内核提供的一批系统调用。而连接你的程序与这些系统调用之间的桥梁,是一个神秘的"小整数"——文件描述符(fd)。

这篇文章我们就把 Linux 基础 IO 拆开揉碎:从"文件到底是什么"讲起,回顾 C 语言文件接口,再一层层揭开系统调用、文件描述符、重定向的底牌,最后落到无数新手栽跟头的缓冲区。这一篇下来,你会彻底看懂 printf 和 write 到底差在哪、为什么 > file 重定向后输出会"迟到甚至消失"、以及你那个打印"hello world"永远只出现一次的困惑是怎么来的。

你应该有的知识准备

在读之前,我默认你至少具备下面这些基础。它们大多散落在 C 语言课里,这里我只做一句话回顾:

  • C 语言基础:会写 main 函数、认识指针、会用 printf / fopen / fwrite / fread 这些库函数。
  • 进程的概念:程序跑起来之后就是一个进程,进程有自己独立的内存、自己独立的"身份"(进程 PID)。理解 /proc/<pid> 这个虚拟文件系统会很有用。
  • 文件就是"字节序列":无论是文本还是二进制,对操作系统来说,文件就是一连串字节,读写就是移动一个"读写位置(偏移)"在这串字节上取放数据。
  • 稍微知道一点位运算:&(按位与)、|(按位或)会在"传递标志位"那节用到,也贯穿 open 的第二个参数。

如果你这些都知道,那这堂课会非常顺畅;如果哪条模糊,看到对应代码时回头补一眼就行。


理解"文件":从磁盘到一切皆文件

狭义理解:磁盘上的文件

最朴素的认识是:文件在磁盘里。磁盘是一种永久性存储介质——断电之后数据不丢,所以我们说文件在磁盘上的存储是"永久性的"。这一点和内存正好相反,内存一断电就全没了。

还要注意一个容易被忽略的身份:磁盘本身是外设。它既是"输出设备"(我们要把数据写到它里面去),也是"输入设备"(我们要把数据从它里面读出来)。所以你看:既然对文件的所有操作,本质上就是对"输入设备"和"输出设备"的输入输出,那么"文件操作"和"IO"这件事是天生画等号的——**IO(Input/Output,输入/输出)**这个词,就是为文件操作准备的。

如果你把一个空文件(0 字节)放进磁盘,你会发现它依然"占地方"。这背后是一个重要结论:

文件 = 元数据 + 内容

一个文件,是由"文件属性(元数据)"和"文件内容"两部分组成的。0 字节的空文件,只是"内容"是空的,它的"属性"可一点都不少:文件名、大小、修改时间、权限、所有者……这些属性占用的那点磁盘空间,就是它"0KB 内容却占用磁盘空间"的原因。

也正因为文件由这两部分组成,所有的文件操作,本质上只有两类:操作内容,或者操作属性。read/write 操作内容,stat/chmod 操作属性。记住这个分类,后面学别的会很有帮助。

广义理解:一切皆文件

如果你把视野放大,会发现 Linux 里"文件"这个概念被大大地升华了。Linux 下"一切皆文件"——不只是磁盘上的普通文件,连键盘、显示器、网卡、磁盘这些硬件设备,也全都被抽象成了文件。

这意味着:你可以像读写普通文件一样去读写一个设备、去读取系统状态、去和一个管道通信。好处是巨大的——开发者只需要学一套 API、一套思维模型,就能操作 Linux 里绝大部分资源。Linux 中几乎所有"读"的操作都能用 read 完成,几乎所有"改"的操作都能用 write 完成。这正是我们后面第四节要展开的"一切皆文件"的精髓,这里先建立一个印象。

系统视角:进程才是操作的主体

最后,换一个"上帝视角"看文件:对文件的操作,本质上是进程对文件的操作;而磁盘的管理者是操作系统,不是某个进程。

这句话拆开来看有两层意思:

第一,文件不是凭空存在的,它一定是被"某个进程"打开的。同一时刻,可能有多个进程都在打开同一个文件。

第二,进程本身没有一个键直接插在磁盘上。进程要想读写文件,必须"请求"操作系统替它干活——因为它没有权限直接触碰硬件。这个"请求操作系统帮忙"的动作,就是我们下节要反复提到的主角:系统调用。

思考题 1.1:为什么说"C 语言 / C++ 的库函数并不是读取文件的最终执行者"?那文件到底是谁读的?

详解:C / C++ 的库函数(fread、fwrite、ifstream 等)只是运行在用户态的程序代码,它们本身没有读写磁盘硬件的权限。最终真正去操作外设、把数据搬进搬出的是 Linux 内核。库函数做的,是把你的请求翻译成一次对内核的"系统调用"(比如 read、write),由内核代表进程去完成真正的 IO,再把结果返回给库函数。所以答案是:文件最终由操作系统内核,通过系统调用替进程读取。


回顾 C 语言的文件接口

在学习系统接口之前,我们先把 C 语言标准库(libc)这套你熟悉的接口完整过一遍,因为下一节讲系统调用时,你会体会到"它们俩几乎一一对应"。

用 fopen 打开文件

先写一个最朴素的三行小例子:

// hello.c —— 打开文件并卡住,观察进程状态
#include <stdio.h>
 
int main()
{
    // fopen("myfile", "w"):以"写"模式打开(若不存在则创建)名为 myfile 的文件
    // 参数1 文件名是相对路径;参数2 是打开方式
    FILE *fp = fopen("myfile", "w");
    if (fp == NULL)           // fopen 失败返回 NULL
    {
        printf("fopen error!\n");
        return 1;
    }
    // 打开成功后用一个死循环卡住进程,方便我们去 /proc 里观察它
    while (1);
    fclose(fp);               // 正常情况用 fclose 关闭(这里永远执行不到)
    return 0;
}

先别管 while(1) 有点"坏坏的",这是故意的——我们想让程序一直跑着,好去观察这个进程。编译运行:gcc hello.c -o hello && ./hello。

myfile 到底被创建在哪个目录下?我们问一个更本质的问题:进程的"当前路径"是怎么被系统知道的?

答案是:每个进程在操作系统里都有一份"档案",记录它自己从哪来、现在在哪个目录。你可以在 /proc(一个虚拟文件系统,每个运行中的进程都有一个以 PID 命名的目录)里亲眼看到它:

# 先查到 hello 这个进程的 PID
$ ps ajx | grep hello | grep -v grep
  506729  533463  533463  506729 pts/2  533463 R+    1002   7:45 ./hello
 
# 用 PID 去 /proc 里查看这个进程的档案
$ ls /proc/533463 -l
total 0
...
lrwxrwxrwx 1 hyb hyb 0 Aug 26 16:53 cwd -> /home/hyb/io   # 当前工作目录
lrwxrwxrwx 1 hyb hyb 0 Aug 26 16:53 exe -> /home/hyb/io/hello  # 可执行文件
dr-x------ 2 hyb hyb 0 Aug 26 16:54 fd                      # 文件描述符表
...

注意其中两个符号链接:

  • cwd:指向这个进程当前的工作目录(current working directory)。哪怕你只写了个相对路径 "myfile",系统只要看一眼 cwd 就知道要在哪个目录里创建文件。
  • exe:指向启动这个进程的可执行文件的完整路径。

所以"打开文件,本质是进程打开文件"。进程知道自己此刻在哪、自己是谁,于是不带路径的 myfile 也能被准确定位。这个 fd 目录,我们待会儿讲文件描述符时会回头看它,它里面就装着这个进程当前打开的所有文件。

fopen 的打开方式(mode)

fopen 的第二个参数是一段字符串,叫 mode,它决定以什么方式打开。这是你需要刻进脑子的表:

mode含义文件不存在时位置
r只读打开失败(返回 NULL)从开头
r+读写打开失败从开头
w只写,且把原文件清空(截断)创建从开头
w+读写,且清空创建从开头
a只追加(写到文件末尾)创建末尾(读的位置不在)
a+读写追加(写总是在末尾)创建读从开头,写总是在末尾

几个容易踩的细节:

  • w 会把文件截断成 0 字节再开始写,也就是说只要用 w 打开,旧内容就被清了。想保留旧内容就绝不能乱用 w。
  • a(append)永远不会覆盖已有内容,写到一半你怎么挪位置它都坚持"追加到末尾"。
  • Linux 世界里其实没有"文本文件 / 二进制文件"之分(r 里的那个"text"只是 C 标准的历史说法),读写都以字节为单位。后面你会看到,系统接口根本不区分这两者。

顺带一提,C 里还有一组配合定位用的函数:fseek(移动到指定位置)、ftell(取当前位置)、rewind(回到开头)。它们在 C 部分已学过,这里不过多展开,它们是"直接操作内容偏移"的一族,和后面要讲的 lseek 对应。

用 fwrite 写文件

我们写一个向文件里写 5 遍 "hello bit!\n" 的小程序:

// write_demo.c —— 用 fwrite 向文件写入内容
#include <stdio.h>
#include <string.h>
 
int main()
{
    // "w" 模式:不存在则创建,存在则清空
    FILE *fp = fopen("myfile", "w");
    if (fp == NULL)
    {
        printf("fopen error!\n");
        return 1;
    }
 
    const char *msg = "hello bit!\n";   // 要写的内容
    int count = 5;
    while (count--)                     // 循环 5 次
    {
        // fwrite(数据, 单个元素大小, 元素个数, 流)
        // fwrite(msg, strlen(msg), 1, fp):写 1 个"长度为 strlen(msg)"的元素
        fwrite(msg, strlen(msg), 1, fp);
    }
 
    fclose(fp);                         // 关闭并刷新缓冲区
    return 0;
}

请你特别注意 fwrite 的返回值语义:size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream),它返回的是"完整写入的元素个数"。这里 size=strlen(msg)、nmemb=1,也就是说把 "hello bit!\n"(12 字节)当作"一个元素",所以返回值要么是 1(成功),要么是 0(失败)——它返回的不是字节数。这一点和后面系统调用 write 的"返回字节数"形成了鲜明的对比,是新手最容易搞混的地方,先记住这个差异。

用 fread 读文件

读写对称,读文件用 fread。下面这段代码把 myfile 的内容读出来:

// read_demo.c —— 用 fread 读文件内容
#include <stdio.h>
#include <string.h>
 
int main()
{
    // "r" 模式:只读,文件必须存在
    FILE *fp = fopen("myfile", "r");
    if (fp == NULL)
    {
        printf("fopen error!\n");
        return 1;
    }
 
    char buf[1024];                     // 存放读到的内容
    // key:这里用 fread(buf, 1, strlen("hello bit!\n"), fp)
    // 即每次都尝试读 strlen(...) 个"1 字节的元素"——这时返回值就等于字节数
    size_t n = strlen("hello bit!\n");
    while (1)
    {
        ssize_t s = fread(buf, 1, n, fp);   // 返回实际读到的字节数
        if (s > 0)
        {
            buf[s] = 0;                     // 手动补上字符串结束符
            printf("%s", buf);
        }
        if (feof(fp))                       // 判断是否读到文件末尾
        {
            break;
        }
    }
 
    fclose(fp);
    return 0;
}

这一版代码有两个坑,需要你格外小心:

  1. fread 的返回值和参数匹配。fread(buf, 1, n, fp) 中 size=1、nmemb=n,所以返回的是"成功读到的元素个数",此时每个元素 1 字节,于是返回值恰好等于"读到的字节数"。若用 fread(buf, n, 1, fp),语义就变成了"读一个 n 字节的元素",只要一次没凑够 n 个字节,返回就是 0,容易丢数据。简单项目里,读文本惯用 fread(buf, 1, sizeof buf, fp),返回的 s 就是字节数。
  2. 缓冲区越界 / 未补结束符。buf[1024] 是固定大小,读到的内容要自己 buf[s] = 0 补上 \0 再用 printf("%s", ...),否则 printf 会一直往后读到内存里某个碰巧为 0 的字节为止,产生"越界读"。

把读文件这段稍微改改,就能写一个简易 cat 命令:

// mycati.c —— 一个简易 cat:./mycati 文件名,把内容打印出来
#include <stdio.h>
#include <string.h>
 
int main(int argc, char *argv[])
{
    if (argc != 2)                 // 只接受一个命令行参数
    {
        printf("argv error!\n");
        return 1;
    }
 
    FILE *fp = fopen(argv[1], "r");  // 打开命令行里指定的文件
    if (fp == NULL)
    {
        printf("fopen error!\n");
        return 2;
    }
 
    char buf[1024];
    while (1)
    {
        // 一次尽量多读:读满 buf(1024 字节),size=1 保证返回的是字节数
        int s = fread(buf, 1, sizeof(buf), fp);
        if (s > 0)
        {
            buf[s] = 0;            // 补结束符
            printf("%s", buf);     // 打印
        }
        if (feof(fp))              // EOF 则结束
        {
            break;
        }
    }
 
    fclose(fp);
    return 0;
}

编译并测试:gcc mycati.c -o mycati && ./mycati myfile,它能像 cat myfile 一样把文件内容打出来。

输出到显示器的多种方法

写文件用一个 FILE*,其实往"显示器"输出也是写文件——显示器被抽象成了一个流。C 库里往显示器输出有好几种写法:

// print_all.c —— 往 stdout 输出的几种方式
#include <stdio.h>
#include <string.h>
 
int main()
{
    const char *msg = "hello fwrite\n";
 
    // 1) fwrite 显式指定 streams=stdout:写出 strlen(msg) 个字节
    fwrite(msg, strlen(msg), 1, stdout);
 
    // 2) printf 默认就往 stdout 输出,还支持格式化
    printf("hello printf\n");
 
    // 3) fprintf 显式指定流,等价于带格式化的 fwrite
    fprintf(stdout, "hello fprintf\n");
 
    return 0;
}

这三种方式在你眼里"长得不一样",但本质是同一件事——都往 stdout 这个流上写。而 printf 不过是 fprintf(stdout, ...) 的便捷写法。

stdin / stdout / stderr

这就引出一个关键点:C 程序一启动,就默认打开了三个文件流,分别是 stdin(标准输入)、stdout(标准输出)、stderr(标准错误)。它们的类型都是 FILE*,和 fopen 的返回值类型一模一样:

#include <stdio.h>
 
// 这三个是 C 库声明的全局"文件流",类型都是 FILE*
extern FILE *stdin;   // 标准输入:默认连着键盘
extern FILE *stdout;  // 标准输出:默认连着显示器
extern FILE *stderr;  // 标准错误:默认连着显示器(用于报错信息)

为什么要分"标准输出"和"标准错误"两个都连着显示器的流?因为输出"正常结果"和"报错信息"在很多场景下是想分开的(比如你想让正常结果进文件、错误直接打到屏幕)。这个区分在我们的重定向和缓冲区课程里会反复用到。

思考题 2.1:fwrite(msg, strlen(msg), 1, fp) 的返回值可能是 1,也可能是 0,那么"1 和 0"分别代表什么?它和 write(fd, msg, len) 的返回值有什么本质区别?

详解:前者 size=strlen(msg)、nmemb=1,返回值是"写成功的完整元素个数",所以 1 表示那 strlen(msg) 个字节作为一个元素完整写入了,0 表示没有完整写入。它不是字节数。后者(系统调用 write)返回的是"实际成功写入的字节数",可能小于你要写的 len(叫"部分写")。一个数"元素个数",一个数"字节数",这就是两者最容易弄混的地方。


系统文件 I/O:真正负责"打开文件"的是系统

前面我们反复强调"打开文件其实靠系统",现在来真的。除了 fopen 这套 C 接口(C++ 有 ifstream,别的语言也有自己的封装,但它们脚下踩的都是同一套东西),Linux 为我们提供了一组以 open / close / read / write / lseek 为代表的系统调用。先别急,做它之前,我们要先学会一个给函数传"标志位"的技巧,因为它是 open 的第二个参数的核心。

传递标志位的方法

需求是:我想给一个函数传一个"组合开关",它可以同时表示打开了好几个选项。最优雅的做法,是用一个整数,每一位代表一个选项——用按位或(|)组合,用按位与(&)判断:

// flags_demo.c —— 用"位"来传递多个标志
#include <stdio.h>
 
// 三个标志,各自占用一个二进制位,互不重叠
// 0001(即十进制 1)、0010(2)、0100(4)
#define ONE   0001   // 二进制 0001
#define TWO   0002   // 二进制 0010
#define THREE 0004   // 二进制 0100
// 注意:C 里以 0 开头的是八进制,0001=1、0002=2、0004=4
 
// func 根据 flags 里"置位"了哪些位来决定行为
void func(int flags)
{
    if (flags & ONE)   printf("flags has ONE! ");   // 按位与:看 ONE 那一位是否为 1
    if (flags & TWO)   printf("flags has TWO! ");
    if (flags & THREE) printf("flags has THREE! ");
    printf("\n");
}
 
int main()
{
    func(ONE);            // 只带 ONE       -> flags has ONE!
    func(THREE);          // 只带 THREE     -> flags has THREE!
    func(ONE | TWO);      // 同时带 ONE 和 TWO
    func(ONE | THREE | TWO); // 三者都带上
    return 0;
}

编译运行,你会看到:ONE | TWO 那两个选项同时生效了。这就是"用位传标志"的核心思想——每个选项占用唯一一个 bit,| 用来叠加,& 用来检测。open 的第二个参数 flags 正是这么设计的:O_RDONLY、O_WRONLY、O_CREAT、O_APPEND……每个都是独立的 bit,你用 | 把它们拼起来一起传给 open。理解了这一个技巧,后面 open 的代码就是顺水推舟。

用 open/write 写文件——第一份"系统口味"的代码

现在我们用系统接口,写前一节一模一样的"写 5 遍 hello":

// sys_write.c —— 用系统调用 open + write 写文件
#include <stdio.h>
#include <string.h>
#include <unistd.h>      // 声明系统调用 write / close / read
#include <sys/types.h>
#include <sys/stat.h>    // 声明 open 所需的一些类型
#include <fcntl.h>       // 声明 open 以及 O_* 一系列标志
 
int main()
{
    umask(0);            // 不屏蔽任何权限位,让 open 的 mode 原样生效(稍后解释)
 
    // open(文件名, 标志, 权限)
    // O_WRONLY:只写打开;O_CREAT:文件不存在则创建
    // 0644:创建新文件时的权限 = rw-r--r--(8 进制)
    int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
    if (fd < 0)          // 系统调用失败返回 -1
    {
        perror("open");  // perror 根据 errno 打印出错原因
        return 1;
    }
 
    int count = 5;
    const char *msg = "hello bit!\n";
    int len = strlen(msg);
    while (count--)
    {
        // write(fd, 缓冲区地址, 想写的字节数)
        // 返回值:实际成功写入的字节数
        write(fd, msg, len);
    }
 
    close(fd);           // 关闭文件描述符
    return 0;
}

注意,这里的 open 比 fopen"原始"很多:它返回的不是 FILE*,而是一个 int——这就是文件描述符,是后面整个章节的核心,先记住"open 成功返回一个小写整数,失败返回 -1"。

用 open/read 读文件

读文件的系统版本:

// sys_read.c —— 用系统调用 open + read 读文件
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
 
int main()
{
    // 只读打开,O_RDONLY 就是"只读"
    int fd = open("myfile", O_RDONLY);
    if (fd < 0)
    {
        perror("open");
        return 1;
    }
 
    char buf[1024];
    while (1)
    {
        // read(fd, 缓冲区, 想读的字节数)
        // 返回值:实际读到的字节数;0 表示读到文件末尾(EOF);-1 表示出错
        ssize_t s = read(fd, buf, sizeof(buf) - 1);
        if (s > 0)
        {
            buf[s] = 0;      // 补结束符
            printf("%s", buf);
        }
        else if (s == 0)     // EOF
        {
            break;
        }
        else                 // 出错
        {
            perror("read");
            break;
        }
    }
 
    close(fd);
    return 0;
}

接口总览:open / close / read / write / lseek

把这几个系统调用放到一起看,它们和 C 接口几乎一一对应:

// 头文件
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
 
// 打开:成功返回一个文件描述符,失败返回 -1
//   两个参数版本:open(pathname, flags)
//   三个参数版本:open(pathname, flags, mode)  —— 当用到 O_CREAT 时必须用这个
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
 
// 关闭:成功返回 0,失败返回 -1
int close(int fd);
 
// 读:返回实际读到的字节数;0=EOF;-1=出错
ssize_t read(int fd, void *buf, size_t count);
 
// 写:返回实际写出的字节数;可能小于 count(部分写)
ssize_t write(int fd, const void *buf, size_t count);
 
// 移动读写位置:成功返回新偏移,失败返回 -1
off_t lseek(int fd, off_t offset, int whence);  // whence: SEEK_SET/SEEK_CUR/SEEK_END

open 的 flags,你最要紧的是这几个:

  • O_RDONLY 只读、O_WRONLY 只写、O_RDWR 读写——这三个必须指定一个,且只能指定一个。
  • O_CREAT:文件不存在则创建。一旦用了它,就必须提供第三个参数 mode 来指定新文件的权限。
  • O_APPEND:追加写——每次写都从文件末尾开始。
  • (还有一个后面讲 CLOSE_ON_EXEC 时会见到的 O_CLOEXEC,先埋个伏笔。)

mode 是权限位,最常见的几个:0644 = rw-r--r--(所有者读写,其它只读),0666 = rw-rw-rw-。这里有个隐藏的坑叫 umask:进程有一个"权限掩码",open 创建的文件,实际权限 = mode & ~umask。比如系统默认 umask 是 022,那么 open(..., 0666) 实际得到的是 0666 & ~022 = 0644。上面代码里的 umask(0) 就是把掩码清零,让权限原样生效,方便你精确观察。现实中服务器往往不让你改 umask,所以别以为 open 传了 0666 就一定是 0666。

返回值与错误处理:一切系统调用的共同语言

这是新手最容易忽略、却又最能区分"懂不懂系统编程"的地方。系统调用的返回值和 C 库函数有一个巨大的不同:它们统一用"负一 + 全局错误码 errno"报告错误。

  • 成功:返回一个非负值(如打开的文件描述符、读到的字节数)。
  • 失败:返回 -1,同时把 errno(一个全局整数)设置成具体的错误码(如 ENOENT 文件不存在、EACCES 权限不足),并在函数内部用 perror / strerror 把它翻译成人类可读的字符串。

所以几乎每个系统调用的返回值,都应该先判一下 < 0,像上面每段代码里的 if (fd < 0) { perror(...); return ...; } 那样。不检查返回值,是系统编程里最危险的坏习惯之一——open 失败了你还拿着 -1 去 write,只会得到另一个莫名其妙的错误。

思考题 3.1:fopen 失败返回 NULL,open 失败返回 -1,这个"返回值的哲学"有什么共同点?为什么要统一用负一而不是随便一个数表示失败?

详解:共同点是"用一个不可能出现在合法结果里的值来表示失败"。open 成功的结果是"文件描述符",它必然是一个非负整数(0、1、2、3……),而 -1 永远不可能是合法的文件描述符,所以用 -1 表示失败就是数学上无歧义的选择。同理 fopen 返回到是 FILE* 指针,NULL 地址永远不会是合法的文件指针,所以用 NULL 表示失败。read 返回字节数也是非负,所以 -1 表示失败、0 表示 EOF 而不会混淆。用"不在值域内的特殊值"表示错误,是 C 风格的通用约定。


文件描述符与重定向的本质

现在我们终于要正面认识本节的主角——文件描述符(file descriptor,简称 fd)。

一句话:文件描述符就是一个"小整数",它是进程打开的文件在"文件描述符表"里的下标。有了它,进程——以及内核——就能在茫茫文件里精确指出"我说的是哪一个"。这也是为什么 open 成功返回一个 int 而不是什么复杂的结构。

文件描述符 0、1、2

就像 C 程序启动默认打开三个流(stdin/stdout/stderr)一样,每个 Linux 进程一启动,就默认打开了三个文件描述符:

文件描述符名字默认设备对应的 C 流
0标准输入 stdin键盘stdin
1标准输出 stdout显示器stdout
2标准错误 stderr显示器stderr

这三个数字 0、1、2 是铁打约定,头文件 <unistd.h> 里用 STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO 三个宏替你把它们记好了。既然 0/1/2 已经是标准输入输出,那么你之后用 open 打开的第一个新文件,拿到的文件描述符就从 3 开始。

知道了 0、1、2,我们甚至不需要 fopen,直接用系统调用往键盘读、往显示器写:

// std_fd.c —— 直接用 fd 0/1/2 完成"从键盘读、往屏幕写"
#include <stdio.h>
#include <string.h>
#include <unistd.h>
 
int main()
{
    char buf[1024];
    // fd 0 是标准输入(键盘)。read(0, ...) 读键盘输入
    ssize_t s = read(0, buf, sizeof(buf));
    if (s > 0)
    {
        buf[s] = 0;                   // 补结束符
        // fd 1 是标准输出(显示器)
        write(1, buf, strlen(buf));
        // fd 2 是标准错误(也是显示器)
        write(2, buf, strlen(buf));
    }
    return 0;
}

运行它,敲几个字回车,屏幕上会把同样的内容打两遍(一遍走 fd 1,一遍走 fd 2)。这直观证明:printf 的底层就是往 fd 1 上 write,fprintf(stderr,...) 的底层是往 fd 2 上 write。

文件描述符到底是什么:内核视角

我们口口声声说 fd 是"小整数",它凭什么是小整数?这要从内核为管理打开的文件所维护的结构说起,推荐你用 uname -a 看内核版本,然后在 /usr/src/kernels/ 里找到对应版本的内核源码来对照(下面给出的是 3.10 内核中的路径):

  • 每个进程都有这样一个大结构体 struct task_struct(进程控制块,PCB):/usr/src/kernels/<版本>/include/linux/sched.h
  • 它里面有一个指针 *files,指向一张表 struct files_struct:/usr/src/kernels/<版本>/include/linux/fdtable.h
  • files_struct 中最重要的部分是一个指针数组,数组的每个元素都指向一个"已打开文件对象" struct file:/usr/src/kernels/<版本>/include/linux/fs.h

于是整个逻辑串起来了:

进程 (task_struct)
   │  *files
   ▼
files_struct ── 指针数组 ──► [0] -> file{...}   (标准输入)
                          [1] -> file{...}   (标准输出)
                          [2] -> file{...}   (标准错误)
                          [3] -> file{...}   (你 open 的 myfile)
                          ...

所以"文件描述符本质就是那个指针数组的下标"。系统一拍脑袋就知道:拿着 fd=3,就是在说"数组里下标 3 那个元素指向的 file",而那个 file 结构体里记录了这个文件的全部状态(当前读写位置 f_pos、权限、引用计数 f_count 等)。你返回给用户层的 fd,其实是一个"门牌号",方便用户层和内核用一个整数指路。

(现代内核用一个更精确的层次:fd 表条目指向"打开文件描述 open file description",再由它指向 inode;多个 fd 可以共享同一个 open file description,这也是后面 dup2 重定向、以及"两次 open 同一个文件偏移是否独立"的关键。入门掌握到"fd 是数组下标、指向 file"这一层就足够。)

fd 的分配规则:从 3 开始

为什么你 open 一个文件,拿到的就是 3?因为规则是:在 fd 表里找到"当前没有被使用的、最小的那个下标",把它作为新 fd。

验证一下:

// fd_rules.c —— 观察 fd 分配规则
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
 
int main()
{
    // 此时 0/1/2 都已被占,所以第一个 open 拿到最小空闲下标 3
    int fd = open("myfile", O_RDONLY);
    printf("fd: %d\n", fd);   // 输出 fd: 3
    close(fd);
    return 0;
}

如果我先 close(0) 或 close(2),把 0 或 2 空出来,那 open 就会捡到那个更小的空位:

// fd_rules2.c —— 关闭 0 或 2 后再 open,观察 fd 会变成 0 或 2
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
 
int main()
{
    close(0);                    // 先把 0 号让出来
    // close(2);                 // 换成关 2 也一样,测试时两个二选一
    int fd = open("myfile", O_RDONLY);
    printf("fd: %d\n", fd);      // 由于 0 被关闭,open 捡到最小的空位 0 -> 输出 0
    close(fd);
    return 0;
}

你会看到输出变成了 fd: 0(或 fd: 2,取决于你关的是哪个)。"最小可用下标"这条分配规则,正是下面所有重定向技巧的基础——因为它意味着"只要你腾出一个位置(比如关掉 1),下一次 open 就一定占用这个位置"。

"系统调用 vs 库函数"那张图

在讲重定向前,我们把关系图定格一下:

用户程序(fopen / fwrite / printf ...)        ← 库函数(libc)
        │
        ▼
系统调用接口(open / read / write / close)    ← 内核提供的接口
        │
        ▼
      内核完成真正的 IO(驱动 + 外设)

fopen、fclose、fread、fwrite 这些以 f 开头的 C 库函数,本质上都是对系统调用的一层封装。它们为什么存在?为了方便——带了缓冲区、带了格式化、降低了出错概率。你可以把 C 库函数理解成"给系统调用套了一层人话壳",系统调用则是内核真正的门面。fd 是这条链里用户层最终拿到的"凭证":C 库函数再怎么封装,最终向内核伸手要的还是 fd。

重定向的本质:替换 fd 1 指向的 file

现在,奇迹时刻。如果我把 fd 1 关掉,再 open 另一个文件,会发生什么?

// redirect_first.c —— 关掉 stdout,观察输出"变形"
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
 
int main()
{
    close(1);                                   // 关上标准输出 fd 1
 
    // 因为 1 空了,open 最小可用下标规则生效:fd 一定拿到 1!
    int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
    if (fd < 0)
    {
        perror("open");
        return 1;
    }
    printf("fd: %d\n", fd);   // 注意:这条 printf 会打进 myfile!
    fflush(stdout);           // 强制刷新,确保内容真的落到文件
    close(fd);
    exit(0);
}

运行后你会发现:屏幕上什么都没打,但 cat myfile 一看,fd: 1 和 printf 的其它内容都跑到文件里去了!

这就是著名的输出重定向。原理一句话:printf 底层往 fd 1 上写,而调用 open 时,因为 fd 1 恰好是"最小可用下标",所以 myfile 就顶替了原来显示器的位置,占据了 fd 1。于是"往标准输出写"就变成了"往 myfile 里写"。常见 shell 重定向 >(覆盖)、>>(追加)、<(输入)全是这个机制。

但注意上面对 fflush(stdout) 的处理——printf 是有用户级缓冲的,后面讲缓冲时你会彻底明白为什么这里不加 fflush 内容可能"消失"。现在先记住这个"钩子",我们把它留到缓冲区章节引爆。

思考题 4.1:为什么上面这段代码里 open 一定拿到 fd 1,而不是 3 或别的数?

详解:因为文件描述符采用"最小可用下标"分配规则。程序启动时 0、1、2 都被占用;close(1) 之后,下标 1 变成了整个表里最小的空位,于是 open 必然在这个空位新建条目,即返回 1。这正是重定向能成立的根本原因——只要腾出某个固定下标(1 或 2 或 0),下一次 open 就会"恰好"占用它。

用 dup2 系统调用,把重定向写得更专业

你可能会说:为了重定向还得先 close(1),万一期间别人又 open 了一个文件把最小空位抢走,不就乱了?没错,所以工程上并不裸用这套"关掉再 open",而是用专门的系统调用——

#include <unistd.h>
 
// 把 oldfd 这个描述符"复制"到 newfd 这个描述符上
// 效果:newfd 从此和 oldfd 指向同一个"打开文件对象"
int dup2(int oldfd, int newfd);

dup2 的语义非常干净,三个要点:

  1. 让 newfd 成为 oldfd 的"复制",两者指向同一个 file(共享一个读写位置)。
  2. 如果 newfd 本来是打开的,会自动先把它关掉(静默地)。
  3. 如果 oldfd == newfd,什么都不做,直接返回 newfd。

用 dup2 重定向标准写法是:先 open 拿到一个高一点的正数 fd(比如 3),再用 dup2(3, 1) 把文件搬到 fd 1 上,再 close(3) 释放临时 fd。因为 dup2 会先关掉旧的 fd 1,所以不需要手动 close(1),且这个过程是原子的,安全许多。而且用 dup2 重定向后,缓冲相关的情形也比"直接 close(1) 再 open"更贴近真实 shell(下面缓冲区章节你还会再遇到它)。

演示一个"把 stdin 重定向到文件、把 stdout 重定向到 log"的小循环:

// dup2_demo.c —— 用 dup2 把 stdin 重定向到 stdin.txt,把 stdout 重定向到 log
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
 
int main()
{
    // 打开输入文件;此时 fd 至少是 3(0/1/2 已占用)
    int in = open("stdin.txt", O_RDONLY);
    int out = open("log", O_CREAT | O_RDWR | O_TRUNC, 0666);
    if (in < 0 || out < 0)
    {
        perror("open");
        return 1;
    }
 
    dup2(in, 0);     // 让 fd 0 指向 in —— 从此"从标准输入读"= 从 stdin.txt 读
    close(in);       // 释放临时 fd 3
    dup2(out, 1);    // 让 fd 1 指向 out —— 从此"printf"= 写进 log
    close(out);
 
    // 循环读文件,printf 往 stdout 打,但 stdout 已经是 log 了
    char buf[1024];
    for (;;)
    {
        ssize_t n = read(0, buf, sizeof(buf) - 1);   // 读的是 stdin.txt
        if (n <= 0) break;
        buf[n] = 0;
        printf("%s", buf);   // 写的是 log
        fflush(stdout);      // 刷掉 printf 的用户缓冲
    }
    return 0;
}

准备好一个 stdin.txt(里面写几行文字),运行后会发现屏幕没有输出,但 log 里出现了 stdin.txt 的内容。输入重定向和输出重定向,本质都是同一件事——通过 dup2 更改某个固定 fd(0/1/2)指向的"打开文件对象"。

亲手实现一个带重定向的 minishell

把上面的知识串起来,我们写一个真正能用的迷你 shell,支持 cd、> file、>> file、< file:

// myshell.c —— 带输入/输出/追加重定向的迷你 shell
// gcc -o myshell myshell.c && ./myshell
#include <ctype.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
 
// 四种重定向状态:无、输入(<)、输出(>)、追加(>>)
enum { NONE, INPUT, OUTPUT, APPEND };
 
int main()
{
    char line[1024];
    for (;;)
    {
        printf("[my-shell]# ");   // 命令提示符
        fflush(stdout);           // 提示符要立刻显示,手动刷新(重点!后面讲缓冲)
 
        // 从标准输入读一行命令,读到 EOF(Ctrl+D)则退出
        if (fgets(line, sizeof(line), stdin) == NULL)
            break;
        line[strcspn(line, "\n")] = 0;   // 去掉行尾换行符
        if (line[0] == 0)
            continue;                   // 空行跳过
 
        // ---------- 第一步:解析重定向符号 ----------
        int redir = NONE;               // 默认无重定向
        char *filename = NULL;          // 重定向目标文件名
        char *p = line + strlen(line) - 1;   // 从行尾往前找重定向符
        while (p >= line)
        {
            if (*p == '<')             // 输入重定向
            {
                redir = INPUT;
                *p = 0;                // 断开命令行与文件名的连接
                filename = p + 1;
                break;
            }
            else if (*p == '>')
            {
                if (p > line && *(p - 1) == '>')   // 连续的 >> 是追加
                {
                    redir = APPEND;
                    *(p - 1) = 0;      // 把两个 '>' 都断开
                    *p = 0;
                }
                else
                {
                    redir = OUTPUT;
                    *p = 0;
                }
                filename = p + 1;
                break;
            }
            p--;
        }
        if (filename)
            while (isspace((unsigned char)*filename)) filename++;  // 去掉文件名前空格
 
        // ---------- 第二步:解析命令与参数 ----------
        char *argv[64];
        int argc = 0;
        char *tok = strtok(line, " ");   // 逐空格切分命令和参数
        while (tok)
        {
            argv[argc++] = tok;
            tok = strtok(NULL, " ");
        }
        argv[argc] = NULL;               // argv 必须以 NULL 结尾
        if (argc == 0)
            continue;
 
        // ---------- 内建命令:必须由 shell 自己执行(因为要影响 shell 自身)----------
        if (strcmp(argv[0], "cd") == 0)
        {
            if (argc == 2 && chdir(argv[1]) == -1) perror("cd");
            continue;
        }
 
        // ---------- 第三步:fork 子进程执行外部命令 ----------
        pid_t pid = fork();
        if (pid == 0)   // 子进程
        {
            // 子进程里做重定向:只会影响子进程,不污染父 shell(重定向要在子进程做!)
            int fd;
            if (redir == INPUT)
            {
                fd = open(filename, O_RDONLY);
                if (fd < 0) { perror("open"); exit(2); }
                dup2(fd, 0);   // 把该文件搬到标准输入
                close(fd);
            }
            else if (redir == OUTPUT)
            {
                fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
                if (fd < 0) { perror("open"); exit(3); }
                dup2(fd, 1);   // 搬到标准输出:之后的 printf 全进文件
                close(fd);
            }
            else if (redir == APPEND)
            {
                fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
                if (fd < 0) { perror("open"); exit(4); }
                dup2(fd, 1);
                close(fd);
            }
            // 换掉当前进程镜像,执行真正的命令
            execvp(argv[0], argv);
            perror("execvp");   // 只有 exec 失败才走到这
            exit(1);
        }
 
        // 父进程 shell 等待子进程运行结束
        waitpid(pid, NULL, 0);
    }
    return 0;
}

这个 shell 只有两三百行,却已经能干不少实事了:

$ gcc -o myshell myshell.c && ./myshell
[my-shell]# ls -l                    # 普通命令,正常执行
[my-shell]# echo hello > out.txt     # 输出重定向:当前没有内建 echo,交给系统 /usr/bin/echo
[my-shell]# cat out.txt              # 读回来:hello
[my-shell]# ls >> out.txt            # 追加重定向
[my-shell]# wc < out.txt             # 输入重定向
[my-shell]# cd /tmp && pwd           # 内建 cd(注意 cd 必须 shell 自己执行)
[my-shell]# exit   (Ctrl+D)

两个关键设计请你务必体会:

  1. 重定向必须在子进程里做,不能由父 shell 做。因为如果 shell 自己把 fd 1 改了,那它自己接下来的提示符输出也会跑进文件,整个 shell 就废了。fork 出的子进程"复制"了父进程的 fd 表,子进程里改 fd 1 只影响自己,exec 之后这个重定向就由被替换的程序继承,父 shell 完全不受影响——所以 DoRedir() 那块逻辑必须放在 pid == 0 的子进程分支里。
  2. 程序替换(exec)不影响重定向。前面讲过 exec 会把当前进程的代码和数据整个换掉,但打开的 fd 不会被关(默认),所以 exec 之后的 ls 一往 fd 1 写,就会写进你 redirect 好的文件里。

思考题 4.2:为什么 cd 必须由 shell 自己(内建)执行,而不能 fork 一个子进程去 chdir?

详解:chdir 改变的是"进程自己的工作目录",而 fork 出的子进程有自己独立的一份内存和状态(chdir 的结果是进程级状态,不随 exec 传递也不写回父进程)。如果 cd 开一个子进程去执行,改变的是子进程的 cwd,父 shell 的工作目录纹丝不动,用户的 cd 就"没反应"。所以 cd、export 这类改变"shell 自身状态"的命令,必须由 shell 进程自己调用对应的系统调用直接执行,这就是"内建命令(builtin)"存在的原因。

CLOSE_ON_EXEC:exec 之后 fd 的归宿

上面说"exec 之后 fd 默认不被关闭",这句话有个重要的坑,就是文件描述符的 close-on-exec(关闭时执行,FD_CLOEXEC)标志。

默认情况下,一个进程 open 出来的所有 fd,在 fork + exec 后都会被原样继承给新程序。如果这个新程序是可信任的、我们需要它继承 fd(这正是重定向的机理),那就没问题;但如果我们在一个"服务型"进程里打开了一些敏感文件/网络套接字,再 fork+exec 一个不可控的程序,那些 fd 就会泄露给它——这是典型的安全漏洞(资源泄漏、权限提升、chroot 逃逸都与此相关)。此外,哪个 fd 会被继承继承到什么时候,大家心里没数,也容易出资源耗尽(EMFILE,打开文件数超限)的问题。

解决办法是给 fd 打上 FD_CLOEXEC 标志:任何带这个标志的 fd,在成功调用 exec 时会被内核自动关闭;如果 exec 失败,则保持打开。

设置它有两种方式:

// cloexec.c —— 设置/清除"close-on-exec"标志
#include <fcntl.h>
#include <unistd.h>
 
int main(void)
{
    // 方式一(推荐,原子):在 open 时顺手带上 O_CLOEXEC
    // Linux 2.6.23 起支持。exec 后这个 fd 自动关闭
    int fd = open("log", O_CREAT | O_RDWR | O_CLOEXEC, 0644);
 
    // 方式二:打开后用 fcntl 单独设置 FD_CLOEXEC
    // F_GETFD 取当前"文件描述符标志",F_SETFD 设置它
    int flags = fcntl(fd, F_GETFD);   // 读当前的 fd 标志
    flags |= FD_CLOEXEC;              // 加上 close-on-exec 这一位
    fcntl(fd, F_SETFD, flags);        // 写回去
 
    // 清除同理:flags &= ~FD_CLOEXEC; 再 fcntl(F_SETFD, flags) 即可
 
    close(fd);
    return 0;
}

两个细节必须澄清:

  1. 在早期版本/多线程下,方式二的 open + fcntl 两步之间有一个竞态窗口,别的线程若恰好在这两步之间 fork+exec,fd 就泄露了。所以现代 Linux 更推荐用 O_CLOEXEC 一步原子搞定。
  2. dup / dup2 / fcntl(F_DUPFD) 复制出来的新 fd,会"清零" FD_CLOEXEC(这是 POSIX 硬性规定)。这恰好是重定向需要的:我们 dup2(fd, 1) 出来的新 fd 1 不应带 close-on-exec,否则 exec 时重定向就没了。反过来,那些你不想传给子程序的高位 fd,请记得设 O_CLOEXEC,这才是一个健壮服务该做的。

回到我们的 minishell:那里我们把 fd 打开后做了 dup2 再 close(fd),等于没留下多余的高位 fd,所以即使不说 O_CLOEXEC 也是干净的。而一个真正的守护进程(长期收请求、fork 处理)如果不加 O_CLOEXEC,是会埋下 fd 泄漏隐患的。

思考题 4.3:在我的 minishell 里,子进程 exec 外部命令,需要给重定向出的 fd 1 设置 FD_CLOEXEC 吗?为什么?

详解:不需要,而且绝对不能设。我们想让外部命令(如 ls)的 printf/write 写进重定向后的目标文件,就是靠"fd 1 在 exec 后依然打开并指向那个文件"来实现的。若给 fd 1 加了 FD_CLOEXEC,exec 成功那一刻 fd 1 就被自动关掉,重定向立刻失效——ls > out.txt 的 out.txt 里什么都看不见。这正是"dup 出的新 fd 会清除 FD_CLOEXEC"这一设计存在的意义:重定向的 fd 要被保留,而临时的高位 fd 我们手动 close 掉了,两不冲突。


理解"一切皆文件"的内涵

前面第一节我们埋了个概念,现在回来取它:为什么 Linux 能"一切皆文件"?做到这一点的关键,是内核里两个结构——struct file 和 struct file_operations。

file 结构体:一个"已打开文件对象"

当进程 open 一个文件时,内核会为这个文件创建一个 struct file 结构体(也常叫 open file description)。它里面记录了这个文件被打开后的各种运行状态,我们挑重点看(新版内核字段相似):

// 这是 3.10 内核 include/linux/fs.h 中 struct file 的一部分(字段示意)
struct file {
    ...
    struct inode       *f_inode;   // 指向 inode(文件的"元数据档案",含大小、权限等)
    const struct file_operations *f_op;  // 关键!指向这个类型文件的操作函数表
    ...
    atomic_long_t       f_count;   // 引用计数:有几个 fd 在引用这个打开对象
    unsigned int        f_flags;   // 打开时用的标志(O_RDONLY/O_APPEND...)
    fmode_t             f_mode;    // 访问模式(只读/只写/读写),定义在 <fcntl.h>
    loff_t              f_pos;     // 当前读写位置(偏移)
    ...
};

对它理解最自然的方式是:f_pos 是"读写位置",f_mode 是"读写权限",f_count 是"几个身子在共用这一份文件状态"。当多个 fd(比如 dup2 出来的)指向同一个 file 时,它们的 f_pos 是共享的;而两次独立的 open 同一个文件,则产生两个独立的 file,各有一个独立的 f_pos——这就是为什么两次打开同一个文件、读位置不共享。

file_operations:把系统调用和具体设备"焊"在一起的函数指针表

struct file 里最值得关注的成员是 f_op。f_op 是指向 struct file_operations 的指针,而 struct file_operations 里几乎全是函数指针:

struct file_operations {
    struct module *owner;                            // 所属内核模块
    loff_t (*llseek) (struct file*, loff_t, int);    // 移动读写位置
    ssize_t (*read)  (struct file*, char __user*, size_t, loff_t*);  // 读
    ssize_t (*write) (struct file*, const char __user*, size_t, loff_t*); // 写
    ssize_t (*aio_read) (struct kiocb*, const struct iovec*, unsigned long, loff_t);
    ssize_t (*aio_write)(struct kiocb*, const struct iovec*, unsigned long, loff_t);
    int (*readdir)(struct file*, void*, filldir_t);  // 读目录(仅文件系统用)
    ...
    int (*open)  (struct inode*, struct file*);      // 打开
    int (*release)(struct inode*, struct file*);     // 关闭时的回收
    int (*fsync) (struct file*, struct dentry*, int); // 强制刷盘
    ...
};

这一张函数指针表,就是**"一切皆文件"的核心机制**:每种类型的"文件"(普通磁盘文件、目录、管道、设备、socket、proc 虚拟文件)都有自己的一套 read/write/open/... 实现,通过 f_op 里的函数指针挂载到统一的 struct file 上。

当用户调用 read(fd, ...) 时,内核顺着 fd → file → f_op->read 找到这个具体文件自己实现的读函数,把控制权转交给它。于是对普通文件的 read 走设备驱动,对管道的 read 走管道实现,对 proc 文件中某个虚拟项的 read 走对应的内核函数——对用户来说,统统都是 read(fd, buf, n) 这一个接口。

这就是"Linux 下一切皆文件"的一半真相:开发者只需一套 open/read/write/close 的 API,就能操作系统中绝大部分资源。多样在底下被 file_operations 这一张"总分发表"隐藏了,统一暴露给用户。将来学套接字(socket)编程时你会看到,它的接口和文件接口也是同构的。

思考题 5.1:既然键盘、显示器、普通文件、socket 的 read/write 内部实现各不相同,为什么用户代码永远只写 read(fd,...) / write(fd,...)?

详解:因为统一由 struct file_operations 这张函数指针表来"分发"。每个 fd 对应的 struct file 里有 f_op 指针,指向该类型资源自己的操作函数表。系统调用进入内核后,不需要知道你是磁盘、网卡还是管道,它只需要顺着 fd → file → f_op->read/write 找到对应实现并调用即可。把"差异"封装在每种资源的驱动实现里,把"统一"暴露给开发者,这就是"一切皆文件"优雅的地方。


缓冲区:库函数与系统调用的分水岭

终于到了全网吐槽最多的部分。很多人学到这里依然一脸懵:为什么我明明 printf 了,屏幕上(或文件里)却什么都没有?为什么重定向到文件后内容会"迟到"甚至"横空冒出两份"?这一切的谜底,都是缓冲区。

什么是缓冲区

一句话:缓冲区就是内存里预留出来的一小块存储空间,用来暂存输入输出的数据。对输入设备服务的那块叫输入缓冲区,对输出设备服务的那块叫输出缓冲区。

printf("abc") 之后,这些字符其实不一定立刻跑到显示器上——它可能先被丢进一个用户空间的小数组里攒着,等到合适的时机才一次性交给显示。

为什么要引入缓冲区

核心就一个词:效率。

每一次 write() 都是一次系统调用,一次系统调用要经历"用户态 → 内核态"的状态切换、进程上下文切换,这非常贵。如果你要往磁盘写 1 万个"一个字节地写",就触发 1 万次系统调用,任务管理器都能看到 CPU 在空转。

而有了缓冲区,C 库可以把好多次小写入攒成一大块,用极少几次(甚至一次)write 批量交给内核。同理从磁盘读也可以一次扛一大块进内存,后续访问直接命中内存,速度远超磁盘。

再者,缓冲区还充当了高速 CPU 与慢速外设之间的"缓冲垫":比如打印机能慢慢打印,CPU 把文档一股脑丢进打印机缓冲区就回去干别的了,不必傻等到打印完。总而言之:

缓冲区 = 内存中的一块蓄水池。它牺牲一点点实时性,换取大幅降低系统调用次数、让 CPU 和外设各自舒服地干活。

三种缓冲类型

标准 IO(stdio)提供三种缓冲模式(对应头文件里的宏 _IONBF / _IOLBF / _IOFBF):

模式宏什么时候真正执行系统调用默认用在谁身上
全缓冲_IOFBF缓冲区填满才写普通的磁盘文件
行缓冲_IOLBF遇到换行符 \n(或缓冲区满)才写关联到终端的标准输入/输出(如 stdout 连显示器时)
无缓冲_IONBF每写一个字节立刻调系统调用标准错误 stderr

决定"用哪种缓冲"的,不是函数,而是这个流背后接的是什么设备。Glibc 的典型行为(也是 POSIX 的约定)是:

  • stderr 永远无缓冲——正因为如此,错误信息能第一时间打出来,哪怕程序下一秒崩了。
  • stdout 如果连着终端(显示器),就是行缓冲——所以你在终端里 printf("hello\n"),那行立刻出现。
  • stdout 一旦被重定向到文件(或管道),就变成全缓冲——这就是"重定向后内容迟到/消失"的根源,也是本节最大的那个坑。
  • stdin 总是有缓冲。

关于缓冲区的"大小",严格来说 C 标准并没有规定一个固定值,它由实现决定。Glibc 里 BUFSIZ 这个宏默认是 8192;实际运行时,行缓冲接到终端时常用 1024 字节,接到普通文件时常见 4096 左右(大体和内核页大小相关)。所以不用死记"1024"或"8192"这种数字——它随实现而变,重要的是"三种模式的逻辑"。想手动改,用 setvbuf/setbuf,且必须在第一次对该流 IO 之前调用。

除了默认的三条规则,还有三种特殊情形会强制刷新缓冲区:

  1. 缓冲区满了;
  2. 显式调用 fflush(流);
  3. 进程正常结束(退出,会自然冲刷所有流的缓冲)。

重定向后"缓冲区未刷"的大坑

现在引爆第一节留的那个钩子。看这段代码——本来想靠"关 1 再 open"把 printf 的内容写进文件:

// buff_unflush.c —— 演示重定向到文件后,printf 的内容"消失"了
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
 
int main()
{
    close(1);                        // 腾出 fd 1
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0)
    {
        perror("open");
        return 1;
    }
    printf("hello world: %d\n", fd); // 想让它写进 log.txt
    close(fd);
    return 0;
}

运行后你却发现:

$ gcc buff_unflush.c -o myfile && ./myfile
$ cat log.txt
$   # 空的!内容不见了!

为什么?因为我们把 fd 1 从"显示器"换成了"磁盘文件",于是 stdout 的缓冲模式从行缓冲变成了全缓冲。printf 的内容进了用户缓冲区,而这条 hello world: %d\n 远没填满缓冲区,既不满足"满",进程里那点数据又因为……等等,你说进程不是正常退出会刷吗?

这里的关键是:进程"正常 return"确实会刷,但我们在 return 0 之前先 close(fd) 了,把那个指向文件的 fd 1 提前关掉,并且没有刷新——此时用户缓冲区里攒着的数据因为流还没刷出来、而所在的底层 fd 已被关闭,内容就丢掉了。换句话说:关闭底层 fd 之前,一定要先让该流把缓冲冲刷出去。

那正确的写法是什么?两个:

方案一:显式 fflush(stdout),在 close 之前强制冲刷:

// buff_flush.c —— fflush 强制把缓冲写出去
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
 
int main()
{
    close(1);
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0)
    {
        perror("open");
        return 1;
    }
    printf("hello world: %d\n", fd);
    fflush(stdout);       // 强制把 stdout 缓冲里的内容刷进 log.txt
    close(fd);
    return 0;
}

现在 cat log.txt 能看到内容了。

方案二:利用"stderr 无缓冲"来当"及时输出"的验证——把目标换成 fd 2(标准错误),用 perror(它往 stderr 写)来输出。因为 stderr 无缓冲,所以不需要 fflush 就能立刻写入文件:

// buff_nobuf.c —— stderr 无缓冲,perror 立即写出
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
 
int main()
{
    close(2);                        // 腾出 fd 2
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0)
    {
        perror("open");
        return 1;
    }
    perror("hello world");           // perror 写 stderr -> 现在 stderr 指向 log.txt
    close(fd);
    return 0;
}

运行后:

$ ./nobuf
$ cat log.txt
hello world: Success

两相对比,你就彻底看清了"行缓冲 vs 无缓冲"的差异:往 stdout 写需要手动/按行刷新,往 stderr 写则水只要冒出来就立刻落地。

思考题 6.1:把 stdout 重定向到文件后,一条不含 \n 的 printf("hi"),在进程直接 return 正常退出的前提下,会不会出现在文件里?

详解:会。进程正常退出(return 0 或 exit(0))时会冲刷所有打开的流的缓冲区,哪怕那条内容没填满缓冲区、也没有换行符。但要注意,如果进程是用 _exit(0) 退出(注意下划线,它不经过 libc 的清理流程)或直接崩溃、或被 SIGKILL 杀掉,则缓冲未必会刷,内容就可能丢失。所以"正常 return 会刷"是可依赖的,但"异常终止不刷"也是最常见的丢数据坑。

FILE 结构体里封装的 fd 与缓冲区

既然 C 库要自己管理"缓冲区",那缓冲区放哪?答案在 FILE 结构体里。FILE 是 struct _IO_FILE 的别名(/usr/include/stdio.h 与 /usr/include/libio.h),它把"底层 fd + 一块用户缓冲"封装成了一个小对象。我们拣关键成员看:

// 这是 glibc 中 struct _IO_FILE 的关键字段(示意,做了精简)
struct _IO_FILE {
    int _flags;                 // 流的状态与标志(含缓冲模式)
    // 下面是这块"积攒数据"的缓冲区状态
    char *_IO_read_ptr;         // 输入缓冲的当前读指针
    char *_IO_read_end;         // 输入缓冲的读区末尾
    char *_IO_read_base;        // 输入缓冲的起始
    char *_IO_write_base;       // 输出缓冲起始
    char *_IO_write_ptr;        // 输出缓冲当前写指针
    char *_IO_write_end;        // 输出缓冲末尾
    char *_IO_buf_base;         // 整体缓冲起始
    char *_IO_buf_end;          // 整体缓冲末尾
    ...
    int   _fileno;              // ★ 封装的底层文件描述符!
};
typedef struct _IO_FILE FILE;   // 对外就叫 FILE

看到没——_fileno 就是那个 fd!所以 fopen 拿到一个 FILE*,本质上就是"拿到一个封装了 fd + 缓冲区的小对象"。printf 先往 stdout->_IO_write_ptr 指的那块内存攒,攒够了(或遇到 \n、或 fflush)再调用内核的 write(STDOUT_FILENO, ...)。这就是"库函数的缓冲是二次加上去的"最直接证据:write 系统调用本身没有用户级缓冲,缓冲区是 C 库在它上面套的一层"缓存"。

顺带一提,int fileno(FILE* stream) 那个函数,就是干"从 FILE 里取回 fd"的,它是验证"FILE 里藏着 fd"这句话的钥匙。

fork 与缓冲的经典坑:内容凭空多一份

把缓冲区、fork、写时拷贝放在一起,就是本节压轴戏。看这段代码:

// fork_buf.c —— printf/fwrite(有缓冲)+ write(无缓冲)+ fork
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
 
int main()
{
    const char *msg0 = "hello printf\n";
    const char *msg1 = "hello fwrite\n";
    const char *msg2 = "hello write\n";
 
    printf("%s", msg0);                    // C 库,有用户缓冲
    fwrite(msg1, strlen(msg1), 1, stdout); // C 库,有用户缓冲
    write(1, msg2, strlen(msg2));          // 系统调用,无缓冲,立刻进内核
 
    fork();                                // 关键:fork 一个子进程
    return 0;                              // 父、子各自正常退出
}

先直接跑,输出是这样(注意顺序):

$ gcc fork_buf.c -o hello && ./hello
hello printf
hello fwrite
hello write

看起来很正常。但如果输出重定向到文件再跑呢?

$ ./hello > file
$ cat file
hello write
hello printf
hello fwrite
hello printf
hello fwrite

你能数出大事了:hello write 只出现一次,但 hello printf 和 hello fwrite 各出现了两次!而且顺序也变了(write 跑到最前面)。这到底为什么?

我们用这节课的知识一点点拆(这是全篇含金量最高的一个案例,请务必看懂每一层):

  1. 直接跑(终端)时为什么正常? 因为 stdout 连着终端 = 行缓冲,printf 和 fwrite 里都有 \n,一碰到换行符就立刻刷新了,用户缓冲区是空的。然后 write 直接进内核。此时 fork 时缓冲区已空,父、子都没东西可刷,于是各输出一次,共一次。一切正常。

  2. 重定向到文件时发生了什么? stdout 从"终端"变"普通文件",缓冲模式从行缓冲变全缓冲。于是 printf 和 fwrite 的内容 "hello printf\nhello fwrite\n" 并没有立刻进内核,而是积压在用户缓冲区里没刷出来。而 write(1, ...) 是无缓冲的系统调用,立刻进内核写进了文件——所以文件里 hello write 出现在最前面。

  3. fork() 是分水岭。 fork 会复制父进程的整个地址空间(含用户缓冲区)。此时用户缓冲区里还躺着 "hello printf\nhello fwrite\n" 这份数据,于是父、子各复制了一份一模一样的缓冲——这就是"写时拷贝":fork 那一刻数据共享,一旦谁要改写就各自独立,最终两份。

  4. 父、子进程最终都正常退出,各自把那份缓冲冲刷进文件。 于是 hello printf\nhello fwrite\n 写了两次,正正好多了一份!

  5. 为什么 write 只一次? 因为 write 没有用户缓冲,数据在 fork 之前已经进了内核(文件里),fork 复制的是用户态的缓冲,跟它无关。所以它老老实实只有一次。

这个坑在真实项目里排得非常痛苦("我的日志怎么打印了两遍?"),而病因就这么一套:库函数缓冲 + fork 写时拷贝 + 进程退出统一刷新。规避的关键动作:

  • 需要精确可靠的控制时,在 fork 之前就 fflush 掉所有有缓冲的流,把"残留数据"清干净。
  • 或者干脆这个程序里别在 fork 前往带缓冲的流里写关键数据。

思考题 6.2:上例中把 printf 的输出改成不含 \n 的 "hello",然后不重定向(即 stdout 还是连显示器),直接跑并重定向到文件,两种情况分别是什么表现?

详解:分两种情况说。 (a)stdout 连显示器(不重定向)时:是行缓冲,但没有 \n,缓冲不会按行刷;不过别忘了个例外——若缓冲塞满了也会刷,而这小数据远没满。所以此时 hello 会一直积压在缓冲里,先走 write 的 hello write\n 直接上屏,然后 fork,父子各自在退出时把 hello 刷到屏幕上 → 表现是 hello write 一次、hello(无换行,可能粘连)两次。这和重定向到文件的道理一模一样——只要有"缓冲未刷 + fork"存在,就会多一份。 (b)重定向到文件时:全缓冲 + 无 \n + 未满,hello 一样积压 → fork → 父子退出时各写一份到文件。所以依然是 hello 出现两次。 换句话说,"fork 多复制走一份未刷的缓冲"这个坑,跟是不是重定向、有没有换行符关系不大,关键在于 fork 之前用户缓冲区里有没有还没刷出去的存量数据。这正是"fork 前务必 fflush"这句忠告的由来。

亲手实现一个迷你 libc

最后,我们把"FILE = fd + 缓冲区 + 刷新的时机"这套逻辑亲手写出来,写一个超级简化版的 fopen/fwrite/fclose,命名成 mFILE,你会瞬间看透 glibc 干的事。

头文件 my_stdio.h:

// my_stdio.h —— 自定义一个迷你"文件流"
#pragma once
#include <stddef.h>
 
#define SIZE 1024
 
// 刷新方式:不刷/行刷/全刷
#define FLUSH_NONE 0
#define FLUSH_LINE 1
#define FLUSH_FULL 2
 
// 迷你 FILE:底层 fd + 一个输出缓冲 + 相关状态
struct IO_FILE
{
    int flag;             // 刷新方式
    int fileno;           // 文件描述符(fd)
    char outbuffer[SIZE]; // 用户级输出缓冲
    int cap;              // 缓冲区容量
    int size;             // 当前已缓存了多少字节
};
typedef struct IO_FILE mFILE;
 
// 自定义的四个"函数",对应 fopen/fwrite/fflush/fclose
mFILE *mfopen(const char *filename, const char *mode);
int    mfwrite(const void *ptr, int num, mFILE *stream);
void   mfflush(mFILE *stream);
void   mfclose(mFILE *stream);

实现文件 my_stdio.c:

// my_stdio.c —— 迷你文件流的实现
#include "my_stdio.h"
 
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
 
// mfopen:把 fopen 的 mode 翻译成 open 的标志,再开出一块带缓冲的 mFILE
mFILE *mfopen(const char *filename, const char *mode)
{
    // 先按 mode 决定用 open 的哪种标志组合
    int fd = -1;
    if (strcmp(mode, "r") == 0)
    {
        fd = open(filename, O_RDONLY);
    }
    else if (strcmp(mode, "w") == 0)
    {
        fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
    }
    else if (strcmp(mode, "a") == 0)
    {
        fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
    }
    if (fd < 0) return NULL;    // open 失败
 
    // 分配 mFILE,把 fd 塞进去,初始化缓冲
    mFILE *mf = (mFILE *)malloc(sizeof(mFILE));
    if (mf == NULL)
    {
        close(fd);              // 申请失败就回滚刚才的 open
        return NULL;
    }
    mf->fileno = fd;            // ★ FILE 里封装了 fd!
    mf->flag   = FLUSH_LINE;    // 先以"行缓冲"演示
    mf->size   = 0;
    mf->cap    = SIZE;
    return mf;
}
 
// mfflush:把用户缓冲里的数据一次性交给内核
void mfflush(mFILE *stream)
{
    if (stream && stream->size > 0)
    {
        // 把缓存的 outbuffer 交给系统调用 write
        write(stream->fileno, stream->outbuffer, stream->size);
        // 等待内核数据真正落盘(这里示意性地加 fsync,真实 getc 一般不用)
        fsync(stream->fileno);
        stream->size = 0;       // 清空"已缓存"计数
    }
}
 
// mfwrite:先复制进缓冲,再根据刷新策略决定何时真正 write
int mfwrite(const void *ptr, int num, mFILE *stream)
{
    // 1. 把数据先拷进用户缓冲
    memcpy(stream->outbuffer + stream->size, ptr, num);
    stream->size += num;
 
    // 2. 判断是否要触发"行刷新":行缓冲模式且末尾恰好是 \n
    if (stream->flag == FLUSH_LINE &&
        stream->size > 0 &&
        stream->outbuffer[stream->size - 1] == '\n')
    {
        mfflush(stream);
    }
    // 若缓冲满了(全缓冲模式)也可在此触发刷新,这里留给读者扩展
 
    return num;
}
 
// mfclose:先冲刷残余缓冲,再关掉底层 fd
void mfclose(mFILE *stream)
{
    if (stream->size > 0)
    {
        mfflush(stream);     // 关闭前把缓冲清干净
    }
    close(stream->fileno);
    free(stream);
}

跑一个例子 main.c:

// main.c —— 用我们的迷你 libc 往日志里写并模拟"进度条"
#include "my_stdio.h"
#include <stdio.h>
#include <string.h>
#include <unistd.h>
 
int main()
{
    mFILE *fp = mfopen("./log.txt", "a");   // 追加打开
    if (fp == NULL)
    {
        return 1;
    }
 
    int cnt = 10;
    while (cnt)
    {
        char buffer[64];
        // 拼一行内容
        snprintf(buffer, sizeof(buffer), "hello message, number is : %d", cnt);
        mfwrite(buffer, strlen(buffer), fp);  // 写进"用户缓冲"
 
        printf("write %d\n", cnt);            // 普通 printf 打行号
        mfflush(fp);                          // 立刻把这条数据刷进 log.txt
 
        cnt--;
        sleep(1);                             // 模拟"进度条":每 1 秒写一条
    }
 
    mfclose(fp);
    return 0;
}

编译三件套并运行:

$ gcc my_stdio.c main.c -o mydemo && ./mydemo
write 10
write 9
write 8
...(每秒一条)

对比 printf 输出立刻出现、而 write 10 这种"进度条"行为,你会直观地体会到:我这个 mFILE 就是把"一个 fd + 一块缓冲 + 何时刷新的策略"包给了你——现在,glibc 里的 FILE,你完全看得懂了(真实的 glibc 还包含读缓冲、可重入锁、错误标志等,但骨架就是上面这套)。

思考题 6.3:我的 mfwrite 只实现了"行缓冲触发刷新"。如果我想加"全缓冲"(缓冲满了才刷),在该函数哪里补上判断最合适?

详解:在 mfwrite 函数的"复制进缓冲并记账"之后,补一个容量判断即可,例如:

// 全缓冲:缓冲被塞满就刷新
else if (stream->flag == FLUSH_FULL && stream->size >= stream->cap)
{
    mfflush(stream);
}

把它和上面的 if (FLUSH_LINE && 末尾是'\n') 并列(用 if / else if),就同时支持了"行满即刷"和"装满即刷"两种策略。这也印证了全缓冲模式的本质:攒到攒不下才交一次系统调用,从而把系统调用次数压到最低。


把零散的知识串成一条链

写到这里,我们把这一趟 Linux 基础 IO 的旅程回望一遍,你会发现它其实是一条层层递进的链子:

第一环,文件到底是什么。狭义上它是磁盘上"元数据 + 内容"的集合,广义上"一切皆文件";而对文件的任何操作,本质都是进程在请求内核帮忙做 IO。

第二环,你熟悉的 C 文件接口。fopen/fwrite/fread 是 libc 对真实机制的封装,stdin/stdout/stderr 三个流一开机就摆在你面前,printf 的底层不过是对 stdout 的 fwrite。

第三环,系统的原生接口。open/close/read/write/lseek 这些系统调用才是"打开文件"的最终执行者,它们用统一的"成功返回非负值、失败返回 -1 + errno"来汇报结果,f 系列函数全是它们的翻译官。

第四环,文件描述符。它就是一个 fd 表的数组下标,从 3 开始往下排;正因为"最小可用下标"这条规则,关掉 1 再 open、或者干脆 dup2(fd,1),便实现了万能的输入/输出重定向;读懂 CLOSE_ON_EXEC,你就知道 exec 之后哪些 fd 该留、哪些该关。

第五环,一切皆文件的骨架。struct file 顶着 f_pos / f_mode / f_count,file_operations 用一张函数指针表把千差万别的设备统一成 read/write 一套接口。

最后一环,也是最贴近日常调试的:缓冲区。全缓冲、行缓冲、无缓冲分别对应普通文件、终端、stderr;write 没有用户缓冲,printf/fwrite 有,连 FILE 里的 _fileno 都摆在明面。而那个令无数人半夜挠头的"fork 之后内容多一份",说白了就是"未刷的缓冲 + 写时拷贝 + 进程退出刷新"三者合谋。

给你留一个终极大作业,把整个链条真刀真枪用一遍:写出 file < in.txt > out.txt 的完整执行过程(用文字 + fd 表变化)。回想我们在 minishell 里是怎么 fork、open、dup2、close、exec 的。这个题没有唯一标准答案,但只要你把"fd 表每一行此刻指向什么"画清楚,这一篇你就真正毕业了。提示起点:file 进程初始 fd 表是 {0:键盘, 1:显示器, 2:显示器};open("in.txt") 之后呢?dup2(fd,0) 之后呢?close(fd) 之后呢?exec 之后呢?——走完这四条,重定向的三个百分之一百就长在你脑子里了。

下一篇文章,我们该把目光从"怎么和文件交互"挪到"进程怎么跑起来"了:fork 到底复制了什么、exec 那满天飞的家族函数、父进程怎么收留自己的子进程。到时候你会发现,今天埋伏下的"fd、缓冲、fork"这些概念,正是理解进程控制的第一块砖——地基越牢,爬坡越稳。准备好了吗?