前置知识:

  • 指针
  • 字符串

文件I/O流

3_2章提到的概念,文件流就是将磁盘中的文件读写抽象成了流。
文件流可以分为2类,一类是常规的文本流,其中的数据是供人阅读的文本;
另一类就是二进制流,其中的数据供程序读取使用。

我们这里学习标准C读写文件的方法,在打开文件的时候,可以选择使用文本模式还是二进制模式进行读写,两者差异其实并不大,一般仅体现在换行符的处理上。

打开文件

FILE类型

首先,在打开一个文件之后,我们势必需要使用某种对象来代表这个文件,并进行读写操作,亦即需要使用某个对象来保持文件的打开状态。

C语言使用 FILE类型实现该功能,它是一个结构体,定义在stdio.h中:

1
2
3
4
5
6
7
8
9
10
11
struct _iobuf {  
char *_ptr;
int   _cnt;
char *_base;
int   _flag;
int   _file;
int   _charbuf;
int   _bufsiz;
char *_tmpfname;
};
typedef struct _iobuf FILE;

我们无需记忆该结构体的实现细节,对外而言,只需要使用一个FILE*即可记录一个文件的状态,即使用一个指向FILE类型的指针来持有一个文件。

在打开一个文件后,后续的读写操作均围绕该FILE*变量展开即可,均封装在C标准提供的若干函数中。

fopen函数

打开一个文件对象(亦即FILE *指针指向的对象)需要2个信息:

  • 文件路径:文件路径包括了文件名,分为2类,一类是绝对路径,另一类是相对路径
  • 文件访问标记:以何种模式打开文件,包括是否可写、文本模式、追加还是从头开始等

C标准提供了fopen函数来打开文件,上述两个信息作为2个字符串参数进行提供:

1
2
FILE *fopen( const char *filename, const char *mode ); // C99前
FILE *fopenconst char *restrict filename, const char *restrict mode ); // C99起

若成功,返回指向新文件流的指针;错误时,返回空指针NULL。

文件路径

其中,filename是一个字符指针,指向了要打开的文件路径字符串,有2种方式:

  • 绝对路径:即从根目录开始写起的路径,可以直接唯一定位文件,例如:
    • Linux下:"/home/username/C/test.txt"
    • Windows下:"D:/data/code/C/test.txt"
  • 相对路径:并非从根目录开始写起,而是以工作路径为基础,在其之上进行相对定位的路径,例如:
    • Linux下:test.txt,此时如果工作路径是/home/username/C/,则文件的实际路径为"/home/username/C/test.txt"
    • Windows下:"./C/text.txt",此时如果工作路径是"D:/data/code/",则文件的实际路径为"D:/data/code/C/test.txt"
    • 工作路径:工作路径是程序在执行时所处的路径,一般情况下是程序可执行文件所在的路径

文件访问标记

mode(文件访问标记)同样是一个字符指针,指向了表示文件访问标记的字符串,fopen使用如下几种访问标记:

文件访问模式字符串 含义 解释 若文件已存在的动作 若文件不存在的动作
“r” 打开文件以读取 从头读 打开失败
“w” 创建文件以写入 销毁内容 创建新文件
“a” 追加 追加到文件 写到结尾 创建新文件
“r+” 读扩展 打开文件以读/写 从头读 错误
“w+” 写扩展 创建文件以读/写 销毁内容 创建新文件
“a+” 追加扩展 打开文件以读/写 写到结尾 创建新文件

如果模式不是以上所列字符串之一,则其行为未定义,不过一些实现会定义额外支持的模式(例如Windows)
此外,在上述模式的基础上可以加上"b"标记来以二进制模式打开文件,例如"rb"、"wb+"等,该标记在POSIX上无效果,而在Windows上会禁用换行符的特殊处理(如下“两种读写模式的区别”所述)。

两种读写模式的区别

现在来解释文本模式二进制模式的区别。

两种操作系统的换行符区别

