回忆一下你之前写的所有 C 程序——数据从键盘输入,结果往屏幕输出。程序一退出,内存收回,一切归零。这种模式下,你的程序和外部世界之间只有"人"这个媒介。如果你想处理几百个学生的成绩、读取一个配置文件、或者记录程序的运行日志,总不能每次都靠人手打进去吧?

文件操作就是来解决这个问题的。它让你的程序能够和外存(硬盘、SSD 等)打交道——从磁盘读入数据、往磁盘写入结果。更重要的是,文件上的数据是持久化的:程序关了,甚至电脑重启了,数据都还在。这是任何一个实用程序的必修课。

在开始之前,先给你一个整体的心智地图。这一讲我们要回答六个问题:

  1. 文件在计算机里到底以什么形态存在?(二进制 vs 文本)
  2. 程序怎么"打开"一个文件、怎么"关闭"它?(fopen / fclose)
  3. 打开之后,怎么把数据读进来、写出去?(四对读写函数)
  4. 怎么在文件里"跳来跳去"地随机访问?(fseek / ftell / rewind)
  5. 怎么知道文件读完了?怎么区分"正常结束"和"出错"?(EOF / feof / ferror)
  6. 数据到底是怎么从内存到达磁盘的?(文件缓冲区)

把这六个问题串起来,你对文件操作的理解就不是"背了几个函数",而是形成一套完整的体系。我们一个一个来。

什么是文件?

日常口语里"文件"指的就是硬盘上的一个文档。但在 C 程序设计中,我们从功能角度把文件分成两类:

程序文件——源代码文件(.c)、目标文件(Windows 上是 .obj,Linux 上是 .o)、可执行文件(.exe)。这些是构建程序本身的文件。

数据文件——程序运行时读写的文件。比如配置文件(.ini、.json)、日志文件(.log)、数据库文件等。本章讨论的"文件操作"指的就是对数据文件的操作。

一个完整的文件名包含三部分:文件路径 + 文件名主干 + 文件后缀,比如 c:\code\test.txt。

这里展开说一下路径(path),因为它和后面 fopen 能不能打开文件直接相关。路径分两种:

  • 绝对路径:从盘符(Windows)或根目录(Linux/macOS)写起的完整路径。比如 c:\code\test.txt、/home/user/data.txt。
  • 相对路径:相对于程序当前工作目录的路径。比如程序运行在 c:\code\ 目录下,那么写 "test.txt" 就等价于 "c:\code\test.txt"。

写代码时有个非常容易踩的坑:在 C 字符串里,反斜杠 \ 是转义字符。所以写 Windows 路径 c:\code\test.txt 时,必须写成 "c:\\code\\test.txt"(两个反斜杠),否则 \c、\t 会被解释成转义序列。当然,更省心的做法是用相对路径,或者统一用正斜杠 /——Windows 的 API 和标准库都接受 "c:/code/test.txt" 这种写法。

另外提醒一句:如果你在 VS 里写 fopen("test.txt", "r") 却老是打开失败,先检查文件是不是放在"当前工作目录"里——VS 默认的工作目录通常是工程文件(.vcxproj)所在的目录,而不是源文件所在的目录。这是一个让无数新手困惑的问题,后面我们还会碰到。

二进制文件和文本文件

这是初学者容易混淆的一对概念。它们的区别不在内容上,而在存储格式上:

  • 文本文件:数据以 ASCII 码(或 UTF-8 等编码)的形式存储。比如整数 10000,在文本文件中是五个字符 '1' '0' '0' '0' '0',占用 5 个字节。
  • 二进制文件:数据直接以内存中的二进制形式存储。同样的整数 10000,如果是 int 类型,只占 4 个字节(在 32 位系统上)。

换句话说,文本文件是"人类可读"的——你用记事本打开能看懂。二进制文件是"机器原生"的——程序读写更快、占用空间更小,但用记事本打开是乱码。

一个关键的事实:字符一律以 ASCII 形式存储,数值型数据既可以 ASCII 存储也可以二进制存储。选择哪种取决于你的需求——如果需要人工查看和编辑,用文本格式;如果追求效率和紧凑,用二进制格式。

来看一个具体的对比,把整数 12345 分别以两种方式存进文件:

#include <stdio.h>
 
int main()
{
    int num = 12345;
    FILE *fp;
 
    // === 文本方式存储 ===
    fp = fopen("num_text.txt", "w");
    fprintf(fp, "%d", num);      // 按 %d 格式输出:存的是 '1''2''3''4''5' 五个字符
    fclose(fp);
 
    // === 二进制方式存储 ===
    fp = fopen("num_bin.dat", "wb");
    fwrite(&num, sizeof(int), 1, fp);   // 把 num 在内存中的 4 个字节原样写入
    fclose(fp);
 
    return 0;
}

写完后用记事本打开两个文件:

文件打开内容占用字节
num_text.txt123455 字节(每个数字一个字节)
num_bin.dat乱码(可能看到 9'00 之类)4 字节

为什么二进制文件里是"乱码"?因为 12345 的十六进制是 0x3039,在内存中按小端序存放是 39 30 00 00 四个字节——其中 0x39 恰好是字符 '9' 的 ASCII 码,0x30 是 '0' 的 ASCII 码,所以你会看到"90"后面跟着两个不可见的 \0。这就是"机器原生"的含义:文件内容就是内存的字节拷贝,不做任何转换。

什么时候用文本,什么时候用二进制? 简单粗暴的判断标准:

  • 文件要给人看、要跨程序交换(比如配置文件、CSV 导出、日志)→ 文本;
  • 文件只是给程序自己读(比如存档、缓存、数据库原始数据)→ 二进制(体积小、读写快、不丢精度)。

还有一点必须强调:文本模式下,Windows 系统会自动做行尾转换。在 Windows 上,文本文件的一行以 \r\n(回车换行,CRLF)结束,而 C 语言内部统一用 \n(换行,LF)表示换行。当你以文本模式(不带 b)写入 \n 时,系统会悄悄把它变成 \r\n 写进磁盘;读取时反向转换。而二进制模式(带 b)不做任何转换,写入什么就是什么。

这个差异在 Linux/macOS 上不存在(它们统一用 \n),所以很多人写跨平台代码时发现 Windows 上读文本文件"多了个 \r"——其实是文本模式的转换在起作用。记住:读写二进制数据(数字、结构体)必须用带 b 的模式,否则行尾转换会篡改数据,导致字节数和内容对不上。

文件的打开和关闭

在深入函数细节之前,先理解 C 语言是如何抽象 I/O 操作的。

流:C 语言的 I/O 抽象

外部设备五花八门——键盘、显示器、磁盘文件、打印机、网络端口……每种设备的 I/O 方式都不一样。如果程序员每次都要针对不同设备写不同的代码,开发效率就太低了。于是 C 语言的设计者引入了"流"(stream)这个概念。你可以把流想象成一条流淌着字节的河——你想从键盘读数据?从"标准输入流"里取。你想把结果打印到屏幕上?往"标准输出流"里写。你想访问文件?打开一个文件流就行了。

"流"这个抽象的伟大之处在于:无论数据来自键盘还是文件,对程序员来说操作方式完全一样——都是"从一个流里取字节"或"往一个流里放字节"。这也是为什么下面要讲的 fgetc、fgets、fscanf 既可以从文件读、也可以从键盘读。统一抽象,是软件工程中降低复杂度的重要手段。

C 程序在启动时,会自动打开三个标准流:

流名含义默认设备
stdin标准输入流键盘
stdout标准输出流显示器
stderr标准错误流显示器

这三个流的类型都是 FILE*(文件指针),这也是为什么你从来不需要手动"打开键盘"或"打开显示器"——操作系统在你程序启动时就帮你做好了。

为什么要有独立的 stderr?因为它和 stdout 有本质区别:stdout 通常有缓冲区(攒一批再显示),而 stderr 默认不缓冲(立即输出)。这意味着你用 fprintf(stderr, "...") 打印错误信息时,即使程序马上崩溃,错误信息也已经显示出来了——用 printf 则可能因为缓冲区没刷新而丢失。在程序发生致命错误时,stderr 是你的救命稻草。

文件指针:FILE 结构体

每个被打开的文件在内存中都有一个对应的文件信息区——一个 FILE 类型的结构体,里面存储着文件名、当前读写位置、文件状态、缓冲区等元信息。这个结构体由 fopen 自动创建并填充,你只需要用一个 FILE* 指针来引用它。

不同编译器的 FILE 结构体长得不太一样,但核心信息大同小异。比如 VS2013 的 stdio.h 中定义的大概是:

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 position indicator)是什么?它记录的是下一次读写操作将从文件的哪个位置开始。每读一个字节,它自动 +1;fseek 可以手动移动它。整个"顺序读写"和"随机读写"的机制,底层就是围绕这个位置指示器展开的。

