本文最后更新于 2025-03-18,学习久了要注意休息哟

第一章 文件与流

1.1 基本概念:文件 & 流

:one: 文件抽象

  • 在操作系统中,“文件”就是对存储设备(如磁盘、闪存等)上数据的一种抽象。
  • 它提供了命名组织数据的方法,使我们能通过文件名定位并访问其中的数据。
  • 不管是文本文件 .txt 还是二进制文件 .bin,本质上都是一串比特流,只是阅读或解释方式不同。

示意图

   [ 硬盘 / SSD 等物理介质 ]
             ↓
         [文件系统]
             ↓
         [文件抽象]

就像大仓库里有一个个小盒子,每个小盒子都用独特的标签(文件名)标识。要找某份资料时,你只要找对盒子名就能打开取东西啦 (•̀ᴗ•́)و ̑̑

:two: 流的抽象

  • 在 C 语言中,使用“流”这一概念来管理数据在程序与外部之间的输入/输出
  • 可以把“流”看作数据流动的管道,从“程序内部”连到“外部文件”或“设备”的通道。
  • 一旦我们打开一个文件,就会得到一个对应的“文件流对象(FILE*)”,通过它进行读写。

可以想象家里水管系统:打开水龙头(打开文件),水(数据)就流入或流出。关掉水龙头(关闭文件),水管就不再有水流动了。

:three: 文件与流

  • 当我们在 C 语言中对文件进行操作时,实际上是把数据装入/取出流,再由系统负责将数据写入/读出物理文件。
  • 文件和流并不对立,而是**“文件” = 操作系统层面的抽象**,“流” = C 语言层面的抽象

示意:

程序(用户视角) --(流)--> FILE*(C库层) --(文件系统)--> 磁盘文件(操作系统层)

:smiley_cat: 总结:文件是“数据盒子”,流是“管道”;通过管道(流)读写数据盒子(文件)。

1.2 标准输入输出流

:one: 标准流

  • stdin(standard input): 代表标准输入(默认从键盘获取输入)
  • stdout(standard output): 代表标准输出(默认输出到终端屏幕)
  • stderr(standard error): 代表标准错误输出(默认也输出到屏幕,但通常与 stdout 分开缓冲或重定向)

在我们写 printf() 的时候,默认就是往 stdout 输出;当我们用 scanf() 读取数据时,默认就是从 stdin 读取。

一个小例子

如下简单程序读取用户输入的数字,并将其打印出来。我们用 stdin / stdout 来演示,虽然在代码中并不需要显式写出 stdinstdout,但背后的概念就是如此。

#include <stdio.h>

int main(void)
{
    int number = 0;

    // 提示用户输入
    printf("请输入一个整数: ");
    // scanf默认从stdin读取数据
    scanf("%d", &number);

    // printf默认往stdout输出数据
    printf("你输入的整数是: %d\n", number);

    return 0;
}

编译 & 运行示例

gcc example_stdio.c -o example_stdio
./example_stdio

示例输出

请输入一个整数: 2023
你输入的整数是: 2023

:two: 重定向

  • 重定向指把程序的标准输入或输出从默认位置(终端)“改道”到其他地方,如文件、管道等。
  • 在 Linux 命令行环境下,我们可以使用 ><2> 等符号做重定向。

常见用法举例

  1. 输出重定向

    ./example_stdio > out.txt
    

    这意味着原本打印到屏幕(stdout)的内容,现在全部写进 out.txt 文件中。

  2. 输入重定向

    ./example_stdio < input_data.txt
    

    程序中的 scanf() 将会从 input_data.txt 文件中读取数据,而不是等待键盘输入。

  3. 错误输出重定向

    ./my_prog 2> error_log.txt
    

    stderr 中的错误信息重定向到 error_log.txt。有助于分离正常输出和错误信息。