首先需要了解Linux和Windows的换行符区别。

  • Linux下的文件换行符就是一个\n即可(即0x0a),也就是所谓的LF
  • 而Windows下则是两个字符\r\n代表换行,如果以十六进制表示就是0x0d0x0a,也就是所谓的CRLF

Windows的CRLF占用2个字节,在文本模式时需要进行转换。

文本模式和二进制模式

文本模式这样处理数据:

  • 写入文件时,将缓冲区中的\n转换为\r\n再写入文件
  • 从文件读取时,将文件中的\r\n转换为\n,再写入缓冲区
    而二进制模式则将\r\n视为普通的两个字节,不进行转换,直接写入文件或缓冲区

打开文件举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>

int main() {
FILE *file;
char filename[] = "./example.txt";

// 以只读模式打开文件
file = fopen(filename, "r");
if (file == NULL) {
perror("Error opening file");
return -1;
}

// 读取文件内容,后续讲解
char buffer[256];
while (fgets(buffer, sizeof(buffer), file)) {
printf("%s", buffer);
}

// 关闭文件流
fclose(file);

return 0;
}

读写文件内容

读写文件有若干函数可以选择,分为“直接读写文件”、“无格式输入/输出”、“有格式输入/输出”

直接读写文件

fread函数-从文件读取若干数据

1
size_t fread(void *buffer, size_t size, size_t count,FILE * stream);

fread函数从输入流 stream 读取至多 count 个对象到数组 buffer 中,每个对象的大小是 size 个字节。
其中:

  • buffer:指向要读取的数组中首个对象的指针,或者说目的数组/缓冲区
  • size:要读取的每个对象的大小,例如读取若干个int对象,size的值就是 sizeof(int)
  • count:要读取的对象数,例如读取4个int对象,count的值就是4
  • stream:读取来源的输入文件流,即从哪个文件读取
  • 返回值:成功读取的对象数,若出现错误或文件尾条件,则可能小于 count

例如从文件中读取10个字符到buffer中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <stdio.h>

int main() {
FILE *file;
char filename[] = "./example.txt"; // 要读取的文件名
char buffer[11]; // 缓冲区,需要额外一个字符的空间来存储字符串的结束符 '\0'
size_t result;

// 以只读模式打开文件
file = fopen(filename, "r");
if (file == NULL) {
perror("Error opening file");
return -1;
}

// 使用fread()从文件中读取10个字符到buffer中
result = fread(buffer, sizeof(char), 10, file);
// 这里为了简洁期间,省略错误检查的代码
// ...

// 添加字符串结束符
buffer[result] = '\0';
printf("Read from file: %s\n", buffer);

// 关闭文件
fclose(file);

return 0;
}

fwrite函数-向文件写入若干数据

1
size_t fwrite(const void *buffer, size_t size, size_t count, FILE * stream);

fwrite函数从数组 buffer 写入 count 个对象到输出流 stream 中,每个对象的大小是 size 个字节。

其中:

  • buffer:指向数组中要被写入的首个对象的指针,或者说源数组/缓冲区
  • size:要写入的每个对象的大小,例如写入若干个int对象,size的值就是 sizeof(int)
  • count:要写入的对象数,例如写入4个int对象,count的值就是4
  • stream:写入目的的输出文件流,即向哪个文件写入
  • 返回值:成功写入的对象数,若出现错误或文件尾条件,则可能小于 count

例如将buffer中的字符串写入到文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <stdio.h>
#include <string.h>

int main() {
FILE *file;
const char filename[] = "./output.txt"; // 文件名
const char *buffer = "Hello, World!\n"; // 要写入的字符串
size_t buffer_length = strlen(buffer); // 计算字符串长度
size_t written;

// 以写入模式打开文件,如果文件已存在则覆盖
file = fopen(filename, "w");
if (file == NULL) {
perror("Error opening file");
return -1;
}

// 使用fwrite()将buffer中的字符串写入到文件中
written = fwrite(buffer, sizeof(char), buffer_length, file);
// 这里为了简洁期间,省略错误检查的代码
// ...

// 关闭文件
fclose(file);
return 0;
}

