【C语言】文件操作(详解)
慕雪年华

[TOC]

好久没有更新C语言学习的博客了,今天带来的是文件部分的知识点!😋

1.为什么需要文件?

之前学习过通讯录的代码实现,可以给通讯录中增加、删除联系人。但是这个通讯录在你exe文件关闭的同时就被销毁了,它的内容并不能顺延到下一次打开这个通讯录,这对我们的使用产生了不便。

而文件可以帮助我们实现数据的持久化:将数据保存在磁盘文件中,下次打开通讯录的时候,之前保存的联系人不会消失。

image


2.什么是文件?

文件就是存放在磁盘上的带特定格式的数据。

2.1文件分类

在程序设计中,一般讨论两种文件:程序文件、数据文件

  • 程序文件:代码源文件如.c,目标文件.obj/.o,可执行文件.exe
  • 数据文件:程序在使用过程中读写的数据,比如读取内容的文件,以及数据输出的文件

这篇博客我们了解的是数据文件

2.2文件名

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

如:c:\code\test.txt

文件标识常被称为文件名

3.文件的使用

3.1文件指针

在文件操作中,非常重要的一个知识点就是文件类型指针,简称文件指针

每个文件在开辟的时候都有一个对于的文件信息区,用于保存文件的名字、状态、当前的位置等相关信息。这些信息保存在了一个结构体中,该结构体系统声明为FILE

不同的C语言编译器都有不同的FILE类型,但是大同小异。

打开一个文件的时候,系统会根据文件的内容,自动创建FILE结构体变量,并填充它的信息。

image

我们需要使用文件的时候,就可以通过一个FILE类型的指针来访问这个结构体变量

image

3.2打开和关闭文件

文件在读写之前需要打开文件,使用结束后需要关闭文件

这一点和动态内存管理很相似

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

打开文件的同时,会返回一个FILE*的指针变量指向该文件。

关闭文件后,文件指针就变成了野指针,需要置为NULL防止错误调用

