欢迎光临
我们一直在努力

Re:Linux 系统篇(二十四):IO 缓冲区深度解析 —— 三种缓冲类型、FILE 底层结构、fork 写时拷贝实验与手搓简易 stdio 库

在这里插入图片描述

观众老爷们大家好 这里是邪修KING的独家频道

本文属于系列Linux系统篇 ——操作指令

一起学Linux的小伙伴可订阅专栏: Linux系统篇
> 前面我们讲了文件描述符、`open/write` 系统调用,也一直在用 `printf` 这类标准 IO 库函数。 > 但很多人一直有个疑问:同样是写文件,`write` 直接就能写到磁盘,`printf` 为什么有时候不立刻输出?重定向到文件后行为为什么不一样?fork 之后为什么会打印两次? > 答案全部指向一个核心机制:**标准 IO 缓冲区**。 > 本篇我们从缓冲区的本质讲起,拆解三种缓冲类型、FILE 结构体底层、经典 fork 缓冲实验,最后手搓一个简易版 stdio 库,彻底把缓冲区讲透。


一、什么是缓冲区

缓冲区是内存空间的一部分:操作系统 / C 标准库会预留一块存储空间,用来临时存放输入或输出的数据,这块预留空间就叫缓冲区。 按对应设备方向,分为:

  • 输入缓冲区:暂存从外设读入的数据
  • 输出缓冲区:暂存要写到外设的数据

我们常说的标准 IO 缓冲区,属于用户态缓冲区,由 C 标准库提供并管理,在系统调用的上层。


二、为什么要引入缓冲区机制

缓冲区的核心价值是减少系统调用、提升整体效率,我们从两个维度理解。

2.1 减少用户态 / 内核态切换开销

如果没有缓冲区,每读写一个字节都调用一次 write/read 系统调用,每调用一次系统调用:

  • CPU 从用户态切换到内核态
  • 内核执行磁盘 IO 操作
  • 再切回用户态

状态切换本身有 CPU 开销,频繁切换会让大部分时间浪费在切换上,真正干活的时间少。

有了缓冲区之后:

  • 写数据:先写到内存缓冲区里,攒够一批再调用一次系统调用批量写入磁盘
  • 读数据:一次从磁盘读一大块进缓冲区,后面读数据直接从内存拿 系统调用次数大幅减少,性能显著提升。

2.2 匹配高速 CPU 与低速外设的速度差

CPU 运算速度是磁盘、打印机这类外设的成千上万倍。如果 CPU 每输出一个字节都等着外设处理完,CPU 大部分时间都在闲置等待。

缓冲区就是中间的 “快递集散中心”:

  • CPU 快速把数据丢进缓冲区,不用等外设,直接去干别的事
  • 外设慢慢从缓冲区里取数据处理

完美解耦高速 CPU 和低速 IO 设备,解放 CPU 算力。

💡 生活类比 缓冲区就像快递驿站: 没有驿站:你每买一件东西,快递员都单独跑一趟送上门,效率极低。 有驿站:快递先攒到驿站,攒够一车统一派送,一趟送几十件,效率大幅提升。


三、标准 IO 的三种缓冲类型

C 标准 IO 库提供了三种缓冲策略,对应不同的使用场景。

3.1 全缓冲(Full Buffering)

  • 规则:缓冲区填满之后,才会触发真正的 IO 系统调用,把数据刷到磁盘。
  • 默认场景:普通磁盘文件的读写,默认全缓冲。
  • 特点:系统调用次数最少,效率最高;但数据不会立刻落盘。

3.2 行缓冲(Line Buffering)

  • 规则:输入输出遇到换行符 \\n 时,触发刷新系统调用;如果缓冲区被填满,即使没遇到换行也会刷新。
  • 默认场景:终端标准输入 stdin、标准输出 stdout,默认行缓冲。默认行缓冲区大小一般是 1024 字节。
  • 特点:兼顾效率和交互体验,换行就输出,符合终端逐行交互的习惯。

3.3 无缓冲(No Buffering)

  • 规则:不做缓存,每次调用 IO 库函数,直接触发系统调用。
  • 默认场景:标准错误 stderr 默认无缓冲。
  • 特点:出错信息立刻输出,不会因为缓冲延迟,方便定位问题。

3.4 缓冲区刷新的触发条件

