1. C库文件IO.md 13 KB

1 C库文件IO

1.1 基本概念

C标准库(libc)提供了一套带缓冲的文件操作函数,封装了底层的系统调用,提供了跨平台的文件读写能力。这套函数以 FILE*(文件流指针)为核心抽象,由库内部维护缓冲区,减少系统调用次数以提高效率。

C库IO与系统调用IO的关系:

用户程序
   ↓
C标准库(fopen/fread/fwrite/fclose)  ← 带缓冲,跨平台
   ↓ 内部最终调用
系统调用(open/read/write/close)     ← 无缓冲,Linux专属
   ↓
内核 VFS
   ↓
文件系统 / 设备驱动

为什么需要C库IO:

  • 系统调用每次都要陷入内核,开销较大(上下文切换)
  • C库在用户空间维护缓冲区,攒够一批数据再统一调用系统调用,减少切换次数
  • C库提供格式化读写(fprintf/fscanf),系统调用只处理字节流

FILE结构体(核心抽象):

FILE 结构体是C库内部维护的一个结构,包含:

  • 文件描述符(底层fd)
  • 缓冲区指针和大小
  • 缓冲区当前读写位置
  • 错误标志和EOF标志
  • 读写模式标志

用户不需要关心FILE内部细节,只需要通过 FILE* 指针操作即可。

1.2 核心API详解

1.2.1 fopen — 打开文件

#include <stdio.h>

FILE *fopen(const char *pathname, const char *mode);

参数:

  • pathname:文件路径(相对路径或绝对路径)
  • mode:打开模式字符串

返回值:

  • 成功:返回 FILE* 指针
  • 失败:返回 NULL,同时设置 errno

打开模式详解:

模式 含义 文件不存在 文件已存在 从哪里开始写
"r" 只读 出错 从头读
"r+" 读写 出错 从头读写
"w" 只写(截断) 创建 清空 文件头
"w+" 读写(截断) 创建 清空 文件头
"a" 追加写 创建 从末尾写 文件末尾
"a+" 读+追加写 创建 读从头,写从末尾 文件末尾

带b的二进制模式: "rb" "wb" "ab" "rb+" 等,在Linux下没有区别(Linux不区分文本和二进制),但在Windows下会禁止 \n\r\n 转换。

易错点: "w" 模式会无条件清空文件内容,即使文件已有重要数据。想保留内容应使用 "a""r+"

1.2.2 fclose — 关闭文件

#include <stdio.h>

int fclose(FILE *stream);

参数: stream:已打开的文件流指针

返回值: 成功返回 0,失败返回 EOF(通常是 -1

重要: fclose 会先把缓冲区中未写入的数据刷新(flush)到文件,然后释放FILE结构体资源。如果不调用 fclose,缓冲区中的数据可能丢失。

易错点: 对同一个 FILE* 多次调用 fclose 是未定义行为(double free)。

1.2.3 fread — 读取数据

#include <stdio.h>

size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);

参数:

  • ptr:读取数据存放的缓冲区
  • size:每个元素的字节大小
  • nmemb:要读取的元素个数
  • stream:文件流指针

返回值: 成功返回实际读取的元素个数(不是字节数!),失败或到达文件末尾返回 0

总字节数 = size × 实际读取的元素个数

示例:读取一个int数组

int arr[10];
size_t n = fread(arr, sizeof(int), 10, fp);
// n 是实际读取的int个数,可能小于10(文件数据不足)
// 读取的字节数 = n * sizeof(int)

1.2.4 fwrite — 写入数据

#include <stdio.h>

size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);

参数:

  • ptr:要写入的数据缓冲区
  • size:每个元素的字节大小
  • nmemb:要写入的元素个数
  • stream:文件流指针

返回值: 成功返回实际写入的元素个数,失败返回 0

示例:写入一个字符串

const char *msg = "Hello, Linux!";
size_t len = strlen(msg);
size_t n = fwrite(msg, 1, len, fp);
// n 应该等于 len,否则写入不完整

1.2.5 fprintf / fscanf — 格式化读写

#include <stdio.h>

int fprintf(FILE *stream, const char *format, ...);
int fscanf(FILE *stream, const char *format, ...);

fprintf 类似 printf,但输出到文件而不是标准输出。

