C语言篇:文件 - 基础知识

394 阅读8分钟

关于文件的内容太多,跨度太大,没有办法在一篇文章内叙述详尽,所以分成多个部分,每一部分对一个特定的模块做详细介绍。

本篇讲的是标准库中文件和流的基础知识,通篇是文字形式的理论解释,阅读起来非常干燥。但是这些知识很重要,是理解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中存在三个预定义流,stdinstdoutstderr,分别对应文件标准输入标准输出标准错误,对应文件描述符012。这三个流都默认关联到终端。

需要注意,这三个流并不总是直接关联到终端,而是继承父进程的相应文件,如果父进程的预定义流被重定向,它们也会跟着被重定向。

C标准中说,stderr默认不是全缓冲的,stdinstdout当且仅当流关联到非交互式设备时是全缓冲的。这个说法非常含糊,POSIX补充到,stderr是无缓冲的,stdout关联到终端时是行缓冲的,stdin关联到终端时缓冲方式并不重要,终端有自己的缓冲方式。

结合两个标准,可以得到以下结论:

  • stderr在任何情况下都是无缓冲的,输出的内容会立刻显示到指定地点,所以log建议在stderr输出。
  • stdout默认(关联到终端)是行缓冲的,如果没有遇到换行符(或缓冲区满),不会显示任何内容,这会给调试输出带来麻烦。当stdout被重定向到普通文件时,它是全缓冲的,这可以提高程序的执行效率。
  • stdin默认情况下(关联到终端)的缓冲方式不重要,因为输入被终端接管,终端有自己的缓冲区,何时刷新缓冲区取决于终端。终端的缓冲方式一般是行缓冲,这让stdin看起来是行缓冲的。更改终端设置可以改变输入时的缓冲行为,更改stdin的缓冲区无效。当stdin被重定向到普通文件时,它是全缓冲的,同样为了提高程序的执行效率。

上面说到的缓冲方式都是默认行为,可以通过setbufsetvbuf函数修改。Windows并不完全符合POSIX规范。

关联到终端的流不支持位置请求,所以rewindftellfseekfgetposfsetpos等函数都不能使用。

EOF

EOF是“End of File”的缩写,表示文件结束。

在大多数现代操作系统上,EOF并不是一个真正存在于文件末尾的字符,而是有些函数在读取到文件末尾时的返回值。判断是否到达文件末尾的方法不是检查EOF字符,而是比较文件大小。

EOF定义为宏,一般如下:

#define EOF (-1)

流包含EOF标志,当读取到文件末尾时,EOF标志就被设置。当EOF标志被设置时,所有读取操作都会直接失败返回,不会对流产生影响。

对于读取单个字节的函数或操作,比如fgetcfscanf(stream, "%c", ptr),并不是读取完流中最后一个字节EOF标志就被设置,而是必须读取一次文件末尾。

比如一个文件包含以下内容:

123

使用fgetc函数每次读取一个字节,当调用函数3次后,文件位置落在了3后,也就是文件末尾。此时并没有触发EOF,如果再调用一次fgetc,才会设置EOF标志,并且返回EOF

对于读取多个字节的的函数,比如fgetsfscanffread等,当读取完流中最后一个字节并尝试继续读取时,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函数的。这种设计明显是存在缺陷的,使用时应注意避开陷阱。