UNIX 输入输出基本概念
在任何一种操作系统中,程序开始读写一个文件的内容之前,必须首先在程序与文件之间建立连接或通信通道,这一过程称为打开文件。打开一个文件的目的可能是要读其中的数据,也可能是要往其中写入数据,还可能是既要读又要写数据。
UNIX系统有两种机制用于描述程序与文件的这种连接:一种称为文件描述字,另一种称为流。因此,系统中关于I/O的函数也分为两大类:一类针对文件描述字操作,另一类针对流操作。
当用流或描述字I/O函数打开一个文件时,它们分别返回一个流或文件描述字,然后便可以将这个流或文件描述字作为参数传递给相应读写函数来完成实际的读写操作。
当已完成对文件的读写之后,可以通过关闭文件而终止程序与文件的这种连接。一旦关闭了一个文件描述字或者一个流,就不能再对它进行输入输出。
1.文件描述字与流
UNIX系统中,文件描述字表示为int类型的对象,而流表示为指向类型为FILE结构的指针。文件描述字函数多数是系统调用,它们提供底层基本的输入输出操作接口。当需要对特定设备进行控制操作时,往往必须使用文件描述字,流函数不能够进行这类操作。另外,如果程序需要按特殊方式进行输入输出(如非阻塞输入),也必须使用文件描述字。
流函数建立在文件描述字之上,通过文件描述字函数而实现,它给程序提供了更高一级的输入输出接口。流函数比对应的文件描述字函数更丰富,功能更强大,也更利于程序的移植。任何运行ANSI C的系统均支持流,但并不是所有系统都支持文件描述字,有的系统根本不支持文件描述字或仅仅实现了文件描述字函数集合的一个子集。因此,一般情况下,应当坚持使用流而不是文件描述字,除非是想做某种特殊操作,而此操作只能用文件描述字才能完成。
2.文件名与路径名
UNIX系统中几乎每一种对象都表示为文件,不仅是通常的数据集合,系统中的每一个设备也表示为文件。文件被安排在目录中,目录本身又含有子目录,由此形成了文件系统的层次结构。
目录本身也是一种文件,不过它的内容是一组连接实际文件的文件名及相关信息,这些连接称为链或目录登记项。我们前面虽然说“文件被安排在目录中”,但是实际上目录只包含指向文件的指针而不是文件本身。为了理解文件名的语法,首先需要理解UNIX文件系统的目录层次结构。
系统中,每一个用户均有一个主目录,其文件通常存储在这个目录以及该目录的子目录中。例如,用户kjzhao,他的主目录是/home/kjzhao,在其主目录中有系统帮助建立的几个标准目录,如.bash_profile等;也有由他自己创建的子目录,如用于日常工作的目录work,用于应用程序的目录program等。另外一些用户的主目录也可能位于/home目录中,而/home则是根目录“/”的子目录。在根目录中通常还包括用于系统程序的子目录/bin,用于系统配置文件的子目录/etc,用于系统库文件的子目录/lib,以及代表各种物理设备的子目录/dev等。图2-1是这种目录层次的一个示例图。
同其他操作系统一样,UNIX中每一个文件都有一个名字,此名字为一字符串,即文件名。文件名用于命名一个文件,它由1至NAME_MAX个字符组成,这些字符可以是字符集中除斜线字符(/)和空字符(NUL)之外的任意字符。系统宏NAME_MAX是POSIX定义的文件名的最大字符个数(不是字符串的长度,该计数不包括结束的空字符)。文件名也称为路径名分量。
3.文件位置
对于已打开的文件,它的属性之一是文件位置。文件位置给出文件中当前可读写字符的位置,在所有POSIX兼容的系统中,它是一个表示距文件开始多少字节数的整数。
当文件刚打开时,文件位置位于文件的开始处,之后每当读出或者写入一个字符,文件的位置便增加一字节。换言之,对文件的访问是顺序的。
但是,对于以“添加”(append)打开的文件,其写出的处理有点特殊。对这种文件的写出总是顺序地附加在该文件的末尾,而不管文件的位置如何。其文件位置只用于控制从文件读出数据。
普通文件允许读写文件的任意位置。这种允许读写任意位置的文件也称为随机文件。可以用函数fseek()或lseek()改变随机文件的位置。如果企图改变一个不支持随机访问的文件的位置,则会得到ESPIPE错误。磁盘文件一般均是随机文件,终端则不是随机文件。
UNIX环境中,多个进程可同时读一个文件。为了使得每个进程都能够按自己的步调读文件,每个进程必须有自己的文件位置指针,这样才不会受到其他进程的影响。事实上,进程每次打开一个文件都会创建一个独立的文件位置。因此,即使在同一个程序中打开一个文件两次,也会得到两个具有独立文件位置的流或描述字。但是,如果打开一个文件描述字,然后复制它得到另一个文件描述字,则这两个文件描述字会共享同一文件位置:改变一个文件描述字的文件位置将影响另一个描述字的文件位置。
流和FILE对象
表示流的数据类型是FILE类型。FILE是系统定义的数据结构,它含有标准I/O库管理流所需要的与文件有关的所有内部状态信息,例如,进行实际I/O的文件描述字、文件位置指针、I/O缓冲区大小和指针、缓冲中当前存放的字符个数、错误和文件结束状态指示器等。
FILE对象由标准I/O库函数内部分配和管理,用户无须自己创建FILE类型的对象,也不需要查看FILE对象的内容。当我们用fopen()打开或创建一个流时,它会返回一个指向FILE结构的指针,此时称在程序和该文件之间建立了一个流。为了引用一个流,我们将它的FILE指针作为参数传递给标准I/O函数,因此,程序中涉及的都只是指向FILE对象的指针,即“FILE *”。所以,有时也使用术语“文件指针”表示流。
标准流
UINX系统中每个进程都有三个预先定义并自动打开的流,它们是:stdin、stdout和stderr。这三个标准流在<stdio.h>中说明,分别代表标准输入、标准输出以及错误输出。
打开和关闭流
以下函数用于打开和关闭一个流。
#include <stdio.h>
FILE * fopen(const char* pathname, const char *opentype);
int fclose(FILE *stream);
FILE * freopen(const char* pathname, const char *opentype, FILE *stream);
fopen()打开由pathname指定的文件并创建一个与之相连的流。如果该文件不存在,则创建一个新文件。如果打开文件成功,它返回指向此流的指针,否则返回空指针NULL。
opentype参数是一字符串,它控制文件打开的方式,其值只能是下述字符串之一:
“r”:为读而打开一个已存在的文件,文件位置定位于文件开始。
“w”:为写而打开一个文件。如果文件存在,则将它的长度截为0,也即文件将被重新写过;如果文件不存在,则创建一个新文件。
“a”:为在文件尾添加内容而打开文件。若文件存在,原来的内容不变且输出添加在文件的末尾;否则,创建一个新文件。
“r+”:为更新(既读又写)而打开一个已存在的文件,文件原有内容不变,文件位置定位于文件开始。
“w+”:为更新(既读又写)而打开一个文件,若文件已存在,其长度被截至0;否则,创建一个新文件。
“a+”:为更新(既读又写)而打开一个文件,若文件已存在,原内容不变;否则,创建一个新文件。用于读的初始文件位置定位于文件开始,但输出总是添加在文件的末尾。
其中,字母r、w和a分别代表read、write和append。字符“+”指明为更新而打开一个文件,当以这种方式打开一个文件时,对它既可写也可读。
以添加方式("a"或"a+"方式)打开的文件,不管文件的当前位置如何,所写出的数据总是顺序地附加在文件的末尾,因此不可能覆盖文件原来的内容。其文件位置只用于控制从文件中读数据。
打开的流通过调用fclose()来关闭。fclose()关闭参数stream指定的流,并中断与对应文件的连接。在流被关闭之前,所有缓冲的输出将被写出。关闭一个流后就不能再对它进行任何操作。
fclose()调用成功返回0,否则返回EOF并置errno指明错误。
当调用fclose()关闭一输出流时,对错误情形进行检查很重要,因为此时可能检测到真实的错误。例如,当fclose()将缓冲区中剩余的数据写出时,它可能得到磁盘空间已满的错误,如果不检测的话,就有可能丢失文件的内容。
进程从main()退出或调用exit()正常终止时,所有打开的流都将自动被关闭。但为了确保输出数据的完整性,编写程序时仍然应当在进程结束前明显地调用fclose()。因为当进程以其他非正常方式终止时,例如调用abort()流产程序或由于致命信号,文件没有正常关闭,输出缓冲区中的数据将不会被写出,从而可能导致文件不完整。2.7节会更详细地讲述流缓冲的问题。
freopen()重新打开pathname指定的文件,它的作用类似于fclose()和fopen()的合并。如果stream指定的流是打开的,它首先关闭该流,忽略任何错误,然后按opentype所给方式如同fopen()一样打开参数pathname指定的文件,并使之与stream给定的同一个流相连。freopen()调用成功返回指向流的指针,否则返回NULL并置errno指明错误。
例2-1 程序2-1是个简单的示例程序,它打开一个文件写入若干数据之后再重新打开此文件读出所写的数据。
int main() {
FILE *stream;
char buf[80] = "";
printf("open and creat tfile \n");
if (stream = fopen("tfile", "w") == NULL) {
perrer("open() failed");
exit(1);
}
printf("write string to tfile \n");
fputs("Hello world", stream);
printf("read string from tfile \n");
if (stream = freopen("tfile", "r", stream) == NULL) {
perrer("read() failed");
exit(1);
}
fgets(buf, sizeof(buf), stream);
printf("the string is \"%s\"\n", buf);
close(stream);
return 0;
}
读和写流
一旦打开了一个流,就能对它进行读写,读写可以按无格式方式也可以按有格式方式进行。这一节介绍无格式I/O函数,下一节介绍有格式I/O函数。
有以下三种类型的无格式I/O函数可供选择:
1)字符I/O函数。这种函数每次读或写一个字符。
2)行I/O函数。这种函数每次读写一行,每一行以换行符结束。
3)块I/O函数。这种函数支持成块I/O,它们每次读写若干个对象,每个对象的大小是指定的。块I/O有时也称为二进制I/O、对象I/O或结构I/O。
字符I/O
如下三个字符输入函数每次读入一个字符:
#include <stdio.h>
int fgetc(FILE *stream);
int getc(FILE *stream);
int getchar(void);
fgetc()从流stream中按unsigned char类型读取下一字符,并将它强制为int类型返回,若遇到文件结束或者出现错误,则返回EOF。
getc()的功能与fgetc()相同,不同的是允许将getc()作为宏来实现,而fgetc()则必须为函数。getc()常常是被高度优化了的,因此是最常用的读单个字符的函数。
getchar()等价于getc(stdin)。
这三个函数之所以将流中的字符视为unsigned char,是为了保证在其高位被设置时函数的返回值不会为负值。要求返回值为int类型是为了能够返回所有可表示的字符,不仅是ASCII字符集中的字符,也包括宽字符集中的字符,还包括遇到文件结束和错误时的指示符EOF。这意味着我们不能将fgetc()的返回值存储在字符类型的变量中。
与三个字符输入函数对应有如下三个字符输出函数:
#include <stdio.h>
int fputc (int c, FILE *stream);
int putc (int c, FILE *stream);
int putchar(int c);
fputc()将字符c转换为unsigned char类型,然后写至流stream并返回字符c。
putc()与fputc()相同,但它常常是用较快的宏来实现的。putc()是用于输出单个字符最合适的函数。类似于输入函数,putchar()等价于putc(stdin)。
例2-2 程序2-2给出的函数y_or_n_ques()在标准输入输出终端提出参数指定的问题,并读用户的回答。回答为’y’则返回真值,回答为’n’则返回假值。用getc()和putc()或者getchar()和putchar()替换其中的fgetc()和fputc(),它也照样工作。其中函数fputs()输出一行字符串,下一节将详细介绍它。tolower()将输入字符转换为小写字符以便随后对它进行检测。
int y_or_n_ques(const char *question) {
fputs(question, stdout);
int c, answer;
while (1) {
fputc(' ', stdout);
c = tolower(fgetc(stdin));
answer = c;
while (c != '\n' && c != EOF)
c = fgetc(stdin);
if (answer == 'y')
return 1;
if (answer == 'n')
return 0;
fputs("Please answer y or n:", stdout);
}
}
注意这个函数读单字符命令的处理,它在用fgetc()读取一个字符之后还继续调用fgetc()抛弃同一行的其他字符。因为默认情况下终端输入只有在键入换行符之后才有效,如果不抛弃这个换行符,它将遗留在输入流中使得下一次读命令字符时会读不到实际键入的正确命令。第9章低级终端I/O中我们将看到对这种问题更为精致的处理方法。
行I/O
有许多应用是按行来处理数据的,例如编译程序通常每次读入一行源程序来进行词法扫描。标准C库中有两个函数用于每次读入一行:
#include <stdio.h>
char * fgets(char *s, int count, FILE *stream);
char * gets(char *s);
fgets()从stream指定的流中连续读字符直至读到换行符或者读够count-1个字符(包括换行符)为止,读入的这一行字符(包括最后的换行符)存储在参数s指定的字符串中,并且在其末尾添加一个空字符(\0)作为结束。参数count指明字符串s的大小。
如果要读入的这一行(包括结尾的换行符)长度大于count-1,则只有部分字符被读入,而字符串s总是以空字符结尾,下一次调用fgets()将返回此行剩余的部分。
gets()函数从标准输入流stdin中读入完整的一行至参数s指定的字符串中。它删除换行符并在字符串s的末尾添加一个空字符作为结束。
注意gets()与fgets()的不同。fgets()不能保证一定读入完整的一行,因此为了判别是否已经读入一行,它需要保留换行符,而gets()则无此需要,故它删除换行符。另外,由于gets()不要求提供字符串s的空间大小,这导致gets()成了危险的函数:它没有为字符串s的溢出提供保护!当要读入的行长度超过字符串s所能容纳的大小时,超出的部分将越过s提供的空间而覆盖其他的数据或程序。因此最好不要使用gets()。
如果调用这两个函数时文件已处在文件尾,则字符串s不发生改变且返回值都是EOF。这两个函数遇到错误时也返回EOF,正常情况下返回指向字符串s的指针。
例2-3 程序2-3说明了fgets()和gets()的不同。该程序首先提问使用fgets()还是gets()读输入行,并根据用户的选择使用不同的函数。由于我们故意指定缓冲区的大小只有8字节,因此,当输入行的长度大于8时,使用fgets()每次只读8个字符,并且为了读入一完整的行需要循环读直至读到换行符为止。而用gets()读一行则不需要循环,但却可能导致读入的数据溢出缓冲区。
#define BUFSIZE 8
int main() {
FILE *fd;
struct iobuf {
char buf[BUFSIZE];
char other[BUFSIZE];
}buffer;
memset(&buffer, '\0', sizeof(struct iobuf));
do{
flags = y_or_n_ques("Should we read by fgets()?");
fprintf(stdout, "Please enter a line\n");
if (flags) {
fgets(buffer.buf, BUFSIZE, stdin);
fprintf(stdout, "fgets() get string \"%s \"\n", buffer.buf);
while (buffer.buf[strlen(buffer.buf) - 1] != '\n') {
fgets(buffer.buf, BUFSIZE, stdin);
fprintf(stdout, "fgets() get string \"%s \"\n", buffer.buf);
}
} else {
gets(buffer.buf);
}
} while (y_or_n_ques("continue?"));
return 0;
}
为了展示gets()导致数组buf溢出的情况,我们利用结构类型将两个数组合在一起。当输入字符超过buf的大小(8个字符)时,fgets()至多读8个字符,而gets()则会导致数据溢出到成员others中。注意,这种结果与编译器的存储分配方法有关。我们这里假定编译器按结构成员书写的顺序分配存储。如果编译器按逆序分配,则应将others书写在buf之前。作为练习,建议你用不同的选择和长度不同的输入运行这个程序来查看运行结果。注意,这个程序要与程序2-2一起连接。
gets()可以读入完整的一行,但存在溢出的危险;fgets()虽然保险,但当输入数据中含有空字符(NUL,即’\0’)时却会遇到麻烦。因为fgets()不能保证每次都能完整地读入一行,并且它自动地在读入的字符串末尾添加空字符,这使得在分辨输入行中原本就有的空字符时需要特别的动作:当在字符串s中读到一个空字符时,必须判别其前面是否有换行符以及所在位置来确定它是原本就有的数据,还是作为字符串结束的空字符。
Linux中的GNU C库为此专门提供了另外一个每次读一行的函数getline(),此外还扩充了一个更通用的类似函数getdelim(),该函数读入一被界定的记录,这种记录定义为直至下一特定分隔符为止的所有内容。
#include <stdio.h>
ssize_t getline(char **lineptr, size_t *n, FILE *stream);
ssize_t getdelim(char **lineptr, size_t *n, int delimiter, FILE *stream);
getline()从流stream中读入一行(包括换行符和一个终止空字符),并存储于lineptr所指缓冲区中,缓冲区的大小由参数n给出。
在调用getline()之前一般先要调用malloc()分配大小为*n个字节的缓冲区,并存放其地址于*lineptr。如果这个缓冲区的大小足够容纳输入行,getline()将此行置于缓冲区中。否则,getline()会自动扩大此缓冲区,然后将新缓冲区的地址回填至*lineptr,新增加后的缓冲区大小存回至*n。特别地,当*lineptr为空指针,*n为0时,getline()会自动分配初始缓冲区。
getline()调用成功的返回值是读入的字符数(包括换行符,但不包括终止空字符),这使我们能够区分行中的空字符和作为终止符的空字符:终止空字符所在的位置一定等于getline()的返回值。
函数getdelim()类似于getline(),不同的只是作为行终止的分隔符不一定是换行符,而可以通过参数delimiter指定,getdelim()一直读至遇到该字符或文件结束为止。读入的正文包括该分隔符和一个终止的空字符。
实际上,getline()是用getdelim()来实现的:
ssize_t getline(char **lineptr, size_t *n, FILE *stream) {
return getdelim(lineptr, n, '\n', stream);
}
这两个函数虽然是GNU的扩充,但它们能从流中可靠地读一行,特别是getdelim()对正文匹配处理特别方便。因此,这里专门介绍了它们。
每次输出一行可由fputs()或puts()函数来完成。
#include <stdio.h>
int fputs(const char* s, FILE * stream);
int puts(const char * s);
fputs()输出以空字符结尾的字符串s至流stream,但结尾的空字符不写入,也不添加换行符,它只输出字符串中的字符。如果发生错误,该函数返回EOF,否则返回一非负值。
puts()输出以空字符结尾的字符串s至标准输出流stdout并添加一个换行符,但字符串结尾的空字符不写出。注意它与fputs()的不同:fputs()不添加换行符,而puts()则添加换行符。例如,下述三次fputs()的连续调用:
fputs("Are", stdout);
fputs("you", stdout);
fputs("hungry?\n", stdout);
输出的正文是“Are you hungry?”后随一个换行符。这个换行符是字符串自身所带的。反之,如果用puts()替代上述调用,则会输出四行:每行一个单词并且最后有一空行。
puts()是用于打印简短消息最方便的函数。
读回退
程序在读输入的过程中,有时候会只想查看一下输入流中的下一字符而并不想将它从输入流中读走,这称为对输入流的超前窥视,因为程序只是对下次要读入的字符提前看一眼。在流I/O的情况下,只能通过首先从流中读出字符,然后再将该字符退回至输入流来实现超前窥视。
回退字符至流的函数是ungetc(),它是getc()的逆操作。
#include <stdio.h>
int ungetc(int c, FILE *stream);
ungetc()将字符c退回至输入流stream。于是下一次从stream的输入将首先读到字符c。若调用成功,该函数返回回退的字符,否则返回EOF。
如果要回退的字符c是EOF,ungetc()不做任何动作并返回EOF,这使得我们在使用getc()的返回值调用ungetc()时无须对getc()的错误进行检测。
大多数系统都只支持回退一个字符,Linux也如此。这意味着连续两次调用ungetc()之间必须有一次读入。如果在未调用getc()的情况下再次调用ungetc()将使前一个回退的字符丢失。
回退的字符不必是从流中最后一次读出的字符,可以是任意字符。事实上并不需要在用ungetc()做回退之前从流中真正读出任何字符!不过以这种方式编程是较奇怪的,通常ungetc()只用来回退刚从同一个流读出的字符。
回退字符并不是将字符送回文件本身,而是送回流的内部缓冲中。因此,如果调用了一个文件定位函数(如fseek()或rewind()),则将丢弃任何未重新被读入的回退字符。
回退一个字符至一个正处在文件尾的流将清除该流的文件尾指示器,因为它使该回退字符重新变为有效输入字符。当读入该字符之后再次读才会遇到文件尾。
例2-4 程序2-4说明了用getc()和ungetc()跳过空白字符的用法。当getc()到达一个非空白字符时,程序用ungetc()回退此字符,从而在下一次读时能再次读到它。
程序2-4 用getc()和ungetc()跳过空白字符
#include <stdio.h>
void skip_whitespace(FILE *stream) {
int c;
do {
c = getc(stream);
} while (isspace(c));
ungetc(c, stream);
}
块I/O
块I/O也称为二进制I/O,它以固定大小的块为单位而不是以字符或行为单位来读写数据。要读写的数据既可以是字符正文,也可以是二进制数据。函数fread()和fwrite()用于进行这种成块的输入输出。
#include <stdio.h>
size_t fread(void *data, size_t size, size_t count, FILE * stream);
size_t fwrite(const void *data, size_t size, size_t count, FILE *stream);
fread()从流stream中读count个数据项,并存放至data所指的数组中,每个数据项的长度为size字节,所读的总字节数为count×size。
fwrite()从data所指的区域中写出count个数据项至流stream,每个数据项的长度为size字节,所写出的总字节数为count×size。
fread()和fwrite()均返回实际读写的数据项数(注意,不是字节数)。若调用成功,返回值等于count;若遇到文件尾或错误,返回值小于count或为EOF;当出现错误时,设置errno指明错误原因;如果count或size为0,则不做任何动作并返回0。
这两个函数常在如下情形中使用:
1)读写一个二进制数组。例如,为了输出一个浮点数组的第二至第五个元素,可以这样调用fwrite():
float data[10];
if (fwrite(&data[2], sizeof(float), 4, stream) != 4) {
perror("fwrite error");
}
此处指定参数size为数组元素的字节大小,参数count为元素个数。
2)读写一个结构。例如,
struct {
short count;
long total;
char name[NAMESIZE];
}item;
if (fwrite(&item, sizeof(item), 1, fp) != 1)
perror("fwrite error");
此处指定参数size为结构的字节大小,参数count为1。
这两种情形的更一般例子是读写一个结构数组。为此,参数size应当是该结构的字节大小,参数count应当是数组的元素个数。
从这两个例子看出,块I/O是按数据原始形态,即二进制格式进行读写的。按二进制格式读写数据的效率常常比使用其他形式的I/O要好,特别是对于浮点数据,二进制格式避免了格式转换处理时精度的丢失。但是它也有自己的问题,就是不能用许多标准的文件处理实用程序(如正文编辑程序)对二进制文件进行检查或修改。特别是,在某个系统中写出的二进制文件一般只能在同一个系统中才能读出,也就是说,二进制文件不能在不同的语言实现或不同类型的计算机系统之间进行移植。其原因主要是:
1)不同的编译器和不同的系统中,由于存储边界对齐要求不同,使得对结构成员在结构内的偏移处理有所不同。有一些编译器有选项开关,允许结构按紧缩方式分配空间(以节省存储)或精确地按边界对齐方式分配空间(以优化运行时各个成员的访问)。这意味着即使在同一个系统,结构的存储分配也是不同的,这取决于具体的编译选项。
2)不同的计算机体系结构存储多字节数据的方式不同。例如,有的采用big-endian,有的采用little-endian(12.3.5节),而有的系统则可以在这两者之间进行选择。
例2-5 程序2-5使用fread()和fwrite()连接一个文件至另一个文件末尾。它的第一个参数指明要复制的文件,第二个参数指明被连接的文件,如果这个文件不存在,则创建它。
程序2-5 fread()和fwrite()连接两个文件之例
int main (int argc, char **argv) {
int n;
FILE *from, *to;
char buf[BUFSIZ];
if (argc != 3) {
fprintf(stderr, "Usage : %s from-file to-file\n", *argv) ;
exit (1);
}
if ((from = fopen(argv[1], "r")) == NULL) {
perror(argv[1]);
exit(1);
}
if ((to = fopen(argv[2], "a")) == NULL) {
perror(argv[2]);
exit(1);
}
while ((n = fread(buf, sizeof(char), BUFSIZ, from)) > 0) {
fwrite(buf, sizeof(char), n, to);
}
close(from);
close(to);
exit(0);
}
文件定位
读写文件过程中,有时会需要读某个特定位置的内容。例如,对于那种由固定大小的记录组成并能用整数索引来引用这些记录的文件,为访问其中某个特定的记录,最快捷的方法是直接定位至该记录位置进行读写,而不必一个一个地顺序跳过之前不需要的记录。为此,我们需要能够随意定位文件的位置,即随机地读写文件的任何部分。
标准I/O库提供了如下两组对随机文件进行定位的函数,用它们可以随机地读写文件的任何部分。
#include <stdio.h>
long int ftell(FILE *stream);
int fseek(FILE *stream, long int offset, int whence);
void rewind(FILE *stream);
int fgetpos(FILE *stream, fpos_t *pos);
int fsetpos(FILE *stream, const fpos_t *pos);
ftell()调用成功返回stream指定流的当前文件位置,它是从文件开始的字节数;否则返回-1并置errno。
fseek()改变流stream的文件位置。具体位置由参数offset和whence指定。offset给出相距由whence给出的起点的字节偏移,它可以是正数,也可以是负数。whence指定起点,起点可以是文件开始、文件尾或者当前文件位置,具体为下述值之一:
SEEK_SET:文件位置定位于文件开始+offset之处,此时offset不能为负数。
SEEK_CUR:文件位置定位于文件当前位置+offset之处。
SEEK_END:文件位置定位于文件尾+offset之处。
fseek()调用成功返回0,失败返回非0。如果调用成功,它将清除流的文件结束指示器并忽略由ungetc()退回的字符。如果是输出流并且缓冲的数据还未写至相连的文件,fseek()将导致未写出的数据被写至文件。因此,对于以更新方式(“+”)打开的文件,调用fseek()之后,在此文件上的下一个操作既可以是输入,也可以是输出。
rewind()定位流stream于文件的开始,其作用等价于fseek(stream,0L,SEEK_SET),不同的是它无返回值且重置了流的错误指示器。
fgetpos()和fsetpos()是ANSI C新补充的,它们比ftell()和fseek()的兼容性更好。这两个函数引入了一个新的抽象类型fops_t来表示文件位置。在POSIX兼容的系统中,这个类型通常就是长整型,但在其他系统中则可能有不同的内部表示。例如在有的系统中,文件位置必须同时用文件内的记录位置和记录内的字符偏移来表示。因此,如果想使程序可以移植至这种系统,最好使用函数fgetpos()和fsetpos()。
fgetpos()获取流stream的当前文件位置并将其存储在pos指定的对象中。
fsetpos()用pos给出的值设置流stream的文件位置。pos给定的值必须是前面对同一个流调用fgetpos()而得到的。如果成功,fsetpos()将清除文件结束指示器并忽略由ungetc()退回的任何字符。
例2-6 fseek()允许设置文件位置超过当前文件尾,如果之后在新文件位置写入了数据,则从原文件尾到新写入的数据之间读出的字节将用0填充,直至到达实际写入的数据为止。程序2-6说明用fseek()定位文件位置超过文件尾的两种情形。第一种是以非添加方式写数据超过文件尾,此时,在当前文件尾和新写入的数据之间将形成所谓的“空洞”。第二种是以添加方式写数据,此时,尽管可以用fseek()定位文件位置超过文件尾,但是在写文件时,文件的当前位置被忽略,所有数据都添加在文件尾并且文件当前位置被重新定位至新的文件尾。
char buf[132];
int main () {
FILE *fd;
fpos_t pos;
if (!y_or_n_ques("should we use append mode?")) {
if (fd = fopen("tfile", "w+") == NULL) {
perror("fopen failed");
exit(1);
} else {
if (fd = fopen("tfile", "a+") == NULL) {
perror("fopen failed");
exit(1);
}
}
fputs("01234567789ABCDEFGHIJ", fd);
fseek(fd, 0, SEEK_END);
fgetpos(fd, &pos);
printf("current file position is %ld \n", pos);
fseek(fd, 30, SEEK_END);
fgetpos(fd, &pos);
printf("Now we call fseek(fd,30,SEEK_END); "
"current file position is %ld\n",pos);
fputs("abcdefg",fd);
printf("Now we write 7 bytes \"%s\"\n","abcdefg");
fclose(fd);
}
}
我们看到以添加方式(a+)写入的数据没有理会fseek()定位的位置(此位置距文件开始为50字节),新写入的数据仍然紧接在当时的文件尾之后,即“G”之后,此位置距文件开始20字节。而以非添加方式(w+)写入的数据“abcdefg”则写在由fseek()定位的位置,即当时的文件尾之后30字节(第50字节,od命令输出中的第一列给出的是八进制数字),在它们之间有30个空字符。
文件结束和错误指示器
本章的多数函数(fgets()、gets()、putc()、ungetc()、fread()等)当调用不成功时都返回EOF,EOF的值为0,它既用于报告文件结束,也用于报告错误情形下的返回。因此,为了区分究竟是错误返回还是文件结束返回,就需要调用ferror()函数来确定是否存在错误,调用feof()函数检查是否遇到文件结束。
每一个流对象内部有两个指示器:一个为错误指示器,当读写文件出错时该指示器被设置;另一个为文件结束指示器,当遇到文件尾时该指示器被设置。函数ferror()和feof()分别检查这两个指示器。
#include <stdio.h>
int ferror (FILE *stream);
int feof(FILE *stream);
void clearerr(FILE *stream);
函数ferror()返回0当且仅当流stream的错误指示器被设置。
feof()返回0当且仅当流stream的文件结束条件指示器被设置。
函数clearerr()用于清除这两个指示器。
对流进行操作的函数除了设置与流相连的错误指示器外,也设置全局变量errno。
例2-7 为了免除每次调用I/O函数时检查返回值的烦琐,可以将这些检查包装在一个函数中。如程序2-7所示。
int get_line(char *buf, int bufsize, FILE *fd) {
if(fgets(buf, bufsize, fd) == EOF) {
printf("We meet enf of file \n");
return EOF;
} else {
perror("fgets error");
return -1;
}
printf("call fgets: %s", buf);
return 1;
}
流缓冲
每一个流都有一个输入输出缓冲区。写入流的字符并不立即写到文件中,而是先在缓冲区中聚集为一块,然后异步地以块为单位传送到文件。类似地,从流读出的字符也不是逐个地从文件中读出,而是以块为单位从文件读到缓冲区,然后从缓冲区传送给进程。这种处理方式称为缓冲。
采用缓冲的目的是为了减少调用低级I/O函数(如read()和write())的次数,因为这些真正读写文件的函数是系统调用,它们是较费时间的操作。例如,对于存储在硬盘上的文件,当进程用read()或write()读写数据时,设备驱动程序必须将数据在文件中的地址转换成硬盘的物理磁道号、卷宗号以及扇段号。之后设备必须移动磁头至相应的卷宗并等待磁盘的相应扇段旋转至磁头之下。一切准备好了之后才能从磁盘开始读写数据。显然,每读写一个或几个字符便导致执行这一串的动作是极不合算的。利用缓冲处理则不必为每读写一个字符而频繁地与外部设备打交道,同时还可以实现异步I/O,即在CPU运行程序的同时从外设传输数据,从而提高输入输出的效率。
标准I/O库函数自动地为我们管理缓冲区,使得我们无须过问何时该从文件中读一块数据至缓冲区和与特定设备有关的细节问题。
流有三种不同的缓冲类型:
1)全缓冲。在这种情况下,真正的I/O操作每次以整个缓冲区为单位读写数据,缓冲区的大小一般为BUFSIZ。对于输出,只有当缓冲区满了时才传送它至文件;对于输入,每次从文件读入数据直至缓冲区满为止。磁盘文件一般是全缓冲的。
2)行缓冲。在这种情况下,仅当在输入或输出中遇到换行符时才执行真正的I/O操作。行缓冲一般用于终端之类交互设备的流。例如,如果我们用fputc()输出15个非换行字符,然后输出一个换行符,则只有当最后这个输出换行符的fputc()被调用后,前面输出的15个字符才能真正出现在终端上。
3)无缓冲。流不设置缓冲区,字符单个地读出或写入。
UNIX系统对新打开的流采用如下默认缓冲类型:
标准错误流总是无缓冲的。这是为了使得错误信息能及时显示出来。这意味着如果用fputc()输出15个字符至代表错误流的终端,则每一个字符都将在函数被执行后立即出现在终端上。
其他的流若引用交互设备则是行缓冲的,否则是全缓冲的。
这种自动默认选择给予输入输出文件或设备一种最方便的缓冲方式。不过,如果不满意这种默认缓冲的话,也可以用如下函数设定自己的缓冲区及希望的缓冲类型和大小。
#include <stdio.h>
void setbuf (FILE *stream, char *buf);
int setvbuf (FILE *stream, char *buf, int type, size_t size);
这两个函数必须在流已打开后且先于其他任何操作执行之前调用。
setbuf()用于打开或关闭流stream的缓冲。为了打开缓冲,参数buf必须指向一个长度为BUFSIZ的缓冲区。BUFSIZ是系统定义的宏常数,它的值至少为256。通常在此函数调用之后流将变成全缓冲的,但如果流是与终端设备相连的话,则有的系统将改变它为行缓冲的。为了关闭缓冲,参数buf必须是NULL。
用setvbuf()可以明确地指定想要的缓冲类型。缓冲类型由参数type指定,它可取如下三种值之一,它们都是定义在<stdio.h>中的常数。
_IOFBF 全缓冲
_IOLBF 行缓冲
_IONBF 无缓冲
如果指定无缓冲类型,setvbuf()将忽略参数buf和size;否则buf和size可以任选地指定缓冲区及其大小。
如果用NULL作为buf的值,setvbuf()会自动地为流分配适当大小的缓冲区。所谓适当大小是指与此流相连文件的stat结构成员st_blksize指定的值。如果系统不能为流确定这个值(例如,当流与设备或管道相连时),则分配BUFSIZ长度的缓冲。当流被关闭时,这样分配的缓冲区将被自动释放。否则,buf应当是至少能容纳size个字符的一个数组。setvbuf()使用此数组作为流缓冲区,并释放标准I/O库原来分配的缓冲区。对于这个数组我们应当注意以下两点:
只要流是打开的,就不能释放该数组的空间。通常应当静态地分配此数组,或者用malloc()为它分配空间。用自动数组作为缓冲区是不好的,除非在退出说明该数组的程序块之前关闭文件。
流I/O函数将这个数组用于内部目的。当流正用它作为缓冲目的时,我们不能直接访问该数组的内容。
setbuf()实际上是setvbuf()的特例,它等价于
setvbuf(stream, buf, buf?_IOFBF:_IONBF, BUFSIZE);
术语刷新表示将缓冲区中的数据写出到文件中。通常,缓冲区中的数据在下述情况下会自动刷新:
1)当流被关闭时。
2)当调用exit()终止程序时。
3)若流是行缓冲的,当写出一换行符时。
4)当企图输出而缓冲区已经满了时。
5)无论何时对流的输入操作导致它实际从文件读数据时。
例如,在多数系统上,行缓冲区的大小通常是固定的,因此,如果在输出换行符之前一次输出的字符太多以致缓冲区满了时,尽管还未输出换行符,系统也会自动刷新缓冲区中的内容。这是上述第4种情形的例子。第5种情形的一个例子是:当用printf()输出不带换行符的一个字符串至终端之后,若紧接着调用从终端读数据的函数,则也导致缓冲区的输出立即被写到终端。这就是为什么用printf()输出不带换行符的字符串时,有时候它能立即出现在终端上(因为其后跟有输入操作),而有时候它却必须使用fflush()才行。
如果想在其他时刻刷新缓冲区的内容,则要显式地调用fflush()函数。
#include <stdio.h>
int fflush(FILE *stream);
fflush()刷新流stream的缓冲区。如果stream是一空指针NULL,fflush()将刷新所有已打开输出流的缓冲。
虽然标准I/O库函数自动地为我们管理I/O缓冲区,但让人感到迷惑然而也最简单的问题却常常是由缓冲引起的。例如,当设计用流进行输入输出的用户界面时,就必须了解流缓冲是怎样工作的,否则可能会发现输出(如显示程序进展或提示性的消息)并不像所预期的那样,甚至出现其他未曾料到的行为。
例2-8 程序2-8是一个由于未注意到缓冲的作用而导致输出行顺序不对的例子。
程序2-8 未注意缓冲作用导致错误之例
int main() {
int answer;
printf ("1: This is a buffer test program. ");
// fflush(stdout);
fprintf(stderr,"2: --test message\n");
answer = y_or_n_ques("3: Hello, Are you a student?");
if(answer == 1)
printf("4: Hope you have high score.");
else
printf("4: Hope you have good salary.");
// fflush(stdout);
fprintf(stderr,"5: bye!\n");
}
这个程序简单地提出一个问题,然后对回答做出反应。程序的本意是想按照打印语句中第一个数字编号的顺序输出信息,但为了表现输出被缓冲的情况,我们在printf中故意没有加上换行符,并且在其后紧接着加入了向标准错误流输出的语句。由于标准错误流是无缓冲的,这使得它的输出将先于printf的输出而出现在终端上。运行这个程序有如下结果:
$ ./a.out
2: --test message
1: This is a buffer test program. 3: Hello, Are you a student? n
5: bye!
4: Hope you have good salary.
这种结果不是我们预期的,我们原本希望按程序执行顺序输出每一行。为了如我们所愿,应当在输出中加入适当的换行符,或者在适当位置加入fflush()调用,例如,去掉程序中对fflush()调用的注释。
格式I/O
前几节介绍的流I/O函数除了以字符或行方式进行读写外,并不对数据进行解释,但在很多时候应用都会需要对输入输出数据进行解释,因为数据在计算机内的表示和人们可读的形式是不同的。数据在计算机内是二进制形式,在计算机外部常常为正文形式。例如,十进制数12在计算机内部的32位二进制表示是:00000000000000000000000000001100。当这个数在打印机上输出或者在终端屏幕上显示时,必须转换为字符’1’和’2’,它们的ASCII编码分别为00110001和00110010。反之,当从键盘读入用ASCII字符表示的十进制整数时,必须将它们转换成计算机可处理的二进制表示。格式I/O函数能够自动完成这种外部和内部格式之间的转换工作,并且能够对输入输出数据进行诸如数据类型、精度、位置等格式控制。
所有格式I/O函数都通过一个格式字符串来对其余参数进行格式描述。格式字符串中用转换区分符来描述待输入输出参数的类型、精度、外部形式以及占据的字节宽度等。掌握好转换区分符是使用格式I/O函数的关键。这一节介绍格式I/O函数,给出转换区分符的语法成分,并通过例子说明它们的用法。
格式输出
格式输出由如下三种printf()函数来处理,它们是完成输出最方便的函数。
#include <stdio.h>
int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...)
int sprintf(char *buf, const char *fromat, ...)
这三个函数功能相同,都在格式字符串format的控制下输出其他参数(这里表示为“…”)。不同的只是输出的流不同:printf()输出至标准输出流;fprintf()输出至参数stream指定的流;sprintf()不是输出至一个文件,而是输出至参数buf所指的字符数组中,并且在buf的末尾自动添加一个空字节。它们调用成功均返回实际输出的字符个数。
这些函数允许任意个数参数,格式字符串format中的转换区分符隐含地指明后继参数个数以及怎样解释和格式化这些参数。如果转换区分符的个数少于后继的参数个数,多余的参数将被忽略;如果转换区分符的个数多于后继的参数个数,多余的转换区分符输出的内容是不确定的。
格式字符串format由两类成分组成:普通字符和转换区分符。普通字符是除转换区分符之外的其余字符。输出格式字符串中的普通字符简单地按原样写至输出流。
格式输入
常用的格式输入函数有如下三个:
#include <stdio.h>
int scanf(const char *format, ...);
int fscanf(FILE *stream, const char *format, ...);
int scanf(char *s, const char *format, ...);
这三个函数都按format规定的格式读数据,不同的是scanf()读标准输入流,fscanf()读stream指定的流,sscanf()不是读文件而是读参数s指定的字符数组。每一个函数根据format参数读取若干字节,按指定的格式解释它们并存储结果于对应的参数中。位于format之后的可选参数应当是指向存放接收输入值的变量的指针,记住这一点非常重要。这些函数的正常返回值是成功读入了值的参数个数。
format字符串由三类成分组成:一至多个连续的空白符,普通字符(不包括’%‘和空白符),转换区分符。这里特别提请读者注意区分术语“空白符”、“空格符”和“空字符”。空白符是空格符’ ‘、制表符’\t’、水平制表符’\v’、换行符’\r’和走纸符’\f’的统称,即isspace()返回值为真的字符的统称;空格符只指’ ‘(即’\040’);空字符是NULL(即’\0’)。
临时文件
应用常常需要使用临时文件来暂存数据,例如,编译程序在编译器和汇编器之间交换编译结果就常常通过临时文件来进行。这种临时文件有两个特点:允许其他用户或进程读写并且名字必须唯一。前一特点要求将它们保存在公共目录中,以便任何用户读写,UNIX系统为此提供了一个专用的公共目录/tmp。后一特点是为了防止其他进程在相同目录中创建同名文件而引起冲突。UNIX系统给应用提供了专门的临时文件命名和临时文件创建函数,以保证临时文件名的唯一性。
唯一的文件名可以通过函数tmpnam()和tempnam()而获得:
#incldue <stdio.h>
char *tmpnam(char *s);
char *tempnam(const char *dir, const char *pfx);
tmpnam()返回一个合法的临时文件名,此文件名的目录由<stdio.h>中定义的宏变量P_tmpdir指定,P_tmpdir通常就是/tmp。如果参数s不是空指针,tmpnam()存储该临时文件名于s指出的字符串中,此时s指向的字符串大小应当至少不小于宏常数L_tmpnam。若s为空指针,返回值指向的字符串是由tmpnam()静态分配的,将被下一次调用tmpnam()所覆盖。在同一个进程内,tmpnam()至多可以调用TMP_MAX次,并且每一次调用生成的临时文件名各不相同。
tempnam()的功能比tmpnam()的功能还要多一点,因此名字中多一个字母“e”。它可以指定临时文件存放的目录以及文件名的前缀。dir参数给出目录的路径,pfx参数给出文件名前缀。

1285

被折叠的 条评论
为什么被折叠?



