张赐荣——一位视障程序员。
赐荣小站: www.prc.cx

張賜榮

张赐荣的技术博客

博客园 首页 新随笔 联系 订阅 管理
  92 随笔 :: 27 文章 :: 2 评论 :: 77074 阅读

I/O 函数

C 语言提供了一些函数,用于与外部设备通信,称为输入输出函数,简称 I/O 函数。输入(import)指的是获取外部数据,输出(export)指的是向外部传递数据。

缓存和字节流

严格地说,输入输出函数并不是直接与外部设备通信,而是通过缓存(buffer)进行间接通信。这个小节介绍缓存是什么。
普通文件一般都保存在磁盘上面,跟 CPU 相比,磁盘读取或写入数据是一个很慢的操作。所以,程序直接读写磁盘是不可行的,可能每执行一行命令,都必须等半天。C 语言的解决方案,就是只要打开一个文件,就在内存里面为这个文件设置一个缓存区。
程序向文件写入数据时,程序先把数据放入缓存,等到缓存满了,再把里面的数据会一次性写入磁盘文件。这时,缓存区就空了,程序再把新的数据放入缓存,重复整个过程。
程序从文件读取数据时,文件先把一部分数据放到缓存里面,然后程序从缓存获取数据,等到缓存空了,磁盘文件再把新的数据放入缓存,重复整个过程。
内存的读写速度比磁盘快得多,缓存的设计减少了读写磁盘的次数,大大提高了程序的执行效率。另外,一次性移动大块数据,要比多次移动小块数据快得多。
这种读写模式,对于程序来说,就有点像水流(stream),不是一次性读取或写入所有数据,而是一个持续不断的过程。先操作一部分数据,等到缓存吞吐完这部分数据,再操作下一部分数据。这个过程就叫做字节流操作。
由于缓存读完就空了,所以字节流读取都是只能读一次,第二次就读不到了。这跟读取文件很不一样。
C 语言的输入输出函数,凡是涉及读写文件,都是属于字节流操作。输入函数从文件获取数据,操作的是输入流;输出函数向文件写入数据,操作的是输出流。

printf()

printf()是最常用的输出函数,用于屏幕输出,原型定义在头文件stdio.h,详见《基本语法》一章。

scanf()

基本用法

scanf()函数用于读取用户的键盘输入。程序运行到这个语句时,会停下来,等待用户从键盘输入。用户输入数据、按下回车键后,scanf()就会处理用户的输入,将其存入变量。它的原型定义在头文件stdio.h
scanf()的语法跟printf()类似。

scanf("%d", &i);

它的第一个参数是一个格式字符串,里面会放置占位符(与printf()的占位符基本一致),告诉编译器如何解读用户的输入,需要提取的数据是什么类型。这是因为 C 语言的数据都是有类型的,scanf()必须提前知道用户输入的数据类型,才能处理数据。它的其余参数就是存放用户输入的变量,格式字符串里面有多少个占位符,就有多少个变量。
上面示例中,scanf()的第一个参数%d,表示用户输入的应该是一个整数。%d就是一个占位符,%是占位符的标志,d表示整数。第二个参数&i表示,将用户从键盘输入的整数存入变量i
注意,变量前面必须加上&运算符(指针变量除外),因为scanf()传递的不是值,而是地址,即将变量i的地址指向用户输入的值。如果这里的变量是指针变量(比如字符串变量),那就不用加&运算符。
下面是一次将键盘输入读入多个变量的例子。

scanf("%d%d%f%f", &i, &j, &x, &y);

上面示例中,格式字符串%d%d%f%f,表示用户输入的前两个是整数,后两个是浮点数,比如1 -20 3.4 -4.0e3。这四个值依次放入ijxy四个变量。
scanf()处理数值占位符时,会自动过滤空白字符,包括空格、制表符、换行符等。所以,用户输入的数据之间,有一个或多个空格不影响scanf()解读数据。另外,用户使用回车键,将输入分成几行,也不影响解读。

1
-20
3.4
-4.0e3

