C 文件 I/O

真实程序几乎都要持久化数据——配置、日志、用户数据、二进制资源。C 的文件操作围绕 FILE * 这个"文件指针"和几个核心函数展开。本篇覆盖文本/二进制文件的读写、文件定位、错误处理。理解 C 的文件 I/O,你就摸到了 Unix "一切皆文件"哲学的脉搏。

一、文件操作的基本流程

C 文件操作永远是三步:打开 → 读写 → 关闭

这三步缺一不可——忘了 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;
}

三种写函数:

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;
}

三种读函数:

循环读取的标准模式: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/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;
}

经典用法:跳到末尾 + 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;
}

八、标准流 stdin / stdout / stderr

C 程序启动时自动打开三个文件:

它们都是 FILE * 类型,可以直接传给文件函数:fprintf(stderr, "错误!");。Linux 的 fprintf(stderr, ...) 默认会立即输出(无缓冲),而 printf 是行缓冲——这就是为什么调试时用 stderr 能及时看到输出。

九、缓冲区(进阶)

为了性能,C 文件 I/O 是缓冲的——读写不直接到磁盘,而是先进内存缓冲区,满了再批量传输。三种缓冲策略:

手动刷新用 fflush(fp)(或 fflush(stdout))。注意 fflush(stdin) 行为未定义(虽然有些编译器支持清空输入缓冲)。

十、实战:逐行读取配置文件

把前面的知识组合起来,实现一个最常见的任务——读配置文件:

这是几乎所有 C 程序(Nginx、Redis)启动时做的事。

小结

文件 I/O 三步:fopen → 读写 → fclose。打开模式 r 读、w 写(覆盖)、a 追加、加 b 是二进制。文本读写用 fgets/fprintf,二进制用 fread/fwrite。fseek/ftell 控制位置。永远检查 fopen 是否返回 NULL。下一篇看 C 的标准库速览。

← 上一篇 C 预处理器

下一篇 C 标准库速览

✈️💬