第二十八天:文件读写操作——fgetc, fputc, fgets, fputs, fscanf, fprintf, fread, fwrite

文件操作,说白了就是让程序跟硬盘上的数据打交道。我刚开始学C语言那会儿,总觉得文件操作可有可无,直到第一次做嵌入式项目,需要把传感器数据存到SD卡里……嗯,从那以后我再也不敢小看这八个函数了。

今天咱们就把这八个函数掰开揉碎了讲清楚。它们分成三组:字符级、字符串级、格式化级、二进制级。每一组都有各自的适用场景。

C语言文件读写函数体系 文件读写操作 字符级 fgetc / fputc 字符串级 fgets / fputs 格式化级 fscanf / fprintf 二进制级 fread / fwrite 从字符到二进制,逐层深入

1. 字符级读写:fgetc 和 fputc

这两个函数是最基础的。一次只读写一个字符。你想想看,如果文件很大,一个一个字符读确实慢,但有些场景非它们不可。

核心区别:

  • fputc(ch, fp):把一个字符 ch 写入文件指针 fp 指向的文件
  • fgetc(fp):从文件指针 fp 指向的文件读一个字符,返回 int 类型

为什么 fgetc 返回 int 而不是 char?这个问题我当年也困惑过。原因很简单:文件结束符 EOF 是 -1,而 char 类型可能是无符号的,没法表示 -1。所以用 int 来接收,才能正确判断是否读到文件尾。

// 用 fgetc 和 fputc 实现文件拷贝
#include <stdio.h>

int main() {
    FILE *src = fopen("source.txt", "r");
    FILE *dst = fopen("dest.txt", "w");
    
    if (src == NULL || dst == NULL) {
        printf("文件打开失败\n");
        return 1;
    }
    
    int ch;  // 注意是 int,不是 char
    while ((ch = fgetc(src)) != EOF) {
        fputc(ch, dst);
    }
    
    fclose(src);
    fclose(dst);
    return 0;
}

我的经验:在嵌入式项目中,我经常用 fgetc 逐字符解析配置文件。因为配置文件通常不大,而且需要逐字符判断注释符号 '#' 或 ';'。用 fgets 读整行反而不好处理。

2. 字符串级读写:fgets 和 fputs

fgets 和 fputs 是处理文本行的利器。我个人习惯用 fgets 来读配置文件,因为它能自动处理缓冲区大小,防止溢出。

函数 原型 说明
fgets char *fgets(char *s, int size, FILE *stream) 最多读 size-1 个字符,遇到换行或 EOF 停止,自动加 '\0'
fputs int fputs(const char *s, FILE *stream) 写入字符串 s,不自动加换行符

注意:fgets 会保留换行符 '\n',而 fputs 不会自动加换行符。我曾经在项目中因为忘记处理 fgets 读到的换行符,导致字符串比较一直失败,排查了半天才发现问题。

// 用 fgets 和 fputs 逐行读写文件
#include <stdio.h>
#include <string.h>

int main() {
    FILE *fp = fopen("log.txt", "r");
    if (fp == NULL) {
        perror("打开文件失败");
        return 1;
    }
    
    char line[256];
    while (fgets(line, sizeof(line), fp) != NULL) {
        // 去掉行尾的换行符
        size_t len = strlen(line);
        if (len > 0 && line[len-1] == '\n') {
            line[len-1] = '\0';
        }
        printf("读取到: %s\n", line);
    }
    
    fclose(fp);
    return 0;
}

3. 格式化读写:fscanf 和 fprintf

这两个函数跟 scanf/printf 用法几乎一样,只是多了一个文件指针参数。说白了,就是把键盘输入换成文件输入,把屏幕输出换成文件输出。

我在项目中用 fprintf 写日志文件特别多。比如记录传感器数据时,用格式化输出可以很方便地控制数据对齐和精度。

// 用 fprintf 写结构化数据
#include <stdio.h>

typedef struct {
    int id;
    char name[32];
    float score;
} Student;

