Skip to content

2. 标准I/O库函数

我们已经多次用到了文件,例如源文件、目标文件、可执行文件、库文件等,现在学习如何用C标准库对文件进行读写操作,对文件的读写也属于I/O操作的一种,本节介绍的大部分函数在头文件stdio.h中声明,称为标准I/O库函数。

2.1 文件的基本概念

文件可分为文本文件(Text File)和二进制文件(Binary File)两种,源文件是文本文件,而目标文件、可执行文件和库文件是二进制文件。文本文件是用来保存字符的,文件中的字节都是字符的某种编码(例如ASCII或UTF-8),用cat命令可以查看其中的字符,用vi可以编辑其中的字符,而二进制文件不是用来保存字符的,文件中的字节表示其它含义,例如可执行文件中有些字节表示指令,有些字节表示各Section和Segment在文件中的位置,有些字节表示各Segment的加载地址。

第5.1节 "目标文件"中我们用hexdump命令查看过一个二进制文件。我们再做一个小实验,用vi编辑一个文件textfile,在其中输入5678然后保存退出,用ls -l命令可以看到它的长度是5:

bash
$ ls -l textfile 
-rw-r--r-- 1 akaedu akaedu 5 2009-03-20 10:58 textfile

5678四个字符各占一个字节,vi会自动在文件末尾加一个换行符,所以文件长度是5。用od命令查看该文件的内容:

bash
$ od -tx1 -tc -Ax textfile 
000000 35 36 37 38 0a
         5   6   7   8  \n
000005

-tx1选项表示将文件中的字节以十六进制的形式列出来,每组一个字节,-tc选项表示将文件中的ASCII码以字符形式列出来。和hexdump类似,输出结果最左边的一列是文件中的地址,默认以八进制显示,-Ax选项要求以十六进制显示文件中的地址。这样我们看到,这个文件中保存了5个字符,以ASCII码保存。ASCII码的范围是0~127,所以ASCII码文本文件中每个字节只用到低7位,最高位都是0。以后我们会经常用到od命令。

文本文件是一个模糊的概念。有些时候说文本文件是指用vi可以编辑出来的文件,例如/etc目录下的各种配置文件,这些文件中只包含ASCII码中的可见字符,而不包含像'\0'这种不可见字符,也不包含最高位是1的非ASCII码字节。从广义上来说,只要是专门保存字符的文件都算文本文件,包含不可见字符的也算,采用其它字符编码(例如UTF-8编码)的也算。

2.2 fopen/fclose

在操作文件之前要用fopen打开文件,操作完毕要用fclose关闭文件。打开文件就是在操作系统中分配一些资源用于保存该文件的状态信息,并得到该文件的标识,以后用户程序就可以用这个标识对文件做各种操作,关闭文件则释放文件在操作系统中占用的资源,使文件的标识失效,用户程序就无法再操作这个文件了。

c
#include <stdio.h>

FILE *fopen(const char *path, const char *mode);
返回值:成功返回文件指针,出错返回NULL并设置errno

path是文件的路径名,mode表示打开方式。如果文件打开成功,就返回一个FILE *文件指针来标识这个文件。以后调用其它函数对文件做读写操作都要提供这个指针,以指明对哪个文件进行操作。FILE是C标准库中定义的结构体类型,其中包含该文件在内核中标识(在第2节 "C标准I/O库函数与Unbuffered I/O函数"将会讲到这个标识叫做文件描述符)、I/O缓冲区和当前读写位置等信息,但调用者不必知道FILE结构体都有哪些成员,我们很快就会看到,调用者只是把文件指针在库函数接口之间传来传去,而文件指针所指的FILE结构体的成员在库函数内部维护,调用者不应该直接访问这些成员,这种编程思想在面向对象方法论中称为封装(Encapsulation)。像FILE *这样的指针称为不透明指针(Opaque Pointer)或者叫句柄(Handle),FILE *指针就像一个把手(Handle),抓住这个把手就可以打开门或抽屉,但用户只能抓这个把手,而不能直接抓门或抽屉。

