目录
[一、为什么要实现 mystdio](#一、为什么要实现 mystdio)
[三、myFILE 结构体](#三、myFILE 结构体)
[四、mfopen 实现](#四、mfopen 实现)
[五、mfflush 实现](#五、mfflush 实现)
[1. 三层架构设计](#1. 三层架构设计)
[2. 代码实现](#2. 代码实现)
[3. 深度解析](#3. 深度解析)
[六、mfwrite 实现](#六、mfwrite 实现)
[为什么 FLUSH_NONE 要在这里处理](#为什么 FLUSH_NONE 要在这里处理)
一、为什么要实现 mystdio
我们在使用 C 语言的 printf、fwrite 或 fputs 时,操作体验十分流畅。但通过前面的学习我们知道,这些标准库接口的背后其实隐藏着复杂的机制:
-
封装系统调用:标准库通过 FILE 结构体封装了底层的 fd,让我们不必直接面对 open、write 等原始接口
-
性能优化 :标准库在用户态维护了一块缓冲区 。它通过化零为整的策略,极大地减少了陷入内核(系统调用)的次数,从而保护了系统的整体性能
本文目标: 我们要实现一个简化版的 mystdio。不必追求标准库严丝合缝的复杂逻辑,但具备系统调用 + 用户级缓冲区 这套完整的 IO 闭环
通过亲手实现它,我们能够彻底明白:缓冲区到底长什么样?它是如何被刷新的?以及 FILE 结构体内部到底在做什么
二、设计思路
功能目标
我们要实现的接口需要覆盖一个文件生命周期的核心节点:
-
mfopen -> 打开文件:支持常用模式(如 w, a, r)
-
mfwrite -> 写入数据:支持用户级缓冲逻辑,而不是每次调用都直接写磁盘
-
mfflush -> 刷新缓冲:强制将用户态数据推向内核
-
mfclose -> 关闭文件:清理资源并确保缓冲区数据安全写入磁盘
核心思想
核心逻辑可以用一个公式表达:
mystdio 接口 = 内存拷贝(用户缓冲区)+ 刷新策略(触发系统调用 write)
我们将模拟三种缓冲策略,以便直观感受他们的差异
三、myFILE 结构体
在 C 标准库中,FILE 是一个结构体。在我们的 mystdio 中,我们也需要定义一个类似的结构
结构体设计
cpp
#define SIZE 1024; // 缓冲区大小
#define FLUSH_NONE 1
#define FLUSH_LINE 2
#define FLUSH_FULL 4
#define FORCE 1 // 强制刷新
#define NORMAL 2 // 非强制刷新
typedef struct _MY_IO_FILE
{
int fd; // 底层文件描述符
int capacity; // 缓冲区总容量
int size; // 当前已使用缓冲区大小
char buffer[SIZE]; // 用户态缓冲区
int flush_mode; // 缓冲刷新策略
}myFILE;
myFILE* mfopen(const char* filename, const char* mode);
size_t mfwrite(const char* ptr, size_t size, size_t count, myFILE* stream);
int mfflush(myFILE* stream);
int mfclose(myFILE* stream);
字段说明
-
fd:文件描述符,记录当前结构体所对应的文件
-
buffer:用户级缓冲区,所有的 mfwrite 都会先往这里塞数据
-
size:记录缓冲区里攒了多少字节,判断是否需要触发 write 的依据
-
flush_mode:决定了我们的库的刷新策略
四、mfopen 实现
一个标准的 mfopen 需要完成以下几件事:解析模式、调用系统接口、申请内存、初始化状态
代码实现
cpp
myFILE* mfopen(const char* filename, const char* mode)
{
int fd;
int flag;
// 1. 解析模式
if (strcmp(mode, "r") == 0) flag = O_RDONLY;
else if (strcmp(mode, "w") == 0) flags = O_WRONLY | O_CREAT | O_TRUNC;
else if (strcmp(mode, "a") == 0) flags = O_WRONLY | O_CREAT | O_APPEND;
else return NULL; // 其他模式暂不处理
// 2. 调用 open
// 如果是创建文件,给与默认权限 0666
int mode = 0666;
if (flag & O_CREAT) fd = open(filename, flag, mode);
else fd = open(filename, flag);
if (fd < 0) return NULL;
// 3. 申请内存
myFILE* stream = (myFILE*)malloc(sizeof(myFILE));
if (stream == NULL)
{
close(fd);
perror("malloc");
return NULL;
}
// 4. 初始化 myFILE
stream->fd = fd;
stream->size = 0;
stream->capacity = SIZE;
stream->buffer[0] = 0;
stream->flush_mode = FLUSH_LINE; // 默认为行刷新
return stream;
}
代码解析
-
底层调用 open :我们发现,mfopen 的本质就是给 open 套了一层壳。它通过字符串("r", "w", "a")来决定内核层的 flags。这就是封装:用更容易理解的概念屏蔽掉底层的位图操作
-
内存分配:为什么要在堆上 malloc?因为 myFILE 需要在整个文件的生命周期内持续存在。如果在栈上开辟,函数返回后结构体就被销毁了,后续的读写将毫无根据
-
缓冲区初始化 :在 malloc 成功的那一刻,我们定义的 char buffer[SIZE] 也正式在堆上分配了空间。此时,这个缓冲区是完全空闲的(size = 0)
-
策略选择:标准库通常会通过 isatty(fd) 来判断一个文件描述符是否指向终端。如果指向终端,则设为行缓冲;如果指向磁盘文件,则设为全缓冲。在我们的简化版中,为了方便观察,暂时默认使用行缓冲
很多初学者会忘记在 malloc 失败时 close(fd)。系统资源和内存资源是两码事。如果内存申请失败但不关闭 fd,就会造成文件描述符泄漏。作为合格的工程师,我们需要时刻关注这种资源对称性问题
五、mfflush 实现
在我们的 mystdio 库中,mfflush 扮演着至关重要的角色,它决定了数据何时从用户态缓冲区 进入内核缓冲区
为了让代码逻辑更清晰、更具工程化,我们采用三层架构 来实现刷新逻辑。这种设计将如何刷新 、何时刷 和外部接口彻底解耦
1. 三层架构设计
-
最底层:flush------ 负责具体写入。它不关心策略,只管调用系统调用 write,并通过循环确保缓冲区里的每一字节都写进内核
-
中间层:__my_flush_core ------ 负责决策。它根据当前的刷新策略以及是否强制来决定要不要调用底层
-
最上层:mfflush------ 负责调用。它是公开的 API,专门用于强制刷新
2. 代码实现
**1. flush:**最底层,负责具体物理刷新
cpp
int flush (myFILE* stream)
{
if (stream->size == 0) return 0;
int total = stream->size; // 一共需要刷新多少数据
int written = 0; // 已经刷新完毕的数据
while (written < total)
{
int remain = total - written; // 剩余需要刷新的数据
int size = write(stream->fd, stream->buffer + written, remain);
if(size == -1) return EOF;
written += size; // 增加已经刷新完毕的个数
}
stream->size = 0;
return 0;
}
**2. __my__flush__core:**中间逻辑层,负责刷新策略判定
cpp
int __my__flush__core(myFILE* stream, int force)
{
if (stream->size == 0) return 0;
int flush_flag = 0; // 决定是否刷新的标志位
// 强制刷新
if (force == FORCE) flush_flag = 1;
// 全刷新
if (stream->flush_mode == FLUSH_FULL
&& stream->size == stream->capacity)
{
flush_flag = 1;
}
// 行刷新
else (stream->flush_mode == FLUSH_LINE
&& stream->buffer[stream->size - 1] == '\n')
{
flush_flag = 1;
}
// 无刷新: 在此处不做处理
if (flush_flag == 1) return flush(stream);
return 0;
}
**3. mfflush:**最上层,公开 API 接口
cpp
int mfflush(myFILE* stream)
{
if (stream == NULL) return -1;
return __my__flush__core(stream, FORCE);
}
3. 深度解析
-
为什么需要循环写入? 虽然在普通磁盘文件操作中 write 通常能一次性写完,但在操作管道 或网络套接字时,由于内核缓冲区可能满额,write 可能只写入了部分数据。通过 while 循环不断推进偏移量,是编写健壮 IO 库的基本
-
决策层 : __my_flush_core 的存在是为了给下一节的 mfwrite 打基础。mfwrite 只需要把数据往缓冲区里塞,然后调一下这个核心层,剩下的该不该刷的问题就交给核心层去判断
这种三层设计模式(Execution -> Policy -> Interface)在内核和复杂的中间件中随处可见。它让代码逻辑变得像乐高积木一样:如果后面想增加一个 "每隔 5 秒自动刷新" 的定时策略,只需要修改中间层的 __my_flush_core,而底层的写入逻辑完全不需要动
六、mfwrite 实现
负责把用户手中的数据源源不断地搬进缓冲区,并在适当的时候触发刷新
在实现这个函数时,我们需要处理两个核心逻辑:对 FLUSH_NONE 的拦截以及应对大数据的循环搬运
代码实现
cpp
int mfwrite(const char* ptr, int size, int count, myFILE* stream)
{
if (!ptr || !stream) return 0;
int total = size * count; // 一共需要拷贝到缓冲区的数据
if (total == 0) return 0;
// 无缓冲策略,直接拦截并透传
if (stream->flush_mode == FLUSH_NONE)
{
ssize_t n = write(stream_>fd, ptr, total);
return n > 0 ? (n / size) : 0;
}
int written = 0; // 已经写入的数据
while(written < total)
{
int remain_space = stream->capacity - stream->size;
// 规避缓冲区大小不足问题, 每次写入取空间与 total 更小的一方
int write_size = MIN(remian_space, total);
if(write_size > 0)
{
// 写入缓冲区
memcpy(stream->buffer + stream->size,
ptr + written, write_size);
// 更新状态
written += write_size;
stream->size += write_size;
// 调用中间层核心逻辑判定是否达到了刷新条件
__my_flush_core(stream, NORMAL);
}
}
// 返回成功写入的元素个数
return written / size;
}
在 memcpy 之后,mfwrite 并没有自己去检查换行符或缓冲区满额,而是调用了 __my_flush_core(stream, 0)。这种 "只管存,不管刷" 的解耦,保证了刷新逻辑的唯一出口,维护起来极其方便
为什么 FLUSH_NONE 要在这里处理
在我们的三层架构中,中间层 __my_flush_core 确实没有必要(也不应该)去专门处理 FLUSH_NONE(无缓冲)策略
1. 避免拷贝额外开销
如果让中间层来处理 FLUSH_NONE,逻辑会变成这样:
-
用户调用 mfwrite:先将数据从用户变量 memcpy 到 mf->buffer 中
-
调用中间层:中间层判断发现是 FLUSH_NONE
-
调用底层:立即调用 flush 执行 write 系统调用
问题出在哪里? 多了一次 memcpy 。对于无缓冲策略,用户追求的就是实时性。最快的做法是直接拿着用户的原始地址调用 write,而不是先倒手进缓冲区再刷出去
因此,FLUSH_NONE 的逻辑通常在最上层的 mfwrite内部就被拦截了------如果检测到无缓冲,直接原地调用系统调用,根本不需要进到中间层的缓冲逻辑里
2. 逻辑一致性
从设计哲学上讲,中间层 __my_flush_core 是缓冲区的管家。它的存在是为了管理那块 1024 字节的内存
-
行缓冲/全缓冲:本质上是 "如何管理这块内存" 的不同规则
-
无缓冲:本质上是 "放弃管理这块内存"
既然已经放弃了管理,那么管家就不应该再参与进来。让中间层处理 FLUSH_NONE,就像是在一个仓储管理系统里去处理一个即买即走、不入库的商品,这不仅增加了系统的复杂性,还模糊了功能边界
在底层编程中,"不做什么" 往往比 "做什么" 更重要。中间层不处理 FLUSH_NONE,是为了把宝贵的 CPU 周期从不必要的内存拷贝中节省出来。这也是为什么 stderr(默认无缓冲)在输出报错时,即使系统压力极大、内存受限,也能比 stdout 更可靠的原因之一
七、mfclose
mfclose 的逻辑必须遵循严格的递进顺序 :强制排空数据 -> 归还系统资源 -> 释放用户内存
cpp
int mfclose(myFILE* stream)
{
if (!stream) return -1;
// 1. 强制刷新所有数据
mfflush(steram);
fsync(stream->fd); // 强制内核缓冲区刷新
// 2. 归还系统资源, 关闭底层描述符
// 如果先关闭描述符会导致刷新失效
int n = close(stream->fd);
if (n < 0) perror("close");
// 3. 释放空间销毁结构体
free(steram);
return 0;
}
为什么先刷新?
如果我们写了 mfwrite("Goodbye", ...),但此时缓冲区没满,也没有换行符。如果直接 close(fd),数据还没来得及执行系统调用 write,底层的文件通道就断了。mfclose 中先刷新是为了完成最后的数据转运
八、测试验证
测试代码
现在,我们的 mystdio 已经初具规模。让我们写一个简单的测试程序来观察它的缓冲行为
cpp
int main() {
// 以写模式打开 test.log
myFILE *fp = mfopen("test.log", "w");
if (!fp) {
perror("mfopen failed");
return 1;
}
// 数据滞留测试
const char *s1 = "Hello Mystdio";
mfwrite(s1, strlen(s1), 1, fp);
printf("调用 mfwrite 写入 "%s",没有换行符。\n", s1);
sleep(5); // 留出 5 秒观察时间
// 行刷新测试
const char *s2 = "Refresh by Newline\n";
mfwrite(s2, strlen(s2), 1, fp);
printf("已写入带有 \\n 的内容\n");
sleep(5);
// 残留数据刷新测试
const char *s3 = "Final data without newline...";
mfwrite(s3, strlen(s3), 1, fp);
printf("写入了最后的残留数据(无换行)\n");
sleep(5);
// 关闭文件
printf("调用 mfclose\n");
mfclose(fp);
printf("文件已关闭。可查看 test.log\n");
return 0;
}
预期观察到的现象:
-
0 - 5秒 :虽然程序执行了 mfwrite,但 test.log 依然是 0 字节。
- 原理:数据被 memcpy 进了 mf->buffer,但因为没攒满且没有 \n,flush 根本没被调用
-
5 - 10秒:文件内容瞬间出现了 Hello Mystdio Refresh by Newline
- 原理:中间层检测到了数据末尾的 \n,于是越级调用了 write 系统调用
-
10 - 15秒 :文件内容静止不动,最后那句 Final data... 依然没出来
- 原理:同阶段一,数据被存在了缓冲区里
-
15 秒后 :文件内容补全了
- 原理:mfclose 内部调用了 mfflush(mf, FORCE),强制执行了最后的清空工作
总结
综上所述,通过手动实现一个简化版的 mystdio 库,我们从用户态的角度重新构建了文件 IO 的基本流程:以文件描述符为基础,结合缓冲区机制,在系统调用之上封装出更高层、更高效的操作接口 。虽然实现较为简化,但已经完整体现了标准库中缓冲 + 系统调用的核心思想
从 open / read / write 到 FILE 封装,再到我们自己的 myFILE 设计,本质上是一条逐层抽象、逐步封装的过程。理解这一过程,意味着我们不仅能够使用 IO 接口,更能够理解其背后的设计逻辑
而进一步思考,这些文件操作最终都依赖于底层存储结构的支持:数据是如何在磁盘上组织的?文件是如何被定位与管理的?在下一篇中,我们将进入文件系统层面,重点探讨 ext 系列文件系统的基本结构与工作原理,从更底层理解文件这一抽象的实现方式