:three: 缓冲模式

  • 行缓冲(Line Buffering)

    :当遇到换行符

    \n
    

    或缓冲区满时,才把数据真正写到目的地。

    • stdout 在连接到终端时通常默认是行缓冲,这样可以保证用户在看终端时,一行行输出比较及时。
  • 全缓冲(Full Buffering)

    :只有当缓冲区填满或手动调用

    fflush()
    

    或程序结束时,数据才会写出去。

    • 常见于对文件流的写操作。
  • 无缓冲(Unbuffered):任何输出操作立即生效,不进行缓冲积攒,比如 stderr 通常是无缓冲,确保错误信息能尽快显示。

小Tips: 如果在调试程序时发现“为什么 printf 之后没有立即输出?”,那可能是因为数据还在缓冲里没写出去,可以试试输出 \nfflush(stdout) 来强制刷新。

1.3 FILE* 与 C 标准库

:one: FILE 结构体

  • 在 C 标准库中,FILE 是一个结构体类型(在 <stdio.h> 中定义),内部包含当前流的缓冲区、文件指针位置、流状态标志等信息。
  • 我们平时在写代码时使用 FILE *fp 来表示一个“文件指针”,其实就是指向了这样一个结构体,用于管理与该文件(或设备)之间的数据通道。

有人会将 FILE *fp 理解成“文件本身”,但其实它只是用来操作文件的“指针+控制块”。就像你手上拿到的不是整个仓库,而是仓库的钥匙和出入记录而已。

:two: 读写函数

常见函数有:

  1. fopen():打开文件并返回一个 FILE*
  2. fprintf()fscanf():带格式的输出/输入
  3. fgets()fputs():行操作
  4. fgetc()fputc():字符操作
  5. fclose():关闭文件

后续章节会更详细地讲解文件操作的各种API,现在先来个初步小示例,让学生直观体会:

#include <stdio.h>

int main(void)
{
    // 1. 打开一个名为 "demo.txt" 的文件,模式 "w" 表示写入
    // 📂 如果没有就自动创建,如果有则清空文件内容
    FILE *fp = fopen("demo.txt", "w");
    if(fp == NULL)
    {
        // 打开失败时,fp返回NULL
        // (╯°□°)╯︵ ┻━┻ 只能叹气~
        perror("无法打开文件");
        return -1;
    }

    // 2. 通过 fprintf 往文件中写入一行文本
    fprintf(fp, "Hello, this is a demo file!\n");

    // 3. 写完后记得关闭,防止资源泄露
    fclose(fp);

    printf("文件已写入: demo.txt\n");
    return 0;
}

编译 & 运行示例

gcc demo_file_write.c -o demo_file_write
./demo_file_write

运行结果

文件已写入: demo.txt

此时可以使用 cat demo.txt 命令查看刚写入的内容。

:three: 读写流程示意

概括来说,对文件的读写流程大多是这样:

  1. 打开文件fopen(),指定文件名与模式(如只读 r、写入 w、追加 a 等)。
  2. 读或写操作:使用各种 I/O 函数(fscanffprintffreadfwritefgetcfputc 等)。
  3. 关闭文件fclose(),释放资源,把缓冲区剩余数据写出。

如果不小心忘记 fclose(),可能导致文件数据丢失内存泄漏(虽然程序结束后操作系统通常会帮你回收资源,但这并不是好习惯)。

第二章 文件操作

2.1 文件打开与关闭

:one: fopen()freopen() 常见模式

fopen() 常见模式

模式含义
r只读方式打开文件,文件必须存在,否则返回 NULL
w写入方式打开文件,若文件不存在则创建,若存在则清空
a追加方式打开文件,若文件不存在则创建,写入内容追加到文件末尾
r+读写方式打开文件,文件必须存在,不清空原内容
w+读写方式打开文件,若文件不存在则创建,若文件存在则清空
a+读写方式打开文件,若文件不存在则创建,写入从文件末尾追加

r+w+a+ 既能读又能写,但具体能否移动读写指针、覆盖写等细节要根据模式来区分。(。・∀・)ノ゙

freopen() 文件重定向

  • 作用:将已存在的流(如 stdinstdoutstderr)重定向到新的文件。
  • 常用于在同一程序中,给 stdoutstdin 改换输出/输入文件,而不用在命令行层面重定向。

