C 语言文件操作:让数据从内存走向持久化

C 语言文件操作:让数据从内存走向持久化

程序运行时,变量和数组通常保存在内存中。内存访问速度快,但它的内容会随着进程结束而消失。一个通讯录、配置文件或日志程序,如果只把数据放在内存里,关闭程序后所有修改都会丢失。要让数据在下次运行时仍然存在,就需要把它保存到磁盘文件,或者保存到数据库。

文件操作正是 C 语言连接程序和外部存储的基础。程序可以打开文件、读取和写入数据、移动读写位置,并在结束时关闭文件。掌握这些操作后,就能把一次性的内存数据变成可以长期保存、重复读取和持续更新的文件数据。

一、程序文件与数据文件

从用途看,文件大致可以分为两类:

  • 程序文件 :保存程序本身,例如 .c 源文件、编译产生的目标文件,以及可执行文件。
  • 数据文件:保存程序运行过程中需要读取或产生的数据,例如用户信息、成绩记录、配置项和日志。

文件名通常由三部分组成:路径、主文件名和后缀。例如:

text 复制代码
C:\data\contacts.txt

路径决定文件所在位置,主文件名用于区分内容,后缀则常常提示文件的组织形式或用途。实际开发中,建议明确使用绝对路径还是相对路径。相对路径是相对于程序启动时的当前工作目录而言的,并不一定是源代码所在目录。

二、文件为什么需要打开和关闭

C 语言通过 FILE 类型描述一个正在使用的文件。FILE 是标准库内部定义的结构类型,保存文件状态、缓冲区信息以及当前读写位置等细节。程序一般不直接操作这个结构,而是使用 FILE * 指针作为文件句柄:

c 复制代码
#include <stdio.h>

FILE *fp;

打开文件使用 fopen:

c 复制代码
FILE *fopen(const char *filename, const char *mode);

关闭文件使用 fclose:

c 复制代码
int fclose(FILE *stream);

打开可能失败,因此必须检查返回值。失败时 fopen 返回 NULL,可以使用 perror 输出更具体的系统错误:

c 复制代码
FILE *fp = fopen("data.txt", "r");
if (fp == NULL) {
    perror("data.txt");
    return 1;
}

/* 在这里读写 fp */

if (fclose(fp) != 0) {
    perror("fclose");
}
fp = NULL;

关闭文件不仅是释放句柄,也通常会刷新输出缓冲区。只要文件已经成功打开,就应该保证每一条执行路径最终都会调用 fclose。

三、fopen 的模式选择

第二个参数决定文件的访问方式。模式中的 b 表示二进制方式,没有 b 时通常按文本方式处理。

模式 用途 文件不存在时
r 只读打开文本文件 打开失败
w 只写打开文本文件 创建新文件;原内容会被清空
a 只写并追加到文本文件末尾 创建新文件
rb 只读打开二进制文件 打开失败
wb 只写打开二进制文件 创建新文件;原内容会被清空
ab 只写并追加到二进制文件末尾 创建新文件
r+ 读写已有文本文件 打开失败
w+ 读写文本文件 创建新文件;原内容会被清空
a+ 读写文本文件,写入位置在末尾 创建新文件
rb+ 读写已有二进制文件 打开失败
wb+ 读写二进制文件 创建新文件;原内容会被清空
ab+ 读写二进制文件,写入位置在末尾 创建新文件

选择模式时要特别留意 w 和 w+:它们会截断原文件。如果只是想在日志末尾增加内容,应使用 a 或 ab,不要误用 w。

一个最小的写文件例子如下:

c 复制代码
#include <stdio.h>

int main(void)
{
    FILE *fp = fopen("message.txt", "w");
    if (fp == NULL) {
        perror("message.txt");
        return 1;
    }

    fputs("file I/O starts here\n", fp);

    if (fclose(fp) != 0) {
        perror("fclose");
        return 1;
    }
    return 0;
}

四、顺序读写:从当前位置向前处理

顺序读写按照文件指针当前所在位置,一次处理一个字符、一行文本、一个格式化字段或一组二进制对象。常用函数可以分成以下几组:

操作 文本或流 常用函数
字符输入 输入流 fgetc
字符输出 输出流 fputc
行输入 输入流 fgets
行输出 输出流 fputs
格式化输入 输入流 fscanf
格式化输出 输出流 fprintf
二进制输入 文件流 fread
二进制输出 文件流 fwrite

1. 字符和字符串