fopen 和 fclose

打开文件用的是 fopen,关闭文件用的是 fclose:

FILE* fopen(const char* filename, const char* mode);
int   fclose(FILE* stream);

filename 是文件路径。mode 是一个字符串,决定你以什么方式打开文件。C 语言提供了丰富的打开模式:

模式含义文件不存在时文件已存在时
"r"只读,打开已有文本文件出错(返回 NULL)从开头读
"w"只写,打开文本文件创建新文件清空内容后从开头写
"a"追加,在文本文件末尾写创建新文件从末尾追加
"rb"只读,打开已有二进制文件出错从开头读
"wb"只写,打开二进制文件创建新文件清空内容后从开头写
"ab"追加,在二进制文件末尾写创建新文件从末尾追加
"r+"读写,打开已有文本文件出错从开头读写
"w+"读写,创建新文本文件创建新文件清空内容后读写
"a+"读写追加,在末尾读写文本文件创建新文件从末尾追加
"rb+"读写,打开已有二进制文件出错从开头读写
"wb+"读写,创建新二进制文件创建新文件清空内容后读写
"ab+"读写追加,在末尾读写二进制文件创建新文件从末尾追加

这个表是整个文件操作的基石,建议你把它背下来,或者至少熟记以下几个规律:

  1. r 系模式(r、r+、rb、rb+):文件必须存在,否则 fopen 返回 NULL。
  2. w 系模式(w、w+、wb、wb+):文件不存在就创建;文件存在就清空——原有数据全部丢失,没有任何恢复手段。这一点最危险:不小心把 "r" 写成 "w",或者把应该追加的文件用 "w" 打开,数据瞬间蒸发。
  3. a 系模式(a、a+、ab、ab+):文件不存在就创建;所有写入总是追加到文件末尾,即使你调用了 fseek 把位置指针移走了也没用——追加模式在写之前会自动把位置指针移到末尾。这是追加模式的特性,也是很多人困惑的地方。
  4. 带 + 的模式表示"可读可写",但从读切换到写(或反过来)时,中间必须有一次 fseek/rewind/fflush 之类的定位操作,否则行为未定义。这是 C 标准的规定,用来让底层缓冲区和文件位置同步。
  5. 带 b 的模式表示二进制模式,不做行尾转换;不带 b 的文本模式在 Windows 上会把 \n 和 \r\n 互相转换。Linux/macOS 上 b 有没有都一样(它们不做转换),但为了可移植性,读写二进制数据时请务必写 b。

还要注意一个 VS 特有的小坑:在 VS 里,fopen 会提示"此函数可能不安全,请考虑使用 fopen_s"并给出 C4996 警告(其实程序能正常编译运行)。如果想消除警告,可以在文件开头加 #define _CRT_SECURE_NO_WARNINGS,或者用 fopen_s(但 fopen_s 不是 C 标准函数,是微软扩展)。本教程统一使用标准 fopen。

先看一个最简单的例子——创建一个文件并写入内容:

#include <stdio.h>
 
int main()
{
    // 以只写(w)模式打开文件
    // 如果文件不存在,会创建新文件
    // 如果文件已存在,会清空原有内容
    FILE *fp = fopen("hello.txt", "w");
 
    // 永远检查 fopen 返回值——文件可能无法创建(权限问题、磁盘满等)
    if (fp == NULL) {
        perror("文件打开失败");  // perror 会打印出具体的系统错误原因
        return 1;
    }
 
    // 向文件写入字符串
    fputs("Hello, File I/O!\n", fp);
    fputs("这是 C 语言文件操作的第一个示例。\n", fp);
 
    // 关闭文件——这一步不能省略!
    // fclose 会自动刷新缓冲区并释放 FILE 结构体
    fclose(fp);
 
    printf("文件写入成功,请查看 hello.txt\n");
    return 0;
}

perror 是一个很方便的函数。它会根据全局变量 errno 的当前值,打印出人类可读的错误描述,比如 "Permission denied" 或 "No such file or directory"。在调试文件操作问题时,perror 是你的好朋友。

再补一个细节:fclose 的返回值。它成功时返回 0,失败时返回 EOF(通常是 -1)。失败的典型场景是缓冲区刷新时磁盘写入出错(比如磁盘满了)。严谨的程序会在关闭后检查返回值:

if (fclose(fp) != 0) {
    fprintf(stderr, "关闭文件时出错,可能有数据没写成功\n");
}

虽然大多数情况下你不会遇到,但"检查返回值"是好习惯——尤其是在写重要数据(银行流水、成绩单)的时候。

文件的顺序读写

掌握了打开和关闭,接下来看看 C 语言提供的八种读写函数。它们可以分成四对:

函数方向操作单元适用场景
fgetc / fputc输入 / 输出单个字符逐字节处理、文件拷贝
fgets / fputs输入 / 输出一行文本读配置文件、逐行处理日志
fscanf / fprintf输入 / 输出格式化数据结构化文本数据(成绩表、通讯录)
fread / fwrite输入 / 输出二进制块二进制数据、结构体数组整体存取

"顺序读写"的意思是:文件内部有一个位置指针,你每读一个字符(或写一个字符),这个指针就自动前移一个位置。你不用手动控制它,数据按从头到尾的顺序被处理。

还有一个容易忽略的通用性:表格里的函数适用于所有输入流/输出流——除了 fread/fwrite 主要用于文件流之外,fgetc/fgets/fscanf 这些输入函数既可以从文件读,也可以从标准输入流 stdin 读(比如 fgets(buf, sizeof(buf), stdin) 可以安全地读取键盘输入的一行,比 gets 安全得多);输出函数同理。明白了这一点,你会发现这套函数远比你想象的通用。

选择哪个函数,核心看你的数据形态:

  • 数据是一个个独立字符 → fgetc/fputc;
  • 数据是"一行一行"的 → fgets/fputs;
  • 数据有固定结构、想按字段解析 → fscanf/fprintf;
  • 数据是二进制(数字、结构体) → fread/fwrite。

下面逐个深入。

字符读写:fgetc / fputc

int fgetc(FILE* stream);   // 从流中读取一个字符,返回其 ASCII 码;失败或结束返回 EOF
int fputc(int c, FILE* stream);  // 把一个字符写入流;成功返回该字符,失败返回 EOF

先从最基础的字符读写开始,用它们写一个文件拷贝程序:

#include <stdio.h>
 
