关于文件的内容太多,跨度太大,没有办法在一篇文章内叙述详尽,所以分成多个部分,每一部分对一个特定的模块做详细介绍。
本篇讲的是标准库中文件和流的基础知识,通篇是文字形式的理论解释,阅读起来非常干燥。但是这些知识很重要,是理解C标准库文件操作的基础,如果耐心看完并且消化,将大有收获。
stdio.h
stdio.h是C语言的标准输入输出库的头文件,申明了很多类型、宏,以及相关函数。如果想使用标准输入输出(如printf
函数),就必须包含该头文件。对于gcc编译器,标准输入输出库默认链接到每个程序,不需要手动链接。
流
在C语言的标准输入输出库中,I/O被抽象成“流”,以忽略不同文件或设备的差异,并且获得更简洁高效的I/O体验。
正如现实中的溪流一样,流将输入或输出抽象成一个任意长度的字节序列,这个序列有一个方向。把数据放入流中,它便会自动流向目的地,我们可以接着放入新的数据;从流中取出数据,它便会将新的数据推到面前以供下次取用。
流一般配合缓冲区实现,文件就像一整条溪流,而缓冲区就像我们目所能及的一小段溪流。读取文件时,流每次从文件中加载一段数据到缓冲区中,我们对缓冲区进行访问,到达缓冲区末尾时,将自动加载下一段数据到缓冲区;写入文件时,我们先在缓冲区上进行操作,满足特定条件后(比如缓冲区满),缓冲区的内容会自动刷新到文件,缓冲区可以重复使用,每次写入文件的内容都会跟在上次内容之后。
流一般会包括关联文件、流类型、缓冲区、文件位置指示器、流状态等,表现形式为一个结构体指针,具体实现取决于编译器。
文件位置指示器
对于支持位置请求的流(比如关联到普通文件的流),包含一个文件位置指示器。它表示当前操作在整个流中的位置。流把数据抽象成一个字节序列,文件位置指示器就是当前位置距离文件开头的偏移量。所有的输入和输出都在文件位置指示器所指的位置进行,并且在操作完成后自动更新文件位置指示器。
文本流与二进制流
首先需要说明,在计算机内所有数据都是二进制的。 所谓文本不过是以特定方式编码的二进制串。
与文本流和二进制流对应的是文本文件和二进制文件。C标准区别二者,在不同操作系统平台上有不同的表现。
对于Unix/Linux以及OS X之后的macOS操作系统,文本流与二进制流完全相同。
对于DOS和Windows,以及早期的macOS操作系统,文本流存在一些额外操作。
在DOS或Windows操作系统上,以\r\n
表示换行,而C语言继承Unix使用\n
。考虑到文本处理程序的可移植性,当在DOS或Windows操作系统上读取到\r\n
时,将自动转换为\n
;写入\n
时,自动转换为\r\n
。
早期的macOS操作系统使用\r
作为换行符,处理文本流时存在\r
和\n
之间的转换。
除此之外,文本流与二进制流没有区别,各种I/O函数也是通用的。
缓冲
当两个设备或过程的I/O速度存在差距时,缓冲技术就很重要。如果没有缓冲,那么速度快的过程就必须等待速度慢的过程完成I/O;有了缓冲以后,速度快的过程可以先把数据放入缓冲区内,然后去执行其他任务。同时,缓冲技术避免了频繁的I/O调用,使总体速度加快。
缓冲一般有两种类型:行缓冲和全缓冲。
- 行缓冲:当缓冲区满,或者遇到一个换行符(
\n
)时,刷新缓冲区。 - 全缓冲:当且仅当缓冲区满时刷新缓冲区。
手动创建的流一般是全缓冲的。
预定义流
stdio.h中存在三个预定义流,stdin,stdout,stderr,分别对应文件标准输入、标准输出、标准错误,对应文件描述符0、1、2。这三个流都默认关联到终端。
需要注意,这三个流并不总是直接关联到终端,而是继承父进程的相应文件,如果父进程的预定义流被重定向,它们也会跟着被重定向。
C标准中说,stderr默认不是全缓冲的,stdin和stdout当且仅当流关联到非交互式设备时是全缓冲的。这个说法非常含糊,POSIX补充到,stderr是无缓冲的,stdout关联到终端时是行缓冲的,stdin关联到终端时缓冲方式并不重要,终端有自己的缓冲方式。
结合两个标准,可以得到以下结论:
- stderr在任何情况下都是无缓冲的,输出的内容会立刻显示到指定地点,所以log建议在stderr输出。
- stdout默认(关联到终端)是行缓冲的,如果没有遇到换行符(或缓冲区满),不会显示任何内容,这会给调试输出带来麻烦。当stdout被重定向到普通文件时,它是全缓冲的,这可以提高程序的执行效率。
- stdin默认情况下(关联到终端)的缓冲方式不重要,因为输入被终端接管,终端有自己的缓冲区,何时刷新缓冲区取决于终端。终端的缓冲方式一般是行缓冲,这让stdin看起来是行缓冲的。更改终端设置可以改变输入时的缓冲行为,更改stdin的缓冲区无效。当stdin被重定向到普通文件时,它是全缓冲的,同样为了提高程序的执行效率。
上面说到的缓冲方式都是默认行为,可以通过setbuf
或setvbuf
函数修改。Windows并不完全符合POSIX规范。
关联到终端的流不支持位置请求,所以rewind
、ftell
、fseek
、fgetpos
、fsetpos
等函数都不能使用。
EOF
EOF是“End of File”的缩写,表示文件结束。
在大多数现代操作系统上,EOF并不是一个真正存在于文件末尾的字符,而是有些函数在读取到文件末尾时的返回值。判断是否到达文件末尾的方法不是检查EOF字符,而是比较文件大小。
EOF定义为宏,一般如下:
#define EOF (-1)
流包含EOF标志,当读取到文件末尾时,EOF标志就被设置。当EOF标志被设置时,所有读取操作都会直接失败返回,不会对流产生影响。
对于读取单个字节的函数或操作,比如fgetc
、fscanf(stream, "%c", ptr)
,并不是读取完流中最后一个字节EOF标志就被设置,而是必须读取一次文件末尾。
比如一个文件包含以下内容:
123
使用fgetc
函数每次读取一个字节,当调用函数3次后,文件位置落在了3
后,也就是文件末尾。此时并没有触发EOF,如果再调用一次fgetc
,才会设置EOF标志,并且返回EOF。
对于读取多个字节的的函数,比如fgets
、fscanf
、fread
等,当读取完流中最后一个字节并尝试继续读取时,EOF标志就被设置。
比如一个文件包含以下内容:
123
如果调用函数fscanf(stream, "%d", ptr)
,当读取完字符3
后,程序不能确定这就是整个数字,后面可能还有其他内容,所以会尝试读取文件末尾。于是EOF标志被设置。
如果调用函数fgets(ptr, 10, stream)
,当读取完字符3
后,没有遇到换行符'\n'
,也没有达到10 - 1
个字节,所以会尝试读取文件末尾,于是EOF标志被设置。如果调用函数gets(ptr, 4, stream)
,当读取完字符3
后,达到了4 - 1
个字节,函数会直接返回,不会设置EOF标志。如果文件末尾有一个换行符,则调用函数fgets
永远不会设置EOF标志,因为读取换行符后函数就会返回。
如果调用函数fread(ptr, 2, 2, stream)
,当读取完字符3
后,没有达到2 * 2
个字节,所以会尝试读取文件末尾,于是EOF标志被设置。如果调用函数fread(ptr, 1, 3, stream)
,当读取完字符3
后,达到了1 * 3
个字节,函数会直接返回,不会设置EOF标志。
产生上述结果的原因是,读取多个字节的函数在实现时都是连续调用fgetc
函数的。这种设计明显是存在缺陷的,使用时应注意避开陷阱。