无格式输入/输出

fgetc和fputc

这两个函数读写单个字符

1
2
int fgetc(FILE * stream);
int fputc(int ch, FILE * stream);
  • fgetc从文件流 stream 中读取一个字符,作为返回值返回,失败则返回 EOF
  • fputc向文件流 stream 中写入字符 ch,成功时返回该字符 ch,失败则返回 EOF

fgets和fputs

这两个函数读写字符串

1
2
char *fgets(char *str, int count, FILE * stream);
int fputs(const char *str, FILE * stream);
  • fgets从给定文件流 stream 读取最多 count-1 个字符到缓冲区 str 中,然后写入一个空字符,读取时遇到文件尾或遇到换行符则停止,后一种情况下换行符也被读取到 str 中。成功返回 str ,失败则返回空指针
  • fputs将以NULL结尾的字符串 str 写入到输出文件流 stream ,不写入终止空字符。成功返回非负值(该值含义由实现定义),失败则返回 EOF

有格式输入/输出

fscanf和fprintf

几乎和scanf和printf一模一样,唯一的区别就是:scanf和printf从 stdinstdout 流输入输出,而fscanf和fprintf从指定的文件流输入输出。

1
2
int fscanf(FILE * stream, const char *format, ...);
int fprintf(FILE * stream, const char *format, ...);

这两个函数除了需要把操作的文件流作为第一个参数传入,其他方面和printf与scanf无异,故省略之

上述函数,读者根据自己的实际情况选择使用即可

文件定位

上述的文件读写都是顺序访问,我们有时还需要对文件进行随机访问,此时就需要在文件中进行定位。

ftell和fseek

这两个函数用于获取并修改当前文件位置。

首先了解文件位置指示器:即当前文件指针所指向的文件内容位置距离文件开始处的字节数

ftell函数返回流 stream 的文件位置指示器:

1
long ftell(FILE * stream);

其中:

  • stream:要检验的文件流
  • 返回值:成功时为文件指示器,失败时为-1L
  • 若流以二进制模式打开,则由此函数获得的值是从文件开始的字节数;
  • 若流以文本模式打开,则由此函数返回的值未指定,且仅若作为 fseek() 的输入才有意义

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <stdlib.h>

int main() {
FILE *file = fopen("./example.txt", "rb"); // 打开文件用于读取
if (file == NULL) {
perror("Error opening file");
return -1;
}

// 读取一些数据
char buffer[100];
fread(buffer, sizeof(char), 10, file); // 读取10个字节

// 获取当前文件位置
long currentPosition = ftell(file);
// 文件内容足够的话,currentPosition的值为10
printf("Current position in file: %ld\n", currentPosition);

// 继续处理文件...
fclose(file);
return 0;
}

ftell 用于获取当前文件的文件位置(指示器),而 fseek 用于设置当前文件的文件位置(指示器)
fseek函数设置文件流 stream 的文件位置指示器为 offset 所指向的值:

1
int fseek(FILE* stream, long offset, int origin);

其中:

  • stream:要修改的文件流
  • offset:相对 origin 迁移的字符数
  • origin:offset需要加上的位置,值为如下之一:SEEK_SET、SEEK_CUR、SEEK_END(只需要记住宏名即可),分别指示文件首、当前位置和文件尾
  • 成功时返回 0 ,否则为非零

需要注意:

  • 若 stream 以二进制模式打开:则新位置被设置为 origin 之后的 offset 字节。
    • 若 origin 为 SEEK_SET,则是文件起始之后
    • 若 origin 为 SEEK_CUR,则是当前文件位置之后
    • 若 origin 为 SEEK_END,则是文件结尾之后
    • 不要求二进制流支持 SEEK_END,尤其是当其输出中附加了空字节时
  • 若 stream 以文本模式打开:则 offset 的值仅支持零(可用于任何 origin)和先前在关联到同一个文件的流上对 ftell 的调用的返回值(仅可用于 SEEK_SET )。
  • 除了更改文件位置指示器, fseek 还撤销 ungetc 的效果并清除文件尾状态。
  • 若发生读或写错误,则设置流的错误指示器( ferror )而不影响文件位置。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>  