int main() {
    FILE *fp = fopen("students.txt", "w");
    if (fp == NULL) return 1;
    
    Student stu = {1001, "张三", 95.5};
    fprintf(fp, "%d,%s,%.1f\n", stu.id, stu.name, stu.score);
    
    fclose(fp);
    
    // 用 fscanf 读回来
    fp = fopen("students.txt", "r");
    if (fp == NULL) return 1;
    
    Student read_stu;
    fscanf(fp, "%d,%[^,],%f", &read_stu.id, read_stu.name, &read_stu.score);
    printf("读回: %d %s %.1f\n", read_stu.id, read_stu.name, read_stu.score);
    
    fclose(fp);
    return 0;
}

避坑指南:fscanf 遇到空格或换行就会停止读取。如果字段里包含空格,用 %[^,] 这种格式来指定分隔符。我曾经用 fscanf 读 CSV 文件时,因为没处理好逗号分隔,数据全乱了。

4. 二进制读写:fread 和 fwrite

fread 和 fwrite 是真正的大杀器。它们直接读写内存块,不关心数据格式。在嵌入式开发中,我经常用它们来读写结构体、数组、甚至整个配置块。

函数 原型 说明
fwrite size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream) 从 ptr 写入 nmemb 个大小为 size 的数据块
fread size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream) 从文件读 nmemb 个大小为 size 的数据块到 ptr

你想想看,用 fprintf 写一个结构体,每个字段都要格式化,读回来还要解析。用 fwrite 直接写内存映像,一个函数调用就搞定了。效率高得多。

// 用 fwrite 和 fread 读写结构体数组
#include <stdio.h>

#define MAX_STUDENTS 100

typedef struct {
    int id;
    char name[32];
    float scores[3];
} Student;

int main() {
    Student students[MAX_STUDENTS];
    // 假设已经填充了数据...
    
    FILE *fp = fopen("students.dat", "wb");
    if (fp == NULL) return 1;
    
    // 一次性写入整个数组
    size_t written = fwrite(students, sizeof(Student), MAX_STUDENTS, fp);
    printf("成功写入 %zu 个学生\n", written);
    
    fclose(fp);
    
    // 读回来
    Student read_back[MAX_STUDENTS];
    fp = fopen("students.dat", "rb");
    if (fp == NULL) return 1;
    
    size_t read = fread(read_back, sizeof(Student), MAX_STUDENTS, fp);
    printf("成功读取 %zu 个学生\n", read);
    
    fclose(fp);
    return 0;
}

重要提醒:二进制文件在不同平台间移植时要注意字节序(大小端)和结构体对齐问题。我在做跨平台项目时,就遇到过 ARM 和 x86 之间结构体对齐不一致导致数据错乱的情况。解决办法是手动序列化,或者用 __attribute__((packed)) 强制紧凑对齐。

5. 如何选择?一张表说清楚

场景 推荐函数 原因
逐字符解析(如解析注释) fgetc / fputc 灵活控制,逐字符判断
逐行读取配置文件 fgets 安全,自动处理缓冲区
写日志、格式化输出 fprintf 方便控制格式和对齐
读写结构化数据(结构体、数组) fread / fwrite 效率高,直接操作内存
解析 CSV 等分隔符文件 fscanf 格式化解析,配合 %[^,] 使用

6. 文件打开模式的小结

不管用哪个读写函数,第一步都是 fopen。打开模式选错了,后面全白搭。我整理了一份速查表:

模式 含义 文件不存在 文件存在
"r" 只读文本 返回 NULL 正常打开
"w" 只写文本 创建新文件 清空内容
"a" 追加文本 创建新文件 追加到末尾
"rb" 只读二进制 返回 NULL 正常打开
"wb" 只写二进制 创建新文件 清空内容
"ab" 追加二进制 创建新文件 追加到末尾

我的习惯:在嵌入式开发中,我几乎只用二进制模式 "rb" 和 "wb"。因为文本模式在 Windows 下会把 '\n' 转成 '\r\n',导致读写不一致。用二进制模式可以避免这种平台差异。

7. 别忘了 fclose

最后说一句:打开的文件一定要关闭。fclose 不仅释放资源,还会刷新缓冲区。我见过太多人写代码只 fopen 不 fclose,结果程序跑着跑着文件句柄耗尽,系统崩溃。

嗯,文件读写就讲到这里。这八个函数你掌握了,日常开发中 90% 的文件操作场景都能应付。剩下的 10%,无非是加一些错误处理和性能优化,那些咱们后面再聊。


公众号:蓝海资料掘金营,微信deep3321