在计算机的世界里,文件如同河流,承载着数据的流动与生命的律动。今天,我们将踏上一段奇妙的旅程,探索如何在Linux系统中模拟实现C语言的文件流,揭开那看似神秘却又充满优雅的面纱。
在C语言中,文件流是一种抽象的概念,它将复杂的文件操作简化为一系列流畅的读写动作。标准库中的FILE结构体及其相关函数如fopen()、fread()、fwrite()等,为程序员提供了一个优雅的接口,使我们能够专注于数据的处理而非底层的细节。
然而,这些优雅的接口背后,隐藏着怎样的奥秘?今天,我们将揭开这层面纱,亲手构建一个简化版的文件流系统。

本文重点 : 模拟实现 FILE 及C语言文件操作相关函数
注意: 本文实现的只是一个简单的 demo,重点在于理解系统调用及缓冲区
在设计 FILE 结构体前,首先要清楚 FILE 中有自己的缓冲区及冲刷方式

缓冲区的大小和刷新方式因平台而异,这里我们将 大小设置为 1024 刷新方式选择 行缓冲,为了方便对缓冲区进行控制,还需要一个下标 _current,当然还有 最重要的文件描述符 _fd
#define BUFFER_SIZE 1024 //缓冲区大小
//通过位图的方式,控制刷新方式
#define BUFFER_NONE 0x1 //无缓冲
#define BUFFER_LINE 0x2 //行缓冲
#define BUFFER_ALL 0x4 //全缓冲
typedef struct MY_FILE
{
char _buffer[BUFFER_SIZE]; //缓冲区
size_t _current; //缓冲区下标
int _flush; //刷新方式,位图结构
int _fd; //文件描述符
}MY_FILE;当前模拟实现的 FILE 只具备最基本的功能,重点在于呈现原理
在模拟实现 C语言 文件操作相关函数前,需要先来简单回顾下
主要实现的函数有以下几个:
#include <stdio.h>
#include <assert.h>
#include <string.h>
int main()
{
//打开文件,写入数据
FILE* fp = fopen("file.txt", "w");
assert(fp);
const char* str = "露易斯湖三面环山,层峦叠嶂,翠绿静谧的湖泊在宏伟山峰及壮观的维多利亚冰川的映照下更加秀丽迷人";
char buff[1024] = { 0 };
snprintf(buff, sizeof(buff), str);
fwrite(buff, 1, sizeof(buff), fp);
fclose(fp);
return 0;
}
#include <stdio.h>
#include <assert.h>
#include <string.h>
int main()
{
//打开文件,并从文件中读取信息
FILE* fp = fopen("file.txt", "r+");
assert(fp);
char buff[1024] = { 0 };
int n = fread(buff, 1, sizeof(buff) - 1, fp);
buff[n] = '\0';
printf("%s", buff);
fclose(fp);
return 0;
}fopen
fclose
fwrite
fread
不同的缓冲区有不同的刷新策略,如果未触发相应的刷新策略,会导致数据滞留在缓冲区中,比如如果内存中的数据还没有刷新就断电的话,会导致数据丢失;除了通过特定方式进行缓冲区冲刷外,还可以手动刷新缓冲区,在 C语言 中,手动刷新缓冲区的函数为 fflush
#include <stdio.h>
#include <unistd.h>
int main()
{
int cnt = 20;
while(cnt)
{
printf("he"); //故意不触发缓冲
cnt--;
if(cnt % 10 == 5)
{
fflush(stdout); //刷新缓冲区
printf("\n当前已冲刷,cnt: %d\n", cnt);
}
sleep(1);
}
return 0;
}
在cnt=15和5时先手动冲刷两次,之后程序结束自动全部冲刷
总的来说,这些文件操作相关函数,都是在对缓冲区进行写入及冲刷,将数据拷贝给内核缓冲区,再由内核缓冲区刷给文件
MY_FILE *my_fopen(const char *path, const char *mode); //打开文件打开文件分为以下几步:
mode 确认打开方式open 打开文件因为打开文件存在多种失败情况:权限不对 / open 失败 / malloc 失败等,所以当打开文件失败后,需要返回 NULL
注意: 假设是因 malloc 失败的,那么在返回之前需要先关闭 fd,否则会造成资源浪费
// 打开文件
// 打开文件
MY_FILE *my_fopen(const char *path, const char *mode)
{
assert(path && mode);
// 确定打开方式
int flags = 0; // 打开方式
// 读:O_RDONLY 读+:O_RDONLY | O_WRONLY
// 写:O_WRONLY | O_CREAT | O_TRUNC 写+:O_WRONLY | O_CREAT | O_TRUNC | O_RDONLY
// 追加: O_WRONLY | O_CREAT | O_APPEND 追加+:O_WRONLY | O_CREAT | O_APPEND | O_RDONLY
// 注意:不考虑 b 二进制读写的情况
if (*mode == 'r')
{
flags |= O_RDONLY;
if (strcmp("r+", mode) == 0)
flags |= O_WRONLY;
}
else if (*mode == 'w' || *mode == 'a')
{
flags |= (O_WRONLY | O_CREAT);
if (*mode == 'w')
flags |= O_TRUNC;
else
flags |= O_APPEND;
if (strcmp("w+", mode) == 0 || strcmp("a+", mode) == 0)
flags |= O_RDONLY;
}
else
{
// 无效打开方式
assert(false);
}
// 根据打开方式,打开文件
// 注意新建文件需要设置权限
int fd = 0;
if (flags & O_CREAT)
fd = open(path, flags, 0666);
else
fd = open(path, flags);
if (fd == -1)
{
// 打开失败的情况
return NULL;
}
// 打开成功了,创建 MY_FILE 结构体,并返回
MY_FILE *new_file = (MY_FILE *)malloc(sizeof(MY_FILE));
if (new_file == NULL)
{
// 此处不能断言,需要返回空
close(fd); // 需要先把 fd 关闭
perror("malloc FILE fail!");
return NULL;
}
// 初始化 MY_FILE
memset(new_file->_buffer, '\0', BUFFER_SIZE); // 初始化缓冲区
new_file->_current = 0; // 下标置0
new_file->_flush = BUFFER_LINE; // 行刷新
new_file->_fd = fd; // 设置文件描述符
return new_file;
}int my_fclose(MY_FILE *fp); //关闭文件文件在关闭前,需要先将缓冲区中的内容进行冲刷,否则会造成数据丢失
注意:my_fclose返回值与close一致,因此可以复用
// 关闭文件
int my_fclose(MY_FILE *fp)
{
assert(fp);
// 刷新残余数据
if (fp->_current > 0)
my_fflush(fp);
// 关闭 fd
int ret = close(fp->_fd);
// 释放已开辟的空间
free(fp);
fp = NULL;
return ret;
}int my_fflush(MY_FILE *stream); //缓冲区刷新缓冲区冲刷是一个十分重要的动作,它决定着 IO 是否正确,这里的 my_fflush 是将用户级缓冲区中的数据冲刷至内核级缓冲区
冲刷的本质:拷贝,用户先将数据拷贝给用户层面的缓冲区,再系统调用将用户级缓冲区拷贝给内核级缓冲区,最后才将数据由内核级缓冲区拷贝给文件
因此IO是非常影响效率的。数据传输过程必须遵循冯诺依曼体系结构
函数 fsync
// 缓冲区刷新
int my_fflush(MY_FILE *stream)
{
assert(stream);
// 将数据写给文件
int ret = write(stream->_fd, stream->_buffer, stream->_current);
stream->_current = 0; // 每次刷新后,都需要清空缓冲区
fsync(stream->_fd); // 将内核中的数据强制刷给磁盘(文件)
if (ret != -1) return 0;
else return -1;
}size_t my_fwrite(const void *ptr, size_t size, size_t nmemb, MY_FILE *stream); //数据写入数据写入用户级缓冲区的步骤:
user_size及用户级缓冲区剩余大小 my_size,方便进行后续操作my_size >= user_size,说明缓冲区容量足够,直接进行拷贝;否则说明缓冲区容量不足,需要重复冲刷->拷贝->再冲刷 的过程,直到将数据全部拷贝行刷新->最后一个字符是否为 \n,如果满足条件就刷新缓冲区user_size)如果是一次写不完的情况,需要通过循环写入数据,并且在缓冲区满后进行刷新,因为循环写入时,目标数据的读取位置是在不断变化的(一次读取一部分,不断后移),所以需要对读取位置和读取大小进行特殊处理
在进行数据读取时,需要经历 文件->内核级缓冲区->用户级缓冲区->目标空间的繁琐过程,并且还要考虑 用户级缓冲区是否能够一次读取完所有数据,若不能,则需要多次读取
注意:
// 数据读取
size_t my_fread(void *ptr, size_t size, size_t nmemb, MY_FILE *stream)
{
// 数据读取前,需要先把缓冲区刷新
if (stream->_current > 0)
my_fflush(stream);
size_t user_size = size * nmemb;
size_t my_size = BUFFER_SIZE;
// 先将数据读取到FILE缓冲区中,再赋给 ptr
if (my_size >= user_size)
{
// 此时缓冲区中足够存储用户需要的所有数据,只需要读取一次
read(stream->_fd, stream->_buffer, my_size);
memcpy(ptr, stream->_buffer, my_size);
*((char *)ptr + my_size - 1) = '\0';
}
else
{
int ret = 1;
size_t tmp = user_size;
while (ret)
{
// 一次读不完,需要多读取几次
ret = read(stream->_fd, stream->_buffer, my_size);
stream->_buffer[ret] = '\0';
memcpy(ptr + (tmp - user_size), stream->_buffer, my_size);
stream->_current = 0;
user_size -= my_size;
}
}
size_t readn = strlen(ptr);
return readn;
}用户在进行文件流操作时,实际要进行至少三次的拷贝:用户->用户级缓冲区->内核级缓冲区->文件,C语言 中众多文件流操作都是在完成 用户->用户级缓冲区 的这一次拷贝动作,其他语言也是如此,最终都是通过系统调用将数据冲刷到磁盘(文件)中

最后再简单提一下 printf 和 scanf 的工作原理
无论是什么类型,最终都要转为字符型进行存储,程序中的各种类型只是为了更好的解决问题
printf
scanf
字符指针数组这也就解释了为什么要确保 输出/输入 格式与数据匹配,如果不匹配的话,会导致 读取/赋值 错误
本篇关于文件操作模拟实现的介绍就暂告段落啦,希望能对大家的学习产生帮助,欢迎各位佬前来支持斧正!!!