除了缓冲类型自身的规则,以下情况也会强制刷新缓冲区:

  • 缓冲区被填满
  • 手动调用 fflush() 函数
  • 进程正常退出时,会自动刷新所有打开的标准 IO 流

  • 四、经典踩坑:重定向后的缓冲区变化

    4.1 现象:printf 重定向到文件,内容丢失

    我们看这段代码:把 1 号文件描述符(标准输出)关闭,重定向到磁盘文件,然后 printf 输出。

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

    int main() {
    close(1);
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0) {
    perror("open");
    return 0;
    }
    printf("hello world: %d\\n", fd);
    close(fd);
    return 0;
    }

    运行后查看 log.txt,发现文件是空的,什么都没写进去。

    原因分析
    • 标准输出原本是终端,默认行缓冲,遇到 \\n 就刷新
    • 重定向到磁盘文件后,缓冲模式自动变成全缓冲
    • printf 的内容只写到了用户态缓冲区,缓冲区没满,不会触发系统调用写磁盘
    • 还没等刷新,我们就直接 close(fd) 了,数据丢在缓冲区里没写出去
    解决方法

    在 close 之前手动 fflush(stdout) 强制刷新:

    printf("hello world: %d\\n", fd);
    fflush(stdout); // 强制把缓冲区数据刷到磁盘
    close(fd);

    此时再运行,内容就正常写入文件了。

    4.2 验证:stderr 天生无缓冲

    #include <stdio.h>
    #include <fcntl.h>
    #include <unistd.h>

    int main() {
    close(2);
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0) {
    perror("open");
    return 0;
    }
    perror("hello world");
    close(fd);
    return 0;
    }

    运行后 log.txt 里立刻有内容。 因为 stderr 默认无缓冲,perror 直接调用系统调用写入,不需要等缓冲区满。


    五、FILE 结构体:封装 fd + 缓冲区

    5.1 核心结论

    • 系统调用 open/write 用文件描述符 fd 操作文件
    • 标准库 printf/fwrite 用 FILE* 操作文件
    • FILE 结构体内部,一定封装了 fd,再加上缓冲区的读写指针、容量、状态等信息

    缓冲区是 C 标准库在用户态加的一层,内核层面的系统调用 write 是没有这个用户态缓冲区的。

    5.2 FILE 结构体底层(glibc)

    glibc 的 _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; // 写缓冲区末尾

    int _fileno; // 封装的文件描述符 fd
    // … 其他字段
    };

    所有标准 IO 函数(printf、fputc、fwrite 等),本质都是:

  • 先操作内存里的缓冲区
  • 满足刷新条件时,调用 write 系统调用,把缓冲区数据写入内核 缓冲区完全由 C 标准库管理,和内核无关。

  • 六、经典实验:fork 与缓冲区写时拷贝

    这是最能证明「缓冲区在用户态」的实验。

    6.1 实验代码

    #include <stdio.h>
    #include <string.h>
    #include <unistd.h>

    int main()
    {
    const char *msg0="hello printf\\n";
    const char *msg1="hello fwrite\\n";
    const char *msg2="hello write\\n";

    printf("%s", msg0);
    fwrite(msg1, strlen(msg1), 1, stdout);
    write(1, msg2, strlen(msg2));

    fork();
    return 0;
    }

    6.2 两种运行场景对比

    场景 1:终端直接运行

    ./a.out

    输出:

    hello printf
    hello fwrite
    hello write

    三句话各出现一次,符合预期。

    场景 2:重定向到文件

    ./a.out > file
    cat file

    输出:

    hello write
    hello printf
    hello fwrite
    hello printf
    hello fwrite

    • printf 和 fwrite 各出现 2 次
    • write 只出现 1 次

    6.3 原因深度解析

  • 终端运行:stdout 是行缓冲,遇到 \\n 立刻刷新。fork 之前三句话都已经刷到终端了,缓冲区是空的。fork 之后父子进程都没东西可刷,所以各一次。
  • 重定向文件:stdout 变成全缓冲,数据只写到用户态缓冲区,没触发系统调用。
    • fork 发生时,缓冲区里还有数据
    • 进程写时拷贝:父子进程各有一份独立的缓冲区数据
    • 父子进程退出时,各自刷新缓冲区,各写一次到文件
    • 所以 printf/fwrite 出现两次
  • write 永远一次:write 是系统调用,直接写进内核,没有用户态缓冲区。fork 之前就已经写完了,所以永远只有一次。
  • 6.4 实验结论

    ✅ printf /fwrite 等库函数,自带用户态缓冲区 ✅ write 系统调用,没有用户态缓冲区 ✅ 缓冲区由 C 标准库提供,位于用户态,fork 写时拷贝会复制缓冲区数据


    七、动手实现:简易版 my_stdio 库

    理解了原理,我们自己手写一个简化版的标准 IO 库,实现行缓冲输出,彻底吃透缓冲区。

    7.1 头文件 my_stdio.h

    #pragma once

    #define SIZE 1024 // 缓冲区大小
    #define FLUSH_NONE 0 // 无缓冲
    #define FLUSH_LINE 1 // 行缓冲
    #define FLUSH_FULL 2 // 全缓冲

    // 自定义FILE结构体
    struct IO_FILE
    {
    int flag; // 缓冲模式
    int fileno; // 封装的文件描述符
    char outbuffer[SIZE]; // 输出缓冲区
    int cap; // 缓冲区总容量
    int size; // 当前已用大小
    };

    typedef struct IO_FILE mFILE;

    // 打开文件
    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);

    7.2 实现 my_stdio.c

    #include "my_stdio.h"
    #include <string.h>
    #include <stdlib.h>
    #include <sys/stat.h>
    #include <sys/types.h>
    #include <fcntl.h>
    #include <unistd.h>

    // 打开文件,创建mFILE对象
    mFILE *mfopen(const char *filename, const char *mode)
    {
    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;

    // 申请自定义FILE结构体
    mFILE *mf = (mFILE*)malloc(sizeof(mFILE));
    if(!mf)
    {
    close(fd);
    return NULL;
    }

    // 初始化
    mf->fileno = fd;
    mf->flag = FLUSH_LINE; // 默认行缓冲
    mf->size = 0;
    mf->cap = SIZE;
    return mf;
    }

    // 强制刷新缓冲区:把缓冲区数据写入磁盘
    void mfflush(mFILE *stream)
    {
    if(stream->size > 0)
    {
    // 调用系统调用write,写入内核
    write(stream->fileno, stream->outbuffer, stream->size);
    // 强制同步到磁盘
    fsync(stream->fileno);
    // 重置缓冲区已用大小
    stream->size = 0;
    }
    }

    // 写数据到缓冲区
    int mfwrite(const void *ptr, int num, mFILE *stream)
    {
    // 1. 拷贝数据到缓冲区
    memcpy(stream->outbuffer + stream->size, ptr, num);
    stream->size += num;

    // 2. 行缓冲模式:遇到换行符就刷新
    if(stream->flag == FLUSH_LINE && stream->size > 0
    && stream->outbuffer[stream->size1] == '\\n')
    {
    mfflush(stream);
    }

    // 3. 全缓冲模式:缓冲区满就刷新
    if(stream->flag == FLUSH_FULL && stream->size >= stream->cap)
    {
    mfflush(stream);
    }

    return num;
    }

    // 关闭文件
    void mfclose(mFILE *stream)
    {
    // 关闭前先刷新缓冲区剩余数据
    if(stream->size > 0)
    {
    mfflush(stream);
    }
    close(stream->fileno);
    free(stream);
    }

    7.3 测试主函数 main.c

    #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 = 5;
    while(cnt)
    {
    char buffer[64];
    snprintf(buffer, sizeof(buffer), "hello message, number is : %d\\n", cnt);
    cnt;
    mfwrite(buffer, strlen(buffer), fp);
    sleep(1);
    }

    mfclose(fp);
    return 0;
    }

    7.4 运行效果

    编译运行后,每秒写入一行日志,遇到 \\n 立刻刷新到文件,和标准库的行缓冲行为完全一致。


    全文总结

  • 缓冲区本质:用户态内存空间,用来缓存 IO 数据,减少系统调用,匹配 CPU 与外设速度差。
  • 三种缓冲:全缓冲(磁盘文件)、行缓冲(终端)、无缓冲(stderr)。
  • FILE 结构体:C 标准库封装,内部包含文件描述符 + 缓冲区读写指针 + 状态信息。
  • fork 实验:库函数带用户态缓冲,fork 写时拷贝会复制,重定向后输出两次;系统调用无缓冲,永远一次。
  • 分层理解:write 是内核级系统调用;printf/fwrite 是用户态库函数,在系统调用之上加了一层缓冲区。
  • 下篇预告: 缓冲区讲完,我们正式进入进程间通信 IPC 专题,第一站:匿名管道与命名管道,看看进程之间怎么通过缓冲区传递数据。 在这里插入图片描述

    赞(0)
    未经允许不得转载:171主机测评 » Re:Linux 系统篇(二十四):IO 缓冲区深度解析 —— 三种缓冲类型、FILE 底层结构、fork 写时拷贝实验与手搓简易 stdio 库
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址