int main()
{
    FILE *src, *dst;
    int ch;  // 必须是 int 而不是 char——既要存放字符,也要能表示 EOF
 
    // 打开源文件(只读)
    src = fopen("source.txt", "r");
    if (src == NULL) {
        perror("无法打开源文件");
        return 1;
    }
 
    // 打开目标文件(只写)
    dst = fopen("copy.txt", "w");
    if (dst == NULL) {
        perror("无法创建目标文件");
        fclose(src);  // 关闭已打开的源文件
        return 1;
    }
 
    // 逐字符读取并写入——最基础的文件拷贝方式
    // fgetc 每次从文件中读取一个字符,返回该字符的 ASCII 码(int 类型)
    // 读到文件末尾或出错时返回 EOF(通常是 -1)
    while ((ch = fgetc(src)) != EOF) {
        fputc(ch, dst);  // fputc 将一个字符写入文件
    }
 
    // 判断是正常结束还是出错
    if (ferror(src)) {
        printf("读取过程中发生错误\n");
    } else {
        printf("文件拷贝完成!\n");
    }
 
    fclose(src);
    fclose(dst);
    return 0;
}

这里有个重要细节:fgetc 的返回类型是 int 而不是 char。原因是 EOF 通常定义为 -1,如果返回 char 类型,有符号的 char 值为 -1 时会和 EOF 混淆。所以接收 fgetc 返回值的变量必须是 int。

为什么 EOF 必须是 -1 而不用其他值?因为 ASCII 字符的取值范围是 0127(扩展 ASCII 是 0255),-1 不可能是一个合法字符的编码,所以它可以安全地作为"文件结束"的哨兵值。C 标准保证 EOF 是一个负的整数常量,几乎所有实现里都是 -1。

对比记忆:fgetc(fp) 从文件读一个字符;getchar() 等价于 fgetc(stdin),从键盘读。fputc(c, fp) 往文件写一个字符;putchar(c) 等价于 fputc(c, stdout)。这一组函数是全家族的地基。

行读写:fgets / fputs

逐行读写更常用,尤其是处理文本配置文件或日志时:

#include <stdio.h>
 
#define MAX_LINE 256  // 每行最大长度
 
int main()
{
    FILE *fp;
    char line[MAX_LINE];
    int line_count = 0;
 
    // 以读模式打开文件
    fp = fopen("input.txt", "r");
    if (fp == NULL) {
        perror("打开文件失败");
        return 1;
    }
 
    // fgets 每次读取一行(直到遇到换行符或缓冲区满或文件结束)
    // 参数:缓冲区地址、缓冲区大小、文件指针
    // 返回值:成功时返回缓冲区地址,失败或文件结束返回 NULL
    printf("文件内容(带行号):\n");
    while (fgets(line, sizeof(line), fp) != NULL) {
        line_count++;
        printf("%3d: %s", line_count, line);
        // line 本身包含换行符,所以 printf 不需要额外加 \n
    }
 
    // 文件结束才退出循环,检查是否因错误退出
    if (ferror(fp)) {
        printf("读取文件时发生错误\n");
    } else {
        printf("\n共读取 %d 行\n", line_count);
    }
 
    fclose(fp);
    return 0;
}

fgets 有几个细节值得注意:

  1. 它会读取换行符 \n 本身,所以读到的字符串末尾可能带换行符。如果你要拼接或比较,可能需要手动去掉行尾的 \n(比如 line[strcspn(line, "\n")] = '\0'; 或判断 line[strlen(line)-1] == '\n')。
  2. 如果一行太长超过了缓冲区大小,它只会读满缓冲区(留出位置放 \0),剩下的留在文件中等下次读。也就是说,fgets 不等于"读一行",严格说是"读一小块",只是大多数情况下"一行刚好能放下"。所以不要假设一次 fgets 一定读到了一整行——要检查末尾是不是 \n。
  3. 它会在字符串末尾自动添加 \0,保证你拿到的始终是一个合法 C 字符串。
  4. 第二个参数是缓冲区大小(包括 \0)。写 fgets(line, 256, fp) 最多读入 255 个字符,留 1 个位置给 \0。传入 sizeof(line) 是最稳妥的写法。
  5. gets 因为不检查缓冲区大小、必然导致缓冲区溢出,已经从 C11 标准中移除——永远不要用 gets,用 fgets 代替。

fputs(s, fp) 往文件写一个字符串,不会自动加换行符——它忠实地把 s 的内容(直到 \0)写入文件。对比 puts(s) 是往标准输出写字符串并自动加一个 \n。所以"fputs 读什么写什么"的特点,让它可以和 fgets 完美配对做逐行拷贝:

#include <stdio.h>
 
int main()
{
    FILE *src = fopen("in.txt", "r");
    FILE *dst = fopen("out.txt", "w");
    char line[512];
 
    if (src == NULL || dst == NULL) {
        perror("打开文件失败");
        return 1;
    }
 
    // fgets 读一行(含换行符),fputs 原样写出——行结构被完整保留
    while (fgets(line, sizeof(line), src) != NULL) {
        fputs(line, dst);
    }
 
    fclose(src);
    fclose(dst);
    return 0;
}

注意这里有个细节:如果某行超过了 512 字节,fgets 会分几次读完(每次带一小段,只有最后一次带 \n),fputs 分几次写出,最终内容拼接起来仍然正确——因为 fputs 忠实于缓冲区内容。这就是"fgets+fputs 是逐行处理的最稳健组合"的原因。

格式化读写:fscanf / fprintf

当你处理结构化数据时,格式化读写就派上用场了:

#include <stdio.h>
 
// 定义一个学生结构体
typedef struct {
    char name[32];
    int  age;
    float score;
} Student;
 
int main()
{
    Student students[3] = {
        {"张三", 20, 88.5},
        {"李四", 21, 92.0},
        {"王五", 19, 76.5}
    };
 
    // === 写入文件(格式化输出)===
    FILE *fp = fopen("students.txt", "w");
    if (fp == NULL) { perror("写入失败"); return 1; }
 
    // fprintf 的用法和 printf 几乎一模一样,只是多了一个 FILE* 参数
    for (int i = 0; i < 3; i++) {
        fprintf(fp, "%s %d %.1f\n",
                students[i].name,
                students[i].age,
                students[i].score);
    }
    fclose(fp);
    printf("数据已写入 students.txt\n");
 
    // === 从文件读取(格式化输入)===
    fp = fopen("students.txt", "r");
    if (fp == NULL) { perror("读取失败"); return 1; }
 
    Student read_data[3];
    int count = 0;
 
    // fscanf 从文件中按格式读取数据
    // 返回值:成功匹配并赋值的参数个数
    while (fscanf(fp, "%s %d %f",
                  read_data[count].name,
                  &read_data[count].age,
                  &read_data[count].score) == 3) {
        count++;
        if (count >= 3) break;  // 安全边界
    }
    fclose(fp);
 
    // 验证读取结果
    printf("\n从文件读回的数据:\n");
    for (int i = 0; i < count; i++) {
        printf("%s, %d岁, 成绩: %.1f\n",
               read_data[i].name,
               read_data[i].age,
               read_data[i].score);
    }
 
    return 0;
}

fprintf 和 fscanf 是 printf/scanf 的文件版本。使用它们时,格式字符串的匹配规则和你在终端 I/O 中用的一样。但要注意,fscanf 对空格和换行比较敏感——如果你写入的格式和读取的格式不完全匹配,解析结果可能出错。

fscanf 的返回值是成功匹配并赋值的参数个数,这是它的灵魂。几个关键点:

  • 如果三个格式都匹配成功,返回 3;
  • 如果匹配到一半失败(比如该读整数时遇到字母),返回已成功的个数(可能是 0、1、2);
  • 如果一开始就遇到文件结束或格式不匹配,返回 EOF(负数)。

所以正确的循环写法是比较返回值和格式个数:

// 正确:严格要求每次都读到 3 个字段
while (fscanf(fp, "%s %d %f", name, &age, &score) == 3) {
    // 处理这条记录
}

