学习 Linux 文件操作时,最先接触到的通常不是open()、read()这些系统调用,而是 C 语言提供的fopen()、fputs()、fread()等标准库函数。它们看起来只是几个熟悉的接口,背后却连接着文件、进程、当前工作目录和标准输入输出流。
这一部分先把 C 标准文件接口的使用链路理清:文件是什么,fopen()到底完成了什么,打开模式怎样选择,读写函数的返回值应该如何判断,以及程序为什么能够直接使用stdin、stdout和stderr。
1. 怎样理解文件
1.1 文件不只有内容
一个普通文件可以从两个部分理解:
文件 = 文件内容 + 文件属性文件内容是我们真正保存的数据,例如文本、图片中的像素信息或者可执行程序的机器指令。文件属性也叫元数据,包括文件类型、权限、大小、时间、所有者等信息。
因此,一个内容长度为 0 的空文件并不等于“什么都没有”。它的逻辑大小虽然是 0 字节,文件系统仍需要保存名称、权限、时间等元数据。至于具体占用多少磁盘空间,要看文件系统的组织方式,不能简单地把空文件理解成必然占用一个数据块。
1.2 文件操作是进程发起的
程序被加载并运行以后成为进程,真正发起文件操作的是进程:
程序代码调用文件接口 ↓ 正在运行的进程发起请求 ↓ C 标准库与操作系统共同完成文件访问 ↓ 进程取得数据或修改文件一个进程可以同时打开多个文件,一个文件也可能被多个进程打开。操作系统需要为已经打开的文件维护相应信息,使每个进程能够找到自己正在使用的文件对象和当前读写位置。
1.3 相对路径从当前工作目录开始解释
下面的调用没有写目录,只写了文件名:
FILE*stream=fopen("notes.txt","w");这里的相对路径从进程的当前工作目录开始解释。当前工作目录不一定是源代码目录,也不一定是可执行程序所在目录。程序从不同目录启动,或者运行过程中修改了当前工作目录,相同的相对路径就可能指向不同位置。
绝对路径从根目录开始定位,不依赖当前工作目录;相对路径更简洁,但程序必须清楚自己的运行环境。
1.4 打开文件不等于把整个文件加载到内存
fopen()的“打开”主要是建立一个可供程序使用的文件流,并返回操作这个流的指针。文件内容通常在执行fread()、fgets()等读取操作时按需取得,而不是在fopen()成功的一刻全部进入内存。
可以把过程理解成:
fopen() 成功 ↓ 程序得到 FILE *,文件流建立 ↓ 调用读取或写入函数 ↓ 数据按操作需要在程序、库和内核之间传递 ↓ fclose() 结束本次文件流使用2.FILE *与文件流
C 标准库把文件访问抽象成“流”。流表示数据在程序与某个输入输出对象之间传递,FILE结构负责保存操作这个流所需的状态,程序通过FILE *使用它。
#include<stdio.h>FILE*fopen(constchar*path,constchar*mode);fopen()的两个参数分别是:
path:需要打开的文件路径;mode:打开模式,决定读取、写入、追加以及文件不存在时的处理方式。
成功时返回有效的FILE *,失败时返回NULL,同时设置errno表示失败原因。因此,文件打开以后必须先检查返回值:
#include<stdio.h>#include<stdlib.h>intmain(void){FILE*stream=fopen("notes.txt","r");if(stream==NULL){perror("fopen");returnEXIT_FAILURE;}/* 在这里使用 stream */if(fclose(stream)==EOF){perror("fclose");returnEXIT_FAILURE;}returnEXIT_SUCCESS;}如果fopen()已经失败,就不能继续把NULL传给fgets()、fputs()或fclose()。只打印错误但不结束当前操作,后面的代码仍然可能访问无效指针。
fclose()也有返回值。输出数据可能暂时保存在库的缓冲区中,某些写入错误要到刷新或关闭流时才能暴露,所以需要可靠保存数据的程序不应完全忽略fclose()的结果。
3.fopen()的六种基本模式
打开模式最容易混淆的地方,是文件不存在时是否创建、文件已经存在时是否清空,以及写入位置在哪里。
| 模式 | 可读 | 可写 | 文件不存在 | 文件已存在时 | 初始位置与写入特点 |
|---|---|---|---|---|---|
r | 是 | 否 | 打开失败 | 保留原内容 | 从文件开头读取 |
r+ | 是 | 是 | 打开失败 | 保留原内容 | 初始位置在文件开头 |
w | 否 | 是 | 创建文件 | 打开时截断为 0 | 从文件开头写入 |
w+ | 是 | 是 | 创建文件 | 打开时截断为 0 | 初始位置在文件开头 |
a | 否 | 是 | 创建文件 | 保留原内容 | 每次写入都追加到文件末尾 |
a+ | 是 | 是 | 创建文件 | 保留原内容 | 写入始终追加到文件末尾 |
3.1w是打开时截断,不是每写一次就清空
w模式打开一个已经存在的文件时,会在打开阶段把文件长度截断为 0。流保持打开以后,连续调用多次写入函数,前面刚写的数据不会在每次调用前重新清空。
原文件:old data ↓ fopen(..., "w") 文件长度变成 0 ↓ 第一次写入 hello 文件内容:hello ↓ 第二次写入 linux 文件内容:hellolinux如果再次调用fopen()并以w模式重新打开,才会再次截断文件。
3.2a保留原内容并保证追加写
a和a+不会在打开时清空已有内容。只要执行写操作,数据就被放到文件末尾。它们适合日志、历史记录等只在末尾增加内容的场景。
需要注意,a+对初始读取位置的规定在不同实现上存在差异。glibc 中读取位置从文件开头开始,但写入仍然始终发生在末尾。要求跨平台时,最好用文件定位函数明确设置准备读取的位置。
3.3+与b
模式中的+表示同时允许读取和写入。对这种更新流交替执行读写操作时,不能随意从一次输出直接切换到输入,或者从未到达文件末尾的输入直接切换到输出。通常需要在两者之间调用fseek()、fsetpos()或rewind()完成定位与同步。
b表示二进制模式,例如rb、wb、ab。在 Linux 等 POSIX 系统上,文本模式和二进制模式没有换行转换差异,b会被忽略;但为了让二进制文件代码更容易移植到其他系统,仍然建议明确写出b。
3.4 快速选择
只读取已有文件 ──► r 修改已有文件且不能自动创建 ──► r+ 重新生成文件,旧内容不要 ──► w / w+ 保留旧内容,只在末尾增加 ──► a / a+4. 写文件:fputs()、fwrite()与fprintf()
4.1fputs():写入一个字符串
intfputs(constchar*s,FILE*stream);第一个参数是以\0结尾的字符串,第二个参数是目标流。调用成功返回非负值,失败返回EOF。
if(fputs("hello Linux\n",stream)==EOF){perror("fputs");}fputs()不会自动补换行。字符串需要独占一行时,换行符要由调用者自己提供。这一点与puts()不同,puts()会向stdout输出字符串并追加换行。
4.2fwrite():按元素写入数据
size_tfwrite(constvoid*ptr,size_tsize,size_tnmemb,FILE*stream);参数可以按下面的方式理解:
ptr ──► 数据起始地址 size ──► 每个元素占多少字节 nmemb ──► 计划写多少个元素 stream ──► 写入哪个文件流fwrite()的返回值是成功写入的元素个数,不是固定意义上的字节数。只有把size设置为 1 时,元素个数才与字节数相同。
size_twritten=fwrite(buffer,1,length,stream);if(written!=length){/* 发生了短写入 */}也可以把一个结构体数组看作若干个固定大小的元素:
size_twritten=fwrite(records,sizeof(records[0]),count,stream);if(written!=count){/* 没有完整写入 count 个记录 */}直接把结构体写入二进制文件适合用来观察size、nmemb和返回值之间的关系,但结构体可能存在填充字节,不同机器还可能有字节序和类型宽度差异。需要长期保存或跨平台交换数据时,应设计明确、稳定的序列化格式。
返回值类型是size_t,使用printf()输出时应配合%zu,不能直接使用%d。
4.3fprintf():格式化写入
intfprintf(FILE*stream,constchar*format,...);fprintf()与printf()的格式规则相同,只是多了目标流参数:
fprintf(stream,"%s %d\n","Linux",90);当数据本身是有明确字段的人类可读文本时,fprintf()很方便;如果需要按原始字节或固定大小元素写入,则更适合使用fwrite()。
5. 读文件:fgets()、fread()与fscanf()
5.1fgets():按缓冲区读取一行的一部分
char*fgets(char*s,intsize,FILE*stream);fgets()最多读取size - 1个字符,遇到换行或文件结束就停止。成功读取换行时,换行符也会保存在缓冲区中,最后再自动补上字符串结束符\0。
charline[128];while(fgets(line,sizeof(line),stream)!=NULL){fputs(line,stdout);}if(ferror(stream)){perror("fgets");}这里使用fputs(line, stdout),没有再额外添加换行。因为只要原文件中的一行能够完整读入,缓冲区通常已经保留原来的\n;如果写成printf("%s\n", line),行与行之间可能多出空行。
当一行比缓冲区长时,一次fgets()只读取其中一段,下一次调用会继续读取剩余内容。因此,把fgets()简单理解成“一次必然得到完整一行”也不准确。
5.2fread():按元素读取原始数据
size_tfread(void*ptr,size_tsize,size_tnmemb,FILE*stream);fread()尝试读取nmemb个元素,每个元素占size字节,并把数据写入ptr指向的有效内存。返回值是实际完整读取的元素个数。
unsignedcharbuffer[1024];size_tcount;while((count=fread(buffer,1,sizeof(buffer),stream))>0){/* buffer 中有 count 个有效字节 */}if(ferror(stream)){perror("fread");}这里有三个容易遗漏的边界:
ptr必须指向容量足够的有效内存,未初始化的指针不能作为目标地址;fread()读取的是原始数据,不会自动在末尾添加\0;- 返回值小于请求数量既可能是到达文件末尾,也可能是读取错误,需要用
feof()和ferror()区分。
5.3 为什么不要用while (!feof(stream))
文件结束标志不是提前预测出来的。只有一次读取操作尝试越过文件末尾以后,EOF 标志才会被设置。
错误写法:
while(!feof(stream)){fread(buffer,1,sizeof(buffer),stream);/* 没有根据本次返回值决定处理多少数据 */}正确思路是让读取函数的返回值控制循环,再在循环结束以后判断原因:
执行一次读取 ↓ 返回了数据 处理这次真正读到的数据,并继续 ↓ 没有数据 检查 ferror():是否发生错误 ↓ 否 feof() 表示正常到达文件末尾5.4fscanf():格式化读取
intfscanf(FILE*stream,constchar*format,...);fscanf()与scanf()的格式规则相同,只是数据来源由stdin变成指定的文件流。它的返回值是成功完成赋值的字段数量;如果在第一次转换前就到达文件末尾,返回EOF。
intscore;if(fscanf(stream,"%d",&score)==1){/* 成功读取一个整数 */}不能只调用fscanf()而完全忽略返回值,否则格式不匹配时,程序可能继续使用没有获得新数据的变量。面对结构不稳定的文本,先用fgets()取得一行,再单独解析,通常更容易控制错误边界。
6. 标准输入、标准输出与标准错误
C 程序启动时通常已经有三个可直接使用的标准流:
| 标准流 | 类型 | 默认用途 | Linux 中通常关联的文件描述符 |
|---|---|---|---|
stdin | FILE * | 标准输入,通常来自终端键盘 | 0 |
stdout | FILE * | 标准输出,通常显示在终端 | 1 |
stderr | FILE * | 标准错误,通常显示在终端 | 2 |
因此,很多看似不同的函数可以放在同一套流模型中理解:
printf(...) ──► 向 stdout 格式化输出 fprintf(stdout, ...) ──► 明确指定 stdout 输出 scanf(...) ──► 从 stdin 格式化读取 fscanf(stdin, ...) ──► 明确指定 stdin 读取 fputs(text, stdout) ──► 把字符串写入标准输出流 fgets(buf, size, stdin) ──► 从标准输入流读取字符串标准流的默认对象通常是终端,但并不是永远固定在键盘和显示器上。Shell 可以改变标准流的去向,程序仍然使用相同的printf()、fputs()或fgets()接口。这也是文件流抽象的价值:上层代码操作的是流,不需要为每一种具体设备重新设计一套接口。
7. 一次可靠的文件操作应该检查什么
完整的文件流生命周期可以整理成:
确定路径和打开模式 ↓ 调用 fopen() ↓ 检查返回值是否为 NULL ↓ 成功 执行读取或写入 ↓ 每次根据函数返回值处理有效数据 ↓ 读取结束时区分 EOF 与错误 ↓ 调用 fclose(),并检查关闭结果常用返回值判断如下:
| 函数 | 成功时 | 失败或结束时 |
|---|---|---|
fopen() | 返回有效FILE * | 返回NULL |
fputs() | 返回非负值 | 返回EOF |
fwrite() | 返回成功写入的元素数 | 返回短元素数 |
fgets() | 返回缓冲区地址 | 返回NULL |
fread() | 返回成功读取的元素数 | EOF 或错误时可能返回短元素数 |
fscanf() | 返回成功赋值的字段数 | 匹配失败、短字段数或EOF |
fclose() | 返回 0 | 返回EOF |
perror()适合在函数返回值已经表示失败以后使用。成功调用不保证把以前的errno清零,所以不能无条件打印errno,再根据那条旧错误信息判断当前操作。
8. 实验一:文本写入、追加与按行读取
#include<stdio.h>#include<stdlib.h>staticintclose_stream(FILE*stream){if(fclose(stream)==EOF){perror("fclose");return-1;}return0;}intmain(void){constchar*path="notes.txt";FILE*stream=fopen(path,"w");if(stream==NULL){perror("fopen");returnEXIT_FAILURE;}if(fputs("Linux 90\n",stream)==EOF||fputs("C language 88\n",stream)==EOF){perror("fputs");fclose(stream);returnEXIT_FAILURE;}if(close_stream(stream)==-1){returnEXIT_FAILURE;}stream=fopen(path,"a");if(stream==NULL){perror("fopen");returnEXIT_FAILURE;}if(fputs("System programming 95\n",stream)==EOF){perror("fputs");fclose(stream);returnEXIT_FAILURE;}if(close_stream(stream)==-1){returnEXIT_FAILURE;}stream=fopen(path,"r");if(stream==NULL){perror("fopen");returnEXIT_FAILURE;}puts("file contents:");charline[64];while(fgets(line,sizeof(line),stream)!=NULL){if(fputs(line,stdout)==EOF){perror("fputs");fclose(stream);returnEXIT_FAILURE;}}if(ferror(stream)){perror("fgets");fclose(stream);returnEXIT_FAILURE;}if(close_stream(stream)==-1){returnEXIT_FAILURE;}if(remove(path)!=0){perror("remove");returnEXIT_FAILURE;}returnEXIT_SUCCESS;}实际结果:
file contents: Linux 90 C language 88 System programming 959. 实验二:按固定大小元素读写
#include<stdio.h>#include<stdlib.h>structrecord{charname[16];unsignedintscore;};staticintclose_stream(FILE*stream){if(fclose(stream)==EOF){perror("fclose");return-1;}return0;}intmain(void){constchar*path="scores.bin";conststructrecordrecords[]={{"Alice",91},{"Bob",86},{"Carol",95},};constsize_tcount=sizeof(records)/sizeof(records[0]);FILE*stream=fopen(path,"wb");if(stream==NULL){perror("fopen");returnEXIT_FAILURE;}size_twritten=fwrite(records,sizeof(records[0]),count,stream);if(written!=count){fprintf(stderr,"fwrite: expected %zu records, wrote %zu\n",count,written);fclose(stream);returnEXIT_FAILURE;}if(close_stream(stream)==-1){returnEXIT_FAILURE;}stream=fopen(path,"rb");if(stream==NULL){perror("fopen");returnEXIT_FAILURE;}puts("records:");structrecorditem;while(fread(&item,sizeof(item),1,stream)==1){printf("%s: %u\n",item.name,item.score);}if(ferror(stream)){perror("fread");fclose(stream);returnEXIT_FAILURE;}if(feof(stream)){puts("read ended: EOF");}if(close_stream(stream)==-1){returnEXIT_FAILURE;}if(remove(path)!=0){perror("remove");returnEXIT_FAILURE;}returnEXIT_SUCCESS;}实际结果:
records: Alice: 91 Bob: 86 Carol: 95 read ended: EOF10. 常见错误整理
10.1 打开失败后仍继续使用流
FILE*stream=fopen("notes.txt","r");if(stream==NULL){perror("fopen");returnEXIT_FAILURE;}只输出错误而不返回,后续函数仍然会收到NULL。
10.2 把未初始化指针交给fread()
char*buffer;fread(buffer,1,64,stream);/* 错误 */buffer没有指向可写内存。可以使用确定大小的数组,或者先分配足够空间,再把地址交给fread()。
10.3 用strlen()决定未知输入缓冲区大小
strlen()只能统计已经存在且以\0结尾的字符串长度。文件中的原始数据还没有读入时,不能对未初始化内存调用strlen(),也不能指望fread()自动补字符串结束符。
10.4 把fread()、fwrite()返回值固定理解成字节数
它们返回的是完整元素个数。只有size == 1时,这个数字才等于传输的字节数。
10.5 不区分文件结束和读取错误
fread()返回短数量、fgets()返回NULL都不能单独说明是正常到达 EOF。读取结束以后要检查ferror(),必要时再看feof()。
10.6 忽略格式化输入的返回值
fscanf()返回成功赋值的字段数。格式不匹配时,如果不检查返回值,后面的逻辑可能误用旧数据。
10.7 忘记关闭文件流
打开成功的流最终应由fclose()关闭。关闭不仅释放 C 标准库维护的流对象,还会处理尚未提交的输出数据;对重要输出还应检查它的返回值。
11. 小结
这一部分可以压缩成一条主线:
文件由内容和属性组成 ↓ 文件操作由进程发起 ↓ 相对路径依赖进程当前工作目录 ↓ fopen() 建立文件流并返回 FILE * ↓ 打开模式决定读、写、创建、截断和追加 ↓ 根据读写函数的返回值处理有效数据 ↓ 读取结束时区分 EOF 与错误 ↓ fclose() 结束流的使用FILE *是 C 标准库提供的文件流抽象。普通文件与stdin、stdout、stderr都可以通过同一套标准 I/O 接口访问。真正写出稳定代码的关键,不是记住函数名字,而是明确每个接口需要什么有效内存、返回值表示什么,以及错误应该在什么位置检查。
下一层再继续进入 Linux 系统调用,就可以把FILE *与文件描述符、库函数与系统调用、标准流与重定向联系起来。