用系统调用从零封装一个C语言标准I/O库 | 附源码

请结合上篇文章一起食用:Linux:缓冲区

目录

1.自己封装(底层调用的开销)

2.核心数据结构(FILE指针本质)

3.逐步实现:从"骨架"到"血肉"

3.1打开文件:Myfopen的底层映射

3.2写入与缓冲的逻辑:Myfwrite

3.3数据刷新:Myfflush

3.4处理缓冲区数据:Myfclose

4.测试

5.源码

结语:从"看代码"到"写代码"的破局之道


在学习 Linux 系统级编程的过程中,很多同学(包括我自己)都会遇到一个经典的困境:上课听老师分析源码时频频点头,下课面对空白 IDE 时大脑一片空白。 这种现象被称为"听懂了的错觉"。

要打破这种错觉,最好的方式就是"造轮子"。今天,我们将不依赖 <stdio.h>,仅使用 Linux 底层的系统调用(open, write, close 等),从零开始封装一个我们自己的标准 I/O 库,实现类似 fopen, fwrite, fflush, fclose 的功能。

通过这篇博客,你不仅能真正掌握 C 语言文件操作的底层逻辑,还能深入理解用户缓冲区内核缓冲区 以及数据刷盘策略的本质。

1.自己封装(底层调用的开销)

在 C 语言中,我们经常使用 printffwrite。为什么不直接使用内核提供的 write 系统调用呢?

因为系统调用是昂贵的 。每次调用 write,CPU 都需要进行上下文切换(Context Switch) ,从用户态陷入内核态。如果我们要写 1000 个字节,每次调用 write 写 1 个字节,就需要进行 1000 次内核态切换,这会极大拖慢程序的运行速度。

C 标准库的解决方案是:引入应用层缓冲区。 把数据先攒在用户态的内存里,等攒够了一定数量(或者遇到特定条件如换行符),再一次性 调用 write 批量送入内核。我们今天就是要复现这个"攒数据"的过程。

2.核心数据结构(FILE指针本质)

我们平时使用的FILE *fp本质上是一个封装了文件描述符(fd)和用户缓冲区的结构体,在我之前的文章有深度讲解:Linux:文件描述符fd

看看我们设计的自己的My_FILE

cpp 复制代码
#pragma once

// 刷新策略的宏定义
#define NONE_CACHE 1  // 无缓冲
#define LINE_CACHE 2  // 行缓冲
#define FULL_CACHE 4  // 全缓冲

typedef struct
{
    int fd;                 // 封装底层的文件描述符
    int flags;              // 文件的打开标志 (只读/只写/追加等)
    int mode;               // 缓冲刷新策略 (例如行刷新)
    char outbuffer[1024];   // 用户态输出缓冲区 (核心!)
    int cap;                // 缓冲区的最大容量
    int size;               // 缓冲区当前已经使用的字节数
} My_FILE;

My_FILE *Myfopen(const char *pathname, const char *mode);
int Myfwrite(const char *message, int size, int num, My_FILE *fp);
void Myfflush(My_FILE *fp);
void Myfclose(My_FILE *fp);

面向结构编程: 看到这里,你的脑海中应该浮现出一个"蓄水池"的模型。outbuffer 就是池子,cap 是池子的容量,size 是当前的水量。只有搞懂了这个结构,后面的代码才不会是死记硬背。

3.逐步实现:从"骨架"到"血肉"

3.1打开文件:Myfopen的底层映射

fopen 的 "w", "r", "a" 实际上是对系统调用 openflags 参数的封装。我们需要进行字符串匹配并转换:

cpp 复制代码
#include "my_stdio.h"
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>

static mode_t gmode = 0666;

