字符读写:fgetc、fputc 与 EOF 的真相

文件操作里,最基础也最容易被忽视的,就是字符级别的读写。很多人一上来就搞 fread、fwrite,结果遇到文本文件里的换行符、乱码问题,回头才发现——连最基础的 fgetc 都没吃透。

我个人习惯,凡是处理文本文件,第一反应就是用 fgetc 和 fputc。为什么?因为字符流是最可控的,你一个字节一个字节地读,出问题能精确定位到第几个字符。今天我们就把这几个函数掰开揉碎了讲清楚。

fputc:写一个字符到文件

先看写操作。fputc 的原型很简单:

int fputc(int ch, FILE *stream);

它把 ch 这个字符(注意是 int 类型)写入 stream 指向的文件。成功时返回写入的字符,失败返回 EOF。

为什么参数是 int 而不是 char?嗯,这里有个历史原因——为了能容纳 EOF 这个特殊值。EOF 在 stdio.h 里通常定义为 -1,而 char 可能是 unsigned 的,用 int 就避免了歧义。

核心要点:fputc 每次只写一个字节。如果你要写字符串,别用它循环——直接用 fputs 或 fprintf 更高效。

看个例子:

#include <stdio.h>

int main() {
    FILE *fp = fopen("test.txt", "w");
    if (fp == NULL) {
        perror("打开文件失败");
        return 1;
    }

    char str[] = "Hello, 嵌入式!";
    int i = 0;
    while (str[i] != '\0') {
        fputc(str[i], fp);
        i++;
    }

    fclose(fp);
    return 0;
}

这段代码把字符串逐个字符写入文件。我在项目中遇到过一个问题:用 fputc 写二进制数据时,遇到 0xFF 就出错了。后来才发现,fputc 本质上是为文本流设计的,写二进制数据最好用 fwrite。

fgetc:读一个字符

读操作对应的是 fgetc:

int fgetc(FILE *stream);

它从 stream 中读取下一个字符,返回该字符的 int 值。读到文件末尾或出错时,返回 EOF。

你想想看,如果文件里正好有一个字节是 0xFF(即 -1 的 unsigned 形式),那它和 EOF 怎么区分?这就是为什么 fgetc 返回 int 而不是 char——它把字符值提升到 int 范围,EOF 是 -1,而 0xFF 作为 int 是 255,两者不冲突。

小技巧:用 fgetc 读文件时,一定要把返回值赋给 int 变量,而不是 char。否则 0xFF 会被截断成 -1,和 EOF 混淆。

一个典型的读文件循环:

#include <stdio.h>

int main() {
    FILE *fp = fopen("test.txt", "r");
    if (fp == NULL) {
        perror("打开文件失败");
        return 1;
    }

    int ch;  // 注意是 int,不是 char
    while ((ch = fgetc(fp)) != EOF) {
        putchar(ch);  // 输出到屏幕
    }

    fclose(fp);
    return 0;
}

这个模式你会在无数代码里看到。但我要提醒你:这个循环假设文件是文本文件,且没有二进制数据。如果文件里有 0xFF,循环就会提前终止——因为 fgetc 返回了 0xFF,而它被提升为 int 后是 255,不等于 -1,所以不会提前退出。但如果你用 char 接收,0xFF 被截断成 -1,循环就提前结束了。

警告:永远不要用 char 变量接收 fgetc 的返回值!这是 C 语言初学者最容易犯的错误之一。我曾经调试过一个串口通信程序,对方发来的数据里包含 0xFF,结果程序读到那里就停了,排查了半天才发现是 char 类型的问题。

EOF 的判定:没那么简单

EOF 是 End Of File 的缩写,在 stdio.h 中定义为 -1。但文件末尾和错误都会导致 fgetc 返回 EOF,怎么区分?

这就是 feof 和 ferror 的用武之地了。

函数 作用 返回值
feof(fp) 检查文件是否到达末尾 到达末尾返回非0,否则返回0
ferror(fp) 检查文件是否发生错误 发生错误返回非0,否则返回0

