【Linux 系统】基础 IO:从文件到 C 标准文件流
2026/7/22 3:55:17 网站建设 项目流程

学习 Linux 文件操作时,最先接触到的通常不是open()read()这些系统调用,而是 C 语言提供的fopen()fputs()fread()等标准库函数。它们看起来只是几个熟悉的接口,背后却连接着文件、进程、当前工作目录和标准输入输出流。

这一部分先把 C 标准文件接口的使用链路理清:文件是什么,fopen()到底完成了什么,打开模式怎样选择,读写函数的返回值应该如何判断,以及程序为什么能够直接使用stdinstdoutstderr


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保留原内容并保证追加写

aa+不会在打开时清空已有内容。只要执行写操作,数据就被放到文件末尾。它们适合日志、历史记录等只在末尾增加内容的场景。

需要注意,a+对初始读取位置的规定在不同实现上存在差异。glibc 中读取位置从文件开头开始,但写入仍然始终发生在末尾。要求跨平台时,最好用文件定位函数明确设置准备读取的位置。

3.3+b

模式中的+表示同时允许读取和写入。对这种更新流交替执行读写操作时,不能随意从一次输出直接切换到输入,或者从未到达文件末尾的输入直接切换到输出。通常需要在两者之间调用fseek()fsetpos()rewind()完成定位与同步。

b表示二进制模式,例如rbwbab。在 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 个记录 */}

直接把结构体写入二进制文件适合用来观察sizenmemb和返回值之间的关系,但结构体可能存在填充字节,不同机器还可能有字节序和类型宽度差异。需要长期保存或跨平台交换数据时,应设计明确、稳定的序列化格式。

返回值类型是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");}

这里有三个容易遗漏的边界:

  1. ptr必须指向容量足够的有效内存,未初始化的指针不能作为目标地址;
  2. fread()读取的是原始数据,不会自动在末尾添加\0
  3. 返回值小于请求数量既可能是到达文件末尾,也可能是读取错误,需要用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 中通常关联的文件描述符
stdinFILE *标准输入,通常来自终端键盘0
stdoutFILE *标准输出,通常显示在终端1
stderrFILE *标准错误,通常显示在终端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 95

9. 实验二:按固定大小元素读写

#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: EOF

10. 常见错误整理

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 标准库提供的文件流抽象。普通文件与stdinstdoutstderr都可以通过同一套标准 I/O 接口访问。真正写出稳定代码的关键,不是记住函数名字,而是明确每个接口需要什么有效内存、返回值表示什么,以及错误应该在什么位置检查。

下一层再继续进入 Linux 系统调用,就可以把FILE *与文件描述符、库函数与系统调用、标准流与重定向联系起来。


需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询