上面示例中,用户分成四行输入,得到的结果与一行输入是完全一样的。每次按下回车键以后,scanf()就会开始解读,如果第一行匹配第一个占位符,那么下次按下回车键时,就会从第二个占位符开始解读。
scanf()处理用户输入的原理是,用户的输入先放入缓存,等到按下回车键后,按照占位符对缓存进行解读。解读用户输入时,会从上一次解读遗留的第一个字符开始,直到读完缓存,或者遇到第一个不符合条件的字符为止。

int x;
float y;
// 用户输入 "    -13.45e12# 0"
scanf("%d", &x);
scanf("%f", &y);

上面示例中,scanf()读取用户输入时,%d占位符会忽略起首的空格,从-处开始获取数据,读取到-13停下来,因为后面的.不属于整数的有效字符。这就是说,占位符%d会读到-13
第二次调用scanf()时,就会从上一次停止解读的地方,继续往下读取。这一次读取的首字符是.,由于对应的占位符是%f,会读取到.45e12,这是采用科学计数法的浮点数格式。后面的#不属于浮点数的有效字符,所以会停在这里。
由于scanf()可以连续处理多个占位符,所以上面的例子也可以写成下面这样。

scanf("%d%f", &x, &y);

scanf()的返回值是一个整数,表示成功读取的变量个数。如果没有读取任何项,或者匹配失败,则返回0。如果读取到文件结尾,则返回常量 EOF。

占位符

scanf()常用的占位符如下,与printf()的占位符基本一致。

  • %c:字符。
  • %d:整数。
  • %ffloat类型浮点数。
  • %lfdouble类型浮点数。
  • %Lflong double类型浮点数。
  • %s:字符串。
  • %[]:在方括号中指定一组匹配的字符(比如%[0-9]),遇到不在集合之中的字符,匹配将会停止。
    上面所有占位符之中,除了%c以外,都会自动忽略起首的空白字符。%c不忽略空白字符,总是返回当前第一个字符,无论该字符是否为空格。如果要强制跳过字符前的空白字符,可以写成scanf(" %c", &ch),即%c前加上一个空格,表示跳过零个或多个空白字符。
    下面要特别说一下占位符%s,它其实不能简单地等同于字符串。它的规则是,从当前第一个非空白字符开始读起,直到遇到空白字符(即空格、换行符、制表符等)为止。因为%s不会包含空白字符,所以无法用来读取多个单词,除非多个%s一起使用。这也意味着,scanf()不适合读取可能包含空格的字符串,比如书名或歌曲名。另外,scanf()遇到%s占位符,会在字符串变量末尾存储一个空字符\0
    scanf()将字符串读入字符数组时,不会检测字符串是否超过了数组长度。所以,储存字符串时,很可能会超过数组的边界,导致预想不到的结果。为了防止这种情况,使用%s占位符时,应该指定读入字符串的最长长度,即写成%[m]s,其中的[m]是一个整数,表示读取字符串的最大长度,后面的字符将被丢弃。
char name[11];
scanf("%10s", name);

上面示例中,name是一个长度为11的字符数组,scanf()的占位符%10s表示最多读取用户输入的10个字符,后面的字符将被丢弃,这样就不会有数组溢出的风险了。

赋值忽略符

有时,用户的输入可能不符合预定的格式。

scanf("%d-%d-%d", &year, &month, &day);

上面示例中,如果用户输入2020-01-01,就会正确解读出年、月、日。问题是用户可能输入其他格式,比如2020/01/01,这种情况下,scanf()解析数据就会失败。
为了避免这种情况,scanf()提供了一个赋值忽略符(assignment suppression character)*。只要把*加在任何占位符的百分号后面,该占位符就不会返回值,解析后将被丢弃。

scanf("%d%*c%d%*c%d", &year, &month, &day);

上面示例中,%*c就是在占位符的百分号后面,加入了赋值忽略符*,表示这个占位符没有对应的变量,解读后不必返回。

sscanf()

sscanf()函数与scanf()很类似,不同之处是sscanf()从字符串里面,而不是从用户输入获取数据。它的原型定义在头文件stdio.h里面。

int sscanf(const char* s, const char* format, ...);

sscanf()的第一个参数是一个字符串指针,用来从其中获取数据。其他参数都与scanf()相同。
sscanf()主要用来处理其他输入函数读入的字符串,从其中提取数据。