下面说说参数pathmodepath可以是相对路径也可以是绝对路径,mode表示打开方式是读还是写。比如fp = fopen("/tmp/file2", "w");表示打开绝对路径/tmp/file2,只做写操作,path也可以是相对路径,比如fp = fopen("file.a", "r");表示在当前工作目录下打开文件file.a,只做读操作,再比如fp = fopen("../a.out", "r");只读打开当前工作目录上一层目录下的a.outfp = fopen("Desktop/file3", "w");只写打开当前工作目录下子目录Desktop下的file3。相对路径是相对于当前工作目录(Current Working Directory)的路径,每个进程都有自己的当前工作目录,Shell进程的当前工作目录可以用pwd命令查看:

bash
$ pwd
/home/akaedu

通常Linux发行版都把Shell配置成在提示符前面显示当前工作目录,例如~$表示当前工作目录是主目录,/etc$表示当前工作目录是/etc。用cd命令可以改变Shell进程的当前工作目录。在Shell下敲命令启动新的进程,则该进程的当前工作目录继承自Shell进程的当前工作目录,该进程也可以调用chdir(2)函数改变自己的当前工作目录。

mode参数是一个字符串,由rwatb+六个字符组合而成,r表示读,w表示写,a表示追加(Append),在文件末尾追加数据使文件的尺寸增大。t表示文本文件,b表示二进制文件,有些操作系统的文本文件和二进制文件格式不同,而在UNIX系统中,无论文本文件还是二进制文件都是由一串字节组成,tb没有区分,用哪个都一样,也可以省略不写。如果省略tbrwa+四个字符有以下6种合法的组合:

  • "r" - 只读,文件必须已存在
  • "w" - 只写,如果文件不存在则创建,如果文件已存在则把文件长度截断(Truncate)为0字节再重新写,也就是替换掉原来的文件内容
  • "a" - 只能在文件末尾追加数据,如果文件不存在则创建
  • "r+" - 允许读和写,文件必须已存在
  • "w+" - 允许读和写,如果文件不存在则创建,如果文件已存在则把文件长度截断为0字节再重新写
  • "a+" - 允许读和追加数据,如果文件不存在则创建

在打开一个文件时如果出错,fopen将返回NULL并设置errnoerrno稍后介绍。在程序中应该做出错处理,通常这样写:

c
if ( (fp = fopen("/tmp/file1", "r")) == NULL) {
    printf("error open file /tmp/file1!\n");
    exit(1);
}

比如/tmp/file1这个文件不存在,而r打开方式又不会创建这个文件,fopen就会出错返回。

再说说fclose函数。

c
#include <stdio.h>

int fclose(FILE *fp);
返回值:成功返回0,出错返回EOF并设置errno

把文件指针传给fclose可以关闭它所标识的文件,关闭之后该文件指针就无效了,不能再使用了。如果fclose调用出错(比如传给它一个无效的文件指针)则返回EOF并设置errnoerrno稍后介绍,EOFstdio.h中定义:

c
/* End of file character.
   Some things throughout the library rely on this being -1.  */
#ifndef EOF
# define EOF (-1)
#endif

它的值是-1。fopen调用应该和fclose调用配对,打开文件操作完之后一定要记得关闭。如果不调用fclose,在进程退出时系统会自动关闭文件,但是不能因此就忽略fclose调用,如果写一个长年累月运行的程序(比如网络服务器程序),打开的文件都不关闭,堆积得越来越多,就会占用越来越多的系统资源。

2.3 stdin/stdout/stderr

我们经常用printf打印到屏幕,也用过scanf读键盘输入,这些也属于I/O操作,但不是对文件做I/O操作而是对终端设备做I/O操作。所谓终端(Terminal)是指人机交互的设备,也就是可以接受用户输入并输出信息给用户的设备。在计算机刚诞生的年代,终端是电传打字机和打印机,现在的终端通常是键盘和显示器。终端设备和文件一样也需要先打开后操作,终端设备也有对应的路径名,/dev/tty就表示和当前进程相关联的终端设备(在第1.1节 "终端的基本概念"会讲到这叫进程的控制终端)。也就是说,/dev/tty不是一个普通的文件,它不表示磁盘上的一组数据,而是表示一个设备。用ls命令查看这个文件:

bash
$ ls -l /dev/tty
crw-rw-rw- 1 root dialout 5, 0 2009-03-20 19:31 /dev/tty

开头的c表示文件类型是字符设备。中间的5, 0是它的设备号,主设备号5,次设备号0,主设备号标识内核中的一个设备驱动程序,次设备号标识该设备驱动程序管理的一个设备。内核通过设备号找到相应的驱动程序,完成对该设备的操作。我们知道常规文件的这一列应该显示文件尺寸,而设备文件的这一列显示设备号,这表明设备文件是没有文件尺寸这个属性的,因为设备文件在磁盘上不保存数据,对设备文件做读写操作并不是读写磁盘上的数据,而是在读写设备。UNIX的传统是Everything is a file,键盘、显示器、串口、磁盘等设备在/dev目录下都有一个特殊的设备文件与之对应,这些设备文件也可以像普通文件一样打开、读、写和关闭,使用的函数接口是相同的。本书中不严格区分"文件"和"设备"这两个概念,遇到"文件"这个词,读者可以根据上下文理解它是指普通文件还是设备,如果需要强调是保存在磁盘上的普通文件,本书会用"常规文件"(Regular File)这个词。

那为什么printfscanf不用打开就能对终端设备进行操作呢?因为在程序启动时(在main函数还没开始执行之前)会自动把终端设备打开三次,分别赋给三个FILE *指针stdinstdoutstderr,这三个文件指针是libc中定义的全局变量,在stdio.h中声明,printfstdout写,而scanfstdin读,后面我们会看到,用户程序也可以直接使用这三个文件指针。这三个文件指针的打开方式都是可读可写的,但通常stdin只用于读操作,称为标准输入(Standard Input),stdout只用于写操作,称为标准输出(Standard Output),stderr也只用于写操作,称为标准错误输出(Standard Error),通常程序的运行结果打印到标准输出,而错误提示(例如gcc报的警告和错误)打印到标准错误输出,所以fopen的错误处理写成这样更符合惯例:

c
if ( (fp = fopen("/tmp/file1", "r")) == NULL) {
    fputs("Error open file /tmp/file1\n", stderr);
    exit(1);
}

fputs函数将在稍后详细介绍。不管是打印到标准输出还是打印到标准错误输出效果是一样的,都是打印到终端设备(也就是屏幕)了,那为什么还要分成标准输出和标准错误输出呢?以后我们会讲到重定向操作,可以把标准输出重定向到一个常规文件,而标准错误输出仍然对应终端设备,这样就可以把正常的运行结果和错误提示分开,而不是混在一起打印到屏幕了。

2.4 errno与perror函数

很多系统函数在错误返回时将错误原因记录在libc定义的全局变量errno中,每种错误原因对应一个错误码,请查阅errno(3)的Man Page了解各种错误码,errno在头文件errno.h中声明,是一个整型变量,所有错误码都是正整数。

如果在程序中打印错误信息时直接打印errno变量,打印出来的只是一个整数值,仍然看不出是什么错误。比较好的办法是用perrorstrerror函数将errno解释成字符串再打印。

c
#include <stdio.h>

void perror(const char *s);

perror函数将错误信息打印到标准错误输出,首先打印参数s所指的字符串,然后打印:号,然后根据当前errno的值打印错误原因。例如:

c
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    FILE *fp = fopen("abcde", "r");
    if (fp == NULL) {
        perror("Open file abcde");
        exit(1);
    }
    return 0;
}

如果文件abcde不存在,fopen返回-1并设置errnoENOENT,紧接着perror函数读取errno的值,将ENOENT解释成字符串No such file or directory并打印,最后打印的结果是Open file abcde: No such file or directory。虽然perror可以打印出错误原因,传给perror的字符串参数仍然应该提供一些额外的信息,以便在看到错误信息时能够很快定位是程序中哪里出了错,如果在程序中有很多个fopen调用,每个fopen打开不同的文件,那么在每个fopen的错误处理中打印文件名就很有帮助。

如果把上面的程序改成这样:

c
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