示例:写入结构化数据

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

struct Student s = {"Zhangsan", 20, 95.5};
fprintf(fp, "%s %d %.1f\n", s.name, s.age, s.score);

1.2.6 fgets / fputs — 字符串读写

#include <stdio.h>

char *fgets(char *s, int size, FILE *stream);
int fputs(const char *s, FILE *stream);

fgets 详解:

  • 最多读取 size - 1 个字符(留一个位置给 \0
  • 遇到 \n(换行)停止读取,\n 会被读入缓冲区
  • 遇到 EOF 停止读取
  • 成功返回 s(缓冲区指针),失败返回 NULL

易错点: fgets 会把换行符 \n 也读进来,如果不需要换行,需要手动去除。

1.2.7 fseek / ftell / rewind — 文件定位

#include <stdio.h>

int fseek(FILE *stream, long offset, int whence);
long ftell(FILE *stream);
void rewind(FILE *stream);

fseek 参数:

  • offset:偏移量(字节),可正可负
  • whence:基准位置
    • SEEK_SET:文件开头(offset必须为正)
    • SEEK_CUR:当前位置(offset可正可负)
    • SEEK_END:文件末尾(offset通常为负)

ftell: 返回当前文件位置(距文件开头的字节数),失败返回 -1L

rewind: 等价于 fseek(stream, 0, SEEK_SET) + 清除错误标志

示例:获取文件大小

fseek(fp, 0, SEEK_END);
long size = ftell(fp);
rewind(fp);  // 回到文件开头
printf("文件大小: %ld 字节\n", size);

1.2.8 feof / ferror — 错误和EOF检测

#include <stdio.h>

int feof(FILE *stream);   // 是否到达文件末尾
int ferror(FILE *stream); // 是否发生I/O错误
void clearerr(FILE *stream); // 清除错误标志

重要易错点: feof 只有在读取操作失败后才会返回非零值。不能在读取前用 feof 判断是否到文件末尾。正确做法是先读取,判断返回值,再用 feof 判断是否因为到达末尾而停止。

错误的EOF判断:

// 错误!feof在读取到末尾后下一次读取失败才返回真
while (!feof(fp)) {
    fgets(buf, sizeof(buf), fp);  // 最后一次循环会多读一次(重复最后一行)
    process(buf);
}

正确的EOF判断:

// 正确:先读取,再判断返回值
while (fgets(buf, sizeof(buf), fp) != NULL) {
    process(buf);
}
// 此时如果是因为到达EOF退出循环,feof(fp)为真

1.3 完整代码示例

示例1:文件复制程序(二进制安全)

#include <stdio.h>
#include <stdlib.h>

#define BUF_SIZE 4096

int main(int argc, char *argv[]) {
    if (argc != 3) {
        fprintf(stderr, "用法: %s <源文件> <目标文件>\n", argv[0]);
        return 1;
    }

    FILE *src = fopen(argv[1], "rb");
    if (!src) {
        perror("打开源文件失败");
        return 1;
    }

    FILE *dst = fopen(argv[2], "wb");
    if (!dst) {
        perror("打开目标文件失败");
        fclose(src);
        return 1;
    }

    char buf[BUF_SIZE];
    size_t n;
    while ((n = fread(buf, 1, BUF_SIZE, src)) > 0) {
        size_t written = fwrite(buf, 1, n, dst);
        if (written != n) {
            fprintf(stderr, "写入不完整\n");
            fclose(src);
            fclose(dst);
            return 1;
        }
    }

    fclose(src);
    fclose(dst);
    printf("复制完成\n");
    return 0;
}

编译:gcc -o filecopy filecopy.c

示例2:读写结构体数据(二进制文件)

#include <stdio.h>
#include <string.h>

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

void write_students(const char *filename, Student students[], int count) {
    FILE *fp = fopen(filename, "wb");
    if (!fp) {
        perror("fopen");
        return;
    }
    fwrite(&count, sizeof(int), 1, fp);         // 先写入数量
    fwrite(students, sizeof(Student), count, fp); // 再写入数据
    fclose(fp);
}

int read_students(const char *filename, Student students[], int max_count) {
    FILE *fp = fopen(filename, "rb");
    if (!fp) {
        perror("fopen");
        return -1;
    }

    int count;
    if (fread(&count, sizeof(int), 1, fp) != 1) {
        fclose(fp);
        return -1;
    }

    if (count > max_count) count = max_count;
    size_t n = fread(students, sizeof(Student), count, fp);
    fclose(fp);
    return (int)n;
}

int main() {
    Student students[] = {
        {1, "Alice", 95.5},
        {2, "Bob", 88.0},
        {3, "Charlie", 92.3}
    };
    int count = sizeof(students) / sizeof(students[0]);

    write_students("students.dat", students, count);

    Student read_buf[10];
    int n = read_students("students.dat", read_buf, 10);
    printf("读取到 %d 条记录:\n", n);
    for (int i = 0; i < n; i++) {
        printf("  ID=%d, Name=%s, Score=%.1f\n",
               read_buf[i].id, read_buf[i].name, read_buf[i].score);
    }
    return 0;
}

编译:gcc -o fileio_struct fileio_struct.c

示例3:逐行读取文本文件并统计

#include <stdio.h>
#include <string.h>
#include <ctype.h>

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "用法: %s <文件名>\n", argv[0]);
        return 1;
    }

    FILE *fp = fopen(argv[1], "r");
    if (!fp) {
        perror("fopen");
        return 1;
    }

    char line[1024];
    int line_count = 0;
    int word_count = 0;
    int char_count = 0;

    while (fgets(line, sizeof(line), fp) != NULL) {
        line_count++;
        char_count += strlen(line);

        // 简单的单词计数:遇空白分隔
        int in_word = 0;
        for (int i = 0; line[i] != '\0'; i++) {
            if (isspace(line[i])) {
                in_word = 0;
            } else if (!in_word) {
                in_word = 1;
                word_count++;
            }
        }
    }

    printf("行数: %d\n", line_count);
    printf("单词数: %d\n", word_count);
    printf("字符数: %d\n", char_count);

    fclose(fp);
    return 0;
}

