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

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 缓冲区刷新的触发条件

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

  1. 缓冲区被填满
  2. 手动调用 fflush() 函数
  3. 进程正常退出时,会自动刷新所有打开的标准 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/fwriteFILE* 操作文件
  • 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 等),本质都是:

  1. 先操作内存里的缓冲区
  2. 满足刷新条件时,调用 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
  • printffwrite 各出现 2 次
  • write 只出现 1 次

6.3 原因深度解析

  1. 终端运行:stdout 是行缓冲,遇到 \n 立刻刷新。fork 之前三句话都已经刷到终端了,缓冲区是空的。fork 之后父子进程都没东西可刷,所以各一次。
  2. 重定向文件:stdout 变成全缓冲,数据只写到用户态缓冲区,没触发系统调用。
    • fork 发生时,缓冲区里还有数据
    • 进程写时拷贝:父子进程各有一份独立的缓冲区数据
    • 父子进程退出时,各自刷新缓冲区,各写一次到文件
    • 所以 printf/fwrite 出现两次
  3. 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->size-1] == '\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 立刻刷新到文件,和标准库的行缓冲行为完全一致。


全文总结

  1. 缓冲区本质:用户态内存空间,用来缓存 IO 数据,减少系统调用,匹配 CPU 与外设速度差。
  2. 三种缓冲:全缓冲(磁盘文件)、行缓冲(终端)、无缓冲(stderr)。
  3. FILE 结构体:C 标准库封装,内部包含文件描述符 + 缓冲区读写指针 + 状态信息。
  4. fork 实验:库函数带用户态缓冲,fork 写时拷贝会复制,重定向后输出两次;系统调用无缓冲,永远一次。
  5. 分层理解write 是内核级系统调用;printf/fwrite 是用户态库函数,在系统调用之上加了一层缓冲区。

下篇预告:
缓冲区讲完,我们正式进入进程间通信 IPC 专题,第一站:匿名管道与命名管道,看看进程之间怎么通过缓冲区传递数据。
在这里插入图片描述

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/mogreat/article/details/164881126

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--