18、文本文件读写:fgetc/fputc、fgets/fputs、fscanf/fprintf
文本文件读写,说白了就是C语言和磁盘上的「纯文本」打交道的方式。我做了这么多年嵌入式开发,发现很多新手一上来就喜欢用格式化读写,觉得方便。但实际项目中,逐字符和逐行读写才是用得最多的——尤其是在处理日志、配置文件、协议报文这些场景下。
今天咱们就把这三组函数掰开揉碎了讲清楚。你想想看,文件IO说白了就三种粒度:一个字符、一行文本、格式化数据。对应到C标准库,就是fgetc/fputc、fgets/fputs、fscanf/fprintf这三对。
核心结论先放这:
- 逐字符读写:最底层,适合流式处理、协议解析
- 逐行读写:最常用,适合日志、配置、文本处理
- 格式化读写:最方便,但性能最差,容易踩坑
18.1 逐字符读写:fgetc 和 fputc
这两个函数是最基础的。fgetc从文件流中读一个字符,fputc往文件流中写一个字符。返回值是int,不是char——这一点我见过太多人栽跟头了。
#include <stdio.h>
// 逐字符复制文件
int copy_file_char_by_char(const char *src, const char *dst) {
FILE *fp_src = fopen(src, "r");
FILE *fp_dst = fopen(dst, "w");
int ch; // 注意:必须是int,不是char!
if (!fp_src || !fp_dst) {
perror("文件打开失败");
return -1;
}
while ((ch = fgetc(fp_src)) != EOF) {
fputc(ch, fp_dst);
}
fclose(fp_src);
fclose(fp_dst);
return 0;
}
⚠️ 避坑指南:我曾经在解析一个二进制协议时,用char类型接收fgetc的返回值。结果文件里有个0xFF字节,char类型把它当成-1,直接和EOF相等了——循环提前退出,丢了一整段数据。记住:fgetc返回int,EOF是-1,而0xFF在int里是255,不会冲突。
逐字符读写的适用场景其实很窄。我个人习惯只在以下情况用它:
- 需要精确控制每个字符的处理(比如转义字符解析)
- 文件大小很小,或者对内存极度敏感
- 实现自定义的文本解析器(比如我做过的一个简易JSON解析器)
其他时候,逐字符读写效率太低——每次调用都要陷入内核态,磁盘IO次数爆炸。你想想看,一个1MB的文件,逐字符读要调用100万次fgetc,这谁受得了?
18.2 逐行读写:fgets 和 fputs
这才是文本文件读写的「主力军」。fgets读一行,fputs写一行。注意:fgets会读取换行符并保留在缓冲区中,而fputs不会自动追加换行符。
#include <stdio.h>
#include <string.h>
#define LINE_BUF_SIZE 1024
// 逐行读取配置文件,跳过注释和空行
void read_config(const char *filename) {
FILE *fp = fopen(filename, "r");
char line[LINE_BUF_SIZE];
if (!fp) {
perror("打开配置文件失败");
return;
}
while (fgets(line, sizeof(line), fp)) {
// 去掉行尾的换行符
line[strcspn(line, "\n")] = '\0';
// 跳过空行和注释行
if (line[0] == '\0' || line[0] == '#') {
continue;
}
printf("配置项: %s\n", line);
}
fclose(fp);
}
💡 经验之谈:fgets的第二个参数是缓冲区大小,包括结尾的'\0'。也就是说,如果缓冲区是1024字节,最多只能读1023个字符。我建议缓冲区至少设成1024,太小了容易读到不完整的行。我在项目中处理过一些奇葩的日志文件,有的行长达8000字符——这时候要么动态分配缓冲区,要么用getline(POSIX扩展)。
fputs和fgets配对使用。它不会自动加换行符,所以如果你要写一行完整的文本,记得手动加'\n':
fputs("这是一行日志\n", fp); // 手动加换行
fputs("这是另一行\n", fp);
逐行读写的优势很明显:
- 缓冲区可控,不会爆内存
- 天然按行处理,符合人类阅读习惯
- 性能比逐字符好得多(一次读一批)
缺点也有:如果一行超长,fgets会截断,下次调用继续读剩下的——这可能导致逻辑错误。我建议在读取关键数据时,检查一下缓冲区末尾有没有换行符,如果没有,说明行被截断了。
18.3 格式化读写:fscanf 和 fprintf
这两个函数是「瑞士军刀」,但也是「双刃剑」。fprintf写格式化数据很方便,fscanf读格式化数据则是个大坑。
#include <stdio.h>
typedef struct {
int id;
char name[64];
float score;
} Student;
// 写入学生信息
void write_student(FILE *fp, const Student *s) {
fprintf(fp, "%d,%s,%.2f\n", s->id, s->name, s->score);
}
// 读取学生信息(注意:这是有问题的版本)
int read_student_bad(FILE *fp, Student *s) {
return fscanf(fp, "%d,%[^,],%f\n", &s->id, s->name, &s->score);
}
// 读取学生信息(改进版)
int read_student_good(FILE *fp, Student *s) {
int ret = fscanf(fp, "%d,%63[^,],%f\n", &s->id, s->name, &s->score);
if (ret != 3) {
// 解析失败,需要处理错误
return -1;
}
return 0;
}
⚠️ 避坑指南:我曾经在一个数据采集项目中用fscanf读CSV文件,结果某天数据里出现了一个空字段(比如"1,,85.5"),fscanf直接卡死,整个采集程序挂掉了。从那以后,我定了个规矩:生产环境里,能用fgets+sscanf就别直接用fscanf。为什么?因为fscanf遇到格式不匹配时,会停留在错误位置,后续所有读取都乱套了。而fgets先读一行,再用sscanf解析,至少能保证每行独立处理。
fprintf相对安全一些,但也要注意:
- 格式化字符串里不要拼接用户输入(格式化字符串注入攻击)
- 浮点数精度控制要明确,别用默认格式
- 大文件频繁fprintf性能很差,建议先拼好字符串再一次性写入
18.4 三组函数的对比与选择
| 函数 | 粒度 | 性能 | 安全性 | 适用场景 |
|---|---|---|---|---|
| fgetc/fputc | 1字符 | 低 | 高 | 协议解析、字符流处理 |
| fgets/fputs | 1行 | 中 | 高 | 日志、配置、文本处理 |
| fscanf/fprintf | 格式化 | 低 | 低 | 结构化数据读写(慎用) |
我个人在实际项目中的选择策略是这样的:
- 写文件:能用fprintf就用fprintf,方便。但如果性能敏感,我会用snprintf拼好缓冲区,然后一次fwrite写入。
- 读文件:90%的情况用fgets读一行,然后用sscanf或自己写解析器。只有数据格式极其稳定、且我完全控制数据源时,才用fscanf。
- 二进制数据:别用这些函数,用fread/fwrite。
18.5 知识体系总览
下面这张图把文本文件读写的核心逻辑串起来了。你可以看到,三种读写方式各有各的生态位,没有银弹。
18.6 避坑总结
嗯,最后再唠叨几句。文本文件读写看似简单,但坑不少。我这些年踩过的雷,列出来给你参考:
- EOF判断:fgetc返回值用int接收,别用char。EOF是-1,不是0xFF。
- 缓冲区溢出:fgets第二个参数是缓冲区大小,包括'\0'。别写错。
- 换行符处理:Windows是\r\n,Linux是\n。fgets在Windows下会保留\r\n,注意跨平台。
- fscanf失败恢复:一旦fscanf匹配失败,文件指针停在原地,后续所有读取都废了。建议用fgets+sscanf。
- 文件打开模式:文本文件用"r"/"w",别用"rb"/"wb"——后者在Windows下不会做换行符转换。
- 错误检查:fopen、fgets、fscanf的返回值一定要检查。别偷懒。
文本文件读写就这些门道。记住一个原则:能用fgets解决的,就别用fscanf;能用fwrite解决的,就别用fprintf。代码写得稳,晚上睡得香。
公众号:蓝海资料掘金营,微信deep3321