- 首页
- 作品
- C 语言教程
- 18. C语言的文件操作
18. C语言的文件操作
本章介绍 C 语言如何操作文件。
文件指针
fopen()
fopen()
函数用来打开文件。所有文件操作的第一步,都是使用fopen()
打开指定文件。这个函数的原型定义在头文件stdio.h
。
FILE* fopen(char* filename, char* mode);
- 它接受两个参数。第一个参数是文件名(可以包含路径),第二个参数是模式字符串,指定对文件执行的操作,比如下面的例子中,
r
表示以读取模式打开文件。
fp = fopen("in.dat", "r");
- 成功打开文件以后,
fopen()
返回一个 FILE 指针,其他函数可以用这个指针操作文件。如果无法打开文件(比如文件不存在或没有权限),会返回空指针 NULL。所以,执行fopen()
以后,最好判断一下,有没有打开成功。
fp = fopen("hello.txt", "r");
if (fp == NULL) {
printf("Can't open file!\n");
exit(EXIT_FAILURE);
}
- 上面示例中,如果
fopen()
返回一个空指针,程序就会报错。
fopen()
的模式字符串有以下几种。
r
:读模式,只用来读取数据。如果文件不存在,返回 NULL 指针。
w
:写模式,只用来写入数据。如果文件存在,文件长度会被截为0,然后再写入;如果文件不存在,则创建该文件。
a
:写模式,只用来在文件尾部追加数据。如果文件不存在,则创建该文件。
r+
:读写模式。如果文件存在,指针指向文件开始处,可以在文件头部添加数据。如果文件不存在,返回 NULL 指针。
w+
:读写模式。如果文件存在,文件长度会被截为0,然后再写入数据。这种模式实际上读不到数据,反而会擦掉数据。如果文件不存在,则创建该文件。
a+
:读写模式。如果文件存在,指针指向文件结尾,可以在现有文件末尾添加内容。如果文件不存在,则创建该文件。
- 上一小节说过,
fopen()
函数会为打开的文件创建一个缓冲区。读模式下,创建的是读缓存区;写模式下,创建的是写缓存区;读写模式下,会同时创建两个缓冲区。C 语言通过缓存区,以流的形式,向文件读写数据。
- 数据在文件里面,都是以二进制形式存储。但是,读取的时候,有不同的解读方法:以原本的二进制形式解读,叫做“二进制流”;将二进制数据转成文本,以文本形式解读,叫做“文本流”。写入操作也是如此,分成以二进制写入和以文本写入,后者会多一个文本转二进制的步骤。
fopen()
的模式字符串,默认是以文本流读写。如果添加b
后缀(表示 binary),就会以“二进制流”进行读写。比如,rb
是读取二进制数据模式,wb
是写入二进制数据模式。
- 模式字符串还有一个
x
后缀,表示独占模式(exclusive)。如果文件已经存在,则打开文件失败;如果文件不存在,则新建文件,打开后不再允许其他程序或线程访问当前文件。比如,wx
表示以独占模式写入文件,如果文件已经存在,就会打开失败。
标准流
fclose()
EOF
- C 语言文件操作函数的设计是,如果遇到文件结尾,就返回一个特殊值。程序接收到这个特殊值,就知道已经到达文件结尾了。
- 头文件
stdio.h
为这个特殊值定义了一个宏EOF
(end of file 的缩写),它的值一般是-1
。这是因为从文件读取的值,不管是二进制形式,还是 ASCII 码的形式,都不可能是负值,所以可以很安全地返回-1
,不会跟文件本身的数据相冲突。
- 需要注意的是,不像字符串结尾真的存储了
\0
这个值,EOF
并不存储在文件结尾,文件中并不存在这个值,完全是文件操作函数发现到达了文件结尾,而返回这个值。
freopen()
freopen()
用于新打开一个文件,直接关联到某个已经打开的文件指针。这样可以复用文件指针。它的原型定义在头文件stdio.h
。
FILE* fopen(char* filename, char* mode, FILE stream);
- 它跟
fopen()
相比,就是多出了第三个参数,表示要复用的文件指针。其他两个参数都一样,分别是文件名和打开模式。
freopen("output.txt", "w", stdout);
printf("hello");
- 上面示例将文件
output.txt
关联到stdout
,此后向stdout
写入的内容,都会写入foo.txt
。由于printf()
默认就是输出到stdout
,所以运行上面的代码以后,文件output.txt
会被写入hello
。
freopen()
的返回值是它的第三个参数(文件指针)。如果打开失败(比如文件不存在),会返回空指针 NULL。
freopen()
会自动关闭原先已经打开的文件,如果文件指针并没有指向已经打开的文件,则freopen()
等同于fopen()
。
- 下面是
freopen()
关联scanf()
的例子。
int i, i2;
scanf("%d", &i);
freopen("someints.txt", "r", stdin);
scanf("%d", &i2);
- 上面例子中,一共调用了两次
scanf()
,第一次调用是从键盘读取,然后使用freopen()
将stdin
指针关联到某个文件,第二次调用就会从该文件读取。
- 某些系统允许使用
freopen()
,改变文件的打开模式。这时,freopen()
的第一个参数应该是 NULL。
freopen(NULL, "wb", stdout);
- 上面示例将
stdout
的打开模式从w
改成了wb
。
fgetc(),getc()
fgetc()
和getc()
用于从文件读取一个字符。它们的用法跟getchar()
类似,区别是getchar()
只用来从stdin
读取,而这两个函数是从任意指定的文件读取。它们的原型定义在头文件stdio.h
。
int fgetc(FILE *stream)
int getc(FILE *stream);
fgetc()
与getc()
的用法是一样的,都只有文件指针一个参数。两者的区别是,getc()
一般用宏来实现,而fgetc()
是函数实现,所以前者的性能可能更好一些。注意,虽然这两个函数返回的是一个字符,但是它们的返回值类型却不是char
,而是int
,这是因为读取失败的情况下,它们会返回 EOF,这个值一般是-1
。
#include <stdio.h>
int main(void) {
FILE *fp;
fp = fopen("hello.txt", "r");
int c;
while ((c = getc(fp)) != EOF)
printf("%c", c);
fclose(fp);
}
- 上面示例中,
getc()
依次读取文件的每个字符,将其放入变量c
,直到读到文件结尾,返回 EOF,循环终止。变量c
的类型是int
,而不是char
,因为有可能等于负值,所以设为int
更好一些。
fputc(),putc()
fprintf()
fprintf()
用于向文件写入格式化字符串,用法与printf()
类似。区别是printf()
总是写入stdout
,而fprintf()
则是写入指定的文件,它的第一个参数必须是一个文件指针。它的原型定义在头文件stdio.h
。
int fprintf(FILE* stream, const char* format, ...)
fprintf()
可以替代printf()
。
printf("Hello, world!\n");
fprintf(stdout, "Hello, world!\n");
- 上面例子中,指定
fprintf()
写入stdout
,结果就等同于调用printf()
。
fprintf(fp, "Sum: %d\n", sum);
- 上面示例是向文件指针
fp
写入指定格式的字符串。
- 下面是向
stderr
输出错误信息的例子。
fprintf(stderr, "Something number.\n");
fscanf()
fscanf()
用于按照给定的模式,从文件中读取内容,用法跟scanf()
类似。区别是scanf()
总是从stdin
读取数据,而fscanf()
是从文件读入数据,它的原因定义在头文件stdio.h
,第一个参数必须是文件指针。
int fscanf(FILE* stream, const char* format, ...);
- 下面是一个例子。
fscanf(fp, "%d%d", &i, &j);
- 上面示例中,
fscanf()
从文件fp
里面,读取两个整数,放入变量i
和j
。
- 使用
fscanf()
的前提是知道文件的结构,它的占位符解析规则与scanf()
完全一致。由于fscanf()
可以连续读取,直到读到文件尾,或者发生错误(读取失败、匹配失败),才会停止读取,所以fscanf()
通常放在循环里面。
while(fscanf(fp, "%s", words) == 1)
puts(words);
- 上面示例中,
fscanf()
依次读取文件的每个词,将它们一行打印一个,直到文件结束。
fscanf()
的返回值是赋值成功的变量数量,如果赋值失败会返回 EOF。
fgets()
fgets()
用于从文件读取指定长度的字符串,它名字的第一个字符是f
,就代表file
。它的原型定义在头文件stdio.h
。
char* fgets(char* str, int STRLEN, File* fp);
- 它的第一个参数
str
是一个字符串指针,用于存放读取的内容。第二个参数STRLEN
指定读取的长度,第三个参数是一个 FILE 指针,指向要读取的文件。
fgets()
读取 STRLEN - 1 个字符之后,或者遇到换行符与文件结尾,就会停止读取,然后在已经读取的内容末尾添加一个空字符\0
,使之成为一个字符串。注意,fgets()
会将换行符(\n
)存储进字符串。
- 如果
fgets
的第三个参数是stdin
,就可以读取标准输入,等同于scanf()
。
fgets(str, sizeof(str), stdin);
- 读取成功时,
fgets()
的返回值是它的第一个参数,即指向字符串的指针,否则返回空指针 NULL。
fgets()
可以用来读取文件的每一行,下面是读取文件所有行的例子。
#include <stdio.h>
int main(void) {
FILE* fp;
char s[1024]; // 数组必须足够大,足以放下一行
int linecount = 0;
fp = fopen("hello.txt", "r");
while (fgets(s, sizeof s, fp) != NULL)
printf("%d: %s", ++linecount, s);
fclose(fp);
}
- 上面示例中,每读取一行,都会输出行号和该行的内容。
- 下面的例子是循环读取用户的输入。
char words[10];
puts("Enter strings (q to quit):");
while (fgets(words, 10, stdin) != NULL) {
if (words[0] == 'q' && words[1] == '\n')
break;
puts(words);
}
puts("Done.");
- 上面的示例中,如果用户输入的字符串大于9个字符,
fgets()
会多次读取。直到遇到q
+ 回车键,才会退出循环。
fputs()
fputs()
函数用于向文件写入字符串,和puts()
函数只有一点不同,那就是它不会在字符串末尾添加换行符。这是因为fgets()
保留了换行符,所以fputs()
就不添加了。fputs()
函数通常与fgets()
配对使用。
- 它的原型定义在
stdio.h
。
int fputs(const char* str, FILE* stream);
- 它接受两个参数,第一个参数是字符串指针,第二个参数是要写入的文件指针。如果第二个参数为
stdout
(标准输出),就是将内容输出到计算机屏幕,等同于printf()
。
char words[14];
puts("Enter a string, please.");
fgets(words, 14, stdin);
puts("This is your string:");
fputs(words, stdout);
- 上面示例中,先用
fgets()
从stdin
读取用户输入,然后用fputs()
输出到stdout
。
- 写入成功时,
fputs()
返回一个非负整数,否则返回 EOF。
fwrite()
fwrite()
用来一次性写入较大的数据块,主要用途是将数组数据一次性写入文件,适合写入二进制数据。它的原型定义在stdio.h
。
size_t fwrite(const void* ptr, size_t size, size_t nmemb, FILE* fp);
- 它接受四个参数。
ptr
:数组指针。
size
:每个数组成员的大小,单位字节。
nmemb
:数组成员的数量。
fp
:要写入的文件指针。
- 注意,
fwrite()
原型的第一个参数类型是void*
,这是一个无类型指针,编译器会自动将参数指针转成void*
类型。正是由于fwrite()
不知道数组成员的类型,所以才需要知道每个成员的大小(第二个参数)和成员数量(第三个参数)。
fwrite()
函数的返回值是成功写入的数组成员的数量(注意不是字节数)。正常情况下,该返回值就是第三个参数nmemb
,但如果出现写入错误,只写入了一部分成员,返回值会比nmemb
小。
- 要将整个数组
arr
写入文件,可以采用下面的写法。
fwrite(
arr,
sizeof(arr[0]),
sizeof(arr) / sizeof(arr[0]),
fp
);
- 上面示例中,
sizeof(a[0])
是每个数组成员占用的字节,sizeof(a) / sizeof(a[0])
是整个数组的成员数量。
- 下面的例子是将一个大小为256字节的字符串写入文件。
char buffer[256];
fwrite(buffer, 1, 256, fp);
- 上面示例中,数组
buffer
每个成员是1个字节,一共有256个成员。由于fwrite()
是连续内存复制,所以写成fwrite(buffer, 256, 1, fp)
也能达到目的。
fwrite()
没有规定一定要写入整个数组,只写入数组的一部分也是可以的。
- 任何类型的数据都可以看成是1字节数据组成的数组,或者是一个成员的数组,所以
fwrite()
实际上可以写入任何类型的数据,而不仅仅是数组。比如,fwrite()
可以将一个 Struct 结构写入文件保存。
fwrite(&s, sizeof(s), 1, fp);
- 上面示例中,
s
是一个 Struct 结构指针,可以看成是一个成员的数组。注意,如果s
的属性包含指针,存储时需要小心,因为保存指针可能没意义,还原出来的时候,并不能保证指针指向的数据还存在。
fwrite()
以及后面要介绍的fwrite()
,比较适合读写二进制数据,因为它们不会对写入的数据进行解读。二进制数据可能包含空字符\0
,这是 C 语言的字符串结尾标记,所以读写二进制文件,不适合使用文本读写函数(比如fprintf()
等)。
- 下面是一个写入二进制文件的例子。
#include <stdio.h>
int main(void) {
FILE* fp;
unsigned char bytes[] = {5, 37, 0, 88, 255, 12};
fp = fopen("output.bin", "wb");
fwrite(bytes, sizeof(char), sizeof(bytes), fp);
fclose(fp);
return 0;
}
- 上面示例中,写入二进制文件时,
fopen()
要使用wb
模式打开,表示二进制写入。fwrite()
可以把数据解释成单字节数组,因此它的第二个参数是sizeof(char)
,第三个参数是数组的总字节数sizeof(bytes)
。
- 上面例子写入的文件
output.bin
,使用十六进制编辑器打开,会是下面的内容。
05 25 00 58 ff 0c
fwrite()
还可以连续向一个文件写入数据。
struct clientData myClient = {1, 'foo bar'};
for (int i = 1; i <= 100; i++) {
fwrite(&myClient, sizeof(struct clientData), 1, cfPtr);
}
- 上面示例中,
fwrite()
连续将100条数据写入文件。
fread()
fread()
函数用于一次性从文件读取较大的数据块,主要用途是将文件内容读入一个数组,适合读取二进制数据。它的原型定义在头文件stdio.h
。
size_t fread(void* ptr, size_t size, size_t nmemb, FILE* fp);
- 它接受四个参数,与
fwrite()
完全相同。
ptr
:数组地址。
size
:数组的成员数量。
nmemb
:每个数组成员的大小。
fp
:文件指针。
- 要将文件内容读入数组
arr
,可以采用下面的写法。
fread(
arr,
sizeof(arr[0]),
sizeof(arr) / sizeof(arr[0]),
fp
);
- 上面示例中,数组长度(第二个参数)和每个成员的大小(第三个参数)的乘积,就是数组占用的内存空间的大小。
fread()
会从文件(第四个参数)里面读取相同大小的内容,然后将ptr
(第一个参数)指向这些内容的内存地址。
- 下面的例子是将文件内容读入一个10个成员的双精度浮点数数组。
double earnings[10];
fread(earnings, sizeof(double), 10, fp);
- 上面示例中,每个数组成员的大小是
sizeof(double)
,一个有10个成员,就会从文件fp
读取sizeof(double) * 10
大小的内容。
fread()
函数的返回值是成功读取的数组成员的数量。正常情况下,该返回值就是第三个参数nmemb
,但如果出现读取错误或读到文件结尾,该返回值就会比nmemb
小。所以,检查fread()
的返回值是非常重要的。
fread()
和fwrite()
可以配合使用。在程序终止之前,使用fwrite()
将数据保存进文件,下次运行时再用fread()
将数据还原进入内存。
- 下面是读取上一节生成的二进制文件
output.bin
的例子。
#include <stdio.h>
int main(void) {
FILE* fp;
unsigned char c;
fp = fopen("output.bin", "rb");
while (fread(&c, sizeof(char), 1, fp) > 0)
printf("%d\n", c);
return 0;
}
- 运行后,得到如下结果。
5
37
0
88
255
12
feof()
feof()
函数判断文件的内部指针是否指向文件结尾。它的原型定义在头文件stdio.h
。
int feof(FILE *fp);
feof()
接受一个文件指针作为参数。如果已经到达文件结尾,会返回一个非零值(表示 true),否则返回0
(表示 false)。
- 诸如
fgetc()
这样的文件读取函数,如果返回 EOF,有两种可能,一种可能是已读取到文件结尾,另一种可能是出现读取错误。feof()
可以用来判断到底是那一种情况。
- 下面是通过
feof()
判断是否到达文件结尾,从而循环读取整个文件的例子。
int num;
char name[50];
FILE* cfPtr = fopen("clients.txt", "r");
while (!feof(cfPtr)) {
fscanf(cfPtr, "%d%s\n", &num, name);
printf("%d %s\n", num, name);
}
fclose(cfPtr);
- 上面示例通过循环判断
feof()
是否读到文件结尾,从而实现读出整个文件内容。
feof()
为真时,可以通过fseek()
、rewind()
、fsetpos()
函数改变文件内部读写位置的指示器,从而清除这个函数的状态。
fseek()
- 每个文件指针都有一个内部指示器(内部指针),记录当前打开的文件的读写位置(file position),即下一次读写从哪里开始。文件操作函数(比如
getc()
、fgets()
、fscanf()
和fread()
等)都从这个指示器指定的位置开始按顺序读写文件。
- 如果希望改变这个指示器,将它移到文件的指定位置,可以使用
fseek()
函数。它的原型定义在头文件stdio.h
。
int fseek(FILE* stream, long int offset, int whence);
fseek()
接受3个参数。
stream
:文件指针。
offset
:距离基准(第三个参数)的字节数。类型为 long int,可以为正值(向文件末尾移动)、负值(向文件开始处移动)或 0(保持不动)。
whence
:位置基准,用来确定计算起点。它的值是以下三个宏(定义在stdio.h
):SEEK_SET
(文件开始处)、SEEK_CUR
(内部指针的当前位置)、SEEK_END
(文件末尾)
- 请看下面的例子。
// 定位到文件开始处
fseek(fp, 0L, SEEK_SET);
// 定位到文件末尾
fseek(fp, 0L, SEEK_END);
// 从当前位置前移2个字节
fseek(fp, 2L, SEEK_CUR);
// 定位到文件第10个字节
fseek(fp, 10L, SEEK_SET);
// 定位到文件倒数第10个字节
fseek(fp, -10L, SEEK_END);
- 上面示例中,
fseek()
的第二个参数为 long 类型,所以移动距离必须加上后缀L
,将其转为 long 类型。
- 下面的示例逆向输出文件的所有字节。
for (count = 1L; count <= size; count++) {
fseek(fp, -count, SEEK_END);
ch = getc(fp);
}
- 注意,
fseek()
最好只用来操作二进制文件,不要用来读取文本文件。因为文本文件的字符有不同的编码,某个位置的准确字节位置不容易确定。
- 正常情况下,
fseek()
的返回值为0。如果发生错误(如移动的距离超出文件的范围),返回值为非零值(比如-1
)。
ftell()
ftell()
函数返回文件内部指示器的当前位置。它的原型定义在头文件stdio.h
。
long int ftell(FILE* stream);
- 它接受一个文件指针作为参数。返回值是一个 long 类型的整数,表示内部指示器的当前位置,即文件开始处到当前位置的字节数,
0
表示文件开始处。如果发生错误,ftell()
返回-1L
。
ftell()
可以跟fseek()
配合使用,先记录内部指针的位置,一系列操作过后,再用fseek()
返回原来的位置。
long file_pos = ftell(fp);
// 一系列文件操作之后
fseek(fp, file_pos, SEEK_SET);
- 下面的例子先将指示器定位到文件结尾,然后得到文件开始处到结尾的字节数。
fseek(fp, 0L, SEEK_END);
size = ftell(fp);
rewind()
fgetpos(),fsetpos()
fseek()
和ftell()
有一个潜在的问题,那就是它们都把文件大小限制在 long int 类型能表示的范围内。这看起来相当大,但是在32位计算机上,long int 的长度为4个字节,能够表示的范围最大为 4GB。随着存储设备的容量迅猛增长,文件也越来越大,往往会超出这个范围。鉴于此,C 语言新增了两个处理大文件的新定位函数:fgetpos()
和fsetpos()
。
- 它们的原型都定义在头文件
stdio.h
。
int fgetpos(FILE* stream, fpos_t* pos);
int fsetpos(FILE* stream, const fpos_t* pos);
fgetpos()
函数会将文件内部指示器的当前位置,存储在指针变量pos
。该函数接受两个参数,第一个是文件指针,第二个存储指示器位置的变量。
fsetpos()
函数会将文件内部指示器的位置,移动到指针变量pos
指定的地址。注意,变量pos
必须是通过调用fgetpos()
方法获得的。fsetpos()
的两个参数与fgetpos()
必须是一样的。
- 记录文件内部指示器位置的指针变量
pos
,类型为fpos_t*
(file position type 的缩写,意为文件定位类型)。它不一定是整数,也可能是一个 Struct 结构。
- 下面是用法示例。
fpos_t file_pos;
fgetpos(fp, &file_pos);
// 一系列文件操作之后
fsetpos(fp, &file_pos);
- 上面示例中,先用
fgetpos()
获取内部指针的位置,后面再用fsetpos()
恢复指针的位置。
- 执行成功时,
fgetpos()
和fsetpos()
都会返回0
,否则返回非零值。
ferror(),clearerr()
- 所有的文件操作函数如果执行失败,都会在文件指针里面记录错误状态。后面的操作只要读取错误指示器,就知道前面的操作出错了。
ferror()
函数用来返回错误指示器的状态。可以通过这个函数,判断前面的文件操作是否成功。它的原型定义在头文件stdio.h
。
int ferror(FILE *stream);
- 它接受一个文件指针作为参数。如果前面的操作出现错误,
ferror()
就会返回一个非零整数(表示 true),否则返回0
。
clearerr()
函数用来重置出错指示器。它的原型定义在头文件stdio.h
。
void clearerr(FILE* fp);
- 它接受一个文件指针作为参数,没有返回值。
- 下面是一个例子。
FILE* fp = fopen("file.txt", "w");
char c = fgetc(fp);
if (ferror(fp)) {
printf("读取文件:file.txt 时发生错误\n");
}
clearerr(fp);
- 上面示例中,
fgetc()
尝试读取一个以”写模式“打开的文件,读取失败就会返回 EOF。这时调用ferror()
就可以知道上一步操作出错了。处理完以后,再用clearerr()
清除出错状态。
- 文件操作函数如果正常执行,
ferror()
和feof()
都会返回零。如果执行不正常,就要判断到底是哪里出了问题。
if (fscanf(fp, "%d", &n) != 1) {
if (ferror(fp)) {
printf("io error\n");
}
if (feof(fp)) {
printf("end of file\n");
}
clearerr(fp);
fclose(fp);
}
- 上面示例中,当
fscanf()
函数报错时,通过检查ferror()
和feof()
,确定到底发生什么问题。这两个指示器改变状态后,会保持不变,所以要用clearerr()
清除它们,clearerr()
可以同时清除两个指示器。
remove()
rename()
rename()
函数用于文件改名,也用于移动文件。它的原型定义在头文件stdio.h
。
int rename(const char* old_filename, const char* new_filename);
- 它接受两个参数,第一个参数是现在的文件名,第二个参数是新的文件名。如果改名成功,
rename()
返回0
,否则返回非零值。
rename("foo.txt", "bar.txt");
- 上面示例将
foo.txt
改名为bar.txt
。
- 注意,改名后的文件不能与现有文件同名。另外,如果要改名的文件已经打开了,必须先关闭,然后再改名,对打开的文件进行改名会失败。
- 下面是移动文件的例子。
rename("/tmp/evidence.txt", "/home/beej/nothing.txt");
下一节:C 语言允许声明变量的时候,加上一些特定的说明符(specifier),为编译器提供变量行为的额外信息。它的主要作用是帮助编译器优化代码,有时会对程序行为产生影响。