前言:我想大家学习C语言接触过的第一个函数应该是printf,但是我们真正理解它了吗?最近看Linux以及网络编程这块,我觉得I/O这块很难理解。以前从来没认识到Unix I/O和C标准库I/O函数压根不是一码事。Unix I/O也叫低级I/O,也叫Unbuffered I/O,是操作系统内核部分,也是系统调用;而C标准I/O函数相对也成Buffered I/O,高级I/O,一般是为了效率考虑对这些系统调用的封装。以前使用getchar()经常为输入完后的回车而出错。那是不理解标准I/O实现时的缓冲区的概念。在网上找了这篇文章参考Unix环境高级编程,写的很详细。
在前面《UNIX环境高级编程----文件描述符浅析》一文中所讲的I/O函数都是针对文件描述符。而对于标准I/O库,它们的操作都是围绕流来进行的。当用标准I/O库打开或创建一个文件时,我们已经使一个流与文件相结合。
一、流和FILE对象
当打开一个流时,标准I/O函数fopen返回一个指向FILE对象的指针。该对象通常是一个结构,它包含了I/O库为管理该流所需要的所有信息:用于实际I / O的文件描述符,指向流缓存的指针,缓存的长度,当前在缓存中的字符数,出错标志等等。
应用程序没有必要检验FILE对象。为了引用一个流,需将FILE指针作为参数传递给每个标准I/O函数。在《UNIX环境高级编程》一书中,我们称指向FILE对象的指针(类型为FILE*)为文件指针。
二、标准I/O库的缓存(需要理解)
标准I/O提供缓存的目的是尽可能少的使用read和write调用量,从而加速对文件的读和写操作。但是不幸的是标准I/O库最让人迷惑的恰好也是它的缓存。为了详细说明缓存的机制,必须先了解下为什么有了这个缓存就能提供文件的操作效率。
用户程序调用标准I/O库函数读写文件,而这些库函数要通过系统调用把读写请求传给内核,最终由内核驱动磁盘或设备完成I/O操作。标准I/O库为每个打开的文件分配一个I/O缓冲区以加速读写操作,通过文件的FILE结构体可以找到这个缓冲区,用户调用读写函数大多数时候都在I/O缓冲区中读写,只有少数时候需要把读写请求传给内核。以fgetc/fputc为例,当用户程序第一次调用fgetc读一个字节时,fgetc函数可能通过系统调用进入内核读1K字节到I/O缓冲区中,然后返回I/O缓冲区中的第一个字节给用户,把读写位置指向I/O缓冲区中的第二个字符,以后用户再调fgetc,就直接从I/O缓冲区中读取,而不需要进内核了,当用户把这1K字节都读完之后,再次调用fgetc时,fgetc函数会再次进入内核读1K字节到I/O缓冲区中。标准I/O库之所以会从内核预读一些数据放在I/O缓冲区中,是希望用户程序随后要用到这些数据,标准I/O库的I/O缓冲区也在用户空间,直接从用户空间读取数据比进内核读数据要快得多。另一方面,用户程序调用fputc通常只是写到I/O缓冲区中,这样fputc函数可以很快地返回,如果I/O缓冲区写满了,fputc就通过系统调用把I/O缓冲区中的数据传给内核,内核最终把数据写回磁盘。有时候用户程序希望把I/O缓冲区中的数据立刻传给内核,让内核写回设备,这称为Flush操作,对应的库函数是fflush,fclose函数在关闭文件之前也会做Flush操作。
下图一以fgets/fputs示意了I/O缓冲区的作用,使用fgets/fputs函数时在用户程序中也需要分配缓冲区(图中的buf1和buf2),注意区分用户程序的缓冲区和C标准库的I/O缓冲区。
图一 I/O缓存区
标准I/O库提供了三种类型的缓存:
1) 全缓存:如果缓冲区写满了就写回内核。常规文件通常是全缓冲的。
2) 行缓存:如果用户程序写的数据中有换行符就把这一行写回内核,或者如果缓冲区写满了就写回内核。标准输入和标准输出对应终端设备时通常是行缓冲的。
行缓存有两个限制:
第一个是行缓存区的缓存长度是固定,系统一般默认为1K,所以只要行缓存区满了,即使没有写一个新换行符,系统也会执行I/O操作;关于这一点,可以从下面的例子看出来。
第二个是任何时候只要通过标准输入输出库要求从( a )一个不带缓存的流,或者( b )一个行缓存的流(它预先要求从内核得到数据)得到输入数据,那么就会造成刷新所有行缓存输出流。
Example 01.c
#include<stdio.h>
int main()
{
printf("Hello World");
Whlie(1);
return 0;
}
编译执行时会发现终端什么都没有输出。如果把whlie(1)去掉,就会在终端打印出Hello World。
Example 02.c
#include<stdio.h>
int main()
{
printf("Hello World\n");
Whlie(1);
return 0;
}
编译执行时会发现终端印出Hello World。
Example 03.c
#include<stdio.h>
int main()
{
printf("Hello World ...Hello World");//...代表1024-11*2个字节
Whlie(1);
return 0;
}
编译执行时会发现终端打印出Hello World ...Hello World。以上三个例子足以说明行缓存类型的缓存区长度是固定的。写入缓存区的数据为换行符或长度超过缓存区长度时系统会执行I/O操作。
3) 不带缓存:用户程序每次调库函数做写操作都要通过系统调用写回内核。标准错误输出通常是无缓冲的,这样用户程序产生的错误信息可以尽快输出到设备。
对于任何一个流,如果我们不喜欢这些系统默认,可以通过调用下面两个函数中一个来更改缓存类型
-----------------------------------------------------------------------------------------------------------------
void setbuf(FILE *fp, char *buf) ;
或
int setvbuf(FILE *fp, char *buf, int mode, size_t size) ;
返回:若成功则为0,若出错则为非0
-----------------------------------------------------------------------------------------------------------------
下图二是setbuf和setvbuf函数各选项说明,可以明显看出函数setvbuf功能更强大一些。
图二 setbuf和setvbuf函数各选项说明
三、标准I/O库函数
1. 打开、关闭I/O流函数
下面三个函数可用于打开一个标准流:
----------------------------------------------------------------------------------------------------------------
FILE *fopen(const char *pathname, const char *type) ;
FILE *freopen(const char *pathname, const char *type, FILE *fp) ;
FILE *fdopen(int filedes, const char *type) ;
三个函数的返回:若成功则为文件指针,若出错则为NULL
-------------------------------------------------------------------------------------------------------------
这三个函数的区别是:
(1) fopen打开路径名由pathname指示的一个文件。
(2) freopen在一个特定的流上(由fp指示)打开一个指定的文件(其路径名由pathname指示),如若该流已经打开,则先关闭该流。此函数一般用于将一个指定的文件打开为一个预定义的流:标准输入、标准输出或标准出错。
(3) fdopen取一个现存的文件描述符(我们可能从open,dup,dup2,fcntl或pipe函数得到此文件描述符),并使一个标准的I/O流与该描述符相结合。
下面函数用于关闭一个标准流:
--------------------------------------------------------------------------------------------------------------
int fclose(FILE *fp)
---------------------------------------------------------------------------------------------------------------
2. 读、写I/O流函数
1)以字节为单位的I/O函数
----------------------------------------------------------------------------------------------------------------
int getc(FILE *stream);
int fgetc(FILE *stream);
int getchar(void);
返回值:成功返回读到的字节,出错或者读到文件末尾时返回EOF
----------------------------------------------------------------------------------------------------------------------
l 第一个跟第三个本身不是函数,是通过宏定义借助fgetc来实现的。比如:
# define getc(_stream) fgetc(_stream)
# define getchar fgetc(stdin)
l 所以fgetc允许作为一个参数传递给另一个函数。
l 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并不是一个字节)。
---------------------------------------------------------------------------------------------------------------
int putc(int c, FILE *stream);
int fputc(int c, FILE *stream);
int putchar(int c);
返回值:若成功返回c,出错则为EOF
---------------------------------------------------------------------------------------------------------------
l 同样第一个跟第三个本身不是函数,是通过宏定义借助fgetc来实现的。
2)以字符串为单位的I/O函数
----------------------------------------------------------------------------------------------------------------
char *fgets(char *s, int size, FILE *stream);
char *gets(char *s);
返回值:成功时s指向哪返回的指针就指向哪,出错或者读到文件末尾时返回NULL
---------------------------------------------------------------------------------------------------------------
l 这两个函数都指定了缓存地址,读入的字符串放入其中。gets是从标准输入读,fgets是从指定流读。
l gets不推荐程序员使用,它的存在只是为了兼容以前的程序,我们写的代码不应该有调用这个函数。
l 现在说说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‘。对于二进制文件可以通过fread来实现
---------------------------------------------------------------------------------------------
int fputs(const char *s, FILE *stream);
int puts(const char *s);
返回值:成功返回一个非负整数,出错返回EOF
------------------------------------------------------------------------------------------------
l 缓冲区s中保存的是以‘\0‘结尾的字符串,fputs将该字符串写入文件stream,但并不写入结尾的‘\0‘。与fgets不同的是,fputs并不关心的字符串中的‘\n‘字符,字符串中可以有‘\n‘也可以没有‘\n‘。puts将字符串s写到标准输出(不包括结尾的‘\0‘),然后自动写一个‘\n‘到标准输出。
l
3)二进制I/O函数
l 上面也提到过用字符串为单位的IO函数不适合二进制文本。当然对于二进制文件,我们可以通过使用fgetc跟fputc来实现,但是必须循环整个二进制文件,明显比较低效。因此标准IO库提供了如下两个函数对二进制文件操作:
----------------------------------------------------------------------------------------------
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
--------------------------------------------------------------------------------------------------
l 使用二进制I/O的基本问题是,它只能用于读已写在同一系统上的数据。其原因是:
(1) 在一个结构中,同一成员的位移量可能随编译程序和系统的不同而异(由于不同的对准要求)。确实,某些编译程序有一选择项,它允许紧密包装结构(节省存储空间,而运行性能则可能有所下降)或准确对齐,以便在运行时易于存取结构中的各成员。这意味着即使在单一系统上,一个结构的二进制存放方式也可能因编译程序的选择项而不同。
(2) 用来存储多字节整数和浮点值的二进制格式在不同的系统结构间也可能不同。
3)二进制I/O函数
l 上面也提到过用字符串为单位的IO函数不适合二进制文本。当然对于二进制文件,我们可以通过使用fgetc跟fputc来实现,但是必须循环整个二进制文件,明显比较低效。因此标准IO库提供了如下两个函数对二进制文件操作:
----------------------------------------------------------------------------------------------
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
-------------------------------------------------------------------------------------------------
l 使用二进制I/O的基本问题是,它只能用于读已写在同一系统上的数据。其原因是:
(1) 在一个结构中,同一成员的位移量可能随编译程序和系统的不同而异(由于不同的对准要求)。确实,某些编译程序有一选择项,它允许紧密包装结构(节省存储空间,而运行性能则可能有所下降)或准确对齐,以便在运行时易于存取结构中的各成员。这意味着即使在单一系统上,一个结构的二进制存放方式也可能因编译程序的选择项而不同。
(2) 用来存储多字节整数和浮点值的二进制格式在不同的系统结构间也可能不同。
3. 定位I/O流函数
两种方法定位标准I/O流:
(1) ftell和fseek。这两个函数自V7以来就存在了,但是它们都假定文件的位置可以存放在一个长整型中。
(2) fgetpos和fsetpos。这两个函数是新由ANSI C引入的。它们引进了一个新的抽象数据类型fpost,它记录文件的位置。在非UNIX系统中,这种数据类型可以定义为记录一个文件的位置所需的长度。所以移植到非UNIX系统的应用程序应当使用fgetpos和fsetpos。
----------------------------------------------------------------------------------------------------
int fseek(FILE *stream, long offset, int whence);
返回值:成功返回0,出错返回-1并设置errno
long ftell(FILE *stream);
返回值:成功返回当前读写位置,出错返回-1并设置errno
void rewind(FILE *stream);
把读写位置移到文件开头
-------------------------------------------------------------------------------------------------------
fseek的whence和offset参数共同决定了读写位置移动到何处,whence参数的含义如下:
SEEK_SET
从文件开头移动offset个字节
SEEK_CUR
从当前位置移动offset个字节
SEEK_END
从文件末尾移动offset个字节
offset可正可负,负值表示向前(向文件开头的方向)移动,正值表示向后(向文件末尾的方向)移动,如果向前移动的字节数超过了文件开头则出错返回,如果向后移动的字节数超过了文件末尾,再次写入时将增大文件尺寸,从原来的文件末尾到fseek移动之后的读写位置之间的字节都是0。
-------------------------------------------------------------------------------------------------------
int fgetpos(FILEf *p, fpos_t *pos) ;
int fsetpos(FILEf *p, const fpos_t *pos) ;
两个函数返回:若成功则为0,若出错则为非0
-----------------------------------------------------------------------------------------------------------
fgetpos将文件位置指示器的当前值存入由pos指向的对象中。在以后调用fsetpos时,可以使用此值将流重新定位至该位置。
4. 格式化I/O流函数
l 格式化输入函数:
----------------------------------------------------------------------------------------------------
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, ...);
int vprintf(const char *format, va_list ap);
int vfprintf(FILE *stream, const char *format, va_list ap);
int vsprintf(char *str, const char *format, va_list ap);
int vsnprintf(char *str, size_t size, const char *format, va_list ap);
返回值:成功返回格式化输出的字节数(不包括字符串的结尾‘\0‘),出错返回一个负值
--------------------------------------------------------------------------------------------------------
l 格式化输出函数:
---------------------------------------------------------------------------------------------------------
int scanf(const char *format, ...);
int fscanf(FILE *stream, const char *format, ...);
int sscanf(const char *str, const char *format, ...);
#include <stdarg.h>
int vscanf(const char *format, va_list ap);
int vsscanf(const char *str, const char *format, va_list ap);
int vfscanf(FILE *stream, const char *format, va_list ap);
返回值:返回成功匹配和赋值的参数个数,成功匹配的参数可能少于所提供的赋值参数,返回0表示一个都不匹配,出错或者读到文件或字符串末尾时返回EOF并设置errno
---------------------------------------------------------------------------------------------------------
这里仅仅说一下printf的一个小技巧,我们在%后面加上#,打印到终端的值,会在前面自动加上0、0x。比如pintf("%#x",1)语句在终端会打印0x1。
5. 创建临时文件I/O流函数
很多情况下,程序会创建一些文件形式的临时文件,这些临时文件可能保存这一个计算的中间结果,也可能是关键操作前的备份等等。这都是临时文件的好处。
标准I/O提供了两个函数创建临时文件
---------------------------------------------------------------------------------------------------------
char *tmpnam(char *ptr) ;
返回:指向一唯一路径名的指针
FILE *tmpfile(void);
返回:若成功则为文件指针,若出错则为NULL
-----------------------------------------------------------------------------------------------------------
l tmpnam函数返回一个不与任何已存在文件同名的有效文件名。每次调用它都会产生一个不同的文件名,但是一个进程中调用最多次数为TMP_MAX【在stdio.h中有定义】。如果ptr不为NULL,则认为字符串ptr的长度至少是L_tmpnam【在stdio.h中有定义】,所产生的文件名会放入该字符串ptr中,因此返回值为ptr的值;如果ptr为NULL,则所产生的文件名存放在一个静态区中,下一次调用时,会重写改静态区。
l tmpfile 创建一个临时二进制文件(类型为wb+),关闭文件或程序结束时将自动删除这种文件。
l 需要注意的是,tmpnam仅仅是创建一个临时文件,并没有打开它,所以我们如果要用它必须尽可能快的打开它,这样减小另一个程序用同样的名字打开文件的风险。而tmpfile除了创建外,会同时以读写方式打开。
四、参考资料
1. 《UNIX环境高级编程》
3. 标准I/O库函数