fgets(str, sizeof(str), stdin);
sscanf(str, "%d%d", &i, &j);

上面示例中,fgets()先从标准输入获取了一行数据(fgets()的介绍详见下一章),存入字符数组str。然后,sscanf()再从字符串str里面提取两个整数,放入变量ij
sscanf()的一个好处是,它的数据来源不是流数据,所以可以反复使用,不像scanf()的数据来源是流数据,只能读取一次。
sscanf()的返回值是成功赋值的变量的数量,如果提取失败,返回常量 EOF。

getchar(),putchar()

(1)getchar()
getchar()函数返回用户从键盘输入的一个字符,使用时不带有任何参数。程序运行到这个命令就会暂停,等待用户从键盘输入,等同于使用scanf()方法读取一个字符。它的原型定义在头文件stdio.h

char ch;
ch = getchar();
// 等同于
scanf("%c", &ch);

getchar()不会忽略起首的空白字符,总是返回当前读取的第一个字符,无论是否为空格。如果读取失败,返回常量 EOF,由于 EOF 通常是-1,所以返回值的类型要设为 int,而不是 char。
由于getchar()返回读取的字符,所以可以用在循环条件之中。

while (getchar() != '\n')
;

上面示例中,只有读到的字符等于换行符(\n),才会退出循环,常用来跳过某行。while循环的循环体没有任何语句,表示对该行不执行任何操作。
下面的例子是计算某一行的字符长度。

int len = 0;
while(getchar() != '\n')
len++;

上面示例中,getchar()每读取一个字符,长度变量len就会加1,直到读取到换行符为止,这时len就是该行的字符长度。
下面的例子是跳过空格字符。

while ((ch = getchar()) == ' ')
;

上面示例中,结束循环后,变量ch等于第一个非空格字符。
(2)putchar()
putchar()函数将它的参数字符输出到屏幕,等同于使用printf()输出一个字符。它的原型定义在头文件stdio.h

putchar(ch);
// 等同于
printf("%c", ch);

操作成功时,putchar()返回输出的字符,否则返回常量 EOF。
(3)小结
由于getchar()putchar()这两个函数的用法,要比scanf()printf()更简单,而且通常是用宏来实现,所以要比scanf()printf()更快。如果操作单个字符,建议优先使用这两个函数。

puts()

puts()函数用于将参数字符串显示在屏幕(stdout)上,并且自动在字符串末尾添加换行符。它的原型定义在头文件stdio.h

puts("Here are some messages:");
puts("Hello World");

上面示例中,puts()在屏幕上输出两行内容。
写入成功时,puts()返回一个非负整数,否则返回常量 EOF。

gets()

gets()函数以前用于从stdin读取整行输入,现在已经被废除了,仍然放在这里介绍一下。
该函数读取用户的一行输入,不会跳过起始处的空白字符,直到遇到换行符为止。这个函数会丢弃换行符,将其余字符放入参数变量,并在这些字符的末尾添加一个空字符\0,使其成为一个字符串。
它经常与puts()配合使用。

char words[81];
puts("Enter a string, please");
gets(words);

上面示例使用puts()在屏幕上输出提示,然后使用gets()获取用户的输入。
由于gets()获取的字符串,可能超过字符数组变量的最大长度,有安全风险,建议不要使用,改为使用fgets()

文件操作

本章介绍 C 语言如何操作文件。

文件指针

C 语言提供了一个 FILE 数据结构,记录了操作一个文件所需要的信息。该结构定义在头文件stdio.h,所有文件操作函数都要通过这个数据结构,获取文件信息。
开始操作一个文件之前,就要定义一个指向该文件的 FILE 指针,相当于获取一块内存区域,用来保存文件信息。

FILE* fp;

上面示例定义了一个 FILE 指针fp
下面是一个读取文件的完整示例。

#include <stdio.h>
int main(void) {
FILE* fp;
char c;
fp = fopen("hello.txt", "r");
if (fp == NULL) {
return -1;
}
c = fgetc(fp);
printf("%c\n", c);
fclose(fp);
return 0;
}

