C 文件 I/O
真实程序几乎都要持久化数据——配置、日志、用户数据、二进制资源。C 的文件操作围绕 FILE * 这个"文件指针"和几个核心函数展开。本篇覆盖文本/二进制文件的读写、文件定位、错误处理。理解 C 的文件 I/O,你就摸到了 Unix "一切皆文件"哲学的脉搏。
一、文件操作的基本流程
C 文件操作永远是三步:打开 → 读写 → 关闭。
- fopen:打开文件,返回
FILE *指针。 - 读写:用 fprintf/fgets/fread/fwrite 等函数操作。
- fclose:关闭文件,刷新缓冲区,释放资源。
这三步缺一不可——忘了 fclose,数据可能没刷盘、文件句柄泄漏(系统限制每个进程最多打开的文件数,通常 1024)。
二、写文件
#include <stdio.h>
#include <string.h>
int main(void) {
// 1. 打开文件:w 表示写(覆盖),不存在就创建
FILE *fp = fopen("note.txt", "w");
if (fp == NULL) { // 永远检查!
perror("打开文件失败");
return 1;
}
// 2. 写入:三种方式
fprintf(fp, "第一行\n"); // 格式化写
fputs("第二行\n", fp); // 写字符串
fputc('A', fp); // 写单字符
fputc('\n', fp);
// 3. 关闭(数据会刷盘)
fclose(fp);
printf("写入完成\n");
// 4. 追加写:a 模式(在文件末尾追加,不覆盖)
fp = fopen("note.txt", "a");
if (fp == NULL) return 1;
fprintf(fp, "第三行(追加)\n");
fclose(fp);
return 0;
}三种写函数:
fprintf(fp, ...):格式化写,跟 printf 用法一样,只是多了文件指针参数。fputs(str, fp):写字符串(不带格式)。fputc(c, fp):写单个字符。
w 模式会清空原文件!如果你想保留原内容,用 a(追加)模式。
三、读文件
#include <stdio.h>
int main(void) {
FILE *fp = fopen("note.txt", "r"); // r 表示读
if (fp == NULL) {
perror("打开文件失败");
return 1;
}
// 方式1:fgets 逐行读(推荐,安全)
char line[256];
while (fgets(line, sizeof(line), fp) != NULL) {
printf("%s", line); // line 已包含 \n,不用补
}
// fgets 一次读一行,最多 sizeof-1 个字符
// 返回 NULL 表示读到末尾或出错
// 方式2:fscanf 格式化读
rewind(fp); // 把读写位置移回开头
char word[50];
while (fscanf(fp, "%49s", word) == 1) { // 按空白分隔读单词
printf("[%s] ", word);
}
printf("\n");
// 方式3:fgetc 逐字符读
rewind(fp);
int c;
while ((c = fgetc(fp)) != EOF) { // EOF 是文件结束标志
putchar(c);
}
fclose(fp);
return 0;
}三种读函数:
fgets(buf, n, fp):读一行(最多 n-1 个字符),最安全。fscanf(fp, fmt, ...):格式化读,跟 scanf 类似(同样有溢出风险,要限长度)。fgetc(fp):读单个字符(返回 int,因为 EOF 是 int)。
循环读取的标准模式:while (fgets(...) != NULL) 或 while ((c = fgetc(fp)) != EOF)。不要用 feof 判断循环——它是在读过末尾后才为真,会多读一次。
四、fopen 打开模式详解
// fopen 的打开模式:
// "r" 读(文件必须存在)
// "w" 写(覆盖,文件不存在则创建)
// "a" 追加(在末尾写,文件不存在则创建)
// "r+" 读写(文件必须存在,从开头写会覆盖)
// "w+" 读写(先清空文件,不存在则创建)
// "a+" 读写(追加,可读)
// 加 b 表示二进制模式(推荐所有平台都加,跨平台更安全):
// "rb" "wb" "ab" "rb+" "wb+" "ab+"
// 关键区别:文本模式 vs 二进制模式
// Windows 上文本模式会把 \n 转成 \r\n,二进制不转换
// Linux/macOS 上两者无区别
// 建议:处理文本文件用文本模式,处理二进制(图片/音频/结构体)用二进制模式
// 一个文件打开失败的常见原因:
// - 路径错误(相对路径相对于可执行文件位置)
// - 权限不足
// - 磁盘满(写模式)
// - 文件不存在(只读模式)记住两个最常见的:"r" 读(文件必须存在),"w" 写(覆盖,不存在则创建)。其他模式按需查表。处理二进制文件时,模式加 b(如 "wb"、"rb")——虽然 Linux/macOS 上加不加都一样,但 Windows 上文本模式会自动转换换行符,可能破坏二进制数据。
五、二进制文件读写
文本文件存的是字符,二进制文件存的是原始字节。结构体、图片、音频等非文本数据必须用二进制模式:
#include <stdio.h>
#include <string.h>
typedef struct {
char name[20];
int age;
float score;
} Student;
int main(void) {
// 写二进制:用 fwrite
Student students[] = {
{"小明", 20, 85.5},
{"小红", 22, 92.0},
{"小李", 21, 78.5}
};
int n = sizeof(students) / sizeof(students[0]);
FILE *fp = fopen("students.dat", "wb"); // wb:二进制写
if (fp == NULL) return 1;
// fwrite(数据指针, 单个元素大小, 元素个数, 文件指针)
fwrite(students, sizeof(Student), n, fp);
fclose(fp);
// 读二进制:用 fread
Student loaded[10];
fp = fopen("students.dat", "rb");
if (fp == NULL) return 1;
int count = fread(loaded, sizeof(Student), n, fp);
printf("读取了 %d 条记录\n", count);
for (int i = 0; i < count; i++) {
printf("%s,%d岁,%.1f分\n",
loaded[i].name, loaded[i].age, loaded[i].score);
}
fclose(fp);
return 0;
}两个核心函数:
fwrite(数据指针, 元素大小, 元素个数, fp):把内存数据原样写入文件。fread(缓冲区指针, 元素大小, 元素个数, fp):从文件读取到内存。
用 fwrite/fread 直接读写结构体非常方便,但要注意跨平台兼容性——不同平台的字节序(大小端)、内存对齐可能不同,直接读写 struct 在不同机器上可能读不出来。跨平台数据交换推荐用 JSON/Protocol Buffers。
六、文件定位 fseek / ftell
每个打开的文件都有一个读写位置指针,记录当前读/写到哪了。默认从头顺序往后,你可以用 fseek 任意跳转:
#include <stdio.h>
int main(void) {
FILE *fp = fopen("data.bin", "wb+");
if (fp == NULL) return 1;
// 写入一些数据
for (int i = 0; i < 10; i++) {
fwrite(&i, sizeof(int), 1, fp);
}
// fseek:移动读写位置
// fseek(文件指针, 偏移量, 起点位置)
// 起点位置:
// SEEK_SET - 文件开头
// SEEK_CUR - 当前位置
// SEEK_END - 文件末尾
// 跳到第 5 个 int(偏移 5*4=20 字节)
fseek(fp, 5 * sizeof(int), SEEK_SET);
int value;
fread(&value, sizeof(int), 1, fp);
printf("第5个: %d\n", value); // 5
// ftell:获取当前位置
long pos = ftell(fp);
printf("当前位置: %ld\n", pos); // 24(刚读完第5个)
// 倒回开头
rewind(fp); // 等价于 fseek(fp, 0, SEEK_SET)
fread(&value, sizeof(int), 1, fp);
printf("第一个: %d\n", value); // 0
// 求文件大小:跳到末尾再看位置
fseek(fp, 0, SEEK_END);
long size = ftell(fp);
printf("文件大小: %ld 字节\n", size); // 40
fclose(fp);
return 0;
}fseek(fp, 偏移, 起点):移动位置。起点是SEEK_SET(开头)、SEEK_CUR(当前)、SEEK_END(末尾)。ftell(fp):返回当前位置(字节偏移)。rewind(fp):回到开头,等价于fseek(fp, 0, SEEK_SET)。
经典用法:跳到末尾 + ftell 求文件大小;随机访问数据库索引;实现"分块读取"大文件。
七、错误处理
文件操作有大量失败可能(文件不存在、权限不够、磁盘满),必须检查:
#include <stdio.h>
#include <errno.h> // errno 全局变量
#include <string.h> // strerror
int main(void) {
FILE *fp = fopen("不存在的文件.txt", "r");
if (fp == NULL) {
// 方式1:perror 自动带上错误描述
perror("fopen");
// 输出: fopen: No such file or directory
// 方式2:errno + strerror
printf("错误码: %d\n", errno);
printf("错误信息: %s\n", strerror(errno));
return 1;
}
// 读写过程中的错误检查
int c = fgetc(fp);
if (ferror(fp)) { // 检查是否出错
printf("读取出错\n");
}
if (feof(fp)) { // 检查是否到末尾
printf("读到文件末尾\n");
}
// ferror vs feof
// ferror(fp):返回非0表示读写出错
// feof(fp):返回非0表示到达文件末尾
// 区分:读到末尾是正常,出错是异常
fclose(fp);
return 0;
}- fopen 返回 NULL:打开失败。用
perror或strerror(errno)打印原因。 - ferror(fp):读写出错(区别于正常读到末尾)。
- feof(fp):到达文件末尾。
- errno:全局错误码(在
<errno.h>),perror自动把它转成可读消息。
八、标准流 stdin / stdout / stderr
C 程序启动时自动打开三个文件:
stdin:标准输入(默认键盘),scanf/getchar用的就是它。stdout:标准输出(默认屏幕),printf用的就是它。stderr:标准错误(默认屏幕,但不被缓冲,适合错误信息)。
它们都是 FILE * 类型,可以直接传给文件函数:fprintf(stderr, "错误!");。Linux 的 fprintf(stderr, ...) 默认会立即输出(无缓冲),而 printf 是行缓冲——这就是为什么调试时用 stderr 能及时看到输出。
九、缓冲区(进阶)
为了性能,C 文件 I/O 是缓冲的——读写不直接到磁盘,而是先进内存缓冲区,满了再批量传输。三种缓冲策略:
- 全缓冲(普通文件):缓冲区满(如 4KB)才刷盘。
- 行缓冲(stdout 到终端):遇到换行符刷一次。
- 无缓冲(stderr):每次操作立即输出。
手动刷新用 fflush(fp)(或 fflush(stdout))。注意 fflush(stdin) 行为未定义(虽然有些编译器支持清空输入缓冲)。
十、实战:逐行读取配置文件
把前面的知识组合起来,实现一个最常见的任务——读配置文件:
- 用
fopen("config.txt", "r")打开。 - 用
while (fgets(line, sizeof(line), fp))逐行读。 - 用
strtok或strchr解析 "key=value"。 - 用
fclose关闭。
这是几乎所有 C 程序(Nginx、Redis)启动时做的事。
小结
文件 I/O 三步:fopen → 读写 → fclose。打开模式 r 读、w 写(覆盖)、a 追加、加 b 是二进制。文本读写用 fgets/fprintf,二进制用 fread/fwrite。fseek/ftell 控制位置。永远检查 fopen 是否返回 NULL。下一篇看 C 的标准库速览。
← 上一篇 C 预处理器
下一篇 C 标准库速览 →