文章目录

  • 0. 前言
  • 1. 为什么使用文件?
  • 2. 什么是文件
    • 2.1 程序文件
    • 2.2 数据文件
    • 2.3 文件名
  • 3. 文件的打开和关闭
    • 3.1 文件信息区
    • 3.2 文件指针
    • 3.3 文件的打开和关闭
      • 3.3.1 文件的打开
      • 3.3.2 文件的关闭
      • 3.3.3 文件打开与关闭的实例
      • 3.3.4 文件名的相对路径和绝对路径
  • 4. 文件的顺序读写
    • 4.1 函数总览
    • 4.2 fputc
    • 4.3 fgetc
    • 4.4 fputs
    • 4.5 fgets(精讲)
    • 4.6 fprintf
    • 4.7 fscanf
    • 4.8 fwrite
    • 4.9 fread
    • 4.10 补充知识(重要)
    • 4.11 杂例(选读)
  • 5. 对比一组函数
  • 6. 文件的随机读写
    • 6.1 fseek
    • 6.2 ftell/rewind
  • 7. 文本文件和二进制文件
  • 8. 文件读取结束的判定
    • 8.1 被错用的feof
  • 9. 文件缓冲区
  • 10. 结语

如果无聊的话,就来逛逛 我的博客栈 吧!

0. 前言

好久不见,我是anduin。这次为大家带来的是C语言的文件操作。C语言中,文件操作也是很重要的一部分。它可以让数据持久化,让数据有效的保存。特别是对于一些小项目:学生管理系统、通讯录等提供了莫大的帮助,因为这样就可以让数据持久化,不必每次打开程序重新录入信息。而本篇文章就是对文件操作做了一个较为详细的讲解。话不多说,我们这就开始讲解!

1. 为什么使用文件?

假设我们使用C语言实现了一个通讯录,当通讯录运行起来的时候,可以给通讯录中增加、删除数据,此时数据是存放在内存中,当程序退出的时候,通讯录中的数据自然就不存在了,等下次运行通讯录程序的时候,数据又得重新录入,如果使用这样的通讯录就很难受。我们在想既然是通讯录就应该把信息记录下来,只有我们自己选择删除数据的时候,数据才不复存在。
这就涉及到了数据持久化的问题,我们一般数据持久化的方法有,把数据存放在磁盘文件、存放到数据库等方式。
使用文件我们可以将数据直接存放在电脑的硬盘上,做到了数据的持久化。

2. 什么是文件

磁盘上的文件是文件。
但是在程序设计中,我们一般谈的文件有两种:程序文件、数据文件(从文件功能的角度来分类的)。

2.1 程序文件

包括源程序文件(后缀为.c),目标文件(windows环境后缀为.obj),可执行程序(windows环境后缀为.exe)。

2.2 数据文件

文件的内容不一定是程序,而是程序运行时读写的数据,比如程序运行需要从中读取数据的文件,或者输出内容的文件。
例如一个test.exe文件,想要从内存中将数据写入test.txt文件。同样的我也可以将test.txt文件读取到内存中。这个test.txt文件就叫做数据文件。

本章讨论的是数据文件。

在以前各章所处理数据的输入输出都是以终端为对象的,即从终端的键盘输入数据,运行结果显示到显示器上。

其实有时候我们会把信息输出到磁盘上,当需要的时候再从磁盘上把数据读取到内存中使用,这里处理的就是磁盘上文件。

2.3 文件名

一个文件要有一个唯一的文件标识,以便用户识别和引用。

文件名包含3部分:文件路径+文件名主干+文件后缀

例如: c:\code\test.txt

  • 文件路径:c:\code\
  • 文件主干:test
  • 文件后缀:.txt

为了方便起见,文件标识常被称为文件名

3. 文件的打开和关闭

在生活中,我们想要往一个瓶子里灌水,那么我们先要打开瓶子。往里灌水或往外取水,使用完毕时候关闭瓶子。

对于文件也是这样,我们必须先打开文件,然后读文件(从文件中读取数据到内存中,相当于往里灌水)或写文件(将内存中数据写入文件中,相当于往外取水)。最后关闭文件。

3.1 文件信息区

每个被使用的文件都在内存中开辟了一个相应的文件信息区,用来存放文件的相关信息(如文件的名字,文件状态及文件当前的位置等)。文件信息区和文件之间建立了一个映射的关系,文件信息区的文件用来描述这个文件的基本信息。这些信息是保存在一个结构体变量中的。

该结构体类型是由系统声明的,取名FILE

例如,VS2013编译环境提供的stdio.h 头文件中有以下的文件类型声明:

// 描述文件的相关信息struct _iobuf {    char *_ptr;    int _cnt;    char *_base;    int _flag;    int _file;    int _charbuf;    int _bufsiz;    char *_tmpfname;};typedef struct _iobuf FILE;// 重命名

不同的C编译器的FILE类型包含的内容不完全相同,但是大同小异。

每当打开一个文件的时候,系统会根据文件的情况自动创建一个FILE结构的变量,并填充其中的信息,使用者不必关心细节。

而当我们真正想要操作文件的时候该如何操作?

3.2 文件指针

缓冲文件系统中,关键的概念是“文件类型指针”,简称“文件指针”。

想要操作一个文件,我们可以通过文件信息区的起始地址,通过地址找到文件信息区,来联系文件进行操作。

一般都是通过一个FILE的指针来维护这个FILE结构的变量,这样使用起来更加方便,比如:

FILE* pf;//文件指针变量

定义pf是一个指向FILE类型数据的指针变量。可以使pf指向某个文件的文件信息区(是一个结构体变量)。

文件指针可以找到文件对应的文件信息区,通过对应的文件信息区,来访问操作对应文件。

通俗的讲,通过文件指针变量能够找到它关联的文件。

3.3 文件的打开和关闭

文件在读写之前应该先打开文件,在使用结束之后应该关闭文件。

在编写程序的时候,在打开文件的同时,都会返回一个FILE*的指针变量指向该文件,也相当于建立了指针和文件的关系。

ANSIC 规定使用fopen函数来打开文件,fclose来关闭文件。

3.3.1 文件的打开

文件的打开对应的是fopen这个函数:

FILE * fopen ( const char * filename, const char * mode );
  • filename:文件名。
  • mode:文件的打开方式。

fopen打开文件成功返回文件信息区的起始地址,类型为FILE*;打开文件失败返回NULL空指针。

下表是mode参数通用的文件打开方式:

文件使用方式含义如果指定文件不存在
“r”(只读)为了输入数据,打开一个已经存在的文本文件出错
“w”(只写)为了输出数据,打开一个文本文件建立一个新的文件
“a”(追加)向文本文件尾添加数据建立一个新的文件
“rb”(只读)为了输出数据,打开一个二进制文件出错
“wb”(只写)为了输出数据,打开一个二进制文件建立一个新的文件
“ab”(追加)向一个二进制文件尾添加数据出错
“r+”(读写)为了读和写,打开一个文本文件出错
“w+”(读写)为了读和写,建立一个新的文件建立一个新的文件
“a+”(读写)打开一个文件,在文件尾进行读写建立一个新的文件
“rb+”(读写)为了读和写打开一个二进制文件出错
“wb+”(读写)为了读和写,新建一个新的二进制文件建立一个新的文件
“ab+”(读写)打开一个二进制文件,在文件尾进行读和写建立一个新的文件

注:

  • 打开方式一定是以"方式"的形式,否则会报错。
  • 当重新运行程序对文件进行写入时,上次的文件中的数据会被清空。

3.3.2 文件的关闭

文件的关闭对应的是fclose这个函数:

int fclose ( FILE * stream );
  • stream:文件对应的文件指针

当关闭之后文件指针并不会置为空指针,为了防止误操作,需要将文件指针置为空指针NULL

这里可以联想一下之前我们使用的malloc和free函数,需要手动置空。

3.3.3 文件打开与关闭的实例

“r”:

