Linux IO入门(三):手写一个简易的 mystdio 库

目录

[一、为什么要实现 mystdio](#一、为什么要实现 mystdio)

二、设计思路

[三、myFILE 结构体](#三、myFILE 结构体)

[四、mfopen 实现](#四、mfopen 实现)

[五、mfflush 实现](#五、mfflush 实现)

[1. 三层架构设计](#1. 三层架构设计)

[2. 代码实现](#2. 代码实现)

[3. 深度解析](#3. 深度解析)

[六、mfwrite 实现](#六、mfwrite 实现)

[为什么 FLUSH_NONE 要在这里处理](#为什么 FLUSH_NONE 要在这里处理)

七、mfclose

八、测试验证

总结


一、为什么要实现 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,逻辑会变成这样:

  1. 用户调用 mfwrite:先将数据从用户变量 memcpy 到 mf->buffer 中

  2. 调用中间层:中间层判断发现是 FLUSH_NONE

  3. 调用底层:立即调用 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;
}

预期观察到的现象:

  1. 0 - 5秒 :虽然程序执行了 mfwrite,但 test.log 依然是 0 字节

    • 原理:数据被 memcpy 进了 mf->buffer,但因为没攒满且没有 \n,flush 根本没被调用
  2. 5 - 10秒:文件内容瞬间出现了 Hello Mystdio Refresh by Newline

    • 原理:中间层检测到了数据末尾的 \n,于是越级调用了 write 系统调用
  3. 10 - 15秒 :文件内容静止不动,最后那句 Final data... 依然没出来

    • 原理:同阶段一,数据被存在了缓冲区里
  4. 15 秒后 :文件内容补全了

    • 原理:mfclose 内部调用了 mfflush(mf, FORCE),强制执行了最后的清空工作

总结

综上所述,通过手动实现一个简化版的 mystdio 库,我们从用户态的角度重新构建了文件 IO 的基本流程:以文件描述符为基础,结合缓冲区机制,在系统调用之上封装出更高层、更高效的操作接口 。虽然实现较为简化,但已经完整体现了标准库中缓冲 + 系统调用的核心思想

从 open / read / write 到 FILE 封装,再到我们自己的 myFILE 设计,本质上是一条逐层抽象、逐步封装的过程。理解这一过程,意味着我们不仅能够使用 IO 接口,更能够理解其背后的设计逻辑

而进一步思考,这些文件操作最终都依赖于底层存储结构的支持:数据是如何在磁盘上组织的?文件是如何被定位与管理的?在下一篇中,我们将进入文件系统层面,重点探讨 ext 系列文件系统的基本结构与工作原理,从更底层理解文件这一抽象的实现方式

相关推荐
telllong2 小时前
MCP协议实战:30分钟给Claude接上你公司的内部API
linux·运维·服务器
正在走向自律2 小时前
KingbaseES 基础 SQL 语法与日常运维实操手册
运维·数据库·sql·kingbasees
㳺三才人子2 小时前
SpringDoc OpenAPI 配置問題
服务器·spring boot
实心儿儿2 小时前
Linux —— 进程概念 - 程序地址空间
linux·运维·算法
buhuizhiyuci2 小时前
linux篇-应用商店:“yum / apt“ 的详解
linux·运维·服务器
Tisfy2 小时前
CORS 跨域重定向后 Origin 变 null —— 一次 Nginx 字体加载失败的排查记录
运维·nginx·html·cors
零号全栈寒江独钓3 小时前
基于c/c++实现linux/windows跨平台ntp时间戳服务器
linux·c语言·c++·windows
程序猿阿伟3 小时前
《QClaw隐藏的GitHub自动化神级用法》
运维·自动化·github
ulias2123 小时前
进程初识(1)
linux·运维·服务器·网络·c++