fputc 写入一个字符,fgetc 读取一个字符。fgets 读取一行或指定数量的字符,fputs 写入一个字符串:

c 复制代码
#include <stdio.h>

int main(void)
{
    FILE *fp = fopen("note.txt", "w");
    if (fp == NULL) {
        return 1;
    }

    fputc('A', fp);
    fputs("\nC language file I/O\n", fp);
    fclose(fp);

    fp = fopen("note.txt", "r");
    if (fp == NULL) {
        return 1;
    }

    char line[128];
    while (fgets(line, sizeof line, fp) != NULL) {
        fputs(line, stdout);
    }

    fclose(fp);
    return 0;
}

使用 fgets 时,缓冲区大小必须传给函数,函数会在空间允许时保留换行符,并自动在末尾补上字符串结束符。读取长度不确定的行时,固定数组仍可能被截断,需要根据业务设计更大的缓冲区或采用动态拼接策略。

2. 格式化读写

fprintf 和 fscanf 的用法分别类似 printf 和 scanf,但数据源或目标变成了文件:

c 复制代码
fprintf(fp, "%s %d\n", "Alice", 95);
fscanf(fp, "%31s %d", name, &score);

格式化输入必须检查返回值,因为输入内容可能缺少字段或格式不符合预期。字符串转换还必须限制最大长度,避免写出数组边界。

可以把三组函数这样理解:

  • scanf、printf 面向标准输入和标准输出。
  • fscanf、fprintf 面向 FILE * 文件流。
  • sscanf、sprintf 面向内存中的字符串。

3. 二进制块读写

fwrite 按字节写入一组对象,fread 按字节读出一组对象:

c 复制代码
size_t written = fwrite(values, sizeof values[0], count, fp);
if (written != count) {
    /* 写入失败或只完成了部分写入 */
}

size_t received = fread(values, sizeof values[0], count, fp);
if (received < count) {
    /* 可能遇到文件尾,也可能发生读取错误 */
}

函数返回实际完成的元素个数,而不是字节数。判断结果时必须使用正确的单位,不能把返回值简单当作布尔值。

五、随机读写:移动文件指针

顺序读取适合从头到尾处理日志或文本;当程序需要直接访问某个位置时,就要移动文件指针。

fseek:按偏移量定位

c 复制代码
int fseek(FILE *stream, long offset, int origin);

origin 常用的基准有:

  • SEEK_SET:从文件开头计算。
  • SEEK_CUR:从当前位置计算。
  • SEEK_END:从文件末尾计算。

下面的例子先写入一段文本,再从开头偏移 9 个字节的位置覆盖内容:

c 复制代码
#include <stdio.h>

int main(void)
{
    FILE *fp = fopen("example.txt", "wb");
    if (fp == NULL) {
        return 1;
    }

    fputs("This is an apple.", fp);
    if (fseek(fp, 9, SEEK_SET) != 0) {
        fclose(fp);
        return 1;
    }
    fputs("sam", fp);

    fclose(fp);
    return 0;
}

二进制文件中,偏移量通常以字节计算。文本模式在不同平台上可能存在换行符转换,因此不要轻易假设任意文本偏移都能跨平台复用。

ftell:获取当前位置

c 复制代码
long ftell(FILE *stream);

它返回当前位置相对于文件起始位置的偏移量。常见做法是先移动到末尾,再调用 ftell 估算文件大小:

c 复制代码
FILE *fp = fopen("data.bin", "rb");
if (fp == NULL) {
    return 1;
}

if (fseek(fp, 0, SEEK_END) != 0) {
    fclose(fp);
    return 1;
}

long size = ftell(fp);
if (size < 0) {
    perror("ftell");
}
fclose(fp);

对于需要严格跨平台的程序,应结合具体文件类型和平台接口选择更合适的文件大小获取方式,不要把 long 能表示的范围当成所有文件都适用。

rewind:回到文件开头

c 复制代码
void rewind(FILE *stream);

rewind 会把文件指针移动到起始位置,并清除相关错误和文件尾状态。它适合在同一个文件上先写入、再从头读取:

c 复制代码
FILE *fp = fopen("letters.txt", "w+");
if (fp == NULL) {
    return 1;
}

for (int ch = 'A'; ch <= 'Z'; ++ch) {
    fputc(ch, fp);
}

rewind(fp);
char buffer[27];
size_t n = fread(buffer, 1, 26, fp);
buffer[n] = '\0';
puts(buffer);

fclose(fp);

六、文本文件与二进制文件