int main(void)
{
    FILE *fp = fopen("abcde", "r");
    if (fp == NULL) {
        perror("Open file abcde");
        printf("errno: %d\n", errno);
        exit(1);
    }
    return 0;
}

printf打印的错误号并不是fopen产生的错误号,而是perror产生的错误号。errno是一个全局变量,很多系统函数都会改变它,fopen函数Man Page中的ERRORS部分描述了它可能产生的错误码,perror函数的Man Page中没有ERRORS部分,说明它本身不产生错误码,但它调用的其它函数也有可能改变errno变量。大多数系统函数都有一个Side Effect,就是有可能改变errno变量(当然也有少数例外,比如strcpy),所以一个系统函数错误返回后应该马上检查errno,在检查errno之前不能再调用其它系统函数。

strerror函数可以根据错误号返回错误原因字符串。

c
#include <string.h>

char *strerror(int errnum);
返回值:错误码errnum所对应的字符串

这个函数返回指向静态内存的指针。以后学线程库时我们会看到,有些函数的错误码并不保存在errno中,而是通过返回值返回,就不能调用perror打印错误原因了,这时strerror就派上了用场:

c
fputs(strerror(n), stderr);

习题

  1. 在系统头文件中找到各种错误码的宏定义。

  2. 做几个小练习,看看fopen出错有哪些常见的原因。

打开一个没有访问权限的文件。

c
fp = fopen("/etc/shadow", "r");
if (fp == NULL) {
    perror("Open /etc/shadow");
    exit(1);
}

fopen也可以打开一个目录,传给fopen的第一个参数目录名末尾可以加/也可以不加/,但只允许以只读方式打开。试试如果以可写的方式打开一个存在的目录会怎么样呢?

c
fp = fopen("/home/akaedu/", "r+");
if (fp == NULL) {
    perror("Open /home/akaedu");
    exit(1);
}

请读者自己设计几个实验,看看你还能测试出哪些错误原因?

2.5 以字节为单位的I/O函数

fgetc函数从指定的文件中读一个字节,getchar从标准输入读一个字节,调用getchar()相当于调用fgetc(stdin)

c
#include <stdio.h>

int fgetc(FILE *stream);
int getchar(void);
返回值:成功返回读到的字节,出错或者读到文件末尾时返回EOF

注意在Man Page的函数原型中FILE *指针参数有时会起名叫stream,这是因为标准I/O库操作的文件有时也叫做流(Stream),文件由一串字节组成,每次可以读或写其中任意数量的字节,以后介绍TCP协议时会对流这个概念做更详细的解释。

对于fgetc函数的使用有以下几点说明:

  1. 要用fgetc函数读一个文件,该文件的打开方式必须是可读的。

  2. 系统对于每个打开的文件都记录着当前读写位置在文件中的地址(或者说距离文件开头的字节数),也叫偏移量(Offset)。当文件打开时,读写位置是0,每调用一次fgetc,读写位置向后移动一个字节,因此可以连续多次调用fgetc函数依次读取多个字节。

  3. fgetc成功时返回读到一个字节,本来应该是unsigned char型的,但由于函数原型中返回值是int型,所以这个字节要转换成int型再返回,那为什么要规定返回值是int型呢?因为出错或读到文件末尾时fgetc将返回EOF,即-1,保存在int型的返回值中是0xffffffff,如果读到字节0xff,由unsigned char型转换为int型是0x000000ff,只有规定返回值是int型才能把这两种情况区分开,如果规定返回值是unsigned char型,那么当返回值是0xff时无法区分到底是EOF还是字节0xff。如果需要保存fgetc的返回值,一定要保存在int型变量中,如果写成unsigned char c = fgetc(fp);,那么根据c的值又无法区分EOF和0xff字节了。注意,fgetc读到文件末尾时返回EOF,只是用这个返回值表示已读到文件末尾,并不是说每个文件末尾都有一个字节是EOF(根据上面的分析,EOF并不是一个字节)。

fputc函数向指定的文件写一个字节,putchar向标准输出写一个字节,调用putchar(c)相当于调用fputc(c, stdout)

c
#include <stdio.h>