上面示例中,新建文件指针fp以后,依次使用了下面三个文件操作函数,分成三个步骤。其他的文件操作,大致上也是这样的步骤。
第一步,使用fopen()打开指定文件,返回一个 File 指针。如果出错,返回 NULL。
它相当于将指定文件的信息与新建的文件指针fp相关联,在 FILE 结构内部记录了这样一些信息:文件内部的当前读写位置、读写报错的记录、文件结尾指示器、缓冲区开始位置的指针、文件标识符、一个计数器(统计拷贝进缓冲区的字节数)等等。后继的操作就可以使用这个指针(而不是文件名)来处理指定文件。
同时,它还为文件建立一个缓存区。由于存在缓存区,也可以说fopen()函数“打开一个了流”,后继的读写文件都是流模式。
第二步,使用读写函数,从文件读取数据,或者向文件写入数据。上例使用了fgetc()函数,从已经打开的文件里面,读取一个字符。
fgetc()一调用,文件的数据块先拷贝到缓冲区。不同的计算机有不同的缓冲区大小,一般是512字节或是它的倍数,如4096或16384。随着计算机硬盘容量越来越大,缓冲区也越来越大。
fgetc()从缓冲区读取数据,同时将文件指针内部的读写位置指示器,指向所读取字符的下一个字符。所有的文件读取函数都使用相同的缓冲区,后面再调用任何一个读取函数,都将从指示器指向的位置,即上一次读取函数停止的位置开始读取。
当读取函数发现已读完缓冲区里面的所有字符时,会请求把下一个缓冲区大小的数据块,从文件拷贝到缓冲区中。读取函数就以这种方式,读完文件的所有内容,直到文件结尾。不过,上例是只从缓存区读取一个字符。当函数在缓冲区里面,读完文件的最后一个字符时,就把 FILE 结构里面的文件结尾指示器设置为真。于是,下一次再调用读取函数时,会返回常量 EOF。EOF 是一个整数值,代表文件结尾,一般是-1
第三步,fclose()关闭文件,同时清空缓存区。
上面是文件读取的过程,文件写入也是类似的方式,先把数据写入缓冲区,当缓冲区填满后,缓存区的数据将被转移到文件中。

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表示以独占模式写入文件,如果文件已经存在,就会打开失败。

标准流

Linux 系统默认提供三个已经打开的文件,它们的文件指针如下。

  • stdin(标准输入):默认来源为键盘,文件指针编号为0
  • stdout(标准输出):默认目的地为显示器,文件指针编号为1
  • stderr(标准错误):默认目的地为显示器,文件指针编号为2
    Linux 系统的文件,不一定是数据文件,也可以是设备文件,即文件代表一个可以读或写的设备。文件指针stdin默认是把键盘看作一个文件,读取这个文件,就能获取用户的键盘输入。同理,stdoutstderr默认是把显示器看作一个文件,将程序的运行结果写入这个文件,用户就能看到运行结果了。它们的区别是,stdout写入的是程序的正常运行结果,stderr写入的是程序的报错信息。
    这三个输入和输出渠道,是 Linux 默认提供的,所以分别称为标准输入(stdin)、标准输出(stdout)和标准错误(stderr)。因为它们的实现是一样的,都是文件流,所以合称为“标准流”。
    Linux 允许改变这三个文件指针(文件流)指向的文件,这称为重定向(redirection)。
    如果标准输入不绑定键盘,而是绑定其他文件,可以在文件名前面加上小于号<,跟在程序名后面。这叫做“输入重定向”(input redirection)。
$ demo < in.dat

上面示例中,demo程序代码里面的stdin,将指向文件in.dat,即从in.dat获取数据。
如果标准输出绑定其他文件,而不是显示器,可以在文件名前加上大于号>,跟在程序名后面。这叫做“输出重定向”(output redirection)。

$ demo > out.dat

上面示例中,demo程序代码里面的stdout,将指向文件out.dat,即向out.dat写入数据。
输出重定向>会先擦去out.dat的所有原有的内容,然后再写入。如果希望写入的信息追加在out.dat的结尾,可以使用>>符号。

$ demo >> out.dat

上面示例中,demo程序代码里面的stdout,将向文件out.dat写入数据。与>不同的是,写入的开始位置是out.dat的文件结尾。
标准错误的重定向符号是2>。其中的2代表文件指针的编号,即2>表示将2号文件指针的写入,重定向到err.txt。2号文件指针就是标准错误stderr

