目录
[1 · 为什么使用文件](#1 · 为什么使用文件)
[2 · 什么是文件](#2 · 什么是文件)
[2 - 1 · 程序文件](#2 - 1 · 程序文件)
[2 - 2 · 数据文件](#2 - 2 · 数据文件)
[2 - 3 · 文件名](#2 - 3 · 文件名)
[3 · 二进制文件和文本文件](#3 · 二进制文件和文本文件)
[4 · 文件的打开和关闭](#4 · 文件的打开和关闭)
[4 - 1 · 流和标准流](#4 - 1 · 流和标准流)
[4 - 1 - 1 · 流](#4 - 1 - 1 · 流)
[4 - 1 - 2 · 标准流](#4 - 1 - 2 · 标准流)
[4 - 2 · 文件指针](#4 - 2 · 文件指针)
[4 - 3 · 文件的打开和关闭](#4 - 3 · 文件的打开和关闭)
[5 · 文件的顺序读写](#5 · 文件的顺序读写)
[5 - 1 · fputc](#5 - 1 · fputc)
[5 - 2 · fgetc](#5 - 2 · fgetc)
[5 - 3 · fputs](#5 - 3 · fputs)
[5 - 4 · fgets](#5 - 4 · fgets)
[5 - 5 · fprintf](#5 - 5 · fprintf)
[5 - 6 · fscanf](#5 - 6 · fscanf)
[5 - 7 · 小说明](#5 - 7 · 小说明)
[5 - 8 · fwrite](#5 - 8 · fwrite)
[5 - 9 · fread](#5 - 9 · fread)
[6 · 文件的随机读写](#6 · 文件的随机读写)
[6 - 1 · fseek](#6 - 1 · fseek)
[6 - 2 · ftell](#6 - 2 · ftell)
[6 - 3 · rewind](#6 - 3 · rewind)
[7 · 文件读取结束的判定](#7 · 文件读取结束的判定)
[7 - 1 · 被错误使用的 feof](#7 - 1 · 被错误使用的 feof)
[7 - 2 · 正确做法](#7 - 2 · 正确做法)
[7 - 3 · 判断结束的原因](#7 - 3 · 判断结束的原因)
[8 · 文件缓冲区](#8 · 文件缓冲区)
1 · 为什么使用文件
我们写的程序是存储在内存中的,随着程序退出,内存回收,数据也就丢失了。
所以为什么使用文件,是为了持久化保存数据
2 · 什么是文件
磁盘(硬盘)上的文件是文件。
但是在程序设计中,我们⼀般谈的文件有两种:程序文件、数据文件(从文件功能的角度来分类 )。
2 - 1 · 程序文件
程序文件包括源程序文件(后缀为.c),目标文件(windows环境后缀为.obj),可执行程序(windows环境后缀为.exe)。
2 - 2 · 数据文件
文件的内容不一定是程序,而是程序运行时读写的数据,比如程序运行需要从中读取数据的文件,或者输出内容的文件。
本篇介绍的就是数据文件
以前所处理数据的输入输出都是以终端为对象的,即从终端的键盘输⼊数据,运行结果显示到显示器上。
其实有时候我们会把信息输出到磁盘上,当需要的时候再从磁盘上把数据读取到内存中使用,这里处理的就是磁盘上文件。
2 - 3 · 文件名
一个文件要有一个唯一的文件标识,以便用户识别和引用。
文件名包含3部分:文件路径+文件名主干+文件后缀
例如: c:\code\test.txt
这里的 c:\code\ 是文件路径 test 是文件名主干 .txt 是文件后缀
为了方便起见,文件标识常被称为文件名。
3 · 二进制文件和文本文件
根据数据的组织形式,数据文件被称为文本文件或者二进制文件。
数据在内存中以⼆进制的形式存储,如果不加转换的输出到外存的文件中,就是二进制文件。
如果要求在外存上以ASCII码的形式存储,则需要在存储前转换。以ASCII字符的形式存储的文件就是文本文件。
⼀个数据在文件中是怎么存储的呢?
字符一律以ASCII形式存储,数值型数据既可以用ASCII形式存储,也可以使用二进制形式存储。
如有整数10000,如果以ASCII码的形式输出到磁盘,则磁盘中占用5个字节(每个字符⼀个字节),而⼆进制形式输出,则在磁盘上只占4个字节。

简单来说,二进制形式存储就是直接将数据以二进制形式原封不动存进去。
ASCII形式存储就是先转换 再存储,比如上面举例的 10000 ,就可以拆分成 1个字符1 和 4 个字符0,所以将对应的ASCII码值存入。
我们可以测试一下:
cpp
#include <stdio.h>
int main()
{
int a = 10000;
FILE* pf = fopen("test.txt", "wb");
fwrite(&a, 4, 1, pf);//二进制的形式写到文件中
fclose(pf);
pf = NULL;
return 0;
}
运行之后,我们会发现文件夹多了一个 test.txt 的文件,打开看看:

可以看到,我们直接双击,打开了也看不懂
下面我们用VS2022这样来打开:
先将 test.txt 添加进来

然后打开方式选择二进制编辑器

我们就能看到:

当然,文件里真正放的是二进制的形式,这里只是以十六进制的形式展示。
至于文本文件,我们是直接就能够看清楚,看得懂的。
4 · 文件的打开和关闭
我们喝水的步骤:打开瓶盖 -> 喝水 -> 关瓶盖
文件操作,就像我们喝水一样: 打开文件 -> 读/写文件 -> 关闭文件
在了解文件的打开和关闭之前,我们需要了解一些基本知识。
4 - 1 · 流和标准流
4 - 1 - 1 · 流
我们程序的数据需要输出到各种外部设备,也需要从外部设备获取数据,不同的外部设备的输入输出操作各不相同,为了方便程序员对各种设备进行方便的操作,我们抽象出了流的概念,可以把流想象成流淌着字符的河。
C程序针对文件、画面、键盘等的数据输入输出操作都是通过流操作的。
⼀般情况下,我们要想向流里写数据,或者从流中读取数据,都是要打开流,然后操作。
4 - 1 - 2 · 标准流
那为什么我们从键盘输⼊数据,向屏幕上输出数据,并没有打开流呢?
那是因为C语言程序在启动的时候,默认打开了3个流:
stdin - 标准输入流,在大多数的环境中从键盘输入,scanf函数就是从标准输入流中读取数据。
stdout - 标准输出流,在大多数的环境中输出至显示器界面,printf函数就是将信息输出到标准输出
流中。
stderr - 标准错误流,在大多数环境中输出到显示器界面。
这是默认打开了这三个流,我们使用scanf、printf等函数就可以直接进行输入输出操作的。
stdin、stdout、stderr 这三个流的类型是: FILE* ,通常称为文件指针。
C语言中,就是通过 FILE* 的文件指针来维护流的各种操作的。
4 - 2 · 文件指针
缓冲文件系统中,关键的概念是"文件类型指针",简称"文件指针"。
由于VS2022封装的太深了,看不到所以,我们去VS2013 编译环境提供的 stdio.h 头文件中能找到以下的文件类型申明:
cpp
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。这个结构体有一个文件指针来找到它。
每当打开⼀个文件的时候,系统会根据文件的情况自动创建⼀个FILE结构的变量,并填充其中的信
息
下面我们可以创建一个 FILE* 类型的指针变量:
cpp
FILE* pf1;//⽂件指针变量
可以使pf1指向某个文件的文件信息区, 通过该文件信息区中的信息就能够访问该文件。也就是说,通过文件指针变量能够间接找到与 它关联的文件。
方便理解,我们看一张图:

所以在进行文件操作的时候,要找到 指向打开文件的 文件信息区地址 的指针。
4 - 3 · 文件的打开和关闭
文件在读写之前应该先打开文件 ,在使用结束之后应该关闭文件。
我们可以使用 fopen 函数打开文件,原型如下:
cpp
FILE * fopen ( const char * filename, const char * mode );
其中,filename 是文件名,mode 是打开方式
下面都是文件的打开模式:
|-----------|----------------------|-----------|
| 文件使用方式 | 含义 | 如果指定文件不存在 |
| "r"(只读) | 为了输入数据,打开⼀个已经存在的文本文件 | 出错 |
| "w"(只写) | 为了输出数据,打开⼀个文本文件 | 建立⼀个新的文件 |
| "a"(追加) | 向文本文件尾添加数据 | 建立⼀个新的文件 |
| "rb"(只读) | 为了输入数据,打开⼀个二进制文件 | 出错 |
| "wb"(只写) | 为了输出数据,打开⼀个二进制文件 | 建立⼀个新的文件 |
| "ab"(追加) | 向⼀个二进制文件尾添加数据 | 建立⼀个新的文件 |
| "r+"(读写) | 为了读和写,打开⼀个文本文件 | 出错 |
| "w+"(读写) | 为了读和写,新建一个新的文件 | 建立⼀个新的文件 |
| "a+"(读写) | 打开⼀个文件,在文件尾进行读写 | 建立⼀个新的文件 |
| "rb+"(读写) | 为了读和写打开⼀个二进制文件 | 出错 |
| "wb+"(读写) | 为了读和写,新建⼀个新的二进制文件 | 建立⼀个新的文件 |
| "ab+"(读写) | 打开⼀个二进制文件,在文件尾进行读和写 | 建立⼀个新的文件 |
注意:双引号不能少,因为参数是 char* 类型的指针。
关于读和写:

读 是从文件里拿数据到程序里面 读也叫输入操作
写 是从程序写到文件当中 写也叫输出操作
注意:
用"w"的打开方式,会先将指定文件清空,再从第一个位置往后写数据。
用"a"的打开方式,是从文件末尾追加数据。
我们注意到,fopen 是有类型的 是有返回值的,fopen 只要成功打开,就会返回一个与文件信息区相关的地址
如果打开失败,就会返回空指针。
所以使用的时候我们可以用一个文件指针类型的变量接收并检验。
cpp
#include <stdio.h>
int main()
{
FILE* p = fopen("test.txt", "r");
if (p == NULL)
{
perror("fopen");
return 1;
}
return 0;
}
在使用结束后,我们就要关闭文件。
可以使用 fclose 关闭文件,原型如下:
cpp
int fclose ( FILE * stream );
传参需要传一个 FILE* 类型的指针,想要关闭哪个文件,就传指向那个文件信息区的指针。
fclose 是有返回值的,如果成功关闭,返回0,如果失败,返回EOF。
注意:fclose 不会把传过去的参数置为空指针,所以使用fclose后,我们要手动将传过去的参数置为空指针。
cpp
#include <stdio.h>
int main()
{
FILE* p = fopen("test.txt", "r");
if (p == NULL)
{
perror("fopen");
return 1;
}
fclose(p);
p = NULL;
return 0;
}
5 · 文件的顺序读写
顺序读写有以下一系列函数:
|---------|---------|-------|
| 函数名 | 功能 | 适用于 |
| fgetc | 字符输入函数 | 所有输入流 |
| fputc | 字符输出函数 | 所有输出流 |
| fgets | ⽂本行输入函数 | 所有输入流 |
| fputs | ⽂本行输出函数 | 所有输出流 |
| fscanf | 格式化输入函数 | 所有输入流 |
| fprintf | 格式化输出函数 | 所有输出流 |
| fread | 二进制输⼊ | 文件输入流 |
| fwrite | 二进制输出 | 文件输出流 |
上面的适用于所有输入流⼀般指适用于标准输入流和其他输入流(如文件输⼊流);所有输出流一
般指适用于标准输出流和其他输出流(如文件输出流)
其中:
fgetc 和 fputc 是针对字符的
fgets 和 fputs 是针对字符串的
fscanf 和 fprintf 是针对格式化数据的
fread 和 fwrite 是针对二进制的
下面我们一个个介绍:
5 - 1 · fputc
原型如下:
cpp
int fputc ( int character, FILE * stream );
fputc 需要传两个参数,第一个是整型类型的字符,第二个是FILE*的指针,返回类型是int 。
功能是把传参的字符写到 FILE*指针所指向的文件中。
这时候我们可能会有疑惑,既然第一个参数需要一个字符,为什么形参是int 类型呢?
传字符其实传的是ASCII码值,用int接收其实是没问题的,我们传参的时候注意不要漏掉单引号。
fputc 一次只能写一个字符,如果想要用fputc写多个字符,就要用到循环。循环中光标是会移动的 每写一个字符 光标就会移动到刚写到字符后面。
那么什么是光标呢:光标是计算机屏幕上的指示符,用于显示当前输入或操作的位置,帮助用户定位和操作内容。比如我们用浏览器搜索的时候

这个一闪一闪的就是光标。
如果fputc 运行成功,写的是什么字符,fputc就会返回什么。
如果写的时候发生错误,就会返回EOF。
那么我们可能又会有疑惑,为什么返回类型是 int 呢?
如果运行成功,返回的是字符的ASCII码值,如果运行失败,返回EOF,EOF本质是 -1,为了兼容返回EOF的情况,返回类型设置成了int。
5 - 2 · fgetc
原型如下:
cpp
int fgetc ( FILE * stream );
fgetc 需要传一个参数 FILE* 类型的文件指针。
与fputc对应 ,fputc 是一次写一个字符 ,fgetc 是一次读一个字符。
fgetc 有返回值,如果运行成功,返回读到的字符,如果运行错误或读的时候遇到了文件末尾,则会返回EOF。
每读一个字符,光标也会移动到刚读的字符后面,这时候再用fgetc就会读取下一个字符,以此类推。
5 - 3 · fputs
那么如果想要一次写一串或一次读一串 那就可以用 fputs 和 fgets。
原型如下:
cpp
int fputs ( const char * str, FILE * stream );
效果是把str 指向的字符串写到文件指针指向的文件中,不会自动在末尾补 '\0'。
fputs 不会主动换行,如果连着用两次fputs,两个字符串会写在同一行,因为写完第一个字符串的时候光标会停留在第一个字符串末尾。
如果想要换行,需要在字符串末尾加上 '\n'。
5 - 4 · fgets
原型如下:
cpp
char * fgets ( char * str, int num, FILE * stream );
效果是从文件中读数据,最多读num个字节,将读到的内容放到str指向的空间中。
str 需要是一个指向一个char类型数组的指针,直接使用数组名进行传参即可。
注意:给num的传参数和 实际读取的个数相比,实际读取的个数会少一个,因为会留一个位置放 '\0'
比如我们给num 传了10,那么实际上只会从文件中读9个字节,因为会为'\0'留一个位置。
那么如果我们给num传了20 但是文件中不够20字节 会怎么样呢?
假如文件中第一行是hello world
第二行是hello
那么会将第一行全部读取 包括\n 全部读取完之后再放\0 相当于读取了6个
只会读取一行 不会跨行读取
我们测试一下:
cpp
int main()
{
FILE* p = fopen("test.txt", "r");
char arr[20] = { 0 };
fgets(arr, 20, p);
printf("%s", arr);
fclose(p);
p = NULL;
return 0;
}
运行一下:

我们用了一次fgets 之后 再用一次fgets就会读第二行了
如下:
cpp
#include <stdio.h>
int main()
{
FILE* p = fopen("test.txt", "r");
char arr[20] = { 0 };
fgets(arr, 20, p);
printf("%s", arr);
fgets(arr, 20, p);
printf("%s", arr);
fclose(p);
p = NULL;
return 0;
}

当一行没有读完的时候,是不会进行换行的。
比如我们给num 传20,而一行超过了20字节,那么下一次fgets会从这一行上次读取的末尾开始读取,这一行仍会完整读取到
下面我们看看:
文件内容如下:

用一次:

用两次:

用三次:

fgets有返回值 如果读取成功 返回目标空间的起始位置 如果遇到了文件末尾或者读取错误 返回空指针 所以如果我们想要读取多行 可以用循环。
cpp
#include <stdio.h>
int main()
{
FILE* p = fopen("test.txt", "r");
char arr[20] = { 0 };
while (fgets(arr, 20, p))
{
printf("%s", arr);
}
fclose(p);
p = NULL;
return 0;
}
5 - 5 · fprintf
原型如下:
cpp
int fprintf ( FILE * stream, const char * format, ... );
用于写文件,是以文本的形式写进去的
与printf 函数的区别仅仅在于 fprintf 函数多了一个文件指针的参数。
cpp
#include <stdio.h>
struct Stu
{
char name[20];
int age;
};
int main()
{
struct Stu s1 = { "zhangsan",18 };
FILE* p = fopen("test.txt", "w");
if (p == NULL)
{
perror("fopen");
return 1;
}
fprintf(p, "%s %d", s1.name, s1.age);
return 0;
}
运行过后,我们看看文件:

也可以直接输入到屏幕上
5 - 6 · fscanf
原型如下:
cpp
int fscanf ( FILE * stream, const char * format, ... );
fscanf 与 scanf 也是一个参数的差异。
cpp
#include <stdio.h>
struct Stu
{
char name[20];
int age;
};
int main()
{
struct Stu s1 = { 0 };
FILE* p = fopen("test.txt", "r");
if (p == NULL)
{
perror("fopen");
return 1;
}
fscanf(p, "%s %d", s1.name, &(s1.age));
printf("%s %d", s1.name, s1.age);
return 0;
}
运行一下:

5 - 7 · 小说明
上面我们介绍的六个函数,其实适用于所有流。
在上述六个函数中 对FILE*stream处传参时
如果是适用于输入流的函数 传stdin
如果是适用于输出流的函数 传stdout
这样就会对应到屏幕上
比如:
cpp
#include <stdio.h>
struct Stu
{
char name[20];
int age;
};
int main()
{
struct Stu s1 = { "zhangsan",18 };
fprintf(stdout, "%s %d", s1.name, s1.age);
printf("\n");
fputc('a',stdout);
return 0;
}
运行一下:

5 - 8 · fwrite
前面介绍的六个函数放在文件中,我们都能看得懂 因为它们都是以文本形式写进去的。
而 fwrite 是以二进制形式写 ,fread 是以二进制形式读,这俩也只适用于文件流。
原型如下:
cpp
size_t fwrite ( const void * ptr, size_t size, size_t count, FILE * stream );
ptr 指向了一个数组 数组中的内容将要被写入文件
size 是ptr中一个元素的大小,单位是字节
count 是要写的元素的个数
最后一个参数是文件指针
注意:使用fwrite时打开文件的模式要用 "wb"。
比如:
cpp
#include <stdio.h>
int main()
{
int a1[] = { 1,2,3,4,5,6,7,8,9 };
int sz = sizeof(a1) / sizeof(a1[0]);
FILE* p = fopen("test.txt", "wb");
if (p == NULL)
{
perror("fopen");
return 1;
}
fwrite(a1, sizeof(int), sz, p);
fclose(p);
p = NULL;
return 0;
}
5 - 9 · fread
原型如下:
cpp
size_t fread ( void * ptr, size_t size, size_t count, FILE * stream );
效果是从流里面读 count 个大小为 size个字节的数据放到 ptr指向的空间里
注意:使用fread时打开文件的模式要用 "rb"。
这就有个问题:如果我们不知道文件中有几个数据,那么应该怎么读?
fread 是有返回值的,返回成功读取元素的个数
比如:
我们使用上面 fwrite 写入的文件
cpp
#include <stdio.h>
int main()
{
int a[20] = { 0 };
FILE* p = fopen("test.txt", "rb");
if (p == NULL)
{
perror("fopen");
return 1;
}
int i = 0;
while (fread(a+i, sizeof(int), 1, p))
{
printf("%d ", a[i]);
i++;
}
fclose(p);
p = NULL;
return 0;
}
运行一下:

6 · 文件的随机读写
上面介绍的八个函数都是写/读一个数据,光标就到下一个,按一定顺序进行。
那我们能不能指哪打哪呢,是可以的,只要能把位置找到。
这就牵扯到文件的随机读写。需要用到3个函数
fseek
ftell
rewind
6 - 1 · fseek
原型如下:
cpp
int fseek ( FILE * stream, long int offset, int origin );
效果是根据文件指针的位置和偏移量来定位文件指针(文件内容的光标)。
需要三个参数
第一个是文件指针
第二个是偏移量,可传正数也可传负数,传负数就会向前(左)偏移
第三个参数只能传SEEK_SET,SEEK_CUR,SEEK_END 这三个,分别对应文件起始位置,文件指针当前位置,文件末尾。
我们测试一下:
test.txt 中我们放的是 abcdefg
cpp
#include <stdio.h>
int main()
{
FILE* p = fopen("test.txt", "r");
char c = 0;
c = fgetc(p);
printf("%c\n", c);
fseek(p, 4, SEEK_CUR);
c = fgetc(p);
printf("%c\n", c);
fclose(p);
p = NULL;
return 0;
}
运行一下:

在第一次 fgetc 后,光标来到了 b ,此时用 fseek ,使光标从当前位置向后偏移4,到了f ,此时再用 fgetc ,就读到了 f 。
6 - 2 · ftell
原型如下:
cpp
long int ftell ( FILE * stream );
返回文件指针相对于起始位置的偏移量。
由此我们也能推出:将光标定位到文件末尾,再使用 ftell 就可以求出文件长度
6 - 3 · rewind
原型如下:
cpp
void rewind ( FILE * stream );
让文件指针的位置回到文件的起始位置。
7 · 文件读取结束的判定
7 - 1 · 被错误使用的 feof
牢记:在文件读取过程中,不能用feof函数的返回值直接来判断文件的是否读取结束。
feof 的作用是:当文件读取结束的时候,判断读取结束的原因是否是:遇到文件尾结束。
因为文件读取结束可能有两个原因:
- 可能是遇到了文件末尾
- 可能是读取时发生了错误
当打开一个流的时候,这个流上有两个标记值
1.是否遇到文件末尾
2.是否发生错误
feof 是检测是否遇到文件末尾的标记的
ferror 是检测是否发生错误的标记的
7 - 2 · 正确做法
当使用 fgetc 时,判断返回值是否为 EOF
当使用 fgets 时,判断返回值是否为 NULL
对二进制文件的读取结束,需判断返回值是否小于实际要读的个数
7 - 3 · 判断结束的原因
判断结束原因的时候,既要用 feof 判断,也要用 ferror 判断。
feof是有返回值的 类型是int
如果返回了一个非0值 那就说明是否遇到文件末尾的标记值被设置了 就是遇到了文件末尾 读取正常结束
ferror也是有返回值的 类型是int
如果返回了一个非0值 那就说明是否发生错误的标记值被设置了 就是发生错误了
所以我们可以这么写:
cpp
if (feof(p))
{
printf("遇到文件末尾,读取正常结束\n");
}
else if (ferror(p))
{
perror("......");
}
8 · 文件缓冲区
ANSIC 标准采用"缓冲文件系统" 处理的数据文件的,所谓缓冲文件系统是指系统自动地在内存中为程序中每⼀个正在使用的文件开辟⼀块"文件缓冲区"。从内存向磁盘输出数据会先送到内存中的缓冲区,装满缓冲区后才⼀起送到磁盘上。如果从磁盘向计算机读入数据,则从磁盘文件中读取数据输入到内存缓冲区(充满缓冲区),然后再从缓冲区逐个地将数据送到程序数据区(程序变量等)。缓冲区的大小根据C编译系统决定的
如下图:

当我们刷新缓冲区的时候,会直接将缓冲区中的数据送入磁盘/程序
关闭文件也会刷新缓冲区。
所以C语言在操作文件的时候,需要做刷新缓冲区或者在文件操作结束的时候关闭文件。如果不做,可能导致读写文件的问题。
总结
以上简单介绍了动态内存管理相关内容,关于C语言的其余内容,请期待后续更新
以上内容如有错误或不准确之处,欢迎指出,或者你有更好的想法,也欢迎交流。