示例:

#include <stdio.h>

int main(void)
{
    // 使用freopen将stdout重定向到文件out.txt
    if(freopen("out.txt", "w", stdout) == NULL)
    {
        perror("freopen 失败");
        return -1;
    }

    // 此后所有的printf都写到 out.txt 文件中,而不是屏幕
    printf("这行文字不会出现在屏幕上,而会写入 out.txt 哦!\n");

    // 如果想恢复stdout到屏幕,需要再调用freopen绑定到"/dev/tty" (Linux)
    // freopen("/dev/tty", "w", stdout);

    return 0;
}

编译 & 运行示例

gcc freopen_example.c -o freopen_example
./freopen_example

输出示例

(屏幕上无输出)

然后查看 out.txt 文件,就能看到那行文字:“这行文字不会出现在屏幕上,而会写入 out.txt 哦!”

:two: fclose() 及常见错误处理

  • fclose(fp):关闭文件指针 fp,释放相关资源,将缓冲区剩余数据写入文件
  • 如果你忘记关闭文件,可能导致数据没有及时刷新到磁盘(尤其在全缓冲模式下),也可能浪费系统资源。

示例:

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

int main(void)
{
    // 试图以写方式打开文件
    FILE *fp = fopen("data.txt", "w");
    if(!fp)
    {
        // 打开失败,返回NULL
        perror("无法打开 data.txt 文件");
        return -1;
    }

    fprintf(fp, "小测试:写入一些文字到 data.txt\n");
    // ⚠ 如果我们不调用 fclose,可能导致后续数据丢失

    // 正确方式:写完记得关闭
    if(fclose(fp) != 0)
    {
        perror("关闭文件时出错");
        return -1;
    }

    printf("操作完成\n");
    return 0;
}

编译 & 运行示例

gcc fclose_example.c -o fclose_example
./fclose_example

输出示例

操作完成

打开 data.txt,可见写入的文字。

错误处理:若 fclose() 返回非 0 表示出错,比如网络文件系统断线、磁盘被移除等极端情况。

:three: 打开文件失败的处理

  • fopen() 如果返回 NULL,要用 perror()strerror(errno) 查看出错原因。
  • 常见失败原因:文件不存在且使用 r 模式、权限不够、磁盘满等。

小Tips:(✿◡‿◡) 学好错误处理!否则调试排错时痛哭流涕就晚了。

2.2 文件读写:文本模式

:one: 字符级读写:fgetc()fputc()

  • int fgetc(FILE *stream):从文件流读取一个字符(返回其 ASCII 码整型),若到达文件尾或出错则返回 EOF
  • int fputc(int c, FILE *stream):向文件流写入一个字符

示例:将文件内容一字符一字符地拷贝

#include <stdio.h>

int main(void)
{
    // 打开源文件source.txt只读
    FILE *fpsrc = fopen("source.txt", "r");
    if(!fpsrc)
    {
        perror("无法打开 source.txt");
        return -1;
    }

    // 打开(或创建)目标文件dest.txt写入
    FILE *fpdst = fopen("dest.txt", "w");
    if(!fpdst)
    {
        perror("无法打开(或创建) dest.txt");
        fclose(fpsrc); // 别忘了先关source
        return -1;
    }

    int ch;
    // 循环读取每个字符,直到EOF
    while((ch = fgetc(fpsrc)) != EOF)
    {
        // 将字符写入目标文件
        fputc(ch, fpdst);
    }

    // 关闭文件
    fclose(fpsrc);
    fclose(fpdst);

    printf("拷贝完成\n");
    return 0;
}

示例输出

拷贝完成

(可以比较 source.txtdest.txt 内容是否一致)

:two: 行级读写:fgets()fputs()

  • char *fgets(char *str, int n, FILE *stream):从文件中读取一行(或 n-1 个字符),以 \0 结尾;若到达文件尾或出错,则返回 NULL
  • int fputs(const char *str, FILE *stream):向文件中写字符串,不自动加换行符(需自行添加 "\n")。

示例:读取 source.txt 的前 3 行并打印

