一直以来对于Linux/C中的流和流缓冲的概念一直不太理解,实际使用过程中也碰到几个问题,翻译了几篇文章后大致弄明白了,写一篇博文总结一下。
之前碰到的两个问题
- 在学《C语言程序设计:现代方法》第二版22.2.7文件缓冲的时候,里面讲到了
setvbuf
这个函数,并说该函数的第三个参数指明了期望的缓冲策略,该参数是三个宏之一:_IOFBF(当缓冲区为空时,从流读入数据;当缓冲区满时,向流写入数据)、 _IOLBF、 _IONBF.
我在ubuntu 16.04 + gcc 5.4 环境下测试满缓冲:
#include <stdio.h>
#include <stdlib.h>
#define SIZE_OF_BUFFER 100
int main(int argc, char const *argv[])
{
char buffer[SIZE_OF_BUFFER];
if(setvbuf(stdout, buffer, _IOFBF, sizeof(buffer)))
exit(-1);
fflush(stdout);
printf("hello");
getchar();
return 0;
}
运行输出如下:
shell frank@under:~/tmp$ gcc test2.c && ./a.out hello
和预想的不符合,因为这时缓冲区是我手动创建的,大小为100字节,模式为满缓冲,这时显然没有满,然而还是立即输出了。
- 上学期和一航有一次做反弹shell的实验,攻击者获得受害者的shell后,执行类似于python这样有提示符输出的命令会没有提示符输出,必须加上
-i
参数(交互模式)才能得到立即输出的提示符和结果。另外,即使在受害者主机上写一个有printf "hello world"的C程序,攻击者执行以后也得不到输出,但是如果在输出语句后加一个fflush,攻击者就能够立即看到输出了。
C语言中流的概念
一句话,流(steam)表示任意输入源或任意输出的目的地。
很多程序是通过一个或多个流进行读入和输出的。这些流可能存储在不同的介质(如硬盘,CD,DVD,闪存等等),也可能是不存储文件的设备(打印机,网络套接字)。头文件<stdio.h>中定义了处理流的函数。(注意不仅仅只有表示文件的流)
C程序中对流的操作是通过FILE *
实现的。所以说这种数据类型表示的就是一个流。一个流对象保存了和文件(也可能不是文件,但是Unix下几乎“一切”都是文件)连接的情况以及缓冲的状态,还有文件位置定位符的状态。每一个流还有一个文件末位指示器和错误指示器,可以通过ferror和feof来监测。(参见EOF and Errors.)
注意不要试图创建自己的FILE *
, 让函数库去实现。
流与文件描述符区别与联系
一句话,流是文件描述符的抽象,一般使用文件描述符是系统层次的调用。
当向一个文件读入或者输出时,既可以选择流,也可以选择使用文件描述符。文件描述符是int
类型的,而流是用FILE *
来表示的。
文件描述符提供了一个原始、低层次的输入输出接口。文件描述符和流都可以表示一个连接,可以是和设备的(例如终端),或者管道,或者一个和另一个进程的套接字,或者就是一个正常的文件(normal file)。但是,如果你想要对特殊设备进行特定的操作,你必须使用文件描述符。另外,如果你的程序需要以特殊模式进行输入输出(例如nonblocking, polled input, 参见File Status Flags),也必须使用文件描述符。
而流提供了一个基于原始的文件描述符的高层次接口。流接口对于所有类型的文件的操作大多都是类似的,唯一的区别就是缓冲的策略(参见下面的流缓冲)。
使用流的主要优势是操作流的函数比文件描述符多得多,而且更加强大方便。文件描述符仅仅提供了一个单一的函数用来传输字符块,但是流接口提供了很多格式化的输入输出(例如printf和scanf)和一些字符函数以及列读入输出函数。
因为流是基于文件描述符的,所以实际上你可以“拆解”一个流得到对应的文件描述符然后进行低层次的操作。相反地,你也可以先用文件描述符和一个文件建立连接,然后建立一个链接这个文件描述符的流对象。
通常情况下,你都应该使用流来进行输入输出,这样不仅方便强大,而且可以保证程序的移植性:你可以在任何一个遵守ISO C标准的机器上使用流,但是在一个非GNU机器上你可能无法使用文件描述符。
建议在看“流缓冲”概念之前看看我翻译的三篇文章:C语言 流缓冲、标准输入输出 stdio 流缓冲、输出流缓冲的意义 何时缓冲。下面这个只是这三篇文章的一个总结。
流缓冲
在系统底层调用这个层次,数据是用write+文件描述符写入的,这种方法将数据写入到文件描述符对应的一个字节缓冲中。大多数语言有着非常快的函数调用,在C/C++这种语言中调用一个函数可能只需要几个cpu周期,时间开销几乎可以忽略不计(只有在近端的情况下才会使用inline.)。然而,一个系统调用时间开销是非常可观的。在Linux上的一个系统调用可能会花费几千个cpu周期并掺杂着上下文转化.所以系统调用比用户空间里的函数调用花的时间多得多。
流缓冲存在的主要目的就是为用户空间函数抵消调用系统函数的开销。当函数做很多写入操作时这非常重要——否则系统调用的时间会占程序运行时间的主要部分。先输出到流缓冲中,然后以块为单位调用系统函数输入到对应目的地,这样花费的时间就会减少。
另外还有一个原因,有的时候设备可能处于堵塞状态(想象打印机的打印速度),这个时候先把要打印的字符放在缓冲区,继续下面的任务,会节省很多时间。(突然想到了中午在食堂坐电梯。。人多的时候很类似。。先进食堂再说)
流缓冲有三个策略:
- 无缓冲 unbuffered :从一个无缓冲的流中读写会马上产生效果
- 行缓冲 line buffered:当遇到一个换行符的时候字符会以块的形式读写。
- 满缓冲 fully buffered:字符会以任意大小的块写入读出。(真的是直译。。感觉和网上一些说满的时候才读写的说法不一样,说明可能是不堵塞的时候就读写缓冲区,最多等到缓冲区满)
新开的流一般是满缓冲的,只有一个例外:当流是一个可交互设备(例如终端)的时候,流将变为行缓冲。如果想了解关于如何选择缓冲策略,参考 Controlling Buffering 。通常情况下,默认会选择出最方便的缓冲策略。GNU libc (glibc) 使用以下的缓冲规则:
Stream | Type | Behavior |
---|---|---|
stdin | input | line-buffered |
stdout (TTY) | output | line-buffered |
stdout (not a TTY) | output | fully-buffered |
stderr | output | unbuffered |
注意:缓冲策略是写入流/文件的充分条件,不是必要的。缓冲区存在的意义就是在使用“Stream-level I/O”时从缓冲区进行异步块写入/读出,这样可以在设备堵塞或者写操作很多的时候加快效率。如果一次只写入少量数据,内核一看没有堵塞,“干脆”就把缓冲区的内容写入了,反正放着也是放着。
回答之前两个问题
-
由于我们只有一个写操作,而且写操作仅仅只有几个字符,所以系统一看就缓冲区里仅有的几个字符作为一个块调用系统函数输出到了屏幕。如果我们大量频繁的输出:
#include <stdio.h>
#include <stdlib.h>
#define SIZE_OF_BUFFER 100
int main(int argc, char const *argv[])
{
char buffer[SIZE_OF_BUFFER];
if(setvbuf(stdout, buffer, _IOFBF, sizeof(buffer)))
exit(-1);
fflush(stdout);
for (char i = 0; i < 10; ++i)
{
printf("hello");
}
getchar();
fflush(NULL);
return 0;
}
编译运行输出:
shell frank@under:~/tmp$ ./a.out hello hellohellohellohellohellohellohellohellohellofrank@under:~/tmp$
可以看到,仅仅立即输出了第一个hello,后面连续写入到stdout流的hello都到了缓冲区里没有立即输出,直到后面使用fflush
清除所有流的缓冲区,剩下的9个hello才输出。
- 当我们通过一个反弹shell控制远程主机时,远程主机的程序的输出仅仅是一个套接字,也就是说远程运行的程序不知道它“应该”是输出到一个可以交互的设备,所以为了提高效率,缓冲策略将会是满缓冲——不会立即输出。(估计python很多高层调用也用到了GNU libc库),给python命令加上
-i
参数,就是告诉它我想以交互模式使用它,于是缓冲策略就会改变为行缓冲,很多提示符句就会立即输出了。同样的,我们写的C程序也不知道输出设备是一个可交互的,于是缓冲策略也是满缓冲(默认大小是4096 bytes),我们加上fflush
就强制输出了缓冲的数据,于是就能够在攻击主机上看到输出了。