int fputc(int c, FILE *stream);
int putchar(int c);
返回值:成功返回写入的字节,出错返回EOF

对于fputc函数的使用也要说明几点:

  1. 要用fputc函数写一个文件,该文件的打开方式必须是可写的(包括追加)。

  2. 每调用一次fputc,读写位置向后移动一个字节,因此可以连续多次调用fputc函数依次写入多个字节。但如果文件是以追加方式打开的,每次调用fputc时总是将读写位置移到文件末尾然后把要写入的字节追加到后面。

下面的例子演示了这四个函数的用法,从键盘读入一串字符写到一个文件中,再从这个文件中读出这些字符打印到屏幕上。

c
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    FILE *fp;
    int ch;

    if ( (fp = fopen("file2", "w+")) == NULL) {
        perror("Open file file2\n");
        exit(1);
    }
    while ( (ch = getchar()) != EOF)
        fputc(ch, fp);
    rewind(fp);
    while ( (ch = fgetc(fp)) != EOF)
        putchar(ch);
    fclose(fp);
    return 0;
}

从终端设备读有点特殊。当调用getchar()fgetc(stdin)时,如果用户没有输入字符,getchar函数就阻塞等待,所谓阻塞是指这个函数调用不返回,也就不能执行后面的代码,这个进程阻塞了,操作系统可以调度别的进程执行。从终端设备读还有一个特点,用户输入一般字符并不会使getchar函数返回,仍然阻塞着,只有当用户输入回车或者到达文件末尾时getchar才返回。这个程序的执行过程分析如下:

bash
$ ./a.out
hello(输入hello并回车,这时第一次调用getchar返回,读取字符h存到文件中,然后连续调用getchar五次,读取ello和换行符存到文件中,第七次调用getchar又阻塞了)
hey(输入hey并回车,第七次调用getchar返回,读取字符h存到文件中,然后连续调用getchar三次,读取ey和换行符存到文件中,第11次调用getchar又阻塞了)
(这时输入Ctrl-D,第11次调用getchar返回EOF,跳出循环,进入下一个循环,回到文件开头,把文件内容一个字节一个字节读出来打印,直到文件结束)
hello
hey

从终端设备输入时有两种方法表示文件结束,一种方法是在一行的开头输入Ctrl-D(如果不在一行的开头则需要连续输入两次Ctrl-D),另一种方法是利用Shell的Heredoc语法:

bash
$ ./a.out <<END
> hello
> hey
> END
hello
hey

<<END表示从下一行开始是标准输入,直到某一行开头出现END时结束。<<后面的结束符可以任意指定,不一定得是END,只要和输入的内容能区分开就行。

在上面的程序中,第一个while循环结束时fp所指文件的读写位置在文件末尾,然后调用rewind函数把读写位置移到文件开头,再进入第二个while循环从头读取文件内容。

习题

  1. 编写一个简单的文件复制程序。
bash
$ ./mycp dir1/fileA dir2/fileB

运行这个程序可以把dir1/fileA文件拷贝到dir2/fileB文件。注意各种出错处理。

  1. 虽然我说getchar要读到换行符才返回,但上面的程序并没有提供证据支持我的说法,如果看成每敲一个键getchar就返回一次,也能解释程序的运行结果。请写一个小程序证明getchar确实是读到换行符才返回的。

2.6 操作读写位置的函数

我们在上一节的例子中看到rewind函数把读写位置移到文件开头,本节介绍另外两个操作读写位置的函数,fseek可以任意移动读写位置,ftell可以返回当前的读写位置。

c
#include <stdio.h>

int fseek(FILE *stream, long offset, int whence);
返回值:成功返回0,出错返回-1并设置errno

long ftell(FILE *stream);
返回值:成功返回当前读写位置,出错返回-1并设置errno

void rewind(FILE *stream);

fseekwhenceoffset参数共同决定了读写位置移动到何处,whence参数的含义如下:

  • SEEK_SET - 从文件开头移动offset个字节
  • SEEK_CUR - 从当前位置移动offset个字节
  • SEEK_END - 从文件末尾移动offset个字节