而不是用 while (!feof(fp))——因为 feof 是事后标志,容易导致最后一次读失败还继续处理脏数据。还有几个 %s 的坑必须提醒:

  1. %s 遇到空白字符(空格、换行、制表符)就停,所以读名字时如果名字里有空格(比如 "张 三")就会出问题——这是 fscanf 读文本数据的天然限制,想读含空格的整行请用 fgets。
  2. %s 不检查缓冲区大小,读到超长的词照样往下写,会缓冲区溢出。安全写法是限制宽度:%31s(最多读 31 个字符,留 1 个给 \0)。
  3. 读取时必须给变量传地址(&age、&score),而数组名本身已经是地址(name 不用加 &)。

fprintf 则是完全把 printf 的格式控制(%d、%f、%.2f、%s、%-10s 左对齐、%05d 补零等)搬运到文件上。你可以用它生成漂亮的报表:

fprintf(fp, "%-10s %5d %8.2f\n", "张三", 20, 88.50);
// 输出: 张三           20    88.50(左对齐10格、数字右对齐5格、保留两位小数)

二进制读写:fread / fwrite

如果需要高效存储,用二进制读写:

#include <stdio.h>
 
int main()
{
    int numbers[] = {10, 20, 30, 40, 50};
    int count = sizeof(numbers) / sizeof(numbers[0]);
 
    // === 以二进制模式写入 ===
    FILE *fp = fopen("data.bin", "wb");  // "wb" = write binary
    if (fp == NULL) { perror("写入失败"); return 1; }
 
    // fwrite: 将内存中的数据原样写入文件
    // 参数:数据地址、每个元素大小、元素个数、文件指针
    // 返回值:实际写入的元素个数
    size_t written = fwrite(numbers, sizeof(int), count, fp);
    printf("成功写入 %zu 个整数到 data.bin\n", written);
    fclose(fp);
 
    // === 以二进制模式读取 ===
    int read_back[5] = {0};
 
    fp = fopen("data.bin", "rb");  // "rb" = read binary
    if (fp == NULL) { perror("读取失败"); return 1; }
 
    // fread: 从文件中读取二进制数据到内存
    // 参数和 fwrite 完全相同
    size_t items_read = fread(read_back, sizeof(int), count, fp);
 
    printf("成功读取 %zu 个整数:\n", items_read);
    for (int i = 0; i < (int)items_read; i++) {
        printf("read_back[%d] = %d\n", i, read_back[i]);
    }
    fclose(fp);
 
    return 0;
}

输出:

成功写入 5 个整数到 data.bin
成功读取 5 个整数:
read_back[0] = 10
read_back[1] = 20
read_back[2] = 30
read_back[3] = 40
read_back[4] = 50

fwrite 和 fread 做的是"原样搬运"——内存里的字节长什么样,文件里就存成什么样。这个过程不涉及任何编码转换,所以读写速度和数据一致性都比文本模式好。但代价是:用记事本打开 data.bin 看到的是一堆乱码。读写二进制数据时务必用 "b" 系列模式——否则 Windows 系统上的行尾转换(0x0A → 0x0D 0x0A)会悄悄篡改你的数据。

这两个函数的签名是:

