我们已经多次用到了文件,例如源文件、目标文件、可执行文件、库文件等,现在学习如何用C标准库对文件进行读写操作,对文件的读写也属于I/O操作的一种,本节介绍的大部分函数在头文件stdio.h
中声明,称为标准I/O库函数。
文件可分为文本文件(Text File)和二进制文件(Binary File)两种,源文件是文本文件,而目标文件、可执行文件和库文件是二进制文件。文本文件是用来保存字符的,文件中的字节都是字符的某种编码(例如ASCII或UTF-8),用cat
命令可以查看其中的字符,用vi
可以编辑其中的字符,而二进制文件不是用来保存字符的,文件中的字节表示其它含义,例如可执行文件中有些字节表示指令,有些字节表示各Section和Segment在文件中的位置,有些字节表示各Segment的加载地址。
在“目标文件”一节中我们用hexdump
命令查看过一个二进制文件。我们再做一个小实验,用vi
编辑一个文件textfile
,在其中输入5678
然后保存退出,用ls -l
命令可以看到它的长度是5:
$ ls -l textfile -rw-r--r-- 1 akaedu akaedu 5 2009-03-20 10:58 textfile
5678
四个字符各占一个字节,vi
会自动在文件末尾加一个换行符,所以文件长度是5。用od
命令查看该文件的内容:
$ 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编码)的也算。
在操作文件之前要用fopen
打开文件,操作完毕要用fclose
关闭文件。打开文件就是在操作系统中分配一些资源用于保存该文件的状态信息,并得到该文件的标识,以后用户程序就可以用这个标识对文件做各种操作,关闭文件则释放文件在操作系统中占用的资源,使文件的标识失效,用户程序就无法再操作这个文件了。
#include <stdio.h> FILE *fopen(const char *path, const char *mode); 返回值:成功返回文件指针,出错返回NULL并设置errno
path
是文件的路径名,mode
表示打开方式。如果文件打开成功,就返回一个FILE *
文件指针来标识这个文件。以后调用其它函数对文件做读写操作都要提供这个指针,以指明对哪个文件进行操作。FILE
是C标准库中定义的结构体类型,其中包含该文件在内核中标识(在“C标准I/O库函数与Unbuffered I/O函数”一节将会讲到这个标识叫做文件描述符)、I/O缓冲区和当前读写位置等信息,但调用者不必知道FILE
结构体都有哪些成员,我们很快就会看到,调用者只是把文件指针在库函数接口之间传来传去,而文件指针所指的FILE
结构体的成员在库函数内部维护,调用者不应该直接访问这些成员,这种编程思想在面向对象方法论中称为封装(Encapsulation)。像FILE *
这样的指针称为不透明指针(Opaque Pointer)或者叫句柄(Handle),FILE *
指针就像一个把手(Handle),抓住这个把手就可以打开门或抽屉,但用户只能抓这个把手,而不能直接抓门或抽屉。
下面说说参数path
和mode
,path
可以是相对路径也可以是绝对路径,mode
表示打开方式是读还是写。比如fp = fopen("/tmp/file2", "w");
表示打开绝对路径/tmp/file2
,只做写操作,path
也可以是相对路径,比如fp = fopen("file.a", "r");
表示在当前工作目录下打开文件file.a
,只做读操作,再比如fp = fopen("../a.out", "r");
只读打开当前工作目录上一层目录下的a.out
,fp = fopen("Desktop/file3", "w");
只写打开当前工作目录下子目录Desktop
下的file3
。相对路径是相对于当前工作目录(Current Working Directory)的路径,每个进程都有自己的当前工作目录,Shell进程的当前工作目录可以用pwd
命令查看:
$ pwd /home/akaedu
通常Linux发行版都把Shell配置成在提示符前面显示当前工作目录,例如~$
表示当前工作目录是主目录,/etc$
表示当前工作目录是/etc
。用cd
命令可以改变Shell进程的当前工作目录。在Shell下敲命令启动新的进程,则该进程的当前工作目录继承自Shell进程的当前工作目录,该进程也可以调用chdir(2)
函数改变自己的当前工作目录。
mode
参数是一个字符串,由rwatb+
六个字符组合而成,r
表示读,w
表示写,a
表示追加(Append),在文件末尾追加数据使文件的尺寸增大。t
表示文本文件,b
表示二进制文件,有些操作系统的文本文件和二进制文件格式不同,而在UNIX系统中,无论文本文件还是二进制文件都是由一串字节组成,t
和b
没有区分,用哪个都一样,也可以省略不写。如果省略t
和b
,rwa+
四个字符有以下6种合法的组合:
在打开一个文件时如果出错,fopen
将返回NULL
并设置errno
,errno
稍后介绍。在程序中应该做出错处理,通常这样写:
if ( (fp = fopen("/tmp/file1", "r")) == NULL) { printf("error open file /tmp/file1!\n"); exit(1); }
比如/tmp/file1
这个文件不存在,而r
打开方式又不会创建这个文件,fopen
就会出错返回。
再说说fclose
函数。
#include <stdio.h> int fclose(FILE *fp); 返回值:成功返回0,出错返回EOF并设置errno
把文件指针传给fclose
可以关闭它所标识的文件,关闭之后该文件指针就无效了,不能再使用了。如果fclose
调用出错(比如传给它一个无效的文件指针)则返回EOF
并设置errno
,errno
稍后介绍,EOF
在stdio.h
中定义:
/* 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
调用,如果写一个长年累月运行的程序(比如网络服务器程序),打开的文件都不关闭,堆积得越来越多,就会占用越来越多的系统资源。
我们经常用printf
打印到屏幕,也用过scanf
读键盘输入,这些也属于I/O操作,但不是对文件做I/O操作而是对终端设备做I/O操作。所谓终端(Terminal)是指人机交互的设备,也就是可以接受用户输入并输出信息给用户的设备。在计算机刚诞生的年代,终端是电传打字机和打印机,现在的终端通常是键盘和显示器。终端设备和文件一样也需要先打开后操作,终端设备也有对应的路径名,/dev/tty
就表示和当前进程相关联的终端设备(在“终端的基本概念”一节会讲到这叫进程的控制终端)。也就是说,/dev/tty
不是一个普通的文件,它不表示磁盘上的一组数据,而是表示一个设备。用ls
命令查看这个文件:
$ 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
目录下都有一个特殊的设备文件与之对应,这些设备文件也可以像普通文件一样打开、读、写和关闭,使用的函数接口是相同的。
那为什么printf
和scanf
不用打开就能对终端设备进行操作呢?因为在程序启动时(在main
函数还没开始执行之前)会自动把终端设备打开三次,分别赋给三个FILE *
指针stdin
、stdout
和stderr
,这三个文件指针是libc
中定义的全局变量,在stdio.h
中声明,printf
向stdout
写,而scanf
从stdin
读,后面我们会看到,用户程序也可以直接使用这三个文件指针。这三个文件指针的打开方式都是可读可写的,但通常stdin
只用于读操作,称为标准输入(Standard Input),stdout
只用于写操作,称为标准输出(Standard Output),stderr
也只用于写操作,称为标准错误输出(Standard Error),通常程序的运行结果打印到标准输出,而错误提示(例如gcc
报的警告和错误)打印到标准错误输出,所以fopen
的错误处理写成这样更符合惯例:
if ( (fp = fopen("/tmp/file1", "r")) == NULL) { fputs("Error open file /tmp/file1\n", stderr); exit(1); }
fputs
函数将在稍后详细介绍。不管是打印到标准输出还是打印到标准错误输出效果是一样的,都是打印到终端设备(也就是屏幕)了,那为什么还要分成标准输出和标准错误输出呢?以后我们会讲到重定向操作,可以把标准输出重定向到一个常规文件,而标准错误输出仍然对应终端设备,这样就可以把正常的运行结果和错误提示分开,而不是混在一起打印到屏幕了。
很多系统函数在错误返回时将错误原因记录在libc
定义的全局变量errno
中,每种错误原因对应一个错误码,请查阅errno(3)
的Man Page了解各种错误码,errno
在头文件errno.h
中声明,是一个整型变量,所有错误码都是正整数。
如果在程序中打印错误信息时直接打印errno
变量,打印出来的只是一个整数值,仍然看不出是什么错误。比较好的办法是用perror
或strerror
函数将errno
解释成字符串再打印。
#include <stdio.h> void perror(const char *s);
perror
函数将错误信息打印到标准错误输出,首先打印参数s
所指的字符串,然后打印:号,然后根据当前errno
的值打印错误原因。例如:
#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并设置errno
为ENOENT
,紧接着perror
函数读取errno
的值,将ENOENT
解释成字符串No such file or directory
并打印,最后打印的结果是Open file abcde: No such file or directory
。虽然perror
可以打印出错误原因,传给perror
的字符串参数仍然应该提供一些额外的信息,以便在看到错误信息时能够很快定位是程序中哪里出了错,如果在程序中有很多个fopen
调用,每个fopen
打开不同的文件,那么在每个fopen
的错误处理中打印文件名就很有帮助。
如果把上面的程序改成这样:
#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
函数可以根据错误号返回错误原因字符串。
#include <string.h> char *strerror(int errnum); 返回值:错误码errnum所对应的字符串
这个函数返回指向静态内存的指针。以后学线程库时我们会看到,有些函数的错误码并不保存在errno
中,而是通过返回值返回,就不能调用perror
打印错误原因了,这时strerror
就派上了用场:
fputs(strerror(n), stderr);
1、在系统头文件中找到各种错误码的宏定义。
2、做几个小练习,看看fopen
出错有哪些常见的原因。
打开一个没有访问权限的文件。
fp = fopen("/etc/shadow", "r"); if (fp == NULL) { perror("Open /etc/shadow"); exit(1); }
fopen
也可以打开一个目录,传给fopen
的第一个参数目录名末尾可以加/
也可以不加/
,但只允许以读方式打开。试试如果以可写的方式打开一个存在的目录会怎么样呢?
fp = fopen("/home/akaedu/", "r+"); if (fp == NULL) { perror("Open /home/akaedu"); exit(1); }
请读者自己设计几个实验,看看你还能测试出哪些错误原因?
fgetc函数的功能是从指定的文件中读一个字符,原型为:
int fgetc(FILE *stream);
例如:ch=fgetc(fp);其意义是从打开的文件fp中读取一个字符并送入ch中。
对于fgetc函数的使用有以下几点说明:
在fgetc函数调用中,文件的打开方式必须是可读的。
系统对于每个打开的文件都记录着一个读写位置,用文件的当前读写位置距离文件开头的字节数来表示。在文件打开时,该位置总是指向文件的第0个字节。使用fgetc函数后,该位置向后移动一个字节,因此可连续多次使用fgetc函数读取多个字符。
读取字符的结果也可以不向字符变量赋值,例如:fgetc(fp);但是读出的字符不能保存。想一想,这样的调用起什么作用?
注意,虽然fgetc返回的是一个字符(char型),但是函数原型中返回值却是int型,这是因为,如果在文件末尾fgetc将会返回常数EOF,其值一般定义为-1。上例ch=fgetc(fp)如果读到了EOF,并且ch是char型,则ch的值为0xff,如果fp所指向的是一个二进制文件,则无法判断到底是文件末尾还是二进制文件中有一个0xff字节。所以,ch一定是int型的,读到EOF就是0xffffffff。读者需要理清一个概念,fgetc读文件末尾时返回EOF,只是用这个返回值表示已读到文件末尾,并不是说每个文件末尾都有一个字节是EOF(根据上面的分析,EOF并不是一个字节)。
例如,读入文件file1,在屏幕上输出。
#include <stdio.h> #include <stdlib.h> int main() { FILE *fp; int ch; if((fp=fopen("file1","r"))==NULL) { perror("error open file file1"); exit(1); } ch=fgetc(fp); while (ch!=EOF) { putchar(ch); ch=fgetc(fp); } fclose(fp); return 0; }
fputc函数的功能是把一个字符写入指定的文件中,原型为:
int fputc(int c, FILE *stream);
对于fputc函数的使用也要说明几点:
被写入的文件可以用写、读写、追加方式打开,用写或读写方式打开一个已存在的文件时将清除原有的文件内容,写入字符从文件开头开始。如需保留原有文件内容,希望写入的字符从文件末尾开始向后存放,必须以追加方式打开文件。被写入的文件若不存在,则创建该文件。
每写入一个字符,文件的读写位置向后移动一个字节。文件的读、写操作都是从当前读写位置开始的,操作完成后读写位置向后移动,但是追加操作总是将数据追加到文件末尾,然后将读写位置移到新的文件末尾。
fputc函数有一个返回值,如写入成功则返回写入的字符,否则返回一个EOF。可以此来判断写入是否成功。
这里的参数c虽然也是int型,但在fputc函数中会强制转换为char型写入文件。这里的int型是为了保持和fgetc函数接口一致,从下面的例子中体会。
例如,从键盘输入一行字符,写入一个文件,再把该文件内容读出显示在屏幕上。
#include <stdio.h> #include <stdlib.h> int main() { FILE *fp; int ch; if((fp=fopen("file2","w+"))==NULL) { perror("error open file file2\n"); exit(1); } printf("input a string:\n"); ch=getchar(); while (ch!='\n') { fputc(ch,fp); ch=getchar(); } rewind(fp); ch=fgetc(fp); while(ch!=EOF) { putchar(ch); ch=fgetc(fp); } printf("\n"); fclose(fp); return 0; }
程序中的rewind函数的作用是把fp所指文件的读写位置移到文件头。从以上两个例子可以看出,getchar()相当于fgetc(stdin),putchar(c)相当于fputc(c, stdout)。
下例是一个简单的文件复制命令。
#include <stdio.h> #include <stdlib.h> int main(int argc,char *argv[]) { FILE *fp1,*fp2; char msg[100]; int ch; if(argc!=3) { printf("usage: command file1 file2\n"); exit(0); } if((fp1=fopen(argv[1],"r"))==NULL) { sprintf(msg, "error open file %s\n",argv[1]); perror(msg); exit(1); } if((fp2=fopen(argv[2],"w"))==NULL) { sprintf(msg, "error open file %s\n",argv[2]); perror(msg); exit(1); } while((ch=fgetc(fp1))!=EOF) fputc(ch,fp2); fclose(fp1); fclose(fp2); return 0; }
我们先前已见到了rewind函数,还有ftell和fseek两个函数,它们的原型如下:
void rewind(FILE *fp); long ftell(FILE *fp); int fseek(FILE *fp, long offset, int whence);
ftell返回当前的文件读写位置,如果调用出错则返回-1L。fseek的whence参数含义如下:
从文件开头移动offset个字节
从当前位置移动offset个字节
从文件末尾移动offset个字节
offset可正可负,负值表示向前(向文件开头的方向)移动,正值表示向后(向文件末尾的方向)移动,如果向前移动的字节数超过了文件开头,则读写位置停在文件开头,如果向后移动的字节数超过了文件末尾,则将增大文件尺寸,从原来的文件末尾到现在的读写位置之间的字节都是0。
fseek调用成功则返回0,失败返回非零。
下面做一个实验:
$ vi hello (编辑该文件的内容为“hello”) $ ls -l hello -rw-r--r-- 1 djkings djkings 6 2008-04-09 10:42 hello
为什么是6个字节呢?原来vi自动在末尾添换行'\n':
$ od -tx1 -tc hello 0000000 68 65 6c 6c 6f 0a h e l l o \n 0000006
现在用如下程序操作这个文件:
#include <stdio.h> #include <stdlib.h> int main() { FILE* fp; if ((fp = fopen("hello","r+")) == NULL) { perror("open hello"); exit(1); } if (fseek(fp, 10, SEEK_SET) != 0) { perror("cannot seek"); exit(1); } fputc('K', fp); fclose(fp); return 0; }
然后再查看这个文件的内容:
$ od -tx1 -tc hello 0000000 68 65 6c 6c 6f 0a 00 00 00 00 4b h e l l o \n \0 \0 \0 \0 K 0000013
读字符串函数fgets的功能是从指定的文件中读一行字符串到缓冲区中(一般是字符数组),函数调用的形式为:
char *fgets(char *s, int size, FILE *stream);
s是缓冲区的首地址,size是缓冲区的长度,fgets从stream中一次最多读取以'\n'结尾的一行到s中(包括'\n'在内),并且在缓冲区末尾添加一个'\0'组成一个完整的字符串表示。
如果文件中的一行太长,fgets从文件中读了size-1个字符还没有读到'\n',则把已经读到的size-1个字符和一个'\0'字符存入缓冲区,文件中剩下的半行可以下次调用fgets时继续读。
注意:对于fgets来说,'\n'是一个特别的字符,'\0'并无任何特别之处,如果读到'\0'就当作普通字符读入。
如果一次fgets调用在读入若干个字符后到达文件末尾,则将已读到的字符串加上'\0'存入缓冲区并返回,如果再次调用fgets,读写位置一开始就位于文件末尾,则返回NULL,可以据此判断是否读到文件末尾。
如果fgets函数没有返回NULL(没有一开始读到位于文件末尾),那么返回值就是缓冲区s的首地址。
fputs函数的功能是向指定的文件写入一个字符串,其原型为:
int fputs(const char *s, FILE *stream);
s是以'\0'结尾的字符串,fputs将该字符串写入文件stream,但并不写入结尾的'\0'。与fgets不同的是,fputs的字符串并不需要以'\n'结尾。
前面用fgetc/fputc实现了一个简单的文件拷贝程序,下面请读者用fgets/fputs实现拷贝文本文件的程序。想一想,拷贝二进制文件能不能用fgets/fputs?
C标准I/O库的I/O函数实现称为buffered I/O。在I/O库中提供了一个缓存,目的是尽可能减少读写磁盘的次数,因为读写磁盘比访问内存要慢得多。C标准I/O库提供的读写函数事实上都是对缓存进行读写。以fgetc和fputc为例,当用户程序第一次调用fgetc时,缓存可能从磁盘上读取比用户请求更多的数据,用户程序下次调用fgetc就可以直接从缓存中读出数据而不需要读磁盘。当用户程序调用fputc时也不是直接写入磁盘,而是先写到缓存中,缓存在满足一定条件时才将数据写入磁盘(将缓存写入磁盘的操作叫flush)。
下图说明了缓存的作用,注意用户程序中的缓存与C标准I/O库缓存的区别。
那么缓存在满足什么条件时才将数据写入磁盘呢?这取决于缓存的类型。关于缓存有三种可选的类型:全缓存、行缓存和无缓存。缓存的类型既影响写操作也影响读操作,在这里我们仅以写操作为例说明。
在这种情况下,写满I/O缓存后才写到设备或磁盘上。用fopen打开磁盘上的常规文件,默认是全缓存的。
在这种情况下,输出换行符时才写到设备或磁盘上。标准输出默认是行缓存的。但是,由于行缓存的长度是固定的,所以如果写满了缓存,即使还没有写换行符,也会写到设备或磁盘上。
每次写操作都直接写到设备或磁盘上。
为了便于初学者理解,这里对于缓存的描述是不完全的,至少还有两个问题没有说明,一是缓存类型如何影响读操作,二是如何更改默认的缓存类型。请读者在熟练之后自行查阅参考资料。
下面通过一个简单的例子证明标准输出是行缓存的。
#include <stdio.h> int main() { printf("hello world"); while(1); return 0; }
运行这个程序,会发现hello world并没有打印到屏幕上。Ctrl-C中止掉程序,去掉while(1);语句再试一下。会发现hello world被打印到屏幕上,并且没有换行,这是因为,程序正常退出时缓存的内容被自动写到设备上。再做一个实验,这次保留while(1);,在hello world末尾加\n,会发现hellow world可以正常打印到屏幕上,这说明只要写了换行符缓存的内容就会写到设备上。因此,为避免错误,每次调用printf时都应该在末尾加换行符。
当缓存尚未写入设备或磁盘时,缓存中的数据与设备或磁盘中的数据是不一致的,这时如果程序异常终止或者计算机断电就会丢失数据。为解决这个问题,以下函数可以强制将缓存写入磁盘。
int fflush(FILE *stream);
成功返回0,失败返回EOF。
调用系统函数一定要有出错判断。自己实现的函数也要符合UNIX系统的惯例,正常返回0,出错返回非0(一般是-1),如果返回值是指针,则出错返回NULL。main函数的返回值也要遵从这一惯例。
getchar()和fgetc()的返回值一定要赋给int型变量而不是char型。
一定不要吝惜使用括号,以保证运算符的优先级,比如本节的if((fp=fopen(...))!=NULL)。
打印出错信息要详尽,比如打开文件错误,要给出文件名,出错信息应打印到stderr而不是stdout,建议使用perror函数。
fopen()和fclose()要配对使用。
printf字符串末尾一定要有\n。
如果你提供的函数接口需要传入缓冲区指针,一定要有另一个参数可传入缓冲区长度,防止缓冲区溢出。参照fgets函数的格式。查阅gets(3)的BUGS节,理解一下防止缓冲区溢出的重要性。