#include <stdio.h>

int main(void)
{
    FILE *fp = fopen("source.txt", "r");
    if(!fp)
    {
        perror("无法打开 source.txt");
        return -1;
    }

    char buffer[100];  // 用于存放读取的行
    int line_count = 0;

    while(line_count < 3)
    {
        // 尝试读取一行
        if(fgets(buffer, sizeof(buffer), fp) == NULL)
        {
            // 如果已经EOF或出错,就停止
            break;
        }
        // 将读取到的一行打印到屏幕
        fputs(buffer, stdout);
        line_count++;
    }

    fclose(fp);
    return 0;
}

示例输出

(视source.txt内容而定, 显示该文件前3行)

:three: 格式化读写:fprintf()fscanf()

常见格式化占位符

占位符对应类型示例
%dintprintf("%d", 123);
%ldlongprintf("%ld", 123456789L);
%ffloat/doubleprintf("%f", 3.14);
%ccharprintf("%c", 'A');
%s字符串printf("%s", "Hello");
.........

scanf/fscanf 中,需要根据实际变量类型来使用匹配的占位符,否则可能出现读取错误或崩溃。

精确控制输入输出的技巧

  • 可以使用宽度限定,如:%5s 表示最多读入5个字符(含终止符)。
  • 可以使用 %*d 来跳过某个整数而不赋值。
  • 可以在输出中用格式控制对齐方式或小数精度:如 printf("%8.2f", 3.14159);

示例:读取并写入学生成绩表

#include <stdio.h>

int main(void)
{
    FILE *fp_in = fopen("scores.txt", "r");  // 形式: 姓名 分数
    FILE *fp_out = fopen("results.txt", "w");

    if(!fp_in || !fp_out)
    {
        perror("无法打开输入或输出文件");
        return -1;
    }

    char name[50];
    int score;

    // 从scores.txt逐行读取 "姓名 分数",直到读不出
    while(fscanf(fp_in, "%s %d", name, &score) == 2)
    {
        // 写入results.txt,附加一些文字
        fprintf(fp_out, "学生: %s 的分数是: %d\n", name, score);
    }

    fclose(fp_in);
    fclose(fp_out);

    printf("处理完成,结果写入 results.txt\n");
    return 0;
}

示例输出

处理完成,结果写入 results.txt

(查看 results.txt 可以看到格式化后的成绩列表)

2.3 文件读写:二进制模式

:one: 读写函数

  • 用于二进制块读写,常配合结构体或字节数组。

  • 函数签名:

    size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
    size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
    
    • ptr:数据存放/读取的内存地址
    • size:单个数据块的大小(字节)
    • nmemb:数据块的个数
    • 函数返回实际读/写的块数(可能小于 nmemb 如果到达EOF或出错)

:two: 结构体读写

示例:写入并读取一个结构体到二进制文件

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

// 定义一个简单的结构体
typedef struct {
    int id;         // 4字节
    float score;    // 4字节
} Record;

int main(void)
{
    // 1. 写阶段
    FILE *fp_write = fopen("data.bin", "wb"); // wb: write binary
    if(!fp_write)
    {
        perror("无法打开data.bin写");
        return -1;
    }

    Record rec_out = {1001, 98.5f};
    // fwrite(地址, 单个结构体大小, 写几个, 流)
    size_t written = fwrite(&rec_out, sizeof(Record), 1, fp_write);
    if(written < 1)
    {
        perror("写结构体出错");
    }
    fclose(fp_write);

    // 2. 读阶段
    FILE *fp_read = fopen("data.bin", "rb"); // rb: read binary
    if(!fp_read)
    {
        perror("无法打开data.bin读");
        return -1;
    }

    Record rec_in;
    size_t read_count = fread(&rec_in, sizeof(Record), 1, fp_read);
    if(read_count < 1)
    {
        perror("读结构体出错或EOF");
    }
    fclose(fp_read);

    // 3. 打印读到的结构体内容
    printf("从data.bin读到结构体: id=%d, score=%.2f\n", rec_in.id, rec_in.score);

    return 0;
}