size_t fread (void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
  • ptr:内存缓冲区地址;
  • size:每个元素的字节数;
  • nmemb:要读写的元素个数;
  • 返回值:实际成功读写的完整元素个数。

注意返回值是"元素个数"而不是"字节数"!而且只要出错或到达文件末尾,返回值就可能小于 nmemb。所以判断读失败的惯用写法是:

// 读 100 个 int,但文件可能没有 100 个
int buf[100];
size_t got = fread(buf, sizeof(int), 100, fp);
if (got != 100) {
    if (feof(fp))
        printf("文件不够长,实际读到 %zu 个元素\n", got);
    else if (ferror(fp))
        printf("读取出错\n");
}

强烈建议把 fread/fwrite 的第三个参数写成 1,让第二个参数等于"整个对象的大小":

// 推荐写法:一次读写一个完整对象
fwrite(&stu, sizeof(Student), 1, fp);
fread(&stu, sizeof(Student), 1, fp);

这样返回值只有 0 或 1,语义极其清晰:0 = 没读/写成功,1 = 成功。反过来,fwrite(&stu, 1, sizeof(Student), fp) 虽然字节数一样,但"写完了 76 个元素中的 40 个"这种中间状态会让你很困惑。一次一个完整对象,还能避免"写入到一半程序崩溃导致文件里留下半个结构体"的脏数据更难发现的问题。

二进制读写结构体数组是实际项目里最常见的用法,比如存档系统:

#include <stdio.h>
 
typedef struct {
    char name[32];
    int  hp;
    int  level;
    float exp;
} Player;
 
int main()
{
    // 三个玩家存档
    Player party[3] = {
        {"勇者", 100, 1, 0.0f},
        {"法师", 80, 2, 120.5f},
        {"弓手", 90, 1, 45.0f}
    };
 
    // 整个数组一次性写入
    FILE *fp = fopen("save.dat", "wb");
    if (fp == NULL) { perror("保存失败"); return 1; }
 
    // 一次写 3 个完整对象
    size_t n = fwrite(party, sizeof(Player), 3, fp);
    printf("已保存 %zu 个角色\n", n);
    fclose(fp);
 
    // 读取时按同样格式读回
    Player loaded[3] = {0};
    fp = fopen("save.dat", "rb");
    if (fp == NULL) { perror("读取存档失败"); return 1; }
 
    n = fread(loaded, sizeof(Player), 3, fp);
    fclose(fp);
 
    for (size_t i = 0; i < n; i++) {
        printf("%s 等级%d HP%d EXP%.1f\n",
               loaded[i].name, loaded[i].level, loaded[i].hp, loaded[i].exp);
    }
    return 0;
}

这个程序的优雅之处在于:不管结构体多大、字段多复杂,fread/fwrite 都是"一次性搬运",代码量不会随字段增加而变多。

但二进制读写有两个跨平台/跨编译器的隐患,必须知道:

  1. 字节序(endianness):x86 是小端(低字节在前),而有些嵌入式平台是大端。同一个 int 在不同平台写出的文件字节序不同,换平台读就错了。对策:跨平台交换数据时,要么用文本格式,要么在读写时统一处理字节序。
  2. 结构体对齐(padding):结构体在内存中会插入填充字节来对齐(比如 char[32]+int+... 实际占的字节可能比字段之和多),而不同编译器、不同对齐设置下填充不同。所以用 fwrite 写出的结构体文件,通常只能由同一编译器、同一平台读回。如果想跨平台,用 #pragma pack(1) 关闭填充,或者干脆用文本格式逐字段读写。

三种 I/O 体系:printf/fprintf/sprintf

实际上,C 语言的格式化 I/O 函数不是只有文件版本。它们有完整的三组对应关系:

#include <stdio.h>
 
int main()
{
    // 第一组:从标准输入/输出
    int a;
    printf("请输入一个整数: ");
    scanf("%d", &a);
    printf("你输入的是: %d\n\n", a);
 
    // 第二组:从文件输入/输出
    FILE *fp = fopen("num.txt", "w+");
    if (fp == NULL) return 1;
    fprintf(fp, "%d", 42);     // 写入文件
    fseek(fp, 0, SEEK_SET);    // 回到文件开头准备读取
    int b;
    fscanf(fp, "%d", &b);      // 从文件读取
    printf("从文件读取的值: %d\n\n", b);
    fclose(fp);
 
    // 第三组:从字符串输入/输出(在内存中操作)
    char buffer[32];
    sprintf(buffer, "score=%d", 95);  // 将格式化数据写入字符串
    printf("sprintf 生成的字符串: %s\n", buffer);
 
    int score;
    sscanf(buffer, "score=%d", &score);  // 从字符串中解析格式化数据
    printf("sscanf 解析出来的分数: %d\n", score);
 
    return 0;
}

三组函数的对应关系非常规律:

目标输出函数输入函数
标准 I/O(键盘/屏幕)printfscanf
文件fprintffscanf
字符串(内存)sprintfsscanf

记住了这个规律,你就能在"控制台 I/O"、"文件 I/O"和"字符串解析"之间无缝切换。它们的格式字符串语法是完全通用的。

这里提醒 sprintf 的一个坑:它不检查目标缓冲区大小,超长就会缓冲区溢出。安全版本是 snprintf(buffer, sizeof(buffer), "score=%d", 95)——指定缓冲区大小,超出部分自动截断。现在写代码应该一律用 snprintf。

文件的随机读写

到目前为止,我们看到的都是"顺序读写"——文件位置指针从开头走到结尾,一路向前不回头。但很多时候你需要随机访问:跳到文件中间修改某个记录、定位到文件末尾追加内容、或者反复横跳处理不同区域的数据。C 语言提供了三个函数来实现这种能力:fseek、ftell、rewind。

#include <stdio.h>
 
int main()
{
    // 创建一个文件,写入 A 到 Z
    FILE *fp = fopen("alphabet.txt", "w+");
    if (fp == NULL) { perror("打开失败"); return 1; }
 
    for (char ch = 'A'; ch <= 'Z'; ch++) {
        fputc(ch, fp);
    }
 
    // === fseek:移动文件位置指针 ===
    // SEEK_SET:从文件开头算起
    fseek(fp, 0, SEEK_SET);
    printf("当前位置(开头): %ld\n", ftell(fp));
 
    // SEEK_CUR:从当前位置算起
    fseek(fp, 5, SEEK_CUR);
    printf("当前位置(+5): %ld\n", ftell(fp));
 
    // SEEK_END:从文件末尾算起
    fseek(fp, -3, SEEK_END);
    printf("当前位置(末尾-3): %ld\n", ftell(fp));
 
    // 读取当前位置的字符
    char ch = fgetc(fp);
    printf("当前位置字符: %c\n", ch);
 
    // === ftell:获取当前位置 ===
    // 移动到文件末尾,ftell 就返回文件大小
    fseek(fp, 0, SEEK_END);
    long size = ftell(fp);
    printf("文件大小: %ld 字节\n", size);
 
    // === rewind:回到文件开头 ===
    // rewind(fp) 等价于 fseek(fp, 0, SEEK_SET),同时清除错误标志
    rewind(fp);
    printf("rewind 后位置: %ld\n", ftell(fp));
 
    fclose(fp);
    return 0;
}

输出:

当前位置(开头): 0
当前位置(+5): 5
当前位置(末尾-3): 23
当前位置字符: X
文件大小: 26 字节
rewind 后位置: 0

fseek 的三个基准点——SEEK_SET(文件开头)、SEEK_CUR(当前位置)、SEEK_END(文件末尾)——让你可以灵活地在文件中任意跳转。结合 ftell 获取当前位置,你可以实现数据库般的随机访问。

三个函数逐个说明:

  • fseek(FILE *stream, long offset, int origin):把位置指针移动到 origin + offset 处。origin 只能取 SEEK_SET/SEEK_CUR/SEEK_END。成功返回 0,失败返回非 0。注意 offset 是 long 类型。
  • ftell(FILE *stream):返回当前位置距离文件开头的偏移量。失败返回 -1L。对二进制文件,这个偏移就是字节数。
  • rewind(FILE *stream):把位置指针拨回文件开头,并清除错误标志和 EOF 标志。它等价于 fseek(fp, 0, SEEK_SET); clearerr(fp);。而 fseek 本身不会清除错误标志。

两个容易踩的坑:

  1. 文本模式下 ftell/fseek 的偏移不是字节数。因为文本模式下有 \r\n 和 \n 的转换,位置指针的值不等于真实字节偏移(在 Windows 上,ftell 返回的偏移和字符数不一致)。所以对文本文件做随机跳转是不安全的,C 标准只保证在二进制模式下 ftell 返回的值可以直接用于 fseek 恢复位置。想随机访问,用二进制模式。
  2. SEEK_END 搭配负偏移:fseek(fp, 0, SEEK_END) 跳到末尾,fseek(fp, -3, SEEK_END) 跳到离末尾 3 字节处。正偏移从 SEEK_END 出发是未定义行为(在文件末尾之后),别这么写。

一个实用的用法是用 fseek + ftell 获取文件大小:

#include <stdio.h>
 
long get_file_size(const char *filename)
{
    FILE *fp = fopen(filename, "rb");  // 二进制模式,避免文本模式的行尾转换干扰
    if (fp == NULL) {
        return -1;  // -1 表示错误
    }
 
    // 跳到文件末尾
    fseek(fp, 0, SEEK_END);
 
    // 获取偏移量(即文件大小)
    long size = ftell(fp);
 
    fclose(fp);
    return size;
}
 
int main()
{
    // 先创建一个测试文件
    FILE *fp = fopen("test_size.txt", "w");
    if (fp == NULL) return 1;
    fprintf(fp, "Hello, this is a test file for size measurement.");
    fclose(fp);
 
    // 获取并打印文件大小
    long size = get_file_size("test_size.txt");
    if (size >= 0) {
        printf("文件大小: %ld 字节\n", size);
    } else {
        printf("无法获取文件大小\n");
    }
 
    return 0;
}

注意:C 标准并不保证 fseek(fp, 0, SEEK_END) 返回的偏移就是文件大小,但在所有主流平台上这都是可行的。另外,这个函数用 "rb" 模式打开文件是故意的——文本模式下 Windows 的行尾转换会让 ftell 返回的偏移量和实际字符数不一致。

再演示一个"随机修改文件中间某个位置"的完整例子——把二进制文件里第 3 个整数改成 999:

#include <stdio.h>
 
int main()
{
    // 先写 10 个整数
    FILE *fp = fopen("nums.dat", "wb+");   // wb+:可读可写的二进制模式
    if (fp == NULL) { perror("打开失败"); return 1; }
 
    for (int i = 0; i < 10; i++) {
        fwrite(&i, sizeof(int), 1, fp);
    }
 
    // 把第 3 个元素(下标 2)改成 999
    // 第 3 个整数在文件中的字节偏移 = 2 * sizeof(int)
    long offset = 2L * sizeof(int);
    if (fseek(fp, offset, SEEK_SET) != 0) {
        perror("定位失败");
        fclose(fp);
        return 1;
    }
 
    int new_value = 999;
    fwrite(&new_value, sizeof(int), 1, fp);   // 写覆盖了原来的值
    fclose(fp);
 
    // 验证:重新读出来看
    fp = fopen("nums.dat", "rb");
    if (fp == NULL) { perror("打开失败"); return 1; }
 
    int val;
    while (fread(&val, sizeof(int), 1, fp) == 1) {
        printf("%d ", val);
    }
    printf("\n");
    fclose(fp);
    return 0;
}

输出:0 1 999 3 4 5 6 7 8 9

这个例子展示了"随机写"的完整套路:先定位(fseek)再写(fwrite)。配合 "w+"/"r+"/"a+" 这类可写模式,就能做到只改文件中的一小块数据,而不是把整个文件重写一遍——在文件很大(比如几十 GB 的数据库)时,这是性能的关键。

文件读取结束的判定

这是 C 语言文件操作中最常见的错误。很多人这样写:

while (!feof(fp)) {
    fgets(buffer, sizeof(buffer), fp);
    printf("%s", buffer);
}

看起来对,实际上最后一行会被打印两次。为什么?

因为 feof 不是"预测"文件是否到达末尾——它是事后判断。它只有在已经尝试过读操作并且确实到达了文件末尾之后,才会返回真。流程是这样的:

  1. 读掉倒数第二行,文件位置指针停在最后一行之前 → feof 返回 0(还没到末尾),进入循环体
  2. fgets 读取最后一行成功 → printf 打印最后一行
  3. 回到 while 条件,feof 还是 0(因为还没有"尝试读但失败"的经历)
  4. 进入循环体,fgets 尝试再读——这次读失败了(已经到了文件末尾),但 fgets 返回 NULL 之前不会修改 buffer 的内容
  5. printf 又把 buffer 里上次的内容打印了一遍

正确的写法是:直接判断读取函数的返回值。

// 正确:fgets 返回 NULL 表示读取结束或出错
while (fgets(buffer, sizeof(buffer), fp) != NULL) {
    printf("%s", buffer);
}

类似地,fgetc 判断返回值是否为 EOF,fread 判断返回值是否小于请求读取的元素个数:

// fgetc 正确用法
while ((c = fgetc(fp)) != EOF) {
    putchar(c);
}
if (ferror(fp)) {
    printf("I/O 错误\n");
} else if (feof(fp)) {
    printf("已到达文件末尾\n");
}

总结成一张表:

函数判断循环结束的条件
fgetc返回值 == EOF
fgets返回值 == NULL
fscanf返回值 < 预期参数个数(遇到 EOF 时返回 EOF)
fread返回值 < 请求读取的元素个数

然后在循环结束后用 feof(fp) 或 ferror(fp) 来区分是正常结束还是发生了错误。

EOF 和 feof 这两个概念要彻底分清:

  • EOF 是一个值(通常是 -1),由读取函数返回,表示"这次读操作没读到数据"。它既可能是"文件真的读完了",也可能是"出了错误"。
  • feof(fp) 是一个函数,查询文件的"结束标志位"。只有发生过一次失败的读操作,这个标志位才会被置位。

打个比方:EOF 像"交警拦下你说前面没路了",而 feof 像"你车上的里程表记录了你曾经开到过没路的地方"。你要判断"能不能继续走",应该看交警(返回值),而不是看里程表(feof)。

什么时候才用 feof? 只有在循环结束之后,用来区分"正常读完"和"读取出错":

// 典型正确用法:读循环用返回值判断,结束后用 feof/ferror 区分原因
while (fread(buf, sizeof(int), 100, fp) == 100) {
    // 处理这一批 100 个整数
}
if (feof(fp)) {
    // 文件恰好读完(或者剩余不足 100 个)
    // 这时还要把最后不足一批的数据读出来
} else if (ferror(fp)) {
    // 发生了真正的 I/O 错误
}

还有一个配套函数 clearerr(fp):清除文件的 EOF 标志和错误标志。比如你用 fgets 读到文件末尾后,又想从头再读一遍——如果只调用 rewind(fp) 还不够(rewind 会清标志,OK 那够了),但如果用 fseek(fp, 0, SEEK_SET) 移回去,EOF 标志还在,后续读操作可能行为异常。所以移动位置指针后想重新读,最好调用 clearerr(fp)。

文件缓冲区

讲到文件操作,有一个幕后机制必须了解——文件缓冲区。

ANSI C 采用"缓冲文件系统"。也就是说,系统自动为每个打开的文件在内存中分配一块"文件缓冲区"。当你调用 fprintf 向文件写数据时,数据不是立刻写到磁盘上——而是先进入这个内存缓冲区。等缓冲区满了(或者在 fclose/fflush 时),数据才会被真正地写入磁盘。

这样做的好处是性能。磁盘 I/O 是计算机系统中最慢的操作之一——和内存读写差了几个数量级。如果在缓冲区中攒够一批数据再一次性写入磁盘,I/O 次数就大大减少。

我们来算一笔账:假如你要写 100 万个字符。如果没有缓冲区,每次 fputc 都是一次磁盘写操作——100 万次磁盘 I/O,假设每次 0.1ms,那就是 100 秒。有了缓冲区(假设 4KB),数据先在内存里攒着,攒满 4KB 才写一次磁盘,大约只需要 250 次磁盘 I/O,耗时可以忽略不计。这就是缓冲区的意义——用内存的容量和速度,换磁盘的读写次数。

C 语言的文件缓冲有三种策略:

缓冲类型何时真正读写磁盘典型对象
全缓冲缓冲区满了才写磁盘文件(默认)
行缓冲遇到 \n 就写交互式终端(stdout 连到终端时)
无缓冲立即写stderr(保证错误信息即时可见)

stdin 和 stdout 在连接到终端时是行缓冲——这也是为什么 printf("请输入:") 后面必须带 \n 或调用 fflush(stdout) 才会立刻显示提示语,否则要等用户输入(输入操作会触发刷新)才看到提示。而 stderr 永远无缓冲。重定向到文件时,stdout 又会变成全缓冲——这也是为什么 a.out > log.txt 时,程序崩溃会导致日志"消失":数据还憋在缓冲区里没写出去。

缓冲区的副作用也很明显:如果程序崩溃或异常退出,缓冲区中尚未刷新的数据就丢了。这也是为什么 fclose 和 fflush 如此重要。

fflush 强制将输出缓冲区的内容写入文件:

int fflush(FILE* stream);

有时候你需要在文件仍然打开的状态下确保数据已经落盘——比如记录日志时,你希望哪怕程序下一秒崩溃,日志也已经写入了。注意:fflush 只对输出流有效,对输入流的行为是未定义的。另外,高版本的 Visual Studio 中 fflush 对 stdin 的行为可能有变化,不建议依赖它来清空输入缓冲区(在 VS 里 fflush(stdin) 是"清空输入缓冲"这个非标准用法,C 标准说这是未定义行为,跨平台代码不要这么写)。

如果你需要精细控制缓冲行为,可以用 setvbuf:

char mybuf[4096];
setvbuf(fp, mybuf, _IOFBF, sizeof(mybuf));  // _IOFBF 全缓冲 / _IOLBF 行缓冲 / _IONBF 无缓冲

setvbuf 必须在 fopen 之后、任何读写操作之前调用,而且要保证 mybuf 的生命周期覆盖文件打开期间(通常用全局数组或 static 数组)。

文件缓冲区解释了为什么我们要强调"一定要 fclose"——即使操作系统在程序退出时会自动关闭文件,但退出过程中的异常可能导致缓冲区没来得及刷新。在你自己的代码里显式调用 fclose,就是确保数据完整落盘的最后一道防线。

错误处理:ferror、perror 与 errno

文件操作很容易出错,一个健壮的程序必须能处理这些错误。C 语言提供的错误处理机制有:

  1. 返回值检查:fopen 返回 NULL、fgetc 返回 EOF、fread 返回不足、fclose 返回 EOF——这些都是"出错了"的信号,必须检查。
  2. ferror(fp):检查某个流是否发生了 I/O 错误。错误一旦发生,标志会一直保持,直到调用 clearerr(fp) 或 rewind。
  3. perror(const char *s):把 errno 对应的错误描述打印到 stderr,前面加上你传入的字符串。perror("打开失败") 会输出类似 打开失败: No such file or directory 的内容。
  4. errno:一个全局整数变量,记录了最近一次系统调用/库函数错误码。需要 #include <errno.h>。注意它不会在成功时清零,所以正确的用法是"先失败,再查 errno",而不是"先查 errno 再判断成败"。

一个综合示范:

#include <stdio.h>
#include <errno.h>
 
int main()
{
    FILE *fp = fopen("no_such_file.txt", "r");
    if (fp == NULL) {
        // errno 在 fopen 失败时被设置
        printf("errno = %d\n", errno);       // 比如 2(ENOENT:文件不存在)
        perror("fopen 失败");                 // fopen 失败: No such file or directory
        return 1;
    }
    fclose(fp);
    return 0;
}

设计程序时的原则:每个可能失败的步骤都要处理失败。fopen 失败、fread 读不完整、fclose 失败……把这些检查写全,程序才能在被问"为什么运行到一半崩了"时给你一个明确答案,而不是留下一堆无法解释的垃圾数据。

综合实战:学生成绩管理系统(文本版)

把这一讲的知识串起来,写一个完整的小项目:从文件读入学生成绩、按总分排序、把结果写回文件。

#include <stdio.h>
#include <string.h>
 
#define MAX_STUDENTS 100
 
typedef struct {
    char name[32];
    int  chinese;
    int  math;
    int  english;
    int  total;
} Student;
 
// 从文件读取学生信息,返回读取的人数
int load_students(Student stu[], int max_num, const char *filename)
{
    FILE *fp = fopen(filename, "r");
    if (fp == NULL) {
        perror("打开成绩文件失败");
        return 0;
    }
 
    int n = 0;
    // fscanf 返回 4 表示 name/chinese/math/english 四个字段都读到了
    while (n < max_num &&
           fscanf(fp, "%s %d %d %d",
                  stu[n].name, &stu[n].chinese,
                  &stu[n].math, &stu[n].english) == 4) {
        stu[n].total = stu[n].chinese + stu[n].math + stu[n].english;
        n++;
    }
 
    if (ferror(fp)) {
        printf("读取过程中发生错误\n");
    } else if (feof(fp)) {
        printf("文件读取完毕,共 %d 名学生\n", n);
    }
 
    fclose(fp);
    return n;
}
 
// 按总分从高到低排序(简单选择排序)
void sort_by_total(Student stu[], int n)
{
    for (int i = 0; i < n - 1; i++) {
        int max_idx = i;
        for (int j = i + 1; j < n; j++) {
            if (stu[j].total > stu[max_idx].total) {
                max_idx = j;
            }
        }
        if (max_idx != i) {
            Student tmp = stu[i];
            stu[i] = stu[max_idx];
            stu[max_idx] = tmp;
        }
    }
}
 
// 把结果写入新文件
void save_ranked(Student stu[], int n, const char *filename)
{
    FILE *fp = fopen(filename, "w");
    if (fp == NULL) {
        perror("创建结果文件失败");
        return;
    }
 
    fprintf(fp, "%-10s %5s %5s %5s %5s\n", "姓名", "语文", "数学", "英语", "总分");
    for (int i = 0; i < n; i++) {
        fprintf(fp, "%-10s %5d %5d %5d %5d\n",
                stu[i].name, stu[i].chinese, stu[i].math,
                stu[i].english, stu[i].total);
    }
 
    fclose(fp);
    printf("排名结果已写入 %s\n", filename);
}
 
int main()
{
    Student stu[MAX_STUDENTS];
 
    int n = load_students(stu, MAX_STUDENTS, "scores.txt");
    if (n == 0) {
        printf("没有读到任何学生数据,程序结束\n");
        return 1;
    }
 
    sort_by_total(stu, n);
 
    printf("\n===== 按总分排名 =====\n");
    printf("%-10s %5s %5s %5s %5s\n", "姓名", "语文", "数学", "英语", "总分");
    for (int i = 0; i < n; i++) {
        printf("%-10s %5d %5d %5d %5d\n",
               stu[i].name, stu[i].chinese, stu[i].math,
               stu[i].english, stu[i].total);
    }
 
    save_ranked(stu, n, "ranked.txt");
    return 0;
}

配套的 scores.txt 格式(每行四个字段):

张三 90 85 92
李四 78 95 88
王五 82 76 91
赵六 95 90 89

这个例子融合了:fopen 模式选择、fscanf 返回值驱动的循环读取、ferror/feof 的收尾判断、fprintf 的格式化为报表。读懂它,你就掌握了文本文件读写的完整套路。

注意一个容易遗漏的点:读文件时 fscanf 的 %s 遇到空白就停,所以姓名不能含空格;如果姓名可能含空格(比如 "张 三"),就要改成 %[^\n] 这种格式,或者改用 fgets 逐行读再 sscanf 解析——这正好把前面讲的 fgets + sscanf 组合用上了:

char line[256];
char name[32];
int c, m, e;                          // 三个成绩字段
FILE *fp = fopen("scores.txt", "r");  // 打开成绩文件
if (fp == NULL) return 1;
 
while (fgets(line, sizeof(line), fp) != NULL) {
    // 从字符串中解析出各字段——sscanf 和 fscanf 格式语法完全一样
    if (sscanf(line, "%s %d %d %d", name, &c, &m, &e) == 4) {
        // 处理这条记录
    }
}
fclose(fp);

文本文件 vs 二进制文件:终极对比

把这一讲反复出现的对比集中整理一下:

维度文本文件二进制文件
存储形式字符编码(ASCII/UTF-8)内存字节原样拷贝
可读性记事本能看记事本乱码
数值精度浮点数转文本可能丢精度原样保存,不丢精度
空间通常更大("10000"占5字节)紧凑(int 占4字节)
速度需编码/解码转换直接搬运,更快
跨平台好(文本格式通用)差(字节序、结构体对齐问题)
随机访问偏移量不等于字符数偏移量就是字节数
打开模式不带 b必须带 b(如 "rb"/"wb")
典型场景配置、日志、报表、CSV存档、缓存、数据库文件

选择建议:数据要给人看、要跨程序交换 → 文本;数据只给自己的程序读写、追求速度和体积 → 二进制。

到这里,文件操作的核心内容就讲完了。从 fopen 选择合适模式打开文件,到八种读写函数的场景选择,再到用 fseek 在文件中自由跳转、用正确的循环条件安全读出所有数据、理解缓冲区保护数据完整性——你手里的 C 程序现在真的能和外部世界对话了。掌握了这些,写一个简单的文本编辑器或成绩管理系统就水到渠成了。

思考题

  1. 用 "w" 模式打开一个已存在的文件会发生什么?如果只想"追加内容"应该用哪个模式?如果既想读又想改原有内容而不清空,应该用哪个模式?
  2. 为什么 fgetc 的返回值要用 int 而不是 char?如果把返回值赋给 char 变量再比较 EOF,在什么情况下会出错?
  3. while (!feof(fp)) 为什么是错的?请写出 fgets、fgetc、fscanf、fread 四种循环的正确写法。
  4. fgets 读到"超长行"时会发生什么?缓冲区里有没有 \0?剩下的内容下次还能读到吗?
  5. 为什么 fread/fwrite 的第二个参数和第三个参数建议写成 sizeof(结构体), 1 而不是 1, sizeof(结构体)?
  6. 在 Windows 上以文本模式读写包含 \n 的二进制数据会出什么问题?怎么避免?
  7. rewind(fp) 和 fseek(fp, 0, SEEK_SET) 有什么区别?哪个会清除错误标志?
  8. 为什么日志程序要用 fflush 或直接写 stderr?如果你写了一个记账程序,客户说"断电后账目丢了",你会从哪里开始排查?

参考答案与详解

1. 用 "w" 模式打开一个已存在的文件会发生什么?如果只想"追加内容"应该用哪个模式?如果既想读又想改原有内容而不清空,应该用哪个模式?

  • "w":文件已存在时会把原有内容全部清空(truncate),然后从头开始写——这是最危险的打开模式之一,一旦用错数据瞬间蒸发且无恢复手段。
  • 只想在末尾追加内容(不破坏已有数据):用 "a"(追加模式),文件指针总在末尾。
  • 既想读又想改原有内容且不清空:用 "r+"(读写、打开已有文件,从开头读写,不清空)。注意它要求文件必须已存在,否则返回 NULL。"w+" 会清空,不符合"不清空"的要求。
FILE* fp;
fp = fopen("data.txt", "w");   // 清空原内容,从头写
fp = fopen("data.txt", "a");   // 追加,不清空
fp = fopen("data.txt", "r+");  // 文件必须存在,可读写,不清空

2. 为什么 fgetc 的返回值要用 int 而不是 char?如果把返回值赋给 char 变量再比较 EOF,在什么情况下会出错?

fgetc 返回的是"读到的那个字符",也可能是"文件结束哨兵" EOF(通常是 -1)。char 取值只有 256 种可能(而且无符号 char 是 0~255,根本表示不了负数 -1),无法同时容纳"字符值"和"EOF"这两个不同的东西。

出错场景:用无符号 char 存 fgetc 的结果时,读到合法字符 0xFF(扩展 ASCII)会变成 255,而真正的 EOF(-1)在比较时会被隐式提升——(char)255 和 EOF 的 int 值对不上或混淆,导致要么"把没读完误判为读完",要么"读到文件尾却没识别出来"。所以标准写法是:

int c;   // 必须是 int,既能存字符值(0~255),也能存 EOF(-1)
while ((c = fgetc(fp)) != EOF) {
    putchar(c);
}

3. while (!feof(fp)) 为什么是错的?请写出 fgets、fgetc、fscanf、fread 四种循环的正确写法。

feof 是事后标志——它只有在"某次读操作真的失败了(到文件末尾)"之后才被置位,不能提前判断是否还有数据。用 while (!feof(fp)) 会在最后一次"读失败"前多进一次循环,把上一次读到的(或缓冲区残留的)数据重复处理一遍(比如最后一行重复打印)。

正确写法一律看读函数的返回值:

// fgetc:读到 EOF 停止
int c;
while ((c = fgetc(fp)) != EOF) { putchar(c); }
 
// fgets:读到 NULL(文件结束或出错)停止
char buf[256];
while (fgets(buf, sizeof buf, fp) != NULL) { fputs(buf, stdout); }
 
// fscanf:成功匹配到预期个数才继续
int v;
while (fscanf(fp, "%d", &v) == 1) { printf("%d\n", v); }
 
// fread:实际读到的个数不足请求数时停止
int buf[100];
size_t n;
while ((n = fread(buf, sizeof(int), 100, fp)) > 0) {
    for (size_t i = 0; i < n; i++) printf("%d ", buf[i]);
}

读完后再用 feof(fp)/ferror(fp) 区分"正常读完"还是"出错"。

4. fgets 读到"超长行"时会发生什么?缓冲区里有没有 \0?剩下的内容下次还能读到吗?

fgets(buf, n, fp) 最多读 n-1 个字符,然后自动在末尾补 \0(所以缓冲区里一定以 \0 结尾,读到的内容就是合法的 C 字符串)。当一行比 n-1 还长时:

  • 它只读走前 n-1 个字符(不读换行符,因为没到行尾);缓冲区末尾有 \0;
  • 剩下那半行留在文件里,文件位置指针停在断点处;下次再调用 fgets 会从断点继续读,接上后半截。

所以 fgets 严格说不是"读一整行",而是"读一小块",只是通常一行放得下。想确认"这次是否读完一整行",要检查读到的内容末尾是不是 \n:if (buf[strcspn(buf, "\n")] == '\0') /* 没读到换行 = 行还没读完 */。用 fgets+fputs 逐块拷贝时,即使断行,拼接结果依然正确。

5. 为什么 fread/fwrite 的第二个参数和第三个参数建议写成 sizeof(结构体), 1 而不是 1, sizeof(结构体)?

这两个参数的顺序是 (ptr, size, nmemb),返回值是"完整元素个数"。建议写 fwrite(&stu, sizeof(Student), 1, fp) 是为了让返回值语义最清晰:

  • 写成 sizeof(Student), 1:返回值为 0 或 1,含义是"成功写了 0/1 个完整对象",一眼看懂,且天然避免"写到一半文件里留下半个结构体"的脏数据更易被发现;
  • 反过来写 fwrite(&stu, 1, sizeof(Student), fp):返回值是"一次性搬了多少个 1-字节元素",即实际字节数。文件被截断时会出现"写了 40 个字节"这种模糊中间态,很难判断是不是完整对象。
// 推荐:一次一个完整对象,返回值只有 0 或 1
size_t n = fwrite(&stu, sizeof(Student), 1, fp);
if (n != 1) { /* 没写完整 */ }

6. 在 Windows 上以文本模式读写包含 \n 的二进制数据会出什么问题?怎么避免?

Windows 文本模式会把写入的 \n(0x0A)自动转换成 \r\n(0x0D 0x0A)写进文件,读取时再反向转换。而二进制数据里字节 0x0A 只是普通数据(比如 int 的某个字节恰好是 0x0A)——文本模式会把它的内容悄悄改掉:

  • 写侧:文件里本应是 0x0A 的地方变成 0x0D 0x0A,多了一个字节,文件字节数对不上,数据被篡改;
  • 读侧:反向转换让读回来的字节又变了。

避免方法:读写二进制数据(数字、结构体、非文本内容)时一律用带 b 的模式,如 "rb"/"wb"/"ab"。b 表示二进制模式,不做任何行尾转换,写入什么就是什么。

FILE *fp = fopen("data.dat", "wb");   // 写二进制必须用 "wb"
int value = 10000;
fwrite(&value, sizeof(int), 1, fp);
fclose(fp);

7. rewind(fp) 和 fseek(fp, 0, SEEK_SET) 有什么区别?哪个会清除错误标志?

  • fseek(fp, 0, SEEK_SET):只把文件位置指针移到开头,不会清除错误标志/EOF 标志;
  • rewind(fp):等价于"fseek(fp, 0, SEEK_SET) + clearerr(fp)",即除了回到开头,还会清除错误标志和 EOF 标志。它返回 void(无返回值)。

所以当你想"回到开头重新读"且希望错误/EOF 状态一并清零时,rewind 更省事、更干净。若只用 fseek 移回开头,之前的 EOF 标志可能还挂着,继续读会行为异常。

// 读完全部内容后想从头再读一遍:
rewind(fp);                  // 既回到开头,又清掉 EOF/错误标志
// 等价于:
// fseek(fp, 0, SEEK_SET);
// clearerr(fp);

8. 为什么日志程序要用 fflush 或直接写 stderr?如果你写了一个记账程序,客户说"断电后账目丢了",你会从哪里开始排查?

  • 为什么用 fflush / 写 stderr:磁盘文件默认是全缓冲,数据先攒在内存缓冲区里,满了才真正写盘。日志/记账这类"必须即时落盘"的写入,如果不清缓冲区就崩溃或断电,内存里那部分数据就丢了。fflush(fp) 强制把缓冲区内容刷到磁盘;而 stderr 默认无缓冲,写进去立即生效,所以 fprintf(stderr, ...) 适合输出即时的错误和日志。

  • 记账程序"断电丢账"排查路线(按可能性从高到低):

    1. 缓冲区没刷:写入是否只是调用了 fprintf/fwrite,但从没 fflush/fclose?断电时缓冲区数据全丢。→ 记账写入后立即 fflush,或改用无缓冲策略;
    2. fclose 返回值没检查:fclose 失败(如磁盘满)时数据可能没写全,但没被发现;
    3. 写盘方式:即使 fflush 到了 C 库,数据可能只到操作系统页缓存,断电依旧有丢失窗口——关键账目应考虑配合"写后校验/顺序写/事务日志/双写"等更强保证;
    4. 先复现,确认到底丢在哪一层(应用缓冲?库缓冲?OS 缓存?)。
// 记账关键写入的稳健做法:写后立即刷新,并检查返回值
FILE *fp = fopen("ledger.txt", "a");
if (fp == NULL) { /* 报错 */ }
fprintf(fp, "入账: %d\n", amount);
fflush(fp);                       // 关键数据写完立刻刷盘
int r = fclose(fp);
if (r != 0) { /* 关闭也出错,要处理 */ }