My_FILE *Myfopen(const char *pathname, const char *mode)
{
    // 判断参数是否为空
    if(pathname == NULL || mode == NULL)
        return NULL;
    // 设置文件默认权限
    umask(0);
    // 根据打开模式设置flag
    int fd = 0;
    int flags = 0;
    // w模式(清空文件,重新写)
    if(strcmp(mode, "w") == 0)
    {
        flags = O_CREAT | O_WRONLY | O_TRUNC;
        fd = open(pathname, flags, gmode);
        (void)fd;
    }
    // 只读
    if(strcmp(mode, "r") == 0)
    {
        flags = O_RDONLY;
        fd = open(pathname, flags);
        (void)fd;
    }
    // 追加(不清空,在末尾写)
    if(strcmp(mode, "a") == 0)
    {
        flags = O_CREAT | O_WRONLY | O_APPEND;
        fd = open(pathname, flags, gmode);
        (void)fd;
    // w模式(清空文件,重新写)
    if(strcmp(mode, "w") == 0)
    {
        flags = O_CREAT | O_WRONLY | O_TRUNC;
        fd = open(pathname, flags, gmode);
        (void)fd;
    }
    // 只读
    if(strcmp(mode, "r") == 0)
    {
        flags = O_RDONLY;
        fd = open(pathname, flags);
        (void)fd;
    }
    // 追加(不清空,在末尾写)
    if(strcmp(mode, "a") == 0)
    {
        flags = O_CREAT | O_WRONLY | O_APPEND;
        fd = open(pathname, flags, gmode);
        (void)fd;
    // w模式(清空文件,重新写)
    if(strcmp(mode, "w") == 0)
    {
        flags = O_CREAT | O_WRONLY | O_TRUNC;
        fd = open(pathname, flags, gmode);
        (void)fd;
    }
    // 只读
    if(strcmp(mode, "r") == 0)
    {
        flags = O_RDONLY;
        fd = open(pathname, flags);
        (void)fd;
    }
    // 追加(不清空,在末尾写)
    if(strcmp(mode, "a") == 0)
    {
        flags = O_CREAT | O_WRONLY | O_APPEND;
        fd = open(pathname, flags, gmode);
        (void)fd;
    }
    else{}

    // 打开失败
    if(fd < 0)
        return NULL;
    // 创建My_FILE对象
    My_FILE *fp = (My_FILE*)malloc(sizeof(My_FILE));
    // fp是结构体指针
    if(!fp)
        return NULL;

    // 初始化结构体
    fp->fd = fd; // 保存文件编号
    fp->flags = flags; // 保存打开方式
    fp->mode = LINE_CACHE; // 默认:行缓冲
    fp->cap = 1024; // 缓冲区最大容量:1024
    fp->size = 0; // 缓冲区开始时为空
    memset(fp->outbuffer, 0, sizeof(fp->outbuffer)); // 清空缓冲区
    return fp;
}

3.2写入与缓冲的逻辑:Myfwrite

这是整个库最精彩的部分。当你调用 Myfwrite 时,数据并没有 立刻写入磁盘,而是被悄悄地塞进了 outbuffer 这个蓄水池中。

cpp 复制代码
int Myfwrite(const char *message, int size, int num, My_FILE *fp) // 写到缓冲区
{
    if(message == NULL || fp == NULL)
        return-1;
    // 写入字节数
    int total = size * num;
    // 判断缓冲区大小
    if(total + fp->size > fp->cap - 1)
        return -1;
    // 数据拷贝到缓冲区(内存操作)
    memcpy(fp->outbuffer + fp->size, message, total);
    // 更新缓冲区长度
    fp->size += total;
    // 字符串后补\0
    fp->outbuffer[fp->size] = 0;
    // 行缓冲刷新规则
    if(fp->outbuffer[fp->size - 1] == '\n' && (fp->mode & LINE_CACHE))
        Myfflush(fp);

    return num; // 返回写入个数
}

3.3数据刷新:Myfflush

当缓冲区满了,或者遇到了 \n,我们需要将数据真正交还给操作系统,这被称为刷新(Flush)。在这个函数里,我们要深刻理解两个概念:WB (Write Back)WT (Write Through)

cpp 复制代码
void Myfflush(My_FILE *fp)
{
    if(!fp) return;
    
    if(fp->size > 0)
    {
        // 1. WB (Write Back,写回)
        // 调用内核 API:将数据从用户缓冲区拷贝到内核的页缓存 (Page Cache)
        write(fp->fd, fp->outbuffer, fp->size);
        fp->size = 0; // 清空用户缓冲区计数
        
        // 2. WT (Write Through,直写)
        // 强制要求操作系统:不要只留在内核缓冲区里,立刻给我写到磁盘等物理硬件上!
        fsync(fp->fd); 
    }
}

深层解析: 仅仅调用 write,数据依然在内存中(内核缓冲区),如果此时机器断电,数据仍然会丢失。只有调用了 fsync,数据才算真正落地到了磁盘上。

3.4处理缓冲区数据:Myfclose

关闭文件前,最重要的一步是处理遗留的缓冲区数据。如果没有主动 flush 且缓冲区里有数据,直接 close 会导致数据丢失。

cpp 复制代码
void Myfclose(My_FILE *fp)
{
    if(!fp) return;
    
    // 强制刷新缓冲区残留数据
    Myfflush(fp); 
    
    // 关闭系统级文件描述符
    close(fp->fd);
    
    // 别忘了释放 malloc 申请的内存
    free(fp);
}

4.测试

main.c:

cpp 复制代码
#include "my_stdio.h"
#include <string.h>
#include <unistd.h>

int main()
{
    My_FILE *fp = Myfopen("log.txt", "a");
    if(!fp) return 1;

    int cnt = 10;
    const char* msg = "hello world "; // 注意,这里没有带换行符
    
    while(cnt--)
    {
        Myfwrite(msg, 1, strlen(msg), fp);
        Myfflush(fp); // 因为没有 \n,所以我们需要手动 flush 确保实时写入
        sleep(2);
    }

    Myfclose(fp);
    return 0;
}

Makefile:

cpp 复制代码
testlibc: main.c my_stdio.c
	gcc -o $@ $^
.PHONY:clean
clean:
	rm -f testlibc

5.源码

见gitee:

https://gitee.com/jxxx404/linux-learning/commit/4967a80de7b97051f73c57a2d9161214cf989d44https://gitee.com/jxxx404/linux-learning/commit/4967a80de7b97051f73c57a2d9161214cf989d44

结语:从"看代码"到"写代码"的破局之道

复现这段代码的过程,让我深刻意识到学习代码是有阶段的。从顺着老师的思路"看懂",到自己面对空白屏幕的"懵圈",这是必经之路。

如何破局?

  1. 抛弃对具体代码行的死记硬背。

  2. 面对头文件编程: 先把 My_FILE 结构体默写出来,因为数据结构决定了程序的上限和业务骨架。

  3. 理顺流程图: 在写 Myfwrite 前,先用大白话写出:先算大小 -> 塞入缓冲 -> 判断是否要调用 fflush

  4. 把查文档作为习惯: 忘记了 open 的标志位?不要去看源码,去终端敲下 man 2 open

希望这篇手写 I/O 库的记录,能帮你在系统级编程的泥沼中找到一丝清晰的脉络。代码虽短,但五脏俱全,每一次系统调用背后,都藏着操作系统对效率与安全的极致权衡。

本章完。

相关推荐
覆东流2 小时前
第4天:Python输入与输出
后端·python·photoshop·输入与输出
并不喜欢吃鱼2 小时前
从零开始C++----七.继承相关模型,解析多继承与菱形继承问题(下篇)
开发语言·c++
计算机魔术师2 小时前
【AI面试八股文 Vol.1.1 | 专题3:State Schema 设计】State Schema设计:TypedDict / Pydantic类型约束
linux·人工智能·面试
devil-J2 小时前
vue3+three.js中国3D地图
开发语言·javascript·3d
Xiaoᴗo.2 小时前
C语言2.0---------
c语言·开发语言·数据结构
ghie90902 小时前
MATLAB 解线性方程组的迭代法
开发语言·算法·matlab
Brilliantwxx2 小时前
【数据结构】排序算法的神奇世界(下)
c语言·数据结构·笔记·算法·排序算法
j_xxx404_2 小时前
面试官灵魂拷问:Linux软链接与硬链接到底有什么区别?(附底层Inode级深度图解)
linux·运维·服务器
人道领域2 小时前
【LeetCode刷题日记】:151翻转字符串的单词(两种解法)
java·开发语言·算法·leetcode·面试