offset可正可负,负值表示向前(向文件开头的方向)移动,正值表示向后(向文件末尾的方向)移动,如果向前移动的字节数超过了文件开头则出错返回,如果向后移动的字节数超过了文件末尾,再次写入时将增大文件尺寸,从原来的文件末尾到fseek移动之后的读写位置之间的字节都是0。

先前我们创建过一个文件textfile,其中有五个字节,5678加一个换行符,现在我们拿这个文件做实验。

c
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    FILE* fp;
    if ( (fp = fopen("textfile","r+")) == NULL) {
        perror("Open file textfile");
        exit(1);
    }
    if (fseek(fp, 10, SEEK_SET) != 0) {
        perror("Seek file textfile");
        exit(1);
    }
    fputc('K', fp);
    fclose(fp);
    return 0;
}

运行这个程序,然后查看文件textfile的内容:

bash
$ ./a.out 
$ od -tx1 -tc -Ax textfile 
000000 35 36 37 38 0a 00 00 00 00 00 4b
         5   6   7   8  \n  \0  \0  \0  \0  \0   K
00000b

fseek(fp, 10, SEEK_SET)将读写位置移到第10个字节处(其实是第11个字节,从0开始数),然后在该位置写入一个字符K,这样textfile文件就变长了,从第5到第9个字节自动被填充为0。

2.7 以字符串为单位的I/O函数

fgets从指定的文件中读一行字符到调用者提供的缓冲区中,gets从标准输入读一行字符到调用者提供的缓冲区中。

c
#include <stdio.h>

char *fgets(char *s, int size, FILE *stream);
char *gets(char *s);
返回值:成功时s指向哪返回的指针就指向哪,出错或者读到文件末尾时返回NULL

gets函数无需解释,Man Page的BUGS部分已经说得很清楚了:Never use gets()。gets函数的存在只是为了兼容以前的程序,我们写的代码都不应该调用这个函数。gets函数的接口设计得很有问题,就像strcpy一样,用户提供一个缓冲区,却不能指定缓冲区的大小,很可能导致缓冲区溢出错误,这个函数比strcpy更加危险,strcpy的输入和输出都来自程序内部,只要程序员小心一点就可以避免出问题,而gets读取的输入直接来自程序外部,用户可能通过标准输入提供任意长的字符串,程序员无法避免gets函数导致的缓冲区溢出错误,所以唯一的办法就是不要用它。

现在说说fgets函数,参数s是缓冲区的首地址,size是缓冲区的长度,该函数从stream所指的文件中读取以'\n'结尾的一行(包括'\n'在内)存到缓冲区s中,并且在该行末尾添加一个'\0'组成完整的字符串。

如果文件中的一行太长,fgets从文件中读了size-1个字符还没有读到'\n',就把已经读到的size-1个字符和一个'\0'字符存入缓冲区,文件中剩下的半行可以在下次调用fgets时继续读。

如果一次fgets调用在读入若干个字符后到达文件末尾,则将已读到的字符串加上'\0'存入缓冲区并返回,如果再次调用fgets则返回NULL,可以据此判断是否读到文件末尾。

注意,对于fgets来说,'\n'是一个特别的字符,而'\0'并无任何特别之处,如果读到'\0'就当作普通字符读入。如果文件中存在'\0'字符(或者说0x00字节),调用fgets之后就无法判断缓冲区中的'\0'究竟是从文件读上来的字符还是由fgets自动添加的结束符,所以fgets只适合读文本文件而不适合读二进制文件,并且文本文件中的所有字符都应该是可见字符,不能有'\0'

fputs向指定的文件写入一个字符串,puts向标准输出写入一个字符串。

c
#include <stdio.h>

int fputs(const char *s, FILE *stream);
int puts(const char *s);
返回值:成功返回一个非负整数,出错返回EOF

缓冲区s中保存的是以'\0'结尾的字符串,fputs将该字符串写入文件stream,但并不写入结尾的'\0'。与fgets不同的是,fputs并不关心的字符串中的'\n'字符,字符串中可以有'\n'也可以没有'\n'puts将字符串s写到标准输出(不包括结尾的'\0'),然后自动写一个'\n'到标准输出。

习题

  1. fgets/fputs写一个拷贝文件的程序,根据本节对fgets函数的分析,应该只能拷贝文本文件,试试用它拷贝二进制文件会出什么问题。

2.8 以记录为单位的I/O函数

c
#include <stdio.h>

size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
返回值:读或写的记录数,成功时返回的记录数等于nmemb,出错或读到文件末尾时返回的记录数小于nmemb,也可能返回0

freadfwrite用于读写记录,这里的记录是指一串固定长度的字节,比如一个int、一个结构体或者一个定长数组。参数size指出一条记录的长度,而nmemb指出要读或写多少条记录,这些记录在ptr所指的内存空间中连续存放,共占size * nmemb个字节,fread从文件stream中读出size * nmemb个字节保存到ptr中,而fwriteptr中的size * nmemb个字节写到文件stream中。

nmemb是请求读或写的记录数,freadfwrite返回的记录数有可能小于nmemb指定的记录数。例如当前读写位置距文件末尾只有一条记录的长度,调用fread时指定nmemb为2,则返回值为1。如果当前读写位置已经在文件末尾了,或者读文件时出错了,则fread返回0。如果写文件时出错了,则fwrite的返回值小于nmemb指定的值。下面的例子由两个程序组成,一个程序把结构体保存到文件中,另一个程序和从文件中读出结构体。

c
/* writerec.c */
#include <stdio.h>
#include <stdlib.h>

struct record {
    char name[10];
    int age;
};

int main(void)
{
    struct record array[2] = {{"Ken", 24}, {"Knuth", 28}};
    FILE *fp = fopen("recfile", "w");
    if (fp == NULL) {
        perror("Open file recfile");
        exit(1);
    }
    fwrite(array, sizeof(struct record), 2, fp);
    fclose(fp);
    return 0;
}
c
/* readrec.c */
#include <stdio.h>
#include <stdlib.h>

struct record {
    char name[10];
    int age;
};

int main(void)
{
    struct record array[2];
    FILE *fp = fopen("recfile", "r");
    if (fp == NULL) {
        perror("Open file recfile");
        exit(1);
    }
    fread(array, sizeof(struct record), 2, fp);
    printf("Name1: %s\tAge1: %d\n", array[0].name, array[0].age);
    printf("Name2: %s\tAge2: %d\n", array[1].name, array[1].age);
    fclose(fp);
    return 0;
}
bash
$ gcc writerec.c -o writerec
$ gcc readrec.c -o readrec
$ ./writerec
$ od -tx1 -tc -Ax recfile 
000000 4b 65 6e 00 00 00 00 00 00 00 00 00 18 00 00 00
         K   e   n  \0  \0  \0  \0  \0  \0  \0  \0  \0 030  \0  \0  \0
000010 4b 6e 75 74 68 00 00 00 00 00 00 00 1c 00 00 00
         K   n   u   t   h  \0  \0  \0  \0  \0  \0  \0 034  \0  \0  \0
000020
$ ./readrec 
Name1: Ken	Age1: 24
Name2: Knuth	Age2: 28

我们把一个struct record结构体看作一条记录,由于结构体中有填充字节,每条记录占16字节,把两条记录写到文件中共占32字节。该程序生成的recfile文件是二进制文件而非文本文件,因为其中不仅保存着字符型数据,还保存着整型数据24和28(在od命令的输出中以八进制显示为030和034)。注意,直接在文件中读写结构体的程序是不可移植的,如果在一种平台上编译运行writebin.c程序,把生成的recfile文件拷到另一种平台并在该平台上编译运行readbin.c程序,则不能保证正确读出文件的内容,因为不同平台的大小端可能不同(因而对整型数据的存储方式不同),结构体的填充方式也可能不同(因而同一个结构体所占的字节数可能不同,age成员在name成员之后的什么位置也可能不同)。

2.9 格式化I/O函数

现在该正式讲一下printfscanf函数了,这两个函数都有很多种形式。

c
#include <stdio.h>

int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...);
int sprintf(char *str, const char *format, ...);
int snprintf(char *str, size_t size, const char *format, ...);

#include <stdarg.h>