编译 & 运行示例

gcc binary_rw.c -o binary_rw
./binary_rw

示例输出

从data.bin读到结构体: id=1001, score=98.50

查看 data.bin 是一堆不可读的二进制数据。

:three: 大小端问题

  • 如果在不同CPU架构(如 x86(小端) vs. ARM大端) 之间共享二进制文件,可能导致读到的字节序不匹配,引起数值错误。
  • 常见解决方案:采用“网络字节序”或统一用小端储存,或者以文本格式(如 JSON、XML)进行跨平台传输。
  • 在嵌入式系统中,我们可以使用字节序转换函数(如 htons(), htonl() 等)或自己写宏来确保一致性。

(╯°□°)╯︵ ┻━┻ 大小端问题是老生常谈,一定要在跨平台或网络通信时留意。

第三章 文件操作技巧

3.1 文件定位

:one: fseek 文件定位

fseek(FILE *stream, long offset, int whence)

  • 用于在文件中移动“文件指针”(即当前读写位置,也可称“游标”)。

  • offset:偏移量(字节数,可正可负)。

  • whence
    

    :基准位置,常见取值:

    1. SEEK_SET:文件开头
    2. SEEK_CUR:当前位置
    3. SEEK_END:文件末尾

示例fseek(fp, 10, SEEK_SET) 表示从文件开头向后移动 10 个字节,把游标定位到第10字节处。

:two: ftell 当前位置

ftell(FILE *stream)

  • 返回当前“文件指针”相对于文件开头的偏移量(单位:字节)。
  • 如果出错(比如对无效流调用),返回 -1L 并设置 errno

:three: rewind 文件开头

rewind(FILE *stream)

  • 等价于 fseek(stream, 0, SEEK_SET),即将文件指针移动到文件开头。
  • 同时清除 ferror()feof() 状态。

:four: 文本模式与二进制模式下的差异

  • 二进制模式 (binary mode)fseek()ftell() 的偏移量通常就是字节数,定位比较直观。

  • 文本模式 (text mode):在某些系统(尤其是 Windows)中,换行符 \n 可能在实际文件中被存储为 \r\n,导致 fseek() 的计算会与想象中的字符数不完全一致。

  • 在绝大多数 Linux/Unix 系统中,文本文件只使用 \n 作为换行符,所以文本模式和二进制模式定位差别不大。不过,在可移植场合还是要小心。((╯°□°)╯︵ ┻━┻ Windows的换行符又来捣乱……)