int main() {
FILE *file = fopen("example.txt", "rb"); // 打开文件用于读取
if (file == NULL) {
perror("Error opening file");
return -1;
}

// 定位到文件开头的第100个字节
if (fseek(file, 100, SEEK_SET) != 0) {
perror("Error seeking file");
fclose(file);
return -1;
}

// 从当前位置读取数据...
fclose(file);
return 0;
}

fgetpos和fsetpos

另一种获取并修改文件位置指示器的方法是使用 fgetposfsetpos
这两个函数使用一个新的类型 fpos_t,该类型足以唯一指定文件的位置和多字节剖析状态,无需关心其具体实现。

fgetpos 获得文件流 stream 的文件位置指示器和当前分析状态(若存在),并将它们存储于 pos 所指向的对象:

1
int fgetpos(FILE *stream, fpos_t *pos);

其中:

  • stream:要检验的文件流
  • pos:指向用于存储获取到的文件位置指示器的 fpos_t 对象的指针
  • 成功时返回 0,否则返回非零值
  • 存储的值仅在作为 fsetpos 的输入的情况有意义

fsetpos 设置文件流 stream 的文件位置指示器和多字节分析状态(若存在)。

1
int fsetpos(FILE *stream, const fpos_t *pos);

其中:

  • stream:要修改的文件流
  • pos:指向 pos_t 对象的指针,用作文件位置指示器的新值
  • 成功时返回 0,否则返回非零值
    注意:
  • 在寻位到宽流的非结尾位置后,下个对任何输出函数的调用可能使剩下的文件内容未定义,例如通过输出不同长度的多字节序列。(例如定位到UTF8编码字符的第二个字符时)

rewind

rewind函数移动文件位置指示器到给定文件流的起始:

1
void rewind(FILE *stream);

该函数等价于调用:

1
fseek(stream, 0, SEEK_SET);

但是它会清除文件尾和错误指示器。
另外,此函数丢弃任何来自先前对 ungetc 调用的结果。

对文件的操作

删除文件

remove函数删除 pathname 所标识的文件:

1
int remove(const char *pathname);

其中:

  • pathname:指向空终止字符串的指针,字符串含标识待删除文件的路径
  • 成功时返回 0,错误时返回非零值
    若当前有任何进程打开了此文件,则此函数行为是实现定义的(此处略)。

重命名文件

rename函数更改文件的文件名:

1
int rename(const char *old_filename, const char *new_filename);

其中:

  • old_filename:指向包含要重命名的文件的路径的空终止字符串的指针
  • new_filename:指向包含文件新路径的空终止字符串的指针
  • 成功时返回 0,错误时返回非零值
    若 new_filename 存在,则行为是实现定义的。

错误处理

feof

feof函数检查是否已抵达给定文件流的结尾:

1
int feof(FILE *stream);

若已抵达流尾则返回非零值,否则返回0

ferror

ferror函数检查给定文件流的错误:

1
int ferror(FILE *stream);

若文件流已出现错误则为非零值,否则返回0

clearerr

clearerr函数重置给定文件流的错误标志和 EOF 指示器:

1
void clearerr(FILE *stream);

本章包含了C语言中大部分常用的文件读写、文件定位、文件处理以及错误检查的操作方式,基本足够平时使用。 当然,POSIX中还提供了 `read` 和 `write` 系统调用,同样可以实现对文件的读写(使用文件描述符),不在本书讨论范围内。

参考资料:

——WAHAHA 2024.4.28

上一篇:C语言教程-15_2-存储期,作用域与链接
下一篇: