C语言简明教程(十五)(文件输入输出工作原理和实例详解)

接上一节:存储类别、链接、内存管理和类型限定
文件输入输出在所有编程语言都有,文件的输入输出是所有高级数据处理的第一步,而了解文件输入输出的工作原理则更为重要,各种编程语言的I/O操作和C语言的都差不多,只是API不同,而C语言相对系统层面会更好地展示出I/O的操作原理和逻辑。文件输入输出处理是非常有用的,本文尽力简单全面地解释文件I/O。
一、文件 1、什么是文件?
用惯了windows系统不知大家有没有这样一种错觉:觉得文件就是文本文件或者一些文档文件,可能有少部分人很少会觉得视频也是文件。很大程度是因为用了windows纯界面傻瓜式操作系统,没有linux的技术性操作。
在编程的世界里,文件用于储存数据,就是一个数据块,更抽象来说,文件表示一种数据源。在计算机上,文件是磁盘或硬盘上一块已命名的存储区域,不过对于操作系统而言,文件数据块不一定是连续的,但是对于C语言而言,文件数据块可被看作是连续的,由一系列连续的字节数据组成。

C语言简明教程(十五)(文件输入输出工作原理和实例详解)

文章图片
一般标准的文件数据结构一般都会有一个文件头,然后是一些文件说明,主要内容是真实的数据,文件的数据结构是读取文件的基本依据。在linux下输入设备,终端也被当做文件,因为这些设备也能提供数据输入或输出,文本文件也是一种文件,特殊的地方在于,它直接就是文本字符码,一般不用考虑数据结构的问题。
C语言简明教程(十五)(文件输入输出工作原理和实例详解)

文章图片
基本上只需要记住文件就是一个数据块就行了,并且要知道文件数据块有其固定的数据结构。这样看来很多东西都是文件了,例如程序、文档、数据文件、图片、语音、视频,更广泛地,一些计算机外设也都是文件,如此看来掌握文件的输入输出也就显得相当重要了,因为计算机的主要任务基本都是在处理文件,也就是数据。
2、标准文件指针和文件模式
相对于系统提供的底层I/O,C提供标准I/O用于读写文件,适用于大多数平台,C语言使用FILE抽象地表示文件,但FILE并不指向真正的文件,FILE是对文件信息和缓冲区信息的封装。C程序执行默认会打开三个标准文件:标准输入stdin、标准输出stdout和标准错误stderr,常用的scanf就是使用标准输入文件stdin,printf使用标准输出文件stdout,stdin、stdout和stderr是标准的文件指针,定义在stdio.h中,可以直接使用。
C语言最初是由Unix开发而开发出来的,因此C语言在Unix和Linux上有更标准的运行,对于文件输入输出也是,Unix和Linux使用一种文件格式。为了适应其它系统,C语言提供两种模式读取文件:文本模式和二进制模式。
文件中的文本内容都使用二进制表示,但是纯文本文件的二进制内容是根据字符编码得到(ASCII或Unicode字符编码),文本模式下,C统一使用\n作为换行符和其它系统进行相应的转换,文本文件中的每个字节都可以转换成一个字符。
二进制文件中的二进制值表示机器代码、文档编码、图像编码、语音编码或视频编码数据,一般不能根据单个字节得到实际的数据值。
二、缓冲区1、文件输入输出和缓冲区
I/O即输入输出(Input/Output),C使用标准I/O处理文件读写,标准I/O都是缓冲的,之前的章节都有讨论过缓冲和缓冲区的内容,缓冲区即缓存,数据缓冲的目的是为了更快速地读写数据,CPU直接从缓冲区批量读取数据相比CPU逐次逐个读取磁盘上的数据快得多。
那么C是如何处理文件输入输出的呢?C的I/O操作直接面向缓冲区,缓冲区中的字节称为字节流,即I/O流,比起流stream的概念本文更建议记住文件数据块和缓冲区的概念,程序和文件之间的互动实质是通过缓冲区,程序从缓冲区读取内容,并不是直接读取文件,程序写入内容到缓冲区,并不是直接写入文件,文件、缓冲区和程序的大概关系如下图:
C语言简明教程(十五)(文件输入输出工作原理和实例详解)

文章图片
2、缓冲区的工作原理和文件处理原理
缓冲区同样是一块内存空间,位于程序和文件之间,用于缓存文件输入输出的数据,下面是文件处理的完整图解:
C语言简明教程(十五)(文件输入输出工作原理和实例详解)

文章图片
左边是文件数据块,C将文件数据看作连续的字节流,中间是缓冲区,右边是C程序。
(1)打开文件:对于文件输入,fopen打开一个文件,创建一个缓冲区,并且创建FILE结构对象,它是一个文件和缓冲区数据结构,读写模式创建两个缓冲区。
(2)调用输入函数:例如fscanf、getc或fgets,将文件中的数据拷贝到缓冲区,上图中缓冲区大小为4字节,那么会首先将文件的前4字节数据填充到缓冲区。并且设置FILE结构对象的值,例如缓冲区的当前位置,缓冲区字节数。
(3)读取数据:输入函数从缓冲区读取数据,设置FILE对象的值,如下一个被读取的位置,所有输入函数都使用同一个缓冲区。
(4)读取完数据:输入函数读取完缓冲区所有字符之后,重新将文件数据拷贝到缓冲区,直到读取最后一个字符,最后输入函数会返回EOF文件结束符。
(5)文件输出:输出函数以类似的方式输出数据到缓冲区,输出函数拷贝数据到缓冲区,缓冲区中的数据保存到文件。
(6)关闭文件:fclose关闭文件,必要时会刷新缓冲区。
3、缓冲类型
C有三种缓冲类型,每种缓冲类型都有其特点和使用场景,相应的缓冲标识在stdio.h头文件中,如下:
【C语言简明教程(十五)(文件输入输出工作原理和实例详解)】(1)全缓冲:_IOFBF(BF,Buffer,Full),填满缓冲区才刷新缓冲区,刷新缓冲区即将缓冲区中的字节数据输送到目的地,并清空缓冲区,文件读写使用全缓冲。
(2)行缓冲:_IOLBF(Line),遇到换行符即刷新缓冲区,一般用于标准输入输出。
(3)无缓冲:_IONBF(No),输入输出不进行缓冲,一般用于标准错误stderr,其它情况使用较少。
C90缓冲规定,标准输入输出不涉及交互设备时才是全缓冲,标准错误不是全缓冲。
4、缓冲区大小
C可自定义缓冲区大小,默认缓冲区大小定义在stdio.h中的宏BUFSIZ,一般是512的倍数,可使用函数setvbuf或setbuf设置缓冲区及其大小,设置缓冲区尽量使用malloc,因为栈空间有限,并且如果缓冲时间较长,仅在一个函数中设置栈空间的缓冲区会出错。
5、刷新缓冲区
刷新缓冲区的情况有以下几个:缓冲区填满、遇到换行符、关闭文件以及手动调用fflush函数刷新。
三、文件输入输出处理1、打开文件
使用方式fopen,打开文件成功返回FILE指针,失败返回NULL。FILE是什么?前面说过FILE并不真正指向文件,它只是包含文件和缓冲区信息的数据对象,FILE结构体定义在stdio.h头文件中,FILE的解释如下:
struct _iobuf { char *_ptr; //文件输入缓冲区的下一个被读取的位置 int_cnt; //剩余未被读取的数据大小 char *_base; //文件缓冲区起始位置 int_flag; //缓冲区读写状态标志 int_file; //文件描述符 int_charbuf; //检查缓冲区状况,若无缓冲区则不读取 int_bufsiz; //缓冲区的大小 char *_tmpfname; //临时文件名 }; typedef struct _iobuf FILE;

fopen函数的原型为:FILE * fopen(const char *path, const char * mode),path为文件路径和文件名,mode为打开文件的模式,输入输出操作归结为读和写,根据文件类型又可分为文本模式和二进制模式。
只读模式为r(read),只写模式为w(write),只写追加模式为a(append),其中只写模式w要注意在fopen后原文件长度截为0,覆盖掉源文件的内容,文件不存在则创建,只写追加模式a在文件末尾添加内容,不存在文件则新建。
更新模式为+(可读可写),除了w和w+,其它基本都是追加模式,其中特别要注意w会覆盖原文件,而r模式可以配合fseek使用,而a模式只能从末尾追加。
二进制模式使用b(binary),C11添加一种x写模式,该模式打开文件不会将文件长度截为0,运行环境允许可以以独占模式打开。
根据以上说明,你可以自定义组合一种模式,例如wb+表示二进制可读可写模式,但会覆盖原文件,ab+二进制可读可写追加模式,r+文本可读可写追加模式,a+文本可读可写追加模式。
下面是一个示例代码,用于输出字符并统计文件的字符数:
FILE *file; unsigned long count = 0; char buffer; if(argc != 2){ printf("arguments wrong: %s\n", argv[0]); exit(EXIT_FAILURE); } if(!(file = fopen(argv[1], "r"))){ printf("open file failed: %s\n", argv[1]); exit(EXIT_FAILURE); } while ((buffer = getc(file)) != EOF){ putc(buffer, stdout); count++; } count = count * sizeof(char); printf("file size: %lu byte\n", count); fclose(file);

2、文本文件读写
读取文件数据实际是从缓冲区中读取,文本文件输入函数有getc、fscanf和fgets。
getc从缓冲区读取一个字符,读取结束返回EOF;fscanf读取一个字符串,跳过换行符或空白,返回字符串的数量,失败返回-1;fgets读取一行字符串,包含换行符,结束或失败返回NULL。
对应的输出函数有putc、fprintf和fputs,综合示例代码如下:
char *filePath = "G:/temp/file.txt"; FILE *file; if(!(file = fopen(filePath, "r"))){ perror("open file failed."); exit(EXIT_FAILURE); } // 1、使用getc和putc处理文件输入输出 int buffer; while((buffer = getc(file)) != EOF){ putc(buffer, stdout); }// 2、使用fscanf和fprintf处理文件输入输出 char buffer[256]; while ((fscanf(file, "%s", buffer)) == 1) { fprintf(stdout, "%s ", buffer); }// 3、使用fgets和fputs处理文件输入输出 char buffer[256]; while ((fgets(buffer, sizeof(buffer), file)) != NULL){ fputs(buffer, stdout); }if(fclose(file) != 0){ perror("close file failed."); exit(EXIT_FAILURE); }

3、二进制文件读写
以程序存储的方式存储数据为二进制存储,但实际文本数据也是二进制内容,只不过文本内容的字符对应的二进制字符码,要注意处理文本就用文本模式,处理二进制数据就用二进制模式,但是二进制模式是通用模式,也可以处理文本。文本模式可能不适宜复杂的数据处理,在换行符和文件结束符问题上可能会出现奇怪的现象,可使用通用的二进制模式,二进制模式下读取数据可以逐个字节读取。
以二进制形式保存数据可以保留数据的精度及其详细信息,适合更高要求的数据保存,例如整型数6,文本模式仅仅保存一个字节的字符6相应的ASCII码,但是如果使用二进制模式存储,则是用4字节保存6,对于浮点数也更大程度上保留了精度。
二进制输入输出函数分别为fread和fwrite,fread读取每个字节,将每个字节填充对应的数据对象,成功返回读取的对象数量,读取错误或到文件末尾,返回值比预期的数据对象数量少。fwrite按二进制写入数据对象到文件,每个数据对象占据固定的字节大小,成功返回写入的对象数量,写入错误,返回值比对象数量少。
feof检测文件末尾,若有返回非零值,否则返回0,ferror函数检测读写错误,若有错误返回非零值,否则返回0.
完整示例代码如下:
struct Http{ float version; char method[16]; char message[256]; }; void print(void){ char *fileName = "G:/temp/html"; FILE *file; if(!(file = fopen(fileName, "ab+"))){ perror("open file error"); exit(EXIT_FAILURE); } struct Http http; fscanf(stdin, "%f", & (http.version)); //http.version = 1.0; strcpy(http.method, "POST"); strcpy(http.message, "Search Result..."); printf("%ld\n", ftell(file)); // 最初文件位置为0,起始位置:fseek(file, 0L, SEEK_SET) if(fwrite(& http, sizeof(struct Http), 1, file) != 1){ perror("write file error"); exit(EXIT_FAILURE); } printf("%ld\n", ftell(file)); // 写数据到文件后,文件位置会移到结尾:fseek(file, 0L, SEEK_END) fflush(file); rewind(file); // 将文件位置移动到起始位置进行读取 fseek(file, sizeof(struct Http), SEEK_SET); // 或者移到第二个数据对象进行读取 struct Http client; if(fread(& client, sizeof(struct Http), 1, file) != 1){ perror("read file error"); exit(EXIT_FAILURE); } printf("version: %.2f\n", client.version); printf("method: %s\n", client.method); printf("message: %s\n", client.message); if(fclose(file) != 0){ perror("close file error"); exit(EXIT_FAILURE); }

4、文件结尾
上几节也提到过文件结尾,用于告诉程序何时停止读取文件,C使用EOF表示文件结束符,也可以使用feof函数检测文件结尾,windows的文件结尾为Ctrl+Z,Unix/Linux的文件结尾为Ctrl+D。
5、关闭文件
使用fclose函数,如有必要会刷新缓冲区再关闭文件访问,fclose函数成功关闭返回0,失败返回EOF,例如磁盘已满或硬盘不存在,或者出现I/O错误。
6、注意事项
文件的打开、读写、刷新缓冲区、关闭等操作,正式编写代码中要记得检查返回值,程序执行错误时,错误代码存在errno.h头文件的errno中,如果程序需要根据errno判断正误,则在执行相应操作时需要将errno清零。可使用string.h中的strerror获取错误信息,或者使用stdio.h中的perror打印错误信息。
四、文件随机访问1、回车与换行
回车符为\r,换行符为\n,\r表示退回当前行的行首,\n表示跳到当前行的下一行,Windows使用\r\n回车和换行,Mac使用\r换行(同时有回车和换行的功能),Unix/Linux使用\n换行。
不过对于回车和换行以及文件结束符的处理并没有那么简单,不同的系统或不同的编译器会造成某些奇怪的现象。文本模式下,Windows下C能够正确识别文件结尾,C一般可以将\r\n转换成\n,但是\r有可能会被转换成\n,而且文本模式下使用fseek也会出现意料之外的结果,应该限制在文本模式使用fseek。
二进制模式下,Windows下C可能会将文件结尾当做一个字符,有必要需要过滤掉文件结束符,它的值为’\032’,windows下的\r\n也会被识别成两个字符,但是ftell当成一个字符计算。
C标准是这样描述,但是编译器并不一定按照C标准办事,所以为了避免意外的事情,尽量使用二进制处理文件数据,在文件随机访问处理中,强烈建议使用二进制模式处理,否则有可能出现奇怪的情况。
2、文件位置和自定义缓冲区
在上面第二小节中的第二幅图展现了文件数据块的图像,文件位置表明了文件数据被读写的初始位置,读取操作从指定的文件位置的下一个字节数据开始加载进缓冲区供读取,如下图:
C语言简明教程(十五)(文件输入输出工作原理和实例详解)

文章图片
默认初始化位置为0L,文件位置使用long int表示。写入操作从指定位置开始写入,会覆盖掉后面的数据,但是只能是rb+模式下,wb+只在首位置写入,ab+只在末尾写入。
ftell和fgetpos用于获取当前文件位置,ftell返回long值,fgetpos配合fpos_t使用,主要用于超大文件的场合。fseek和fsetpos用于设置文件位置,rewind提供更方便的方式将文件位置移到文件起始位置。
文件位置有三种位置标识:SEEK_SET起始位置、SEEK_CUR当前位置和SEEK_END末尾位置。
可以通过将文件位置设置到末尾得到文件的长度:fseek(file, 0, SEEK_END),长度length=ftell(file),fseek的第二个参数是相当于位置标识的偏移量,可为正负值。
自定义缓冲区
ugetc函数可以将一个字符放回缓冲区中,fflush函数一般用于输出刷新缓冲区,输入刷新行为未定义。setvbuf定义缓冲区,函数原型为:int setvbuf(FILE*, char *buf, int mode, size_t size),在打开文件之后,读写文件之前调用该函数,size为缓冲区大小,buf为缓冲区,若为NULL则让函数自动分配,如果自定义分配建议使用malloc分配。
综合示例代码如下:
char *fileName = "G:/temp/file.txt"; FILE *file; if(!(file = fopen(fileName, "rb+"))){ // 使用rb+模式,可在指定位置插入内容 perror("open file error"); exit(EXIT_FAILURE); }setvbuf(file, NULL, _IOFBF, 512 * 2); // 指定缓冲区大小,让函数自动创建缓冲区,如果显式自定缓冲区请使用mallocprintf("init position: %ld\n", ftell(file)); // 获取起始文件位置 fseek(file, 4, SEEK_SET); char buffer = 'O'; // abcd{e}fg,写入e的位置,替换掉e fwrite(& buffer, sizeof(char), 1, file); // 写入一个字符,文件位置向前移动1位 fflush(file); // 强制刷新缓冲区,将内容保存到文件中 printf("after written: %ld\n", ftell(file)); // 此时文件位置应该为5rewind(file); // 将位置倒回初始位置char message[10]; fread(message, sizeof(message), 1, file); // 如果没有使用rewind,此时应该从文件位置5开始读取 message[9] = '\0'; printf("%s\n", message); if(fclose(file) != 0){ perror("close file error"); exit(EXIT_FAILURE); }

五、小结大部分编程语言都提供文件输入输出操作,根本原因在于计算机的主要基本功能:处理文件数据,不要将文件I/O处理仅限于文本处理,它在二进制文件处理方面的应用更为广泛。而I/O操作很大程度依赖于缓冲区,多数语言I/O操作都是缓冲的,扩展到其它语言你可以发现,C语言反而是最简单的,但高级语言的好处也只是在语法上稍微更语义化,糟糕之处在于,如果在高级语言中丢弃对内存的理解,编写的代码将是非常垃圾。
如果你学过高级语言,学习本系列C语言教程可以让你更理解编程,在后面结构体的使用中,可以发现C和高级语言的面向对象差不多。编程其实就是面向内存编写代码(本教程基本每节都有内存的分析),最基本的工具是数据结构和算法。

    推荐阅读