Appearance
文件操作
⏱️ 本讲 L1 正课约 90 分钟,建议拆成两段:打开关闭与顺序读写一段,随机读写与缓冲区一段。
🎯 学习目标
学完本讲,你应当能够:
- 区分程序文件与数据文件、文本文件与二进制文件。
- 用 fopen / fclose 正确打开关闭文件,并按返回值处理失败。
- 熟练使用四组顺序读写函数:fgetc/fputc、fgets/fputs、fscanf/fprintf、fread/fwrite。
- 正确判断读取结束的原因(文件末尾 vs 读错误),不再误用 feof。
- 用 fseek、ftell、rewind 完成随机读写。
- 解释文件缓冲区的作用,知道何时需要 fflush。
🔗 先修知识
自检三问:
- 为什么需要
void *?它和char *有什么不同? - 结构体变量的地址和它第一个成员的地址是什么关系?
- 一个字节最多能表示多少种状态?
一、为什么使用文件
没有文件时,程序的数据只活在内存里:程序一退出,内存回收,上次输入的数据、算出的结果全部消失。文件的作用就是把数据放到磁盘上持久化——这次写入,下次启动再读回来。配置、存档、日志,靠的都是文件。
二、什么是文件
从功能上分两种:
- 程序文件:源文件
.c、目标文件(Windows.obj/ Linux.o)、可执行文件.exe。 - 数据文件:内容是程序运行时读写的数据。本讲讨论的是数据文件。
文件名由三部分组成唯一标识:路径 + 文件主干 + 后缀,例如 c:\code\test.txt。
三、二进制文件与文本文件
数据在内存中本来就是二进制。原样输出到磁盘、不做转换的,是二进制文件;要求在磁盘上以字符(ASCII/编码)形式存储、存储前做转换的,是文本文件。
以整数 10000 为例:文本方式存为 5 个字符 '1' '0' '0' '0' '0',占 5 字节;二进制方式存它的 int 位模式,占 4 字节。
📄 01_binary_write.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
int a = 10000;
FILE *pf = fopen("num.bin", "wb"); /* b = binary */
if (pf == NULL)
{
perror("fopen");
return 1;
}
fwrite(&a, sizeof a, 1, pf);
fclose(pf);
pf = NULL;
return 0;
}用十六进制查看器打开 num.bin,会看到 10 27 00 00(10000 = 0x2710,小端排列),而不是字符。
四、文件的打开与关闭
4.1 流与标准流
程序可能要和键盘、屏幕、文件、网络等各种设备交换数据,设备操作各不相同。C 把这些交互统一抽象成流:可以想象成一条流淌着字符的河,程序往里写或从中读。
通常流要先打开再使用,但三个标准流在程序启动时自动打开:
| 流 | 含义 |
|---|---|
stdin | 标准输入(一般是键盘),scanf 从这里读 |
stdout | 标准输出(一般是屏幕),printf 往这里写 |
stderr | 标准错误(一般是屏幕),专门用来报错 |
三者类型都是 FILE *。
4.2 文件指针
每个打开的文件,系统都会在内存中建一个文件信息区:文件名、状态、当前读写位置等,存放在一个结构体里,类型名为 FILE。不同编译器的 FILE 成员不同,例如 MSVC 风格的声明大致为:
c
struct _iobuf
{
char *_ptr;
int _cnt;
char *_base;
int _flag;
int _file;
/* ... */
};
typedef struct _iobuf FILE;我们不直接操作 FILE 的成员,而是通过 FILE * 指针使用它——系统根据文件信息区维护实际读写。
4.3 fopen 与 fclose
🧩 函数原型(示意片段)
c
FILE *fopen(const char *filename, const char *mode);
int fclose(FILE *stream);fopen 成功返回 FILE 指针,失败返回 NULL;fclose 成功返回 0,失败返回 EOF。
常用 mode:
| mode | 含义 | 文件不存在时 |
|---|---|---|
"r" | 只读文本 | 失败 |
"w" | 只写文本(清空已有内容) | 新建 |
"a" | 追加文本(写到末尾) | 新建 |
"rb"/"wb"/"ab" | 对应的二进制版本 | 同上 |
"r+" | 可读可写文本 | 失败 |
"w+" | 可读可写文本(清空) | 新建 |
"a+" | 可读可写,写位置在末尾 | 新建 |
"rb+"/"wb+"/"ab+" | 对应的二进制版本 | 同上 |
标准打开模板(以读取一个尚不存在的文件为例,程序走失败分支):
📄 02_open_template.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
FILE *fp = fopen("maybe_exist.txt", "r");
if (fp == NULL)
{
perror("fopen"); /* 打印:fopen: No such file or directory */
return 1;
}
/* 此处进行读写 */
fclose(fp);
fp = NULL;
return 0;
}📜 标准卡(L2 选读)
- 文件操作整体见 C11 第 7.21 节(
<stdio.h>)。 - fopen/fclose 见 C11 §7.21.5。
- EOF 是负值整型宏(C11 §7.21.1),通常展开为 −1,用来表示字符读取的结束/出错。
五、文件的顺序读写
| 函数 | 功能 | 适用 |
|---|---|---|
fgetc | 读一个字符 | 所有输入流 |
fputc | 写一个字符 | 所有输出流 |
fgets | 读字符串(一行) | 所有输入流 |
fputs | 写字符串 | 所有输出流 |
fscanf | 格式化读 | 所有输入流 |
fprintf | 格式化写 | 所有输出流 |
fread | 二进制读块 | 文件流 |
fwrite | 二进制写块 | 文件流 |
“所有流”意味着这些函数既能用于文件,也能直接用于 stdin/stdout。
5.1 fputc / fgetc
📄 03_char_rw.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
FILE *fp = fopen("alphabet.txt", "w");
if (fp == NULL) { perror("fopen w"); return 1; }
for (int ch = 'a'; ch <= 'z'; ch++)
fputc(ch, fp); /* 写入后位置自动后移 */
fclose(fp);
fp = NULL;
fp = fopen("alphabet.txt", "r");
if (fp == NULL) { perror("fopen r"); return 1; }
int c;
while ((c = fgetc(fp)) != EOF)
fputc(c, stdout); /* 同一份字符写到屏幕 */
printf("\n");
fclose(fp);
fp = NULL;
return 0;
}输出一行 abcdefghijklmnopqrstuvwxyz。fputc 成功返回写入字符,失败返回 EOF。
5.2 读取结束的判定:feof / ferror
fgetc 读到 EOF 只表示“读不到了”,原因可能是两种:正常到末尾,或读取出错。要读之后再区分:
feof(fp):是否遇到了文件末尾。ferror(fp):是否发生了读写错误。
📄 04_error_check.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
FILE *fp = fopen("only_write.txt", "w"); /* 只写方式 */
if (fp == NULL) return 1;
int c = fgetc(fp); /* 对只写流做读取 → 错误 */
if (c == EOF)
{
if (ferror(fp))
printf("读取发生错误\n");
else if (feof(fp))
printf("遇到文件末尾\n");
}
fclose(fp);
fp = NULL;
return 0;
}输出 读取发生错误。注意:不能用 feof 当循环条件——它不会预判,只有真的读过 EOF 后才变真,拿它做 while 条件会多处理一轮无效数据。
5.3 fputs / fgets
📄 05_string_rw.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
FILE *fp = fopen("lines.txt", "w");
if (fp == NULL) return 1;
fputs("Hello World\n", fp); /* fputs 不自动补换行,自己写 \n */
fputs("Goodbye\n", fp);
fclose(fp);
fp = NULL;
fp = fopen("lines.txt", "r");
if (fp == NULL) return 1;
char buf[100];
while (fgets(buf, sizeof buf, fp) != NULL)
printf("读到:%s", buf); /* buf 里带着行尾的 \n */
fclose(fp);
fp = NULL;
return 0;
}输出:
text
读到:Hello World
读到:Goodbyefgets 的 num 参数含结尾 '\0',所以最多实际读 num−1 个字符;遇到换行就把 \n 一起读入并结束;文件末尾无换行也能正常读到。
5.4 fprintf / fscanf
📄 06_format_rw.c · ✅ 完整程序(可直接复制编译)
c
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
struct Stu
{
char name[30];
int age;
float score;
};
int main(void)
{
struct Stu s1 = {"zhangsan", 20, 85.5f};
FILE *fp = fopen("stu.txt", "w");
if (fp == NULL) return 1;
fprintf(fp, "%s %d %.1f", s1.name, s1.age, s1.score);
fclose(fp);
fp = NULL;
struct Stu s2 = {0};
fp = fopen("stu.txt", "r");
if (fp == NULL) return 1;
fscanf(fp, "%s %d %f", s2.name, &s2.age, &s2.score);
printf("读回:%s %d %.1f\n", s2.name, s2.age, s2.score);
fclose(fp);
fp = NULL;
return 0;
}输出 读回:zhangsan 20 85.5。fscanf 返回成功匹配的项数,可用来判断文件格式是否正确。
5.5 fwrite / fread
📄 07_block_rw.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
int data[5] = {1, 2, 3, 4, 5};
FILE *fp = fopen("data.bin", "wb");
if (fp == NULL) return 1;
size_t n = fwrite(data, sizeof(int), 5, fp);
printf("写入 %zu 项\n", n);
fclose(fp);
fp = NULL;
int back[5] = {0};
fp = fopen("data.bin", "rb");
if (fp == NULL) return 1;
size_t m = fread(back, sizeof(int), 5, fp);
printf("读回 %zu 项:", m);
for (size_t i = 0; i < m; i++)
printf("%d ", back[i]);
printf("\n");
fclose(fp);
fp = NULL;
return 0;
}fread/fwrite 的参数顺序统一是:数据地址、每项大小、项数、流。fread 返回实际读到的项数;少于请求值时,配合 feof/ferror 判断原因。
5.6 sprintf / sscanf:以字符串为目标和来源
📄 08_sprintf_sscanf.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
struct Stu
{
char name[30];
int age;
float score;
};
int main(void)
{
struct Stu s1 = {"zhangsan", 100, 85.5f};
char buf[100] = {0};
sprintf(buf, "%s %d %.1f", s1.name, s1.age, s1.score);
printf("字符串:%s\n", buf);
struct Stu s2 = {0};
sscanf(buf, "%s %d %f", s2.name, &s2.age, &s2.score);
printf("解析:%s %d %.1f\n", s2.name, s2.age, s2.score);
return 0;
}输出:
text
字符串:zhangsan 100 85.5
解析:zhangsan 100 85.5六个函数的分工要记牢:scanf/printf 对终端,fscanf/fprintf 对任意流,sscanf/sprintf 对字符串。工程上 sprintf 有越界风险,可用 snprintf(buf, sizeof buf, ...) 限定长度。
六、文件的随机读写
6.1 fseek
🧩 函数原型(示意片段)
c
int fseek(FILE *stream, long offset, int origin);定位基准 origin 有三个:SEEK_SET(开头)、SEEK_CUR(当前位置)、SEEK_END(末尾)。
📄 09_fseek_edit.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
FILE *fp = fopen("edit.txt", "wb");
if (fp == NULL) return 1;
fputs("This is an apple.", fp);
fseek(fp, 9, SEEK_SET); /* 偏移 9,落在 "an" 的 'n' 上 */
fputs(" sam", fp);
fclose(fp);
fp = NULL;
fp = fopen("edit.txt", "r");
if (fp == NULL) return 1;
char buf[64];
fgets(buf, sizeof buf, fp);
printf("%s\n", buf);
fclose(fp);
fp = NULL;
return 0;
}输出 This is a sample.。注意结果不是在末尾追加:fseek 把写位置移到偏移 9(原句的 n), sam 覆盖了 n、空格和 ap,而 ple. 仍然保留,拼起来恰好是一句话。
6.2 ftell
返回当前位置相对开头的字节偏移,失败返回 −1。下例自建文件后测量自身大小:
📄 10_ftell_size.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
long file_size_of(const char *name)
{
FILE *fp = fopen(name, "rb");
if (fp == NULL) return -1;
fseek(fp, 0, SEEK_END);
long size = ftell(fp);
fclose(fp);
return size;
}
int main(void)
{
FILE *fp = fopen("eight.txt", "wb");
if (fp == NULL) return 1;
fputs("hello123", fp); /* 8 字节 */
fclose(fp);
printf("大小:%ld 字节\n", file_size_of("eight.txt"));
return 0;
}输出 大小:8 字节。
6.3 rewind
让位置回到开头,并清除 EOF 标志和错误标志(单纯的 fseek(fp,0,SEEK_SET) 不清标志)。
📄 11_rewind_demo.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
FILE *fp = fopen("az.txt", "w+");
if (fp == NULL) return 1;
for (int ch = 'A'; ch <= 'Z'; ch++)
fputc(ch, fp);
rewind(fp); /* 回到起点 */
char buffer[27] = {0};
size_t n = fread(buffer, 1, 26, fp);
printf("读回 %zu 个字符:%s\n", n, buffer);
fclose(fp);
fp = NULL;
return 0;
}输出 读回 26 个字符:ABCDEFGHIJKLMNOPQRSTUVWXYZ。
📜 标准卡(L2 选读)
- fseek/ftell/rewind 见 C11 §7.21.9。
- 文本流的 fseek 只保证两种用法:偏移 0 相对 SEEK_SET,或偏移使用 ftell 的返回值;任意数值定位请用二进制流。
七、文件缓冲区
ANSI C 采用缓冲文件系统:系统为每个打开的文件在内存中开辟缓冲区。写数据先进缓冲区,攒满后一次性写到磁盘;读数据则先填满缓冲区,程序再从缓冲区取。这样显著减少慢速磁盘操作的次数。
📄 12_flush.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
FILE *fp = fopen("flush.txt", "w");
if (fp == NULL) return 1;
fputs("abcdef", fp); /* 此刻数据可能还在缓冲区 */
fflush(fp); /* 强制立即写入磁盘 */
fclose(fp); /* fclose 本身也会刷新 */
fp = NULL;
return 0;
}fflush 对输出流有标准保证;对输入流的行为不由标准规定(所以常见的 fflush(stdin) 是不可移植的写法,附录 B 已标注)。程序正常退出或 fclose 会自动刷新,但异常崩溃时缓冲区里未落盘的数据会丢失。
想亲眼观察缓冲效果,可以在写文件后、fflush 前用资源管理器打开文件查看;配合 Sleep()(需 <windows.h>,属于 Windows 扩展)能留出观察时间。
八、更新文件:r+、w+、a+ 的纪律
在"读写两用"的流上,读和写直接切换需要先定位/刷新:
- 写完要读:先
fflush,再fseek/rewind定位。 - 读完要写:先
fseek/rewind定位(写位置在末尾的追加流除外)。
📄 13_update_demo.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
FILE *fp = fopen("upd.txt", "w+");
if (fp == NULL) return 1;
fputs("abcdefghi", fp);
fflush(fp); /* 写完刷新 */
fseek(fp, 1, SEEK_SET); /* 定位到 'b' */
int ch = fgetc(fp); /* 读 → b */
printf("读到:%c\n", ch);
fseek(fp, -1, SEEK_CUR); /* fgetc 后位置后移,退回 1 格 */
fputs("hello", fp); /* 覆盖 */
fclose(fp);
fp = NULL;
fp = fopen("upd.txt", "r");
char buf[32];
fgets(buf, sizeof buf, fp);
printf("最终:%s\n", buf);
fclose(fp);
return 0;
}输出:
text
读到:b
最终:ahellofghi⚠️ 常见坑与报错表
| 坑 | 现象 | 对策 |
|---|---|---|
| fopen 后不判空 | 文件不存在时对 NULL 操作崩溃 | 固定判空 + perror |
用 "w" 打开重要文件 | 内容被立刻清空 | 改已有内容用 r+,先备份 |
| 用 feof 当循环条件 | 多处理一轮,打印乱码/重复 | 以读取函数返回值为循环条件 |
| fgets 的 num 当可读数 | 实际只读 num−1 | 缓冲区开大,留意 \0 |
| fwrite/fread 参数顺序写反 | 读写数量异常 | 地址、项大小、项数、流 |
| 文本流里随意 fseek 数值 | 行为不保证 | 用二进制流或只用合法两种定位 |
| fflush(stdin) 清输入 | 不可移植 | 用循环读走剩余字符 |
| 读写直接切换不定位 | 读写位置错乱 | fflush + fseek/rewind |
🛠️ 动手练习
📊 本讲网页练习进度0 / 8(0%)
进度自动保存在本浏览器;编程题不计数,请在编辑器中完成。
第 1 题 · 知识点:打开方式 · 难度:⭐
用 "r" 打开一个不存在的文件,结果是?
第 2 题 · 知识点:feof 语义 · 难度:⭐
第 3 题 · 知识点:fgets 容量 · 难度:⭐
fgets(buf, 100, fp) 最多实际读入多少个非结尾字符?(填数字)
空①
第 4 题 · 知识点:函数适用范围 · 难度:⭐⭐
下列哪些函数可以用于标准输入流 stdin?
第 5 题 · 知识点:ftell · 难度:⭐⭐
把文件指针定位到末尾后调用 ftell,得到 8,文件大小是多少字节?(填数字)
空①
第 6 题 · 知识点:文本/二进制 · 难度:⭐⭐
整数 10000 以文本和二进制形式存储,分别约占多少字节?
第 7 题 · 知识点:更新模式 · 难度:⭐⭐
希望保留已有内容、打开一个可能不存在的文件,并在末尾追加数据,应选?
第 8 题 · 知识点:综合实践 · 难度:⭐⭐⭐
写一个文件拷贝函数:把源文件完整复制到目标文件,文本和二进制文件都要能正确拷贝。
🛠 动手编程题 · 请在 VS2026(或你的编辑器)中完成
独立完成后再对照参考答案。
✅ 过关标准:对一个文本文件和一个二进制文件(如图片、exe)各拷贝一次,拷贝与原文件字节一致;源文件打不开时返回 −1 而不崩溃。
💡 思路
统一用二进制模式("rb"/"wb")拷贝,避免文本模式对换行等字节做转换。
🧭 步骤
- 以 rb 打开源文件;失败返回 −1。
- 以 wb 打开目标文件;失败关闭源文件后返回 −1。
- 循环 fgetc/fputc(或 fread/fwrite 分块),结束后关闭两个流。
🔑 参考答案与解释
c
#include <stdio.h>
int copy_file(const char *src, const char *dst)
{
FILE *fin = fopen(src, "rb");
if (fin == NULL) return -1;
FILE *fout = fopen(dst, "wb");
if (fout == NULL)
{
fclose(fin);
return -1;
}
int c;
while ((c = fgetc(fin)) != EOF)
{
if (fputc(c, fout) == EOF)
{
fclose(fin);
fclose(fout);
return -1;
}
}
fclose(fin);
fclose(fout);
return 0;
}
int main(void)
{
if (copy_file("data.bin", "data_copy.bin") == 0)
printf("拷贝成功\n");
else
printf("拷贝失败\n");
return 0;
}大文件拷贝建议改用 fread/fwrite 分块(如每次 4 KB),逐字节版本用于理解原理。
网页内无法练习写代码,亲手敲、亲手编译才能真正学会。下方按顺序展开 思路 → 步骤 → 答案。
📝 小结与自测
本讲主线:文件是持久化数据的手段,所有操作围绕"打开→读写→关闭"。
- 两个视角:程序/数据文件,文本/二进制文件。
- 打开关闭:fopen 的 mode 决定读/写/追加与文本/二进制;必判空;fclose 会刷新。
- 八组函数:字符、字符串、格式化、数据块,分别覆盖所有流或文件流。
- 结束判定:看读取函数返回值,再用 feof/ferror 区分末尾与错误。
- 随机读写:fseek 定位、ftell 报偏移、rewind 回头。
- 缓冲区:攒批写盘提高效率,需要立即落盘时 fflush(只对输出流保证)。
自测三问:
- 为什么逐字节拷贝也要用 "rb"/"wb" 而不是 "r"/"w"?
- fgetc 返回 EOF 时,怎样确定是到了末尾还是出错?
- w 和 a 都能新建文件,它们的关键区别是什么?
📖 点击查看答案
- 文本模式可能转换字节(Windows 上换行符、Ctrl+Z 等),拷贝二进制文件会被改坏;二进制模式逐字节原样处理,文本文件也不会丢内容。
- 然后分别检查 feof(非 0 即正常到末尾)和 ferror(非 0 即出错)。
- w 会先清空已存在文件的全部内容再从头写;a 保留原内容,所有写入追加到末尾。
🔬 选学拓展(L3)
WARNING
以下内容不影响本讲目标,数值均为 gcc 16(64 位 Windows)实测。
拓展 1:文本模式到底偷偷改了什么字节
📄 L3_text_mode.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
FILE *t = fopen("mode_t.txt", "w"); /* 文本模式 */
FILE *b = fopen("mode_b.bin", "wb"); /* 二进制模式 */
if (t == NULL || b == NULL) return 1;
fputs("a\nb", t);
fputs("a\nb", b);
fclose(t);
fclose(b);
for (int k = 0; k < 2; k++)
{
const char *name = (k == 0) ? "mode_t.txt" : "mode_b.bin";
FILE *fp = fopen(name, "rb");
printf("%s:", name);
int c;
while ((c = fgetc(fp)) != EOF)
printf("%02x ", c);
printf("\n");
fclose(fp);
}
return 0;
}实测输出:
text
mode_t.txt:61 0d 0a 62
mode_b.bin:61 0a 62文本模式把 \n(0a)写成了 Windows 的 \r\n(0d 0a)。读回时再反向转换——这就是跨平台看文件大小、行数时常对不上的原因。
拓展 2:不刷新也不关文件,数据真的会留在缓冲区吗
在写文件后立刻让程序异常终止(如 _exit 或直接崩溃),缓冲区里的内容可能丢;正常调用 fclose 或从 main 返回则会自动 flush。结论:写完关键数据,要么 fflush 强制落盘,要么确保文件被关闭。
拓展 3:setvbuf:自定义缓冲区
setvbuf(fp, buf, _IOFBF, size) 可以在打开文件后自己指定缓冲区与缓冲模式(全缓冲/行缓冲/无缓冲)。想让日志“每行立即落盘”,可用行缓冲或无缓冲模式;网络报文、硬件串口等场景也常用无缓冲。