C标准库的I/O缓冲区
读写过程
用户程序调用C标准I/O库函数读写文件或设备,而这些库函数要通过系统调用把读写请求传给内核(以后我们会看到与I/O相关的系统调用),最终由内核驱动磁盘或设备完成I/O操作。 C标准库为每个打开的文件分配一个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缓冲区中。 在这个场景中用户程序、C标准库和内核之间的关系就像在“Memory Hierarchy”中CPU、Cache和内存之间的关系一样,C标准库之所以会从内核预读一些数据放在I/O缓冲区中,是希望用户程序随后要用到这些数据,C标准库的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缓冲区。
缓冲区类型
C标准库的I/O缓冲区有三种类型:全缓冲、行缓冲和无缓冲。当用户程序调用库函数做写操作时,不同类型的缓冲区具有不同的特性。
-
全缓冲 如果缓冲区写满了就写回内核。常规文件通常是全缓冲的。
-
行缓冲 如果用户程序写的数据中有换行符就把这一行写回内核,或者如果缓冲区写满了就写回内核。标准输入和标准输出对应终端设备时通常是行缓冲的。
-
无缓冲 用户程序每次调库函数做写操作都要通过系统调用写回内核。标准错误输出通常是无缓冲的,这样用户程序产生的错误信息可以尽快输出到设备。
行缓冲flush条件
1.程序退出exit
下面通过一个简单的例子证明标准输出对应终端设备时是行缓冲的。
#include <stdio.h>
int main()
{
printf("hello world");
while(1);
return 0;
}
运行这个程序,会发现hello world并没有打印到屏幕上。用Ctrl-C终止它,去掉程序中的while(1);语句再试一次:
$ ./a.out
hello world$
hello world被打印到屏幕上,后面直接跟Shell提示符,中间没有换行。
我们知道main函数被启动代码这样调用:exit(main(argc, argv));。main函数return时启动代码会调用exit,exit函数首先关闭所有尚未关闭的FILE *指针(关闭之前要做Flush操作),然后通过_exit系统调用进入内核退出当前进程[35]。
在上面的例子中,由于标准输出是行缓冲的,printf("hello world");打印的字符串中没有换行符,所以只把字符串写到标准输出的I/O缓冲区中而没有写回内核(写到终端设备),如果敲Ctrl-C,进程是异常终止的,并没有调用exit,也就没有机会Flush I/O缓冲区,因此字符串最终没有打印到屏幕上。
我们再做个实验,在程序中直接调用_exit退出。
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("hello world");
_exit(0);
}
结果也不会把字符串打印到屏幕上,如果把_exit调用改成exit就可以打印到屏幕上。
2.写入换行符
如果把打印语句改成printf("hello world\n");,有换行符,就会立刻写到终端设备,或者如果把while(1);去掉也可以写到终端设备,因为程序退出时会调用exitFlush所有I/O缓冲区。在本书的其它例子中,printf打印的字符串末尾都有换行符,以保证字符串在printf调用结束时就写到终端设备。
3.调用库函数引发内核读取数据
除了写满缓冲区、写入换行符之外,行缓冲还有一种情况会自动做Flush操作。如果:
-
用户程序调用库函数从无缓冲的文件中读取
-
或者从行缓冲的文件中读取,并且这次读操作会引发系统调用从内核读取数据
那么在读取之前会自动Flush所有行缓冲。例如:
#include <stdio.h>
#include <unistd.h>
int main()
{
char buf[20];
printf("Please input a line: ");
fgets(buf, 20, stdin);
return 0;
}
虽然调用printf并不会把字符串写到设备,但紧接着调用fgets读一个行缓冲的文件(标准输入),在读取之前会自动Flush所有行缓冲,包括标准输出。
4. fflush函数手动flush
如果用户程序不想完全依赖于自动的Flush操作,可以调fflush函数手动做Flush操作。
#include <stdio.h>
int fflush(FILE *stream);
返回值:成功返回0,出错返回EOF并设置errno
对前面的例子再稍加改动:
#include <stdio.h>
int main()
{
printf("hello world");
fflush(stdout);
while(1);
}
虽然字符串中没有换行,但用户程序调用fflush强制写回内核,因此也能在屏幕上打印出字符串。fflush函数用于确保数据写回了内核,以免进程异常终止时丢失数据。作为一个特例,调用fflush(NULL)可以对所有打开文件的I/O缓冲区做Flush操作。
标准IO函数库
fopen/fclose
在操作文件之前要用fopen打开文件,操作完毕要用fclose关闭文件。打开文件就是在操作系统中分配一些资源用于保存该文件的状态信息,并得到该文件的标识,以后用户程序就可以用这个标识对文件做各种操作,关闭文件则释放文件在操作系统中占用的资源,使文件的标识失效,用户程序就无法再操作这个文件了。
#include <stdio.h>
FILE *fopen(const char *path, const char *mode);
返回值:成功返回文件指针,出错返回NULL并设置errno
int fclose(FILE *fp);
返回值:成功返回0,出错返回EOF并设置errno
FILE是C标准库中定义的结构体类型,其中包含该文件在内核中标识(文件描述符)、I/O缓冲区和当前读写位置等信息 mode参数是一个字符串,由rwatb+六个字符组合而成,r表示读,w表示写,a表示追加(Append),在文件末尾追加数据使文件的尺寸增大。t表示文本文件,b表示二进制文件,有些操作系统的文本文件和二进制文件格式不同,而在UNIX系统中,无论文本文件还是二进制文件都是由一串字节组成,t和b没有区分,用哪个都一样,也可以省略不写。如果省略t和b,rwa+四个字符有以下6种合法的组合:
- "r" 只读,文件必须已存在
- "w" 只写,如果文件不存在则创建,如果文件已存在则把文件长度截断(Truncate)为0字节再重新写,也就是替换掉原来的文件内容
- "a" 只能在文件末尾追加数据,如果文件不存在则创建
- "r+" 允许读和写,文件必须已存在
- "w+" 允许读和写,如果文件不存在则创建,如果文件已存在则把文件长度截断为0字节再重新写
- "a+" 允许读和追加数据,如果文件不存在则创建
stdin/stdout/stderr
我们经常用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目录下都有一个特殊的设备文件与之对应,这些设备文件也可以像普通文件一样打开、读、写和关闭,使用的函数接口是相同的。本书中不严格区分“文件”和“设备”这两个概念,遇到“文件”这个词,读者可以根据上下文理解它是指普通文件还是设备,如果需要强调是保存在磁盘上的普通文件,本书会用“常规文件”(Regular File)这个词。 那为什么printf和scanf不用打开就能对终端设备进行操作呢?因为在程序启动时(在main函数还没开始执行之前)会自动把终端设备打开三次,分别赋给三个FILE *指针stdin、stdout和stderr,这三个文件指针是libc中定义的全局变量,在stdio.h中声明,printf向stdout写,而scanf从stdin读,后面我们会看到,用户程序也可以直接使用这三个文件指针。这三个文件指针的打开方式都是可读可写的,但通常stdin只用于读操作,称为标准输入(Standard Input),stdout只用于写操作,称为标准输出(Standard Output),stderr也只用于写操作,称为标准错误输出(Standard Error),通常程序的运行结果打印到标准输出,而错误提示(例如gcc报的警告和错误)打印到标准错误输出
以字节为单位的I/O函数
// fgetc函数从指定的文件中读一个字节,getchar从标准输入读一个字节,调用getchar()相当于调用fgetc(stdin)。
int fgetc(FILE *stream);
int getchar(void);
返回值:成功返回读到的字节,出错或者读到文件末尾时返回EOF
// fputc函数向指定的文件写一个字节,putchar向标准输出写一个字节,调用putchar(c)相当于调用fputc(c, stdout)。
int fputc(int c, FILE *stream);
int putchar(int c);
返回值:成功返回写入的字节,出错返回EOF
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 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个字节
以字符串为单位的I/O函数
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'。
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'到标准输出。
以记录为单位的I/O函数
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
fread和fwrite用于读写记录,这里的记录是指一串固定长度的字节,比如一个int、一个结构体或者一个定长数组。参数size指出一条记录的长度,而nmemb指出要读或写多少条记录,这些记录在ptr所指的内存空间中连续存放,共占size * nmemb个字节,fread从文件stream中读出size * nmemb个字节保存到ptr中,而fwrite把ptr中的size * nmemb个字节写到文件stream中。
nmemb是请求读或写的记录数,fread和fwrite返回的记录数有可能小于nmemb指定的记录数。例如当前读写位置距文件末尾只有一条记录的长度,调用fread时指定nmemb为2,则返回值为1。如果当前读写位置已经在文件末尾了,或者读文件时出错了,则fread返回0。如果写文件时出错了,则fwrite的返回值小于nmemb指定的值。下面的例子由两个程序组成,一个程序把结构体保存到文件中,另一个程序和从文件中读出结构体。
分配内存的函数
除了malloc之外,C标准库还提供了另外两个在堆空间分配内存的函数,它们分配的内存同样由free释放。
void *calloc(size_t nmemb, size_t size);
void *realloc(void *ptr, size_t size);
返回值:成功返回所分配内存空间的首地址,出错返回NULL
calloc的参数很像fread/fwrite的参数,分配nmemb个元素的内存空间,每个元素占size字节,并且calloc负责把这块内存空间用字节0填充,而malloc并不负责把分配的内存空间清零。
有时候用malloc或calloc分配的内存空间使用了一段时间之后需要改变它的大小
- 一种办法是调用malloc分配一块新的内存空间,把原内存空间中的数据拷到新的内存空间,然后调用free释放原内存空间。
- 使用realloc函数简化了这些步骤,把原内存空间的指针ptr传给realloc,通过参数size指定新的大小(字节数),realloc返回新内存空间的首地址,并释放原内存空间。新内存空间中的数据尽量和原来保持一致,如果size比原来小,则前size个字节不变,后面的数据被截断,如果size比原来大,则原来的数据全部保留,后面长出来的一块内存空间未初始化(realloc不负责清零)。 注意,参数ptr要么是NULL,要么必须是先前调用malloc、calloc或realloc返回的指针,不能把任意指针传给realloc要求重新分配内存空间。作为两个特例,如果调用realloc(NULL, size),则相当于调用malloc(size),如果调用realloc(ptr, 0),ptr不是NULL,则相当于调用free(ptr)。
void *alloca(size_t size);
返回值:返回所分配内存空间的首地址,如果size太大导致栈空间耗尽,结果是未定义的
参数size是请求分配的字节数,alloca函数不是在堆上分配空间,而是在调用者函数的栈帧上分配空间,类似于C99的变长数组,当调用者函数返回时自动释放栈帧,所以不需要free。这个函数不属于C标准库,而是在POSIX标准中定义的。