
观众老爷们大家好 这里是邪修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 缓冲区刷新的触发条件
除了缓冲类型自身的规则,以下情况也会强制刷新缓冲区:
四、经典踩坑:重定向后的缓冲区变化
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 等),本质都是:
六、经典实验: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 原因深度解析
- fork 发生时,缓冲区里还有数据
- 进程写时拷贝:父子进程各有一份独立的缓冲区数据
- 父子进程退出时,各自刷新缓冲区,各写一次到文件
- 所以 printf/fwrite 出现两次
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 立刻刷新到文件,和标准库的行缓冲行为完全一致。
全文总结
下篇预告: 缓冲区讲完,我们正式进入进程间通信 IPC 专题,第一站:匿名管道与命名管道,看看进程之间怎么通过缓冲区传递数据。 