$ demo > out.dat 2> err.txt

上面示例中,demo程序代码里面的stderr,会向文件err.txt写入报错信息。而stdout向文件out.dat写入。
输入重定向和输出重定向,也可以结合在一条命令里面。

$ demo < in.dat > out.dat
// or
$ demo > out.dat < in.dat

重定向还有另一种情况,就是将一个程序的标准输出stdout,指向另一个程序的标准输入stdin,这时要使用|符号。

$ random | sum

上面示例中,random程序代码里面的stdout的写入,会从sum程序代码里面的stdin被读取。

fclose()

fclose()用来关闭已经使用fopen()打开的文件。它的原型定义在stdin.h

int fclose(FILE* stream);

它接受一个文件指针fp作为参数。如果成功关闭文件,fclose()函数返回整数0;如果操作失败(比如磁盘已满,或者出现 I/O 错误),则返回一个特殊值 EOF(详见下一小节)。

if (fclose(fp) != 0)
printf("Something wrong.");

不再使用的文件,都应该使用fclose()关闭,否则无法释放资源。一般来说,系统对同时打开的文件数量有限制,及时关闭文件可以避免超过这个限制。

EOF

C 语言的文件操作函数的设计是,如果遇到文件结尾,就返回一个特殊值。程序接收到这个特殊值,就知道已经到达文件结尾了。
头文件stdio.h为这个特殊值定义了一个宏EOF(end of file 的缩写),它的值一般是-1。这是因为从文件读取的二进制值,不管作为无符号数字解释,还是作为 ASCII 码解释,都不可能是负值,所以可以很安全地返回-1,不会跟文件本身的数据相冲突。
需要注意的是,不像字符串结尾真的存储了\0这个值,EOF并不存储在文件结尾,文件中并不存在这个值,完全是文件操作函数发现到达了文件结尾,而返回这个值。

freopen()

freopen()用于新打开一个文件,直接关联到某个已经打开的文件指针。这样可以复用文件指针。它的原型定义在头文件stdio.h

FILE* freopen(char* filename, char* mode, FILE stream);

它跟fopen()相比,就是多出了第三个参数,表示要复用的文件指针。其他两个参数都一样,分别是文件名和打开模式。

freopen("output.txt", "w", stdout);
printf("hello");

上面示例将文件output.txt关联到stdout,此后向stdout写入的内容,都会写入output.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()

fputc()putc()用于向文件写入一个字符。它们的用法跟putchar()类似,区别是putchar()是向stdout写入,而这两个函数是向文件写入。它们的原型定义在头文件stdio.h

int fputc(int char, FILE *stream);
int putc(int char, FILE *stream);

fputc()putc()的用法是一样,都接受两个参数,第一个参数是待写入的字符,第二个参数是文件指针。它们的区别是,putc()通常是使用宏来实现,而fputc()只作为函数来实现,所以理论上,putc()的性能会好一点。
写入成功时,它们返回写入的字符;写入失败时,返回 EOF。

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里面,读取两个整数,放入变量ij
使用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()以及后面要介绍的fread(),比较适合读写二进制数据,因为它们不会对写入的数据进行解读。二进制数据可能包含空字符\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()

rewind()函数可以让文件的内部指示器回到文件开始处。它的原型定义在stdio.h

void rewind(file* stream);

它接受一个文件指针作为参数。
rewind(fp)基本等价于fseek(fp, 0l, seek_set),唯一的区别是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()

remove()函数用于删除指定文件。它的原型定义在头文件stdio.h

int remove(const char* filename);

它接受文件名作为参数。如果删除成功,remove()返回0,否则返回非零值。

remove("foo.txt");

上面示例删除了foo.txt文件。
注意,删除文件必须是在文件关闭的状态下。如果是用fopen()打开的文件,必须先用fclose()关闭后再删除。

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");
posted on   张赐荣  阅读(751)  评论(0编辑  收藏  举报
编辑推荐:
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!

感谢访问张赐荣的技术分享博客!
博客地址:https://cnblogs.com/netlog/
知乎主页:https://www.zhihu.com/people/tzujung-chang
个人网站:https://prc.cx/

点击右上角即可分享
微信分享提示