【Linux 系统】基础 IO:从文件到 C 标准文件流

📅 2026/7/22 3:55:25
【Linux 系统】基础 IO:从文件到 C 标准文件流
学习 Linux 文件操作时最先接触到的通常不是open()、read()这些系统调用而是 C 语言提供的fopen()、fputs()、fread()等标准库函数。它们看起来只是几个熟悉的接口背后却连接着文件、进程、当前工作目录和标准输入输出流。这一部分先把 C 标准文件接口的使用链路理清文件是什么fopen()到底完成了什么打开模式怎样选择读写函数的返回值应该如何判断以及程序为什么能够直接使用stdin、stdout和stderr。1. 怎样理解文件1.1 文件不只有内容一个普通文件可以从两个部分理解文件 文件内容 文件属性文件内容是我们真正保存的数据例如文本、图片中的像素信息或者可执行程序的机器指令。文件属性也叫元数据包括文件类型、权限、大小、时间、所有者等信息。因此一个内容长度为 0 的空文件并不等于“什么都没有”。它的逻辑大小虽然是 0 字节文件系统仍需要保存名称、权限、时间等元数据。至于具体占用多少磁盘空间要看文件系统的组织方式不能简单地把空文件理解成必然占用一个数据块。1.2 文件操作是进程发起的程序被加载并运行以后成为进程真正发起文件操作的是进程程序代码调用文件接口 ↓ 正在运行的进程发起请求 ↓ C 标准库与操作系统共同完成文件访问 ↓ 进程取得数据或修改文件一个进程可以同时打开多个文件一个文件也可能被多个进程打开。操作系统需要为已经打开的文件维护相应信息使每个进程能够找到自己正在使用的文件对象和当前读写位置。1.3 相对路径从当前工作目录开始解释下面的调用没有写目录只写了文件名FILE*streamfopen(notes.txt,w);这里的相对路径从进程的当前工作目录开始解释。当前工作目录不一定是源代码目录也不一定是可执行程序所在目录。程序从不同目录启动或者运行过程中修改了当前工作目录相同的相对路径就可能指向不同位置。绝对路径从根目录开始定位不依赖当前工作目录相对路径更简洁但程序必须清楚自己的运行环境。1.4 打开文件不等于把整个文件加载到内存fopen()的“打开”主要是建立一个可供程序使用的文件流并返回操作这个流的指针。文件内容通常在执行fread()、fgets()等读取操作时按需取得而不是在fopen()成功的一刻全部进入内存。可以把过程理解成fopen() 成功 ↓ 程序得到 FILE *文件流建立 ↓ 调用读取或写入函数 ↓ 数据按操作需要在程序、库和内核之间传递 ↓ fclose() 结束本次文件流使用2.FILE *与文件流C 标准库把文件访问抽象成“流”。流表示数据在程序与某个输入输出对象之间传递FILE结构负责保存操作这个流所需的状态程序通过FILE *使用它。#includestdio.hFILE*fopen(constchar*path,constchar*mode);fopen()的两个参数分别是path需要打开的文件路径mode打开模式决定读取、写入、追加以及文件不存在时的处理方式。成功时返回有效的FILE *失败时返回NULL同时设置errno表示失败原因。因此文件打开以后必须先检查返回值#includestdio.h#includestdlib.hintmain(void){FILE*streamfopen(notes.txt,r);if(streamNULL){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 / a4. 写文件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_twrittenfwrite(buffer,1,length,stream);if(written!length){/* 发生了短写入 */}也可以把一个结构体数组看作若干个固定大小的元素size_twrittenfwrite(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((countfread(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 中通常关联的文件描述符stdinFILE *标准输入通常来自终端键盘0stdoutFILE *标准输出通常显示在终端1stderrFILE *标准错误通常显示在终端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 *返回NULLfputs()返回非负值返回EOFfwrite()返回成功写入的元素数返回短元素数fgets()返回缓冲区地址返回NULLfread()返回成功读取的元素数EOF 或错误时可能返回短元素数fscanf()返回成功赋值的字段数匹配失败、短字段数或EOFfclose()返回 0返回EOFperror()适合在函数返回值已经表示失败以后使用。成功调用不保证把以前的errno清零所以不能无条件打印errno再根据那条旧错误信息判断当前操作。8. 实验一文本写入、追加与按行读取#includestdio.h#includestdlib.hstaticintclose_stream(FILE*stream){if(fclose(stream)EOF){perror(fclose);return-1;}return0;}intmain(void){constchar*pathnotes.txt;FILE*streamfopen(path,w);if(streamNULL){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;}streamfopen(path,a);if(streamNULL){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;}streamfopen(path,r);if(streamNULL){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. 实验二按固定大小元素读写#includestdio.h#includestdlib.hstructrecord{charname[16];unsignedintscore;};staticintclose_stream(FILE*stream){if(fclose(stream)EOF){perror(fclose);return-1;}return0;}intmain(void){constchar*pathscores.bin;conststructrecordrecords[]{{Alice,91},{Bob,86},{Carol,95},};constsize_tcountsizeof(records)/sizeof(records[0]);FILE*streamfopen(path,wb);if(streamNULL){perror(fopen);returnEXIT_FAILURE;}size_twrittenfwrite(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;}streamfopen(path,rb);if(streamNULL){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*streamfopen(notes.txt,r);if(streamNULL){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 *与文件描述符、库函数与系统调用、标准流与重定向联系起来。