int main(){FILE* pf = fopen("test.txt", "r");// =>没有这个文件,r形式打开必须得有这个文件if (pf == NULL){perror("fopen()");// 会报错return 1;}// 读文件// 关闭文件fclose(pf);pf = NULL;return 0;}

运行结果:

“w”:

int main(){// 打开文件FILE* pf = fopen("test.txt", "w");// w形式打开,若不存在这个文件,会在目录下自动创建该文件// 判断是否打开成功    if (NULL == pf){perror("fopen");return 1;}// 写文件// 关闭文件fclose(pf);pf = NULL;return 0;}

分析:

当以"w"形式打开文件时,若当前工程的目录中不存在该文件,会在该目录中自动创建一个名为text.txt的文件。

运行结果:

3.3.4 文件名的相对路径和绝对路径

在文件名那块,我们知道文件名还包含着路径。但是我们上面的实例缺直接使用了文件名主干 + 文件后缀的形式,这样对吗?

这样是对的,我们并不是漏写或少些,而是使用了相对路径。

相对路径:是从当前路径开始的路径。那么我们输入的test.txt就意味着直接在本工程目录下的文件。

同样的我们还可以使用绝对路径,比如在c:\code\test.txt路径下以"w"形式打开一个文件:

int main(){// 打开文件// 绝对路径FILE* pf = fopen("c:\\code\\test.txt", "w");    // \可能会将路径中字符变为转义字符,所以要写成\\的形式,让它为一个\if (NULL == pf){perror("fopen");return 1;}// 写文件// 关闭文件fclose(pf);pf = NULL;return 0;}

那么程序运行时,若无此文件,就会在该路径的目录下,创建此文件:

4. 文件的顺序读写

顺序读写,即按照文件中信息的顺序,逐次读写。

但是说到读写,它到底是什么意思?

之前我们只了解printfscanf函数,scanf是从外部设备(键盘),输入(读取)数据到内存中,为读。printf是从内存中,输出数据到外部设备(屏幕)上,为写。

而文件的读,是从文件中读取(输入)信息,到内存中;文件的写,是由内存向文件写入(输出)数据。

4.1 函数总览

功能函数名适用于
字符输入函数fgetc所有输入流
字符输入函数fputc所有输出流
文本行输入函数fgets所有输入流
文本行输出函数fputs所有输出流
格式化输入函数fcanf所有输入流
格式化输出函数fprintf所有输出流
二进制输入fread文件
二进制输出fwirte文件

4.2 fputc

向文件中写入一个字符:

int fputc ( int character, FILE * stream );
  • character:写入的字符
  • stream:相关文件的文件流(即字符指针)。

返回值为字符的ascii码值,所以类型为int。

接下来我们使用循环来向文件中写入26个英文字母:

int main(){// 打开文件FILE* pf = fopen("test.txt", "w");// 写if (NULL == pf){perror("fopen");return 1;}// 向文件中写入26个英文字母for (int i = 0; i < 26; i++){fputc('a' + i, pf);// 通过循环得到所有的小写字母字符}// 关闭文件fclose(pf);pf = NULL;return 0;}

运行结果:

4.3 fgetc

向文件中读取一个字符:

int fgetc ( FILE * stream );
  • stream:相关文件的文件流(即字符指针)。

返回值为字符的ascii码值,所以类型为int。

我们上次已经将26个英文字母写入了文件中,那么我们再将它读出来:

int main(){// 打开文件FILE* pf = fopen("test.txt", "r");// 读if (NULL == pf){perror("fopen");return 1;}// 向文件中读取26个英文字母for (int i = 0; i < 26; i++){int ch = fgetc(pf);// 将读到的字符的ascii码值放到ch中printf("%c ", ch);}// 关闭文件fclose(pf);pf = NULL;return 0;}

运行结果:

但是如果我们不知道文件中到底有多少个字母,我们该如何读取?

其实在函数介绍的返回值部分,还有一点需要补充fgetc读取失败返回EOF

所以,纠正一下:fgetc读取成功返回字符的ascii码值,读取失败返回EOF。

所以我们可以改写成这样的形式:

int main(){// 打开文件FILE* pf = fopen("test.txt", "r");// 读if (NULL == pf){perror("fopen");return 1;}int ch = 0;while ((ch = fgetc(pf)) != EOF)// fgetc返回值不等于EOF则继续读取{printf("%c ", ch);}// 关闭文件fclose(pf);pf = NULL;return 0;}

4.4 fputs

从内存中写入数据到文件中:

int fputs ( const char * str, FILE * stream );
  • str:写入文件的字符串。
  • stream:相关文件的文件流(即字符指针)。

fputs是按照顺序写入的,只关注写入的内容,不考虑换行。

将字符串写入文件中:

// 按照顺序写入文本行int main(){// 打开文件FILE* pf = fopen("test.txt", "w");// 写if (NULL == pf){perror("fopen");return 1;}// 写文件,按行写入fputs("hello\n", pf);// 手动写入换行fputs("world\n", pf);// 手动写入换行// 关闭文件fclose(pf);pf = NULL;return 0;}

运行结果:

4.5 fgets(精讲)

向文件中按行读取数据到内存中:

char * fgets ( char * str, int num, FILE * stream );
  • str:存放读取数据的位置(读取的数据会被存入该空间中)。
  • num:读取字符的最大数。
  • stream:相关文件的文件流(即字符指针)。

那么fgets函数有没有什么细节?使用的时候我们需要注意什么?我们接下来一一观察:

(注:当前test.txt文件中依然存放着fputs写入的数据)

int main(){// 打开文件FILE* pf = fopen("test.txt", "r");// 读if (NULL == pf){perror("fopen");return 1;}// 读文件,按行读取char arr[20] = "##########";fgets(arr, 3, pf);// 关闭文件fclose(pf);pf = NULL;return 0;}

当我num = 3时,只读取了2个字符。这说明当读取的最大字符数小于文本行数据的总长度时,fgets实际上只会读取num - 1个数据,还有一个为填充的’\0’!!!


当我们本行数据没有读取完成,下一次读取,仍然会读取这一行,直到本行读取完毕才会读取下一行:

运行结果:


而当我们读取的最大字符数大于文本行数据的总长度时(例如我要读取10个字符,但是我的文本行只有hello),会发生什么?

我们发现当最大字符数大于文本行数据的总长度时,本行数据被读取完毕,停止读取,在字符串末尾加上’\0’,且不会读取下一行


那么读取两行文本行怎么做?

int main(){// 打开文件FILE* pf = fopen("test.txt", "r");// 读if (NULL == pf){perror("fopen");return 1;}// 读文件,按行读取char arr[20] = "##########";fgets(arr, 7, pf);printf("%s", arr);fgets(arr, 7, pf);printf("%s", arr);// 关闭文件fclose(pf);pf = NULL;return 0;}

运行结果:


注意点:若fgets读取数据到字符指针中一定要初始化!!!

例如:

char* p;// 一定要初始化,否则没有空间来存放数据

我们可以使用动态内存为其分配空间:

char* p = (char*)malloc(sizeof(int) * 20);

使用:

#include int main(){// 打开文件FILE* pf = fopen("test.txt", "r");// 读if (NULL == pf){perror("fopen");return 1;}// 读文件,按行读取char* p = (char*)malloc(sizeof(int) * 20);// 动态分配内存fgets(p, 7, pf);printf("%s", p);fgets(p, 7, pf);printf("%s", p);// 关闭文件fclose(pf);pf = NULL;return 0;}

运行结果:


总结一下fgets在使用时的注意点:

  1. 当最大字符数count小于文本行数据的总长度时,fgets实际上只会读取num - 1个数据,还有一个为填充的’\0’。
  2. 当最大字符数count大于文本行数据的总长度时,本行数据被读取完毕,停止读取,在字符串末尾加上’\0’,且不会读取下一行。
  3. fgets在未完全读取一行元素后,下一次读取会先读取未读取完的这一行。本行读取完毕开始读取下一行。
  4. str参数为指针时,需要初始化。
  5. fgets读取失败时,会返回NULL空指针。

4.6 fprintf

前面的fgec、fgets分别对应着字符和字符串的操作,但是格式化数据如何写入文件?这时就要说起fprintf函数。

把格式化数据从内存写入文件中:

int fprintf ( FILE * stream, const char * format, ... );
  • stream:相关文件的文件流(即字符指针)。
  • format:格式化字符串

其实对比printf我们就多了一个stream参数,只需要加上一个文件指针即可。

比如我们向文件中写入一个结构体:

struct S{char name[20];int age;float score;};int main(){struct S s = { "灰太狼", 10086, 114.514f };// 把s中的数据写到文件中FILE* pf = fopen("test.txt", "w");// 写if (NULL == pf){perror("fopen");return 1;}// 写文件fprintf(pf, "%s %d %f", s.name, s.age, s.score);fclose(pf);    pf = NULL;return 0;}

运行结果:

4.7 fscanf

把格式化数据从文件读取到内存中:

int fscanf ( FILE * stream, const char * format, ... );
  • stream:相关文件的文件流(即字符指针)。
  • format:格式化字符串。

fscanf对比scanf也是多了一个文件指针。

例如将文件中的内容读取到内存中:

(注:当前test.txt文件中依然存放着fprintf写入的数据)

struct S{char name[20];int age;float score;};int main(){struct S s = { "灰太狼", 10086, 114.514f };// 把s中的数据写到文件中FILE* pf = fopen("test.txt", "r");// 读if (NULL == pf){perror("fopen");return 1;}// 读文件fscanf(pf, "%s %d %f", s.name, &(s.age), &(s.score));printf("%s %d %f\n", s.name, s.age, s.score);fclose(pf);pf = NULL;return 0;}s

运行结果:

4.8 fwrite

以二进制形式向文件中写入数据:

size_t fwrite ( const void * ptr, size_t size, size_t count, FILE * stream );
  • ptr:被写入元素的起始地址。
  • size:写入元素的大小。
  • count:写入元素的个数。
  • stream:相关文件的文件流(即字符指针)。

通俗讲就是,从ptr开始的数据,一次写入countsize大小的元素到文件中。

例如我们以二进制形式向文件中写入一个结构体的数据:

struct S{char name[20];int age;float score;};int main(){struct S s = { "灰太狼", 10086, 114.514f };FILE* pf = fopen("test.txt", "wb");// 二进制只写if (NULL == pf){perror("fopen");return 1;}// 从s的地址处,以二进制形式向文件中写入一个大小为s的元素fwrite(&s, sizeof(s), 1, pf);fclose(pf);pf = NULL;return 0;}

运行结果:

里面有些数据是看不懂的,因为我们是以二进制写入的。

4.9 fread

以二进制形式从文件中读取数据到内存中:

size_t fread ( void * ptr, size_t size, size_t count, FILE * stream );
  • ptr:存放读取数据的空间的起始地址。
  • size:读取数据的大小。
  • count:读取数据的个数。
  • stream:相关文件的文件流(即字符指针)

fread的返回值:读取正常返回size的大小;读取失败返回实际读到的完整元素的个数(小与size)。

通俗讲就是,从文件流中读取count个大小为size的数据,到ptr指向的空间中。

(注:当前test.txt文件中依然存放着fwirte写入的数据)

struct S{char name[20];int age;float score;};int main(){struct S s = { "灰太狼", 10086, 114.514f };FILE* pf = fopen("test.txt", "rb");// 二进制只读if (NULL == pf){perror("fopen");return 1;}fread(&s, sizeof(s), 1, pf);printf("%s %d %f\n", s.name, s.age, s.score);fclose(pf);pf = NULL;return 0;}

运行结果:

虽然我们肉眼看不懂二进制形式的数据,但是使用程序,以二进制形式读取这些数据到屏幕上,还是可以看懂的。

4.10 补充知识(重要)

在函数总览的表中,我们看到了上述函数适用于什么流。我们当前只看到上述函数适用于文件,但是它们是否能适用于其他流?

我们在操作文件时,需要打开或关闭文件。但是我们平常使用printf、scanf函数时,并没有打开键盘和屏幕。这是因为我们的屏幕和键盘是默认打开的,而文件则不是默认打开的。

键盘、屏幕、文件均为外部设备。

对任何一个C程序,只要运行起来,就默认打开3个流:

  • stdin:标准输入流 – 键盘
  • stdout:标准输出流 – 屏幕
  • stderr:标准错误流 – 屏幕

而上述流的类型都是**FILE***的。

那么,既然我们的标准输入流stdinFILE*,那么这是否说明,也能被上述函数使用?

举个例子,我们的fgetcfputc函数是这样的形式:

int fgetc ( FILE * stream );

int fputc ( int character, FILE * stream );

它们的参数有FILE*类型,那么我们将stdin传入fgetc,将stdout传入fputc

int main(){    int ch = fgetc(stdin);    fputc(ch, stdout);        return 0;}

运行结果:

那么通过这个样例,我们也可以推导出:

scanf(...) <==> fscanf(stdin, ...);-----------------------------------printf(...) <==> fprintf(stdout, ...);

同样的我们也可以举出样例:

int main(){int arr[] = { 1,2,3,4,5 };int sz = sizeof(arr) / sizeof(arr[0]);// 输入    for (int i = 0; i < sz; i++){fscanf(stdin, "%d", &arr[i]);}    // 输出for (int i = 0; i < sz; i++){fprintf(stdout, "%d ", arr[i]);}return 0;}

运行结果:

4.11 杂例(选读)

这是博主探究fgetsfputs时,偶然发现的一个问题。就博主看来知识点与本篇博客有些不符,但是秉持让博客内容更加丰富的原则,加上这个问题也是博主踩过的一个坑,加上探究过程也是一波三折,于是将其作为选读,记录在本博客中,和大家一起分享!

当我在工程的目录下,手动创建一个test.txt,然后往里面写入中文:圣光背叛了我!

当我在程序中,使用只读的方式打印的是乱码:

int main(){FILE* pf = fopen("test.txt", "r");if (NULL == pf){perror("fopen");return 1;}char arr[20] = "###########";fgets(arr, 20, pf);printf("%s", arr);fclose(pf);pf = NULL;return 0;}

运行结果:

但是如果我先用fputs将这句话重新写入文件,再以fgets的方式取出时,就是正常的:

int main(){FILE* pf = fopen("test.txt", "r");if (NULL == pf){perror("fopen");return 1;}char arr[20] = "###########";//fputs("圣光背叛了我!\n", pf);// 以fputs写入fgets(arr, 20, pf);printf("%s", arr);fclose(pf);pf = NULL;return 0;}

运行结果:

这是为什么?

这里其实是一个字符编码集的问题。我们平常的设备默认的字符编码集是UTF - 8的。但是我们从程序里面读取数据的时候,放到运行的黑框中,它的默认显示的字符集是GB2312的。然后就导致它们两个的字符集不一样了。写入的字符集和打印输出的字符集不同,就显示就乱码了。

而我们用fputs写入用fgets读取是正常的是因为它们的字符集是一样的,不存在写入的软件和读取的软件的程序字符集不一样的情况。

然后博主就开始了尝试…

我是直接更改本文件的字符集的,在vs2022中大体步骤为:

编辑区找到工具 -> 自定义 -> 命令 -> 菜单栏下拉找到文件 -> 添加命令 -> 类别中找到文件 -> 命令中下拉找到高级保存选项 -> 点击确定 -> 关闭自定义任务栏

编辑区找到文件 -> 经过以上操作后会出现高级保存选项 -> 再打开将编码从简体中文(GB2312)改为UTF - 8

注:编辑区就是vs2022中最上面一条栏,有文件,编辑等…

由于这是一篇文件相关的操作,所以只列出最后一步,过程就不一一截图展示操作了,有兴趣的可以上网查找操作。

然后查看本电脑字符集是否为UTF - 8,不是的需要重启更改一下。

更改完毕就可以重新开始测试了,这时将文件内容手动输入为圣光背叛了我,无需使用fputs进行写入,直接使用fgets就可以读取到文件的内容。因为这时,电脑字符集和程序显示的字符集相同了。

int main(){FILE* pf = fopen("test.txt", "r");if (NULL == pf){perror("fopen");return 1;}char arr[20] = "###########";fgets(arr, 20, pf);printf("%s", arr);fclose(pf);pf = NULL;return 0;}

运行结果:

注:这里博主电脑的默认字符集其实不是UTF - 8。在测试时,由于字符集不同,所以重启后测试时,可能是核心文件找不到的原因,导致一直跳弹窗,很令人苦恼,而且博主还测试错了好几遍,当时简直快奔溃了…如果你电脑的默认字符集不是UTF - 8的话,谨慎尝试,很烦,真的很烦!!!这也是为什么我将这个杂例设定为选读的原因…至于这个知识点,还是了解就好~

5. 对比一组函数

scanf/fscanf/sscanf
printf/fprintf/sprintf

上面这些函数,我们已经了解过的有:scanf/printf、fscanf/fprintf。我们可以大致对它们进行归纳。

1) 适用于标准输入/输出流的格式化的输入/输出语句:

scanf:按照一定的格式从键盘输入数据。

printf:按照一定的格式把数据打印(输出)到屏幕上。

2) 适用于所有的输入/输出流的格式化输入/输出语句:

fscanf:按照一定的格式从输入流(文件/stdin)输入数据。

fprintf:按照一定的格式向输出流(文件/stdout)输出数据。

而剩下的sscanf/sprintf就有些特殊,让我们先了解一下它们:

sscanf:从字符串中读取格式化数据。

int sscanf ( const char * s, const char * format, ...);
  • s:被读取数据的字符串的地址。
  • format:格式化字符串。

sprintf:把格式化的数据写入字符串。

int sprintf ( char * str, const char * format, ... );
  • str:被写入数据的字符串的地址。
  • format:格式化字符串。

光看函数形态还是比较模糊,还是得用样例来理解它们:

struct S{char name[20];int age;float score;};int main(){char buf[100] = { 0 };// 被写入数据的字符串struct S s = { "灰太狼", 10086, 114.514f };// 能否把这个结构体的数据,转化成字符串// "灰太狼 10086 114.514"sprintf(buf, "%s %d %.3f", s.name, s.age, s.score);// 将格式化数据写入字符串// 以字符串形式打印printf("%s\n", buf);// 被写入格式化数据的字符串// 能否将buf中的字符串还原成一个结构体数据?struct S tmp = { 0 };// 还原的结构体sscanf(buf, "%s %d %f", tmp.name, &(tmp.age), &(tmp.score));// 从字符串中读取数据到结构体tmp中// 以结构体形式打印printf("%s %d %.3f", tmp.name, tmp.age, tmp.score);return 0;}

运行结果:

总结一下:

sscanf:从字符串中按照一定的格式读取出格式化的数据。

sprintf:把格式化的数据按照一定的格式转化成字符串。

6. 文件的随机读写

上面我们学习了文件的顺序读写,但是我们能不能实现随机读写?

这当然是可以的,接下来介绍几个关于文件随机读写的函数。

6.1 fseek

根据文件指针的位置和偏移量来定位文件指针:

int fseek ( FILE * stream, long int offset, int origin );
  • stream:文件指针。
  • offset:文件指针的偏移量。
  • origin:文件开始读写的位置,有SEEK_SET(文件指针起始位置)、SEEK_CUR(文件指针当前位置)、SEEK_END(文件指针末尾位置)。

如果还不清晰,可以用下图来理解:

接下来,我们在用一个样例再次加深理解:

int main(){// 当前文件内容:abcdefFILE* pf = fopen("test.txt", "r");if (pf == NULL){perror("fopen()");return 1;}// 读文件fseek(pf, 3, SEEK_SET);// 从起始位置偏移三个位置读取int ch = fgetc(pf);// dprintf("%c\n", ch);fseek(pf, -3, SEEK_END);// 从结尾位置向前偏移3个位置(相当于向后-3个位置)读取ch = fgetc(pf);//d printf("%c\n", ch);// 经fgetc后,d被读取,文件指针偏移,当前文件指针指向efseek(pf, 1, SEEK_CUR);// 从当前位置向后偏移1个位置读取ch = fgetc(pf);// fprintf("%c\n", ch);// 关闭文件fclose(pf);pf = NULL;return 0;}

运行结果:

6.2 ftell/rewind

ftell返回文件指针相对于起始位置的偏移量:

long int ftell ( FILE * stream );
  • stream:文件指针。

rewind让文件指针的位置回到文件的起始位置:

void rewind ( FILE * stream );

下面样例中,为了测试ftell,当我文件指针偏离起始位置很远时,再使用rewind回到起始位置,并用ftell测试功能,所以我将这两个函数放在一块讲解:

int main(){// 当前文件内容:abcdefFILE* pf = fopen("test.txt", "r");if (pf == NULL){perror("fopen()");return 1;}// 读文件int ch = fgetc(pf);// 读取完指向bch = fgetc(pf);// 读取完指向cch = fgetc(pf);// 读取完指向dint pos = ftell(pf);// d相对于起始位置偏移量为3printf("%d\n", pos);rewind(pf);// 回到起始位置aprintf("%d\n", ftell(pf));// a相对于起始位置偏移量为0// 关闭文件fclose(pf);pf = NULL;return 0;}

运行结果:

7. 文本文件和二进制文件

有些文件,我们打开可以看懂,但是有些文件就看不懂,就像这样:

看得懂:

看不懂:

根据数据的组织形式,我们将文件分成两类:

  • 能看懂的称为文本文件
  • 看不懂的称为二进制文件

数据在内存中以二进制的形式存储,如果不加转换的输出到外存,就是二进制文件
如果要求在外存上以ASCII码的形式存储,则需要在存储前转换。以ASCII字符的形式存储的文件就是文本文件

一个数据在内存中是怎么存储的呢?

字符一律以ASCII形式存储,数值型数据既可以用ASCII形式存储,也可以使用二进制形式存储。
如有整数10000,如果以ASCII码的形式输出到磁盘,则磁盘中占用5个字节(每个字符一个字节),而二进制形式输出,则在磁盘上只占4个字节。

举个例子,如果10000以文本文件形式存储,那么就是将其的每一位看做字符,分别转化为ASCII值,以ASCII码值存入内存中。那么就是将49、48、48、48、48存入内存中。但是以二进制形式存储,就只需要把10000转化为二进制存入内存中,仅占用四个字节。

让我们用一个例子加深理解:

#include int main(){    int a = 10000;    FILE* pf = fopen("test.txt", "wb");    fwrite(&a, 4, 1, pf);//二进制的形式写到文件中    fclose(pf);    pf = NULL;    return 0;}

我以二进制形式写入10000到文件中,而这时如果想要查看以二进制写入文件这个数据要占多少空间是看不出来的,因为我们无法看懂二进制文件:

于是我们使用另一种方式:

首先,将test.txt添加进源文件:

然后右击,选择打开方式,以二进制编辑器方式打开:

随后,我们就可以看到10000在以二进制写入内存后,是如何存储的:

如果10000写成16进制的形式的话为:00 00 27 10,如果以小端字节序存储放入内存中(低字节内容放到低地址处,高字节内容放到高地址处),就为:10 27 00 00。

这里可以做出一个区分:

这里所说的文本文件,就是将数据以ASCII码值形式写进文件的意思,二进制文件就是将数据以二进制形式存储。而我们通常说的文本文件,就是以.txt为后缀的文件,千万不要混淆了。

8. 文件读取结束的判定

8.1 被错用的feof

feof的作用在于文件读取结束的时候,判断是读取失败结束,还是遇到文件尾结束

int feof ( FILE * stream );

feof返回的值为非0,说明是遇到了文件末尾结束。否则说明文件读取失败结束。

要知道一个文件在读取的过程中可能遇到io错误等,导致文件读取失败。或者读取到文件末尾,读取结束,这时就可以使用feof判断文件到底是读取结束的原因。

那么我们在日常写代码时,如何判断文件是否读取结束?

  1. 文本文件的读取结束判断:
    1. fgetc如果读取正常,会返回读取到的字符的ASCII值,如果读取失败,会返回EOF。
    2. fgets如果读取正常,返回的是存放读取到的数据的地址,如果读取失败,返回的是空指针。
    3. fscanf如果读取正常,返回的是格式化字符串中指定的数据的个数,如果读取失败,返回的数字小于格式化字符串指定数据的个数
  2. 二进制文件的读取结束判断:
    1. fread判断返回值是否小于实际要读的个数。

文本文件样例:

#include #include int main(){int c; // 注意:int,非char,要求处理EOFFILE* pf = fopen("test.txt", "r");if (pf == NULL) {perror("File opening failed");return 1;}//fgetc 当读取失败的时候或者遇到文件结束的时候,都会返回EOFwhile ((c = fgetc(pf)) != EOF){putchar(c);}//判断是什么原因结束的if (ferror(pf))// ferror返回值为真,说明遇到了I/O型错误puts("I/O error when reading");else if (feof(pf))// feof为真,成功读取到文件尾结束puts("End of file reached successfully");fclose(pf);pf = NULL;return 0;}

二进制文件样例:

#include enum { SIZE = 5 };int main(void){double a[SIZE] = { 1.,2.,3.,4.,5. };FILE* fp = fopen("test.bin", "wb"); // 必须用二进制模式fwrite(a, sizeof * a, SIZE, fp); // 写 double 的数组fclose(fp);double b[SIZE];fp = fopen("test.bin", "rb");size_t ret_code = fread(b, sizeof * b, SIZE, fp); // 读 double 的数组if (ret_code == SIZE)// 正常读取{puts("Array read successfully, contents: ");for (int n = 0; n < SIZE; ++n) printf("%f ", b[n]);putchar('\n');}else// 读取不正常{ if (feof(fp))printf("Error reading test.bin: unexpected end of file\n");else if (ferror(fp)) perror("Error reading test.bin");}fclose(fp);}

9. 文件缓冲区

可否记得,我们在将文件指针时,提到的缓冲文件系统?那么它该如何理解?

ANSIC 标准采用“缓冲文件系统”处理的数据文件的,所谓缓冲文件系统是指系统自动地在内存中为程序中每一个正在使用的文件开辟一块“文件缓冲区”。从内存向磁盘输出数据会先送到内存中的缓冲区,装满缓冲区后才一起送到磁盘上。如果从磁盘向计算机读入数据,则从磁盘文件中读取数据输入到内存缓冲区(充满缓冲区),然后再从缓冲区逐个地将数据送到程序数据区(程序变量等)。缓冲区的大小根据C编译系统决定的。

那么这句话这么生涩,该如何理解呢?我们通过一个样例来解读:

#include int main(){int a = 10000;FILE* pf = fopen("test.txt", "wb");fwrite(&a, 4, 1, pf);//二进制的形式写到文件中fclose(pf);pf = NULL;return 0;}

我们将10000从内存写入文件中,需要经过什么步骤?它是直接写入的吗?

其实10000就相当于我们的内存(程序数据区),而test.txt就相当于我们的硬盘。在内存中有一块区域为输出缓冲区,当我们需要写入信息时,数据就会被放入输出缓冲区中,当输出缓冲区达到装满时,就会把数据输送到硬盘。同样的,当我们需要将硬盘上的数据输入到内存中时,会依托内存中的输入缓冲区,将数据输送到内存(程序数据区)中。

大概流程为这样:

由于将数据写入硬盘时,需要依托操作系统将数据写进硬盘。为了不让操作系统经常被打扰,于是就有了输出缓冲区,当输出缓冲区装满时,再调用操作系统,将数据一次性写入硬盘,这样效率也能提高。

但是这并不代表,只有装满输出缓冲区才能将数据写入硬盘。我们可以通过\n(行缓冲)fflush(stdout)(强制刷新)关闭文件等方法刷新缓冲区。

接下来,我们通过一个例子,感受缓冲区的存在:

int main(){FILE* pf = fopen("test.txt", "w");fputs("abcdef", pf);//先将代码放在输出缓冲区printf("睡眠10秒-已经写数据了,打开test.txt文件,发现文件没有内容\n");Sleep(10000);printf("刷新缓冲区\n");fflush(pf);//刷新缓冲区时,才将输出缓冲区的数据写到文件(磁盘)printf("再睡眠10秒-此时,再次打开test.txt文件,文件有内容了\n");Sleep(10000);fclose(pf);//注:fclose在关闭文件的时候,也会刷新缓冲区pf = NULL;return 0;}

运行结果:

输出缓冲区未刷新:

输出缓冲区刷新:

运行结果:

这里可以得出一个结论

因为有缓冲区的存在,C语言在操作文件的时候,需要刷新缓冲区或者在文件操作结束的时候关闭文件。

如果不做,可能会在不恰当的操作下导致数据的丢失,导致读写文件的问题。

10. 结语

到这里,本篇博客就到此结束了。相信大家对C语言文件操作也有了一定的了解。希望我的博客对您有帮助!

如果觉得anduin写的还不错的话,还请一键三连!如有错误,还请指正!

我是anduin,一名C语言初学者,我们下期见!