内存中的数据本质上以二进制形式存储。写入磁盘时,如果先把数值转换成可读字符,就是文本文件;如果直接保存内存中的字节,就是二进制文件。

例如整数 10000:

  • 以文本形式保存时,需要保存字符 1、0、0、0、0,通常占 5 个字节。
  • 以二进制形式保存时,常见的 int 可能只占 4 个字节。

文本文件便于人工查看、调试和跨工具交换,例如配置文件和日志。二进制文件体积更紧凑,读写速度通常更高,适合图片、音视频、索引和结构化数据。但二进制数据会受到类型大小、字节序、对齐方式和浮点表示等因素影响,不能简单假定不同平台上的内存布局完全相同。

以二进制方式保存一个整数:

c 复制代码
#include <stdio.h>

int main(void)
{
    int value = 10000;
    FILE *fp = fopen("number.bin", "wb");
    if (fp == NULL) {
        return 1;
    }

    if (fwrite(&value, sizeof value, 1, fp) != 1) {
        perror("fwrite");
    }

    fclose(fp);
    return 0;
}

如果要设计长期保存、跨平台交换的格式,更稳妥的做法是明确字段宽度、字节序和编码,而不是直接把包含指针的结构体整体写入文件。指针保存的是进程地址,在下一次运行或另一台机器上没有意义。

七、正确判断文件是否读取结束

feof 经常被误用。它不是"下一次读取会不会结束"的预判函数,而是用于在读取操作失败或停止后,判断是否确实到达了文件尾。因此,不能写成:

c 复制代码
while (!feof(fp)) {
    /* 读取数据 */
}

这种写法通常会多处理一次无效数据。正确方式是直接检查具体读取函数的返回值。

文本字符:检查 EOF

fgetc 返回 int,而不是 char。这样它才能同时表示所有有效字符和特殊的 EOF 值:

c 复制代码
int ch;
while ((ch = fgetc(fp)) != EOF) {
    putchar(ch);
}

读取停止后,再区分是正常到达文件尾,还是发生了 I/O 错误:

c 复制代码
if (ferror(fp)) {
    puts("I/O error while reading");
} else if (feof(fp)) {
    puts("end of file reached");
}

文本行:检查 NULL

fgets 成功返回传入的缓冲区地址,失败或到达文件尾时返回 NULL:

c 复制代码
while (fgets(line, sizeof line, fp) != NULL) {
    process_line(line);
}

if (ferror(fp)) {
    perror("fgets");
}

二进制块:检查元素个数

fread 返回实际读到的元素数量。请求读取 10 个元素时,返回值小于 10 就表示没有完成整批读取;此时再用 feof 和 ferror 判断具体原因:

c 复制代码
size_t got = fread(items, sizeof items[0], 10, fp);
if (got != 10) {
    if (feof(fp)) {
        puts("file ended before all items were read");
    } else if (ferror(fp)) {
        perror("fread");
    }
}

八、文件缓冲区与 fflush

标准 C I/O 通常使用缓冲区。程序向文件写入时,数据可能先进入内存中的输出缓冲区,达到一定条件后才真正写到磁盘。读取时,系统也可能先把一批数据装入输入缓冲区,再交给程序逐步处理。

缓冲区减少了频繁访问磁盘的次数,但也意味着"调用写函数返回"不一定等于"数据已经落盘"。需要提前让输出可见时,可以调用:

c 复制代码
if (fflush(fp) != 0) {
    perror("fflush");
}

关闭文件时,fclose 通常会自动刷新输出缓冲区,所以正常结束文件操作必须调用 fclose。如果程序异常终止,缓冲区中的内容可能来不及写入;对于真正重要的数据,还需要考虑操作系统和存储设备层面的持久化保证,不能只依赖 C 标准库的刷新。

标准输入输出流也可能有缓冲。交互程序中常见的现象是提示文字没有立即显示,可以在适当位置刷新 stdout:

c 复制代码
printf("Input: ");
fflush(stdout);

不要频繁无目的地刷新缓冲区,否则会削弱缓冲带来的性能优势。应在需要与用户交互、需要观察文件最新内容或即将关闭文件时刷新。

九、一个更稳健的文件复制示例

下面的程序使用块读写复制二进制文件。它把打开、读取、写入和关闭的错误都纳入处理,并且不使用 feof 作为循环条件:

c 复制代码
#include <stdio.h>

