1.C标准库的I/O缓冲区
UNIX的传统是Everything is a file,键盘、显示器、串口、磁盘等设备在/dev目录下都有一个特殊的设备文件与之对应,这些设备文件也可以像普通文件(保存在磁盘上的文件)一样打开、读、写和关闭,使用的函数接口是相同的。用户程序调用C标准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操作。我们知道main函数被启动例程这样调用:exit(main(argc, argv)),main函数return时启动代码会调用exit ,exit函数首先关闭所有尚未关闭的FILE *指针(关闭之前要做Flush操作),然后通过_exit系统调用进入内核退出当前进程.
(注:以前我们说main
函数是程序的入口点其实不准确,_start
才是真正的入口点,而main
函数是被_start
调用的。_start
首先做一些初始化工作(称为启动例程),然后调用C代码中提供的main
函数。main
函数最标准的原型应该是int main(int argc, char *argv[])
,也就是说启动例程会传两个参数给main
函数,我们到目前为止都把main
函数的原型写成int main(void)
,这也是C标准允许的,多传了参数而不用是没有问题的,少传了参数却用了则会出问题。由于main
函数是被启动例程调用的,所以从main
函数return
时仍返回到启动例程中,main
函数的返回值被启动例程得到,如果将启动例程表示成等价的C代码(实际上启动例程一般是直接用汇编写的),则它调用main
函数的形式是:exit(main(argc, argv));也就是说,启动例程得到main
函数的返回值后,会立刻用它做参数调用exit
函数。exit
也是libc
中的函数,它首先做一些清理工作,然后调用_exit
系统调用终止进程,main
函数的返回值最终被传给_exit
系统调用,成为进程的退出状态。我们也可以在main
函数中直接调用exit
函数终止进程而不返回到启动例程,如 int main(void} {exit(4);}和 int main(void) { return 4;}
的效果是一样的。)
C标准库的I/O缓冲区有三种类型:全缓冲、行缓冲和无缓冲。当用户程序调用库函数做写操作时, 不同类型的缓冲区具有不同特性。
全缓冲:如果缓冲区写满了就写回内核。常规文件通常是全缓冲的。
行缓冲:如果用户程序写的数据中有换行符就把这一行写回内核,或者如果缓冲区写满了就写回内 核。标准输入和标准输出对应终端设备时通常是行缓冲的。
无缓冲:用户程序每次调库函数做写操作都要通过系统调用写回内核。标准错误输出通常是无缓冲的,这样用户程序产生的错误信息可以尽快输出到设备。
除了写满缓冲区、写入换行符之外,行缓冲还有两种情况会自动做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所有行缓冲,包括标准输出。
如果用户程序不想完全依赖于自动的Flush操作,可以调fflush函数手动做Flush操作。#include <stdio.h>
int fflush(FILE *stream);
返回值:成功返回0,出错返回EOF并设置errno
fflush函数用于确保数据写回了内核,以免进程异常终止时丢失数据,如fflush(stdout); 作为一个特例,调 用fflush(NULL)可以对所有打开文件的I/O缓冲区做Flush操作。
2. 用户程序的缓冲区
在函数栈上分配的如char buf[10];之类的缓冲区, strcpy(buf, str); str所指向的字符串有可能超过10个字符而导致写越界,这种写越界可能当时不出错, 而在函数返回时出现段错误,原因是写越界覆盖了保存在栈帧上的返回地址, 函数返回时跳转到非法地址,因而出错。像buf这种由调用者分配并传给函数读或写的一段内存通 常称为缓冲区(Buffer),缓冲区写越界的错误称为缓冲区溢出(Buffer Overflow)。如果只是出现段错误那还不算严重,更严重的是缓冲区溢出Bug经常被恶意用户利用,使函数返回时跳转到一个事先设好的地址,执行事先设好的指令,如果设计得巧妙甚至可以启动一个Shell,然后随心所欲执行任何命令,可想而知,如果一个用root权限执行的程序存在这样的Bug,被攻陷了,后果将很严重。
下图以fgets/fputs示意了I/O缓冲区的作用,使用fgets/fputs函数时在用户程序中也需要分配缓冲区(图中的buf1和buf2),注意区分用户程序的缓冲区和C标准库的I/O缓冲区。