编译:gcc -o wordcount wordcount.c

1.4 注意事项与易错点

序号 坑点 说明
1 fwrite返回值检查 返回值是元素个数,不是字节数;写入不完整时不会报错
2 fgets会读入\n 读入的字符串末尾包含换行符,需要手动去除
3 feof不能先判断 必须先读取再判断feof,否则会多处理一行(重复最后一行)
4 fopen失败返回NULL 必须检查返回值,不能直接使用FILE*
5 缓冲区大小选择 fread/fwrite的size参数影响缓冲效率,通常选择4KB的倍数
6 文本模式与二进制模式 Linux下无区别,Windows下文本模式会做\n\r\n转换
7 文件指针位置 fread/fwrite会自动移动文件指针,不需要手动fseek
8 fclose的flush作用 不调用fclose,缓冲区中的数据可能丢失
9 fprintf返回值 返回写入的字符数,出错返回负数,可用于判断写入是否成功
10 ftell在二进制文件中 二进制文件用ftell获取文件位置是可靠的;文本文件中换行符转换可能导致偏移不准确

1.5 面试要点

Q: C库IO与系统调用IO的区别? A: C库IO(fopen等)在用户空间维护缓冲区,减少系统调用次数,效率更高;系统调用(open等)每次操作都陷入内核,开销大但更底层。C库IO提供格式化读写,系统调用只处理字节流。

Q: fread的返回值是什么含义? A: 返回的是成功读取的元素个数,不是字节数。如果文件剩余数据不足,实际读取的元素个数会小于请求的nmemb。总字节数 = size × 返回值。

Q: 为什么不能用while(!feof(fp))来循环读取? A: feof 只有在读取操作失败后才会返回非零值。如果用 while(!feof(fp)),最后一次读取失败后feof才变为真,但此时循环体还会再执行一次,导致重复处理最后一行数据。

Q: 如何用C库函数获取文件大小? A: fseek(fp, 0, SEEK_END) 移到末尾,ftell(fp) 返回文件大小(字节数),rewind(fp) 回到开头。注意二进制文件可靠,文本文件因换行转换可能不准确。

Q: fwrite写入不完整怎么处理? A: 检查fwrite返回值,如果不等于请求写入的元素个数,说明写入失败(磁盘满等)。需要处理错误并决定是否重试。