目录
1. FILE 结构设计
2、函数使用及分析
3、文件打开 fopen
4. 缓冲区刷新fflush
5. 数据写入fwrite
6. 文件关闭 fclose
7. 测试
8. 小结
1. FILE 结构设计
在设计 FILE
结构体前,首先要清楚 FILE
中有自己的缓冲区及冲刷方式
缓冲区的大小和刷新方式因平台而异,这里我们将 大小设置为 1024
刷新方式选择 行缓冲
,为了方便对缓冲区进行控制,还需要一个下标 _current
,当然还有 最重要的文件描述符 _fd
#include <stdio.h>//通过位图的方式,控制刷新方式
#define BUFFER_NONE 0x1 //无缓冲
#define BUFFER_LINE 0x2 //行缓冲
#define BUFFER_ALL 0x4 //全缓冲
#define BUFFER_SIZE 1024 //缓冲区大小typedef struct MY_FILE
{char _buffer[BUFFER_SIZE]; //缓冲区size_t _current; //缓冲区下标int _flush; //刷新方式,位图结构int _fd; //文件描述符
}MY_FILE;
2、函数使用及分析
主要实现的函数有以下几个:
fopen
打开文件fclose
关闭文件fflush
进行缓冲区刷新fwrite
对文件中写入数据fread
读取文件数据
#include <stdio.h>
#include <assert.h>
#include <string.h>#define SIZE 1024
#define FE "file.txt"int main()
{//打开文件,写入数据FILE* fp = fopen(FE, "w");assert(fp);const char* str = "good morning\n";char buff[SIZE] = { 0 };snprintf(buff, sizeof(buff), str);fwrite(buff, 1, sizeof(buff), fp);fclose(fp);return 0;
}
- fopen -- 打开指定文件,可以以多种方式打开,若是以读方式打开时,文件不存在会报错
- fclose -- 根据
FILE*
关闭指定文件,不能重复关闭 - fwrite -- 对文件中写入指定数据,一般是借助缓冲区进行写入
不同的缓冲区有不同的刷新策略,如果未触发相应的刷新策略,会导致数据滞留在缓冲区中,比如如果内存中的数据还没有刷新就断电的话,会导致数据丢失;除了通过特定方式进行缓冲区冲刷外,还可以手动刷新缓冲区,在 C语言
中,手动刷新缓冲区的函数为 fflush
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;
}
先手动冲刷五次,再手动冲刷十次,最后程序结束后,自动冲刷剩余五次.
总的来说,这些文件操作相关函数,都是在对缓冲区进行写入及冲刷,将数据拷贝给内核缓冲区,再由内核缓冲区刷给文件
3、文件打开 fopen
MY_FILE *my_fopen(const char *path, const char *mode); //打开文件
打开文件分为以下几步:
- 根据传入的
mode
确认打开方式 - 通过系统接口
open
打开文件 - 创建
MY_FILE
结构体,初始化内容 - 返回创建好的
MY_FILE
类型
因为打开文件存在多种失败情况:权限不对 / open
失败 / malloc
失败等,所以当打开文件失败后,需要返回 NULL
注意: 假设是因 malloc
失败的,那么在返回之前需要先关闭 fd
,否则会造成资源浪费
// 读: 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 二进制读写的情况MY_FILE *my_fopen(const char *path, const char *mode)
{// 1. 识别打开方式int flag = 0; // 打开方式if(strcmp(mode, "r") == 0) flag |= O_RDONLY;else if(strcmp(mode, "r+") == 0) flag |= (O_RDONLY | O_WRONLY);else if(strcmp(mode, "w") == 0) flag |= (O_WRONLY | O_CREAT | O_TRUNC);else if(strcmp(mode, "w+") == 0) flag |= (O_WRONLY | O_CREAT | O_TRUNC | O_RDONLY);else if(strcmp(mode, "a") == 0) flag |= (O_WRONLY | O_CREAT | O_APPEND);else if(strcmp(mode, "a+") == 0) flag |= (O_WRONLY | O_CREAT | O_APPEND | O_RDONLY);// 2. 根据打开方式打开文件mode_t m = 0666;// 默认权限int fd = 0;if(flag & O_CREAT) fd = open(path, flag, m);// 注意新建文件需要设置权限else fd = open(path, flag);// 打开失败if(fd == -1) return NULL;// 3.打开成功就创建并返回MY_FILE对象MY_FILE *newfile = (MY_FILE*)malloc(sizeof(MY_FILE));if(newfile == NULL){close(fd);return NULL;}// 4.初始化MY_FILE对象memset(newfile->_buffer, '\0', sizeof(newfile->_buffer));newfile->_current = 0;newfile->_flush = BUFFER_LINE;newfile->_fd = fd;// 5. 返回文件return newfile;
}
4. 缓冲区刷新fflush
int my_fflush(MY_FILE *stream); //缓冲区刷新
缓冲区冲刷是一个十分重要的动作,它决定着 IO
是否正确,这里的 my_fflush
是将用户级缓冲区中的数据冲刷至内核级缓冲区。
冲刷的本质:拷贝,用户先将数据拷贝给用户层面的缓冲区,再系统调用将用户级缓冲区拷贝给内核级缓冲区,最后才将数据由内核级缓冲区拷贝给文件。
因此 IO 是非常影响效率的。数据传输过程必须遵循冯诺依曼体系结构。
函数 fsync
- 将内核中的数据手动拷贝给目标文件(内核级缓冲区的刷新策略极为复杂,为了确保数据能正常传输,可以选择手动刷新)
注意: 在冲刷完用户级缓冲区后(write
),需要将缓冲区清空,否则缓冲区就一直满载了
// 缓冲区刷新
int my_fflush(MY_FILE *stream)
{assert(stream);// 利用系统接口将用户数据冲刷给OSint ret = write(stream->_fd, stream->_buffer, stream->_current);stream->_current = 0; // 每次刷新后,都需要清空缓冲区fsync(stream->_fd); // 将内核中的数据强制刷给磁盘(文件)if (ret != -1) return 0;else return -1;
}
5. 数据写入fwrite
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_fwrite(const void *ptr, size_t size, size_t nmemb, MY_FILE *stream)
{// 1. 先判断缓冲区是否已满if(stream->_current == BUFFER_SIZE) my_fflush(stream);// 2.根据缓冲区剩余容量情况,进行拷贝size_t user_size = size * nmemb; // 用户想写入的字节数size_t my_size = BUFFER_SIZE - stream->_current; // 缓冲区剩余容量size_t writen = 0;if(my_size >= user_size){// 剩余容量足够则全部拷贝memcpy(stream->_buffer+stream->_current, ptr, user_size);// 更新字段stream->_current += user_size;writen = user_size;}else{memcpy(stream->_buffer+stream->_current, ptr, my_size);// 更新字段stream->_current += my_size;writen = my_size;}// 4. 开始刷新// 4.1 全缓冲直接刷新if(stream->_flush & BUFFER_ALL){if(stream->_current == BUFFER_SIZE) my_fflush(stream);}// 4.2 行缓冲else if(stream->_flush & BUFFER_LINE){if(stream->_buffer[stream->_current-1] == '\n') my_fflush(stream);}else{//TODO}// 为了简化,这里返回用户实际写入的字节数return writen;
}
6. 文件关闭 fclose
int my_fclose(MY_FILE *fp); //关闭文件
文件在关闭前,需要先将缓冲区中的内容进行冲刷,否则会造成数据丢失
注意: my_fclose
返回值与 close
一致,因此可以复用
// 关闭文件
int my_fclose(MY_FILE *fp)
{assert(fp);// 刷新残余数据if (fp->_current > 0)my_fflush(fp);// 关闭 fdint ret = close(fp->_fd);// 释放已开辟的空间free(fp);fp = NULL;return ret;
}
7. 测试
#include <stdio.h>
#include <string.h>
#include <assert.h>
#include <unistd.h>int main()
{//打开文件,写入一段话FILE* fp = fopen("log.txt", "w+");assert(fp);char inPutBuff[512] = "good morning\n";int n = fwrite(inPutBuff, 1, strlen(inPutBuff), fp);printf("本次成功写入 %d 字节的数据\n", n);fclose(fp);fp = NULL;return 0;
}
8. 小结
用户在进行文件流操作时,实际要进行至少三次的拷贝:用户->用户级缓冲区->内核级缓冲区->文件,C语言
中众多文件流操作都是在完成 用户->用户级缓冲区 的这一次拷贝动作,其他语言也是如此,最终都是通过系统调用将数据冲刷到磁盘(文件)中。
在以前篇章中提过了一个疑问:为什么会打印两次 hello fprintf 就很好理解了:因为没有触发刷新条件(文件一般为全缓冲),所以数据滞留在用户层缓冲区中,fork 创建子进程后,子进程结束,刷新用户层缓冲区[子进程],此时会触发写时拷贝机制,父子进程的用户层缓冲区不再是同一个;父进程结束后,刷新用户层缓冲区[父进程],因此会看见打印两次的奇怪现象。
最后再简单提一下 printf
和 scanf
的工作原理
无论是什么类型,最终都要转为字符型进行存储,程序中的各种类型只是为了更好的解决问题
printf
- 根据格式读取数据,如整型、浮点型,并将其转为字符串
- 定义缓冲区,然后将字符串写入缓冲区(stdout)
- 最后结合一定的刷新策略,将数据进行冲刷
scanf
- 读取数据至缓冲区(stdin)
- 根据格式将字符串扫描分割,存入字符指针数组
- 最后将字符串转为对应的类型,赋值给相应的变量
- 这也就解释了为什么要确保 输出/输入 格式与数据匹配,如果不匹配的话,会导致 读取/赋值 错误
这也就解释了为什么要确保 输出/输入 格式与数据匹配,如果不匹配的话,会导致 读取/赋值 错误