请结合上篇文章一起食用:Linux:缓冲区
目录
在学习 Linux 系统级编程的过程中,很多同学(包括我自己)都会遇到一个经典的困境:上课听老师分析源码时频频点头,下课面对空白 IDE 时大脑一片空白。 这种现象被称为"听懂了的错觉"。
要打破这种错觉,最好的方式就是"造轮子"。今天,我们将不依赖
<stdio.h>,仅使用 Linux 底层的系统调用(open,write,close等),从零开始封装一个我们自己的标准 I/O 库,实现类似fopen,fwrite,fflush,fclose的功能。通过这篇博客,你不仅能真正掌握 C 语言文件操作的底层逻辑,还能深入理解用户缓冲区 、内核缓冲区 以及数据刷盘策略的本质。
1.自己封装(底层调用的开销)
在 C 语言中,我们经常使用 printf 或 fwrite。为什么不直接使用内核提供的 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" 实际上是对系统调用 open 的 flags 参数的封装。我们需要进行字符串匹配并转换:
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:
结语:从"看代码"到"写代码"的破局之道
复现这段代码的过程,让我深刻意识到学习代码是有阶段的。从顺着老师的思路"看懂",到自己面对空白屏幕的"懵圈",这是必经之路。
如何破局?
-
抛弃对具体代码行的死记硬背。
-
面对头文件编程: 先把
My_FILE结构体默写出来,因为数据结构决定了程序的上限和业务骨架。 -
理顺流程图: 在写
Myfwrite前,先用大白话写出:先算大小 -> 塞入缓冲 -> 判断是否要调用fflush。 -
把查文档作为习惯: 忘记了
open的标志位?不要去看源码,去终端敲下man 2 open。
希望这篇手写 I/O 库的记录,能帮你在系统级编程的泥沼中找到一丝清晰的脉络。代码虽短,但五脏俱全,每一次系统调用背后,都藏着操作系统对效率与安全的极致权衡。
本章完。