正确的做法是:先判断 fgetc 是否返回 EOF,如果是,再用 feof 和 ferror 判断具体原因。

#include <stdio.h>

int main() {
    FILE *fp = fopen("test.txt", "r");
    if (fp == NULL) {
        perror("打开文件失败");
        return 1;
    }

    int ch;
    while (1) {
        ch = fgetc(fp);
        if (ch == EOF) {
            if (feof(fp)) {
                printf("正常到达文件末尾\n");
            } else if (ferror(fp)) {
                printf("读取过程中发生错误\n");
                perror("错误详情");
            }
            break;
        }
        putchar(ch);
    }

    fclose(fp);
    return 0;
}

这个写法虽然啰嗦,但很安全。我见过太多人直接用 while(!feof(fp)) 来循环,结果多读了一次——因为 feof 在读取到末尾之后才会被设置,而不是之前。

避坑指南:不要用 while(!feof(fp)) 作为循环条件!正确的做法是:先读,再判断返回值,最后用 feof/ferror 查原因。

知识体系图

下面这张图把字符读写相关的函数和判定逻辑串起来了:

字符读写知识体系 FILE *fp fputc(ch, fp) fgetc(fp) 返回写入的字符 或 EOF 返回读取的字符 或 EOF EOF 判定:feof() / ferror() feof → 文件末尾 ferror → 读写出错 都不是 → 数据问题

feof 和 ferror 的细节

这两个函数其实很简单,但有几个坑要注意:

  • feof 不会自动清除:一旦文件末尾标志被设置,它会一直保持,直到你调用 clearerr 或 rewind。所以如果你在循环中用了 feof,记得在适当位置清除它。
  • ferror 同样需要手动清除:错误标志一旦设置,后续所有操作都会失败,直到你调用 clearerr。
  • clearerr 函数:同时清除 feof 和 ferror 的标志。原型是 void clearerr(FILE *stream);

我曾经在一个日志系统里遇到过这个问题:程序运行几天后突然不写日志了,排查发现是某个文件操作出错后,ferror 标志一直没清除,导致后续所有 fputc 都直接返回 EOF。从那以后,我每次在错误处理分支里都会加一句 clearerr。

综合示例:文件复制

把上面这些知识点串起来,写一个文件复制程序:

#include <stdio.h>

int main() {
    FILE *src = fopen("source.txt", "r");
    FILE *dst = fopen("dest.txt", "w");
    
    if (src == NULL || dst == NULL) {
        perror("打开文件失败");
        return 1;
    }
    
    int ch;
    int count = 0;
    
    while ((ch = fgetc(src)) != EOF) {
        if (fputc(ch, dst) == EOF) {
            if (ferror(dst)) {
                fprintf(stderr, "写入错误,已复制 %d 个字符\n", count);
                clearerr(dst);
                break;
            }
        }
        count++;
    }
    
    if (feof(src)) {
        printf("文件复制完成,共 %d 个字符\n", count);
    } else if (ferror(src)) {
        fprintf(stderr, "读取错误\n");
        clearerr(src);
    }
    
    fclose(src);
    fclose(dst);
    return 0;
}

这个程序里,我同时检查了读和写的错误。实际项目中,你还要考虑磁盘空间不足、权限问题等。但核心逻辑就是:读一个,写一个,出错就停,并报告进度。

个人建议:在嵌入式系统里,文件操作往往伴随着 SD 卡、Flash 等存储介质。这些介质的读写速度差异很大,而且容易受到电源波动的影响。我建议你在每次 fputc 后都检查返回值,不要偷懒。虽然会多几行代码,但能避免很多诡异的 bug。

好了,字符读写就讲到这里。记住三个关键点:fgetc 返回 int、EOF 不等于文件末尾、feof 和 ferror 要配合使用。把这些基础打牢,后面学 fread/fwrite 时就会轻松很多。


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