因此,如果需要跨平台精确定位文件中的字节位置,最好使用二进制模式 ("rb", "wb", etc.`) 打开文件,或者对换行符做适配处理。

:five: “文件游标”的概念

  • 当我们打开一个文件时,系统内部会维护一个“文件指针”或“游标”,记录当前读取或写入的位置。
  • 每次执行读写操作,游标会向前移动相应的字节数,就像在磁带上不断往前读写一样。
  • fseek()ftell()rewind() 等函数就是操纵或读取游标位置的接口。

示意图:

  文件内容: [字节0][字节1][字节2] ... [字节N]
                      ^
                (游标当前位置)

📌示例1 读写并移动游标

下面的示例演示了如何:

  1. 打开文件
  2. 写入几行文本后,把游标移回文件开头
  3. 重新读取并输出文件中的文本
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    // 1. 以读写模式打开文件 (若文件不存在则创建)
    FILE *fp = fopen("seek_example.txt", "w+");
    if(!fp)
    {
        perror("无法打开 seek_example.txt");
        return -1;
    }

    // 2. 写入几行数据
    fputs("第1行\n", fp);   // 游标移动到第1行末尾
    fputs("第2行\n", fp);   // 游标移动到第2行末尾
    fputs("第3行\n", fp);   // 游标移动到第3行末尾

    // 3. 获取当前游标位置
    long pos = ftell(fp);  // ftell返回相对于文件开头的字节偏移
    printf("当前游标位置(字节) = %ld\n", pos);

    // 4. 移动游标到文件开头
    rewind(fp);  // 或者 fseek(fp, 0, SEEK_SET);

    // 5. 逐行读取并打印
    char buffer[100];
    while(fgets(buffer, sizeof(buffer), fp))
    {
        printf("读取到: %s", buffer);
    }

    // 6. 关闭文件
    fclose(fp);

    return 0;
}

编译 & 运行示例(Linux)

gcc fseek_ftell_example.c -o fseek_ftell_example
./fseek_ftell_example

示例输出(可能略有差异)

当前游标位置(字节) = 18
读取到: 第1行
读取到: 第2行
读取到: 第3行

(你也可以用 cat seek_example.txt 查看文件内容。)

📌 示例2 从文件末尾倒数 N 个字节读取

有时我们想读取“距离文件末尾 N 字节处的内容”,这在查看日志尾部、读取文件尾标记等场景很常见。我们可以使用 fseek(fp, -N, SEEK_END) 来实现。

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

int main(void)
{
    FILE *fp = fopen("testdata.bin", "rb");
    if(!fp)
    {
        perror("无法打开 testdata.bin");
        return -1;
    }

    // 定义要倒数读取的大小,例如 10 个字节
    int N = 10;

    // 移动到文件末尾倒数 N 字节处
    // SEEK_END 表示文件末尾为基准点
    if(fseek(fp, -N, SEEK_END) != 0)
    {
        perror("移动游标出错 - 文件可能比10字节还小");
        fclose(fp);
        return -1;
    }

    // 读取这 N 个字节到一个缓冲区
    char buffer[11]; // 多留1字节给'\0'
    // fread(目的地址, 每块大小, 块个数, 流)
    size_t read_count = fread(buffer, 1, N, fp);

    // 确保以'\0'结尾,便于打印(若只是二进制数据需要另处理)
    buffer[read_count] = '\0';

    printf("文件末尾倒数%d个字节: %s\n", N, buffer);

    fclose(fp);
    return 0;
}

编译 & 运行示例

gcc fseek_end_example.c -o fseek_end_example
./fseek_end_example

示例输出(视 testdata.bin 的实际内容而定)

文件末尾倒数10个字节: ...SomeTailData...

(如果文件不足10个字节,则会报错或读取不到那么多字节。)

3.2 错误与结束判断

:one: 函数说明

  • int ferror(FILE *stream):检查流中是否发生了读写错误。若出错则返回非0。
  • int feof(FILE *stream):检查是否已到达文件末尾(EOF)。若到达则返回非0。

重要提示

  • feof() 只有在试图读取EOF后才会返回非0(并不是在真正到达EOF时立刻被置位)。所以一般写法是在读函数返回值为“读取失败”后,再用 feof() 确认是否EOF导致,还是其他错误。
  • EOF 是一个宏,通常定义为 -1

:two: EOF 的含义

  • EOF (End Of File) 并不一定代表“真正结束了文件”,有时也可能是因为读取出错而返回 EOF
  • 读/写函数返回 EOF 时,需要配合 ferror()feof() 来进一步判断。

:three: 异常处理

  • 如果 ferror(stream) 返回非0,可根据具体错误决定是否重试或退出。
  • 若文件IO是关键操作(如写日志、配置信息),应加更多健壮性检查,以免数据丢失。
#include <stdio.h>

int main(void)
{
    FILE *fp = fopen("test.txt", "r");
    if(!fp)
    {
        perror("无法打开 test.txt");
        return -1;
    }

    int ch;
    // 逐字符读取
    while((ch = fgetc(fp)) != EOF)
    {
        putchar(ch); // 打印到stdout
    }

    // 读取失败跳出循环后,需要判断是EOF还是错误
    if(ferror(fp))
    {
        // 如果 ferror(fp)为非0,说明出错
        perror("读文件时出错");
    }
    else if(feof(fp))
    {
        // 如果feof(fp)非0,说明是正常到达文件末尾
        printf("\n已到达文件末尾.\n");
    }

    fclose(fp);
    return 0;
}

示例输出

(视test.txt内容而定,若正常读取到末尾,就显示: 已到达文件末尾.)