int copy_file(const char *source, const char *target)
{
    FILE *in = fopen(source, "rb");
    if (in == NULL) {
        perror(source);
        return 0;
    }

    FILE *out = fopen(target, "wb");
    if (out == NULL) {
        perror(target);
        fclose(in);
        return 0;
    }

    unsigned char buffer[4096];
    int ok = 1;

    for (;;) {
        size_t n = fread(buffer, 1, sizeof buffer, in);
        if (n > 0 && fwrite(buffer, 1, n, out) != n) {
            perror("write");
            ok = 0;
            break;
        }

        if (n < sizeof buffer) {
            if (ferror(in)) {
                perror("read");
                ok = 0;
            }
            break;
        }
    }

    if (fclose(in) != 0) {
        ok = 0;
    }
    if (fclose(out) != 0) {
        ok = 0;
    }
    return ok;
}

这个例子使用 rb 和 wb,因此不会因为文本模式的换行转换而改变二进制内容。循环先处理实际读到的字节,再通过 ferror 判断短读的原因,适合复制任意类型的文件。

十、文件操作中的常见问题

打开文件后没有检查 NULL

文件路径错误、权限不足、文件不存在或资源耗尽,都可能导致打开失败。直接使用空的 FILE * 会造成未定义行为。

用 w 更新已有文件

w 会清空原内容。如果只想增加内容,使用 a;如果要修改某个位置,使用适当的读写模式和 fseek。

把 char 用来接收 fgetc

EOF 需要通过 int 表示。使用 char 接收可能导致某些合法字节和 EOF 混淆。

用 feof 控制读取循环

文件尾状态通常是在一次读取尝试之后才被设置。直接以 !feof(fp) 作为循环条件,容易多处理一次数据。

忘记关闭文件

不关闭文件会造成句柄泄漏,输出缓冲区也可能没有及时写入。每次 fopen 都应对应一次 fclose。

把结构体内存直接当成通用文件格式

结构体可能包含填充字节、指针和平台相关类型。短期同平台程序可以使用二进制写入,但需要长期保存或跨平台交换时,应逐字段序列化。

十一、实用检查清单

写文件代码时,可以按照下面的顺序自检:

  1. 文件路径是否正确,当前工作目录是否符合预期?
  2. fopen 的模式是否会意外清空原文件?
  3. fopen 的返回值是否检查过?
  4. 每个成功打开的文件是否都能走到 fclose?
  5. 文本读取是否检查 EOF 或 NULL?
  6. 二进制读取是否比较了 fread 返回的元素数量?
  7. 是否在读取停止后用 ferror 和 feof 区分原因?
  8. fwrite 是否检查了实际写入的数量?
  9. 是否在需要的时机刷新输出缓冲区?
  10. 二进制格式是否考虑类型大小、字节序和指针不可持久化的问题?

总结

文件操作解决的是数据持久化问题:程序把内存中的信息写到磁盘,下一次运行时再把它读回内存。fopen 和 fclose 管理文件生命周期,fgetc、fgets、fprintf、fread 等函数完成不同粒度的顺序读写,fseek、ftell 和 rewind 支持随机定位。

写出可靠的文件程序,关键不只是记住函数名,还要始终检查返回值,正确判断文件尾和 I/O 错误,理解文本与二进制的差异,并在合适的时候刷新和关闭文件。把这些原则落实到每一次打开、读取、写入和关闭中,文件就能成为稳定可靠的数据存储接口。

相关推荐
大圣编蚕1 小时前
C语言怎么写小游戏?从零开始做一个贪吃蛇
c语言·开发语言
阳光九叶草LXGZXJ1 小时前
达梦数据库-学习-69-DM9主备集群部署
linux·运维·服务器·数据库·sql·学习
jnrjian1 小时前
Connect Microsoft Tools to Oracle Databases powerbi 之类的oracle 客户端
数据库·microsoft·oracle
一条破秋裤1 小时前
TCP 并发服务器:连接分析、多线程与多进程
服务器·网络·tcp/ip
Chen—LSN2 小时前
数据结构——拿捏复杂度
java·c语言·开发语言·数据结构·经验分享·笔记·算法
北京恒星科通刘军2 小时前
灾害监测预警系统中,应急疏散广播的“应急叫应”实际送达率与有效性研究
网络·数据库·人工智能
Chen_harmony2 小时前
六、排序详解
c语言·数据结构·算法·排序算法
ShineWinsu2 小时前
对于Redis:List类型的解析
数据库·c++·redis·链表·缓存·面试·list
问商十三载2 小时前
AI引擎生成式优化服务商怎么选?技术核验清单(附脚本)
数据库·人工智能·机器学习