fopen函数打开文件失败,会返回空指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <errno.h>
#include <string.h>
int main()
{
//打开文件
FILE* pf = fopen("test.txt", "r");
if (pf == NULL)
{
printf("%s\n", strerror(errno));//用该函数打印错误信息
return 0;
}
//1.读文件

//关闭文件
fclose(pf);
pf = NULL;
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <errno.h>
#include <string.h>
int main()
{
//打开文件
FILE* pf = fopen("test.txt", "w");
if (pf == NULL)
{
printf("%s\n", strerror(errno));//用该函数打印错误信息
return 0;
}
//2.写文件

//关闭文件
fclose(pf);
pf = NULL;

return 0;
}

strerror函数在这篇博客里面有讲解👉点我

3.2.1文件使用方式

通过这张表格,我们可以了解一下文件使用方式的不同类型

  • 注意:使用它们用的都是双引号,而不是单引号!

image

用w写入的时候,会覆盖原本已有内容。如果需要在已有内容后面追加,需要使用a

image

3.2.2标准输入输出流

  • 输出:内存→文件
  • 输入:文件→内存

image

这里还有一个小知识点:C语言程序,运行的时候会默认打开3个流

  • stdin:标准输入流
  • stdout:标准输出流
  • stderr:标准错误流

在执行输入输出操作的时候,之前我们是直接将内存中的数据printf打印到屏幕上

现在我们可以通过文件指针,将数据输入到标准输出流,达到类似printf的效果

image

3.3文件输入输出函数

上述代码中,用到了fputc函数,这个函数的作用是将一个字符输入到文件中

下表列出了一些我们会用到的文件函数

image

3.3.1字符输入输出

fputc函数:向文件中写入单个字符

image

fgetc函数:从文件中读取单个字符

可以看到,我们把刚刚文件中写入的字符全部打印出来了

image

实现文件拷贝

将一个文件的内容拷贝到另外一个文件中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int main()
{
//实现一个代码将data.txt 拷贝一份 生成data2.txt
FILE* pr = fopen("data.txt", "r");
if (pr == NULL)
{
printf("open for reading: %s\n", strerror(errno));
return 0;
}

FILE* pw = fopen("data2.txt", "w");
if (pw == NULL)
{
printf("open for writting: %s\n", strerror(errno));
fclose(pr);
pr = NULL;
return 0;
}
//拷贝文件
int ch = 0;
while ((ch = fgetc(pr)) != EOF)
{
fputc(ch, pw);
}

fclose(pr);
pr = NULL;
fclose(pw);
pw = NULL;

return 0;
}

image


3.3.2文本行输入输出

fputs函数:将字符串写入到文件中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//写一行
#include <stdio.h>
int main()
{
FILE* pf = fopen("data.txt", "w");
if (pf == NULL)
{
printf("%s\n", strerror(errno));
return 0;
}
fputs("hello world\n", pf);
fputs("hehe\n", pf);


fclose(pf);
pf = NULL;

return 0;
}

运行代码,可以看到两行字符串已经被写入到了项目路径下的data.txt文件中

image

fgets函数:从文件中读取规定长度的字符串

该函数在使用的时候具有第3个参数,用于限制读取字符串的长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
////读文件-读一行
int main()
{
FILE* pf = fopen("data.txt", "r");
if (pf == NULL)
{
printf("%s\n", strerror(errno));
return 0;
}
char buf[1000] = {0};
//读文件
fgets(buf, 3, pf);
printf("%s\n", buf);

fgets(buf, 3, pf);
printf("%s\n", buf);

fclose(pf);
pf = NULL;

return 0;
}

运行程序,可以看到我们设置的是3,却只读取了2个字符出来

image

buf[2]更改为1,调试查看

image

可以看到,在执行第一个fgets函数后,原本的1被写入成了\0

image

这就证实:fgets函数在读取字符的时候,会以\0作为结尾

如果我们需要读取3个字符,就需要将限制设置为4


3.3.3格式化输入输出

这里的“格式化”指的是结构体这种具有特定格式的数据内容

fprintf函数:将格式化数据写入文件中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include<stdio.h>
//……
struct Stu
{
char name[20];
int age;
double d;
};
int main()
{
struct Stu s = { "张三", 20, 95.5 };
FILE* pf = fopen("data.txt", "w");
if (pf == NULL)
{
printf("%s\n", strerror(errno));
return 0;
}
//写格式化的数据
fprintf(pf, "%s %d %lf", s.name, s.age, s.d);

fclose(pf);
pf = NULL;

return 0;
}

image

fscanf函数:从文件中读取格式化数据,存放到对应结构体变量s中

image


3.3.4二进制输入输出

  • fread、fwrite可以操作任意类型的数据
  • 正如它的名字,二进制输入函数是将内容以二进制的方式输入到文件中

使用该函数的时候需要使用**”rb”,”wb”**来打开文件

image

1
2
3
4
5
fwrite(s, sizeof(struct Stu), 2, pf);
//s 来源
//sizeof 需要写入元素的大小
//2 需要写入元素的个数
//pf 写入的目标文件指针

以下是写入结构体变量的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct Stu
{
char name[20];
int age;
double d;
};
//二进制的写
int main()
{
struct Stu s[2] = { {"张三", 20, 95.5} , {"lisi", 16, 66.5}};

FILE* pf = fopen("data.txt", "wb");
if (pf == NULL)
{
printf("%s\n", strerror(errno));
return 0;
}
//按照二进制的方式写文件
fwrite(s, sizeof(struct Stu), 2, pf);

fclose(pf);
pf = NULL;

return 0;
}

可以看到,此时写入的数据已经部分成了乱码。这时候它的内容已经是用二进制存放的了,txt阅读器无法正确读出这些数据

image

二进制的读取就是复现这一步,将文本中的二进制数据以特定格式读取出来,并放入对应变量

1
2
3
4
5
fread(s, sizeof(struct Stu), 2, pf);
//s 存放文件内容的变量
//sizeof 需要读取元素的大小
//2 需要读取元素的个数
//pf 读取的目标文件指针

image

3.3.5 sscanf/sprintf函数

这两个函数比较特殊,它们的作用是将文件里面的格式化数据(如结构体)以字符串的形式拷贝到字符数组里面

image

见下图

image

image


3.4.其他文件函数

3.4.1 fseek

https://cplusplus.com/reference/cstdio/fseek/?kw=fseek

该函数的作用是:将文件指针移动到相对于某个位置的特定偏移量的位置

image

听起来有点绕口,举例说明就知道了


给定一个字符串“abcdef”

每次使用一次fgetc,文件指针就会往后进一位。使用两次,文件指针指向的是字符c

如果我们需要指向f,就让指针

  • 从开始位置向后进5位
  • 从当前位置向后进3位
  • 从结束位置向前进1位

image

我们可以用该函数,定位文件指针,将其更改到我们需要的位置,进行字符替换等操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int main()
{
FILE* pf = fopen("test.txt", "w");
if (pf == NULL)
{
printf("%s\n", strerror(errno));
return 0;
}
//写文件
int ch = 0;
for (ch = 'a'; ch <= 'z'; ch++)
{
fputc(ch, pf);
}

//定位文件指针

fseek(pf, -2, SEEK_END);
fputc('#', pf);//将当前字符替换成#

fclose(pf);
pf = NULL;
return 0;
}

image

3.4.2 ftell

返回文件指针当前的偏移量(相对于文件开头)

image

3.4.3 rewind

https://cplusplus.com/reference/cstdio/rewind/?kw=rewind

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

1
2
3
fseek(pf, 0, SEEK_SET);
//rewind函数与该fseek函数操作等价
//但是rewind更方便
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int main()
{
FILE* pf = fopen("test.txt", "r");
if (pf == NULL)
{
printf("%s\n", strerror(errno));
return 0;
}
//读文件
int ch = fgetc(pf);
printf("%c\n", ch);//a
ch = fgetc(pf);
printf("%c\n", ch);//b

int ret = ftell(pf);
printf("%d\n", ret);//2
rewind(pf);
//fseek(pf, 0, SEEK_SET);
ret = ftell(pf);
printf("%d\n", ret);//0
fclose(pf);
pf = NULL;
return 0;
}

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

我们现在已经知道了fread/fwrite函数可以实现二进制的输入输出,它们是怎么具体实现的呢?


根据数据的组织形式,数据文件被称为文本文件或者二进制文件。 数据在内存中以二进制的形式存储,如果不加转换的输出到外存,就是二进制文件。

如果要求在外存上以ASCII码的形式存储,则需要在存储前转换。以ASCII字符的形式存储的文件就是文本文件。

在内存中,字符一律以ASCII形式存储,数值型数据既可以用ASCII形式存储,也可以使用二进制形式存储。

对于数字10000,可以用下面两种方式存储

  • 1 0 0 0 0 作为5个字符来存储–占用5个字节
  • 以数字本身的二进制形式存储–占用4个字节

这时候使用二进制的方式,就能节省空间

image

用如下代码,将10000以二进制方式写入文件中

image

在VS中我们可以以特定打开方式二进制编辑器打开test.txt文档

image

可以看到10000是以二进制码的形式存放在文件中的

image

这里涉及到了大小端的问题👉点我


5.文件读取结束的判定

在一般情况下,我们可以直接判断fgets/fgetc的返回值来判断文件是否读取结束。但这两个函数在文件读取错误的时候,也会返回和文件读取结束一样的结果,此时就需要判断文件是因为什么情况结束的了

此时就需要用到两个函数,ferror和feof

5.1 ferror

ferror函数:判断文件是否出现了读取错误。

1
2
// 来自https://cplusplus.com/reference/cstdio/ferror/
int ferror ( FILE * stream );

image

在函数描述里面可以看到,该函数会判断传入的文件流最近的一次文件读写是否出现了错误。其判断的是文件流中的errror indicator错误描述符。

  • 如果有,返回为真 (非0值)
  • 如果没有,返回0

同时在函数释义里面,提到了错误会被clearerr/rewind/freopen这三个函数清除

5.2 feof

此函数用于判断文件流是否读取到了文件末尾

1
2
// https://cplusplus.com/reference/cstdio/feof/
int feof ( FILE * stream );

image

  • 如果遇到了文件末尾,返回非0值(真)
  • 如果没有遇到,返回0

在函数的描述处,提到了流的内部指针可能指向的是文件结尾。但在尝试读取文件结尾之前,不一定会设置EOF指示符。这句话不是很好理解,看下图

image

指针只有读取了文件末尾,才能知道自己已经走到了文件末尾,从而设置EOF标识符

5.3 二者使用场景

feof/ferror的先后顺序并没有特定的要求,因为它们两个的判断功能是不同的

  • ferror判断的是读取是否出现了错误
  • feof判断的是读取是否到了文件尾部

依照读取退出的不同情况,可以分为下面几种类型

  • 读取成功,文件指针走到文件尾部,属于正常退出;feof能够判断出文件指针是否走到了文件尾部。
  • 读取失败,出现了一些错误,此时应该用ferror查看错误原因

理论上而言,我们应该先用feof判断:因为读取到文件尾退出是没有问题的;

只有feof判断没有到文件尾部,而读取函数又退出了,才需要我们用ferror进行错误的判断。

1
2
3
4
if(feof(fp))
printf("EOF reach");
else if(ferror(fp))
printf("error while readfile");

实际上,这两个函数的返回值是互斥的

  • 成功读取到了文件尾部,那肯定是没出错的
  • 出错了,肯定没能走到文件尾部并设置EOF标识符(注意,文件指针可能已经指向了文件尾部,但出错了)

所以,这两个函数的先后顺序并不会相互影响😂

5.4 不推荐只用feof判断读取是否结束

上课时,老师告诉我们,并不推荐在读取文件过程中,使用feof函数的返回值来判断文件是否读取结束,如下

1
2
3
4
5
while(!feof(fp))//用feof的返回值判断文件是否读取结束
{
c = fgetc(fp);
printf("%c",c);
}

fgetc函数本身的返回值就拥有此功能,更推荐在fgetc的循环结束后,用feof函数判断是读取失败结束,还是遇到文件结尾正常结束

1
2
3
4
5
6
7
8
9
10
11
12
13
//1. 文件打开操作
//2. fgetc 当读取失败的时候或者遇到文件结束的时候,都会返回EOF
while ((c = fgetc(fp)) != EOF) // 标准C I/O读取文件循环
{
putchar(c);
}
printf("\n");

//3. 判断是什么原因结束的
if (ferror(fp))
puts("I/O error when reading");
else if (feof(fp))
puts("End of file reached successfully");

但在我查阅资料的时候,看到C语言中文网站上使用了feof来判断文件是否读取结束。我感觉C语言中文网里面说的并没有啥问题,单看feof的函数作用,也确实能用来判断文件是否读取完毕。

后来经过询问,得知老师的意思是:不能只用feof来判断文件是否正常读取结束。必须要将feof和ferror结合起来。包括上面提供的示例代码中步骤3也是同时使用了ferror和feof!

image

5.5 文件读取判错流程

5.5.1 文本文件

文本文件读取是否结束,判断读取函数fgetc/fgets的返回值

  • EOF (fgetc)
  • NULL (fgets)

退出读取文件的循环后,用feof和ferror判断是否出现了错误

image

5.5.2 二进制文件

二进制文件的读取结束,则需要判断返回值是否小于实际要读的个数:

  • fread的返回值是成功读取数据的个数,判断返回值是否小于实际要读取的个数
  • 如果不等于(实际上只会是小于),有两种情况
    • 读取失败,遇到错误
    • 读取完毕文件,文件中的内容没有SIZE大小
  • 此时的场景,适合用feof判断是否已到文件尾部,再用ferror判断是否为文件错误
    • 如果feof判断已经读到文件尾部,说明是文件中的内容少于SIZE大小,应该提示文件中的数据不足SZIE字节
    • 如果feof判断未走到文件尾部,说明出现了错误,用ferror判断,并提示文件读取遇到错误

再次说明,feof和ferror使用的先后顺序没有明确区别和硬性要求,我们只需要根据自己的场景,给错误判断加上对应的提示信息即可!

image

6.文件缓冲区

ANSIC 标准采用“缓冲文件系统”处理数据文件。

所谓缓冲文件系统是指系统自动地在内存中为程序中每一个正在使用的文件开辟一块“文件缓冲区”。从内存向磁盘输出数据会先送到内存中的缓冲区,装满缓冲区后才一起送到磁盘上。

这就和git一样,是先将需要push的文件放入缓存区,确认文件无误后再push到远程仓库中

如果从磁盘向计算机读入数据,则从磁盘文件中读取数据输入到内存缓 冲区(充满缓冲区),然后再从缓冲区逐个地将数据送到程序数据区(程序变量等)。缓冲区的大小是C编译系统(编译器)决定的。

image

因为有缓冲区的存在,C语言在操作文件的时候,需要做刷新缓冲区或者在文件操作结束的时候关闭文 件。 如果不做,可能导致读写文件的问题。

代码示例1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <windows.h>

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;
}

运行程序,通过sleep函数暂停程序,可以看到刚开始字符串并没有存入文件中

image

而是先写入输入缓存区,刷新缓存区后,才写入txt文件

image

代码示例2

image

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
#include <windows.h>
int main()
{
while (1)
{
printf("hehe\n");
//在linux环境中,不带'\n'的时候,并不会打印(没有刷新缓存区)
//而在VS环境中,带不带都会正常打印
Sleep(1000);//linux环境中,sleep函数的参数,单位是秒(VS是毫秒)
// linux环境下,sleep函数需要小写,VS下是Sleep
}
return 0;
}

image

在Linux环境下(树莓派)测试这个代码

可以看到,去掉\n后,代码并不会打印hehe

image

编译的时候,遇到报错👇,但是程序依旧编译出来了

1
implicit declaration of function ‘sleep’

CSDN查了查,发现是需要引用头文件#include <unistd.h>

重新编译,没有报错了(此处hehe已经加了\n,程序正常打印)

image


结语

文件章节的内容非常丰富,你学费了吗!😁

大多数内容还是需要我们多多操作来熟悉它的真正作用

如果内容有误,还请大佬无情指正!