一、文件操作中的“隐形杀手”——错误处理

写C语言文件操作时,我最怕什么?不是读写逻辑复杂,而是程序跑起来一声不吭地失败。你想想看,fopen返回NULL,fwrite没写进去,fclose没关掉——这些错误如果不处理,程序就像在走钢丝。

我刚开始做嵌入式项目时,就吃过这个亏。有一次写日志文件,程序跑了一整天,结果发现文件根本没写进去。排查了半天,原来是磁盘满了。嗯,从那以后,我养成了一个习惯:每次文件操作后,必须检查返回值,并用errno定位问题。

1.1 errno:系统给你的错误码

errno是个全局变量,定义在里。每次系统调用失败,内核就会给它赋一个整数值。比如:

  • ENOENT(2):文件或目录不存在
  • EACCES(13):权限不足
  • ENOSPC(28):设备空间不足
  • EINVAL(22):参数无效

说白了,errno就是系统给你的“错误暗号”。每个数字代表一种失败原因。

重要提醒:errno只在函数调用失败时才有意义。如果函数成功了,errno的值可能是上次残留的。所以一定要先判断返回值,再看errno。

1.2 perror:最直接的错误打印

perror是我个人最常用的调试工具。它接受一个字符串参数,然后自动打印“你的字符串 + 冒号 + 系统错误描述”。

#include <stdio.h>
#include <errno.h>

int main() {
    FILE *fp = fopen("nonexist.txt", "r");
    if (fp == NULL) {
        perror("打开文件失败");
        // 输出:打开文件失败: No such file or directory
        return -1;
    }
    fclose(fp);
    return 0;
}

你看,一行代码就把错误原因说清楚了。我在项目中经常这样写:

if (fwrite(buf, 1, size, fp) != size) {
    perror("写入文件失败");
    // 立刻就能知道是磁盘满了还是其他问题
}

1.3 strerror:把错误码转成字符串

有时候你想自己控制错误信息的格式,或者把错误信息存到日志里。这时候strerror就派上用场了。

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

int main() {
    FILE *fp = fopen("/root/secret.txt", "r");
    if (fp == NULL) {
        // 把错误信息格式化到字符串中
        char err_buf[256];
        snprintf(err_buf, sizeof(err_buf), 
                 "错误码: %d, 原因: %s", 
                 errno, strerror(errno));
        printf("%s\n", err_buf);
        // 输出:错误码: 13, 原因: Permission denied
        return -1;
    }
    fclose(fp);
    return 0;
}

strerror是线程安全的吗?嗯,这个问题要注意。早期的strerror不是线程安全的,因为它内部用了静态缓冲区。但现在大多数系统提供了strerror_r,这个才是线程安全的版本。我建议你在多线程程序里用strerror_r。

我的习惯:调试阶段用perror,简单粗暴。正式项目里用strerror_r,把错误信息记录到日志文件中,方便事后排查。

二、常见文件操作错误与避坑指南

2.1 文件打开失败

这是最常见的错误。原因无非几种:路径不对、权限不够、文件被占用。

FILE *fp = fopen("data.txt", "r");
if (fp == NULL) {
    // 我曾经遇到一个坑:路径用了相对路径,但程序的工作目录不是我想的那样
    perror("fopen失败");
    // 输出:fopen失败: No such file or directory
    return;
}

避坑指南:我曾经在服务器上部署程序时,fopen一直返回NULL。查了半天,发现是程序的工作目录被systemd改成了根目录。从那以后,我写文件路径时一律用绝对路径,或者在程序启动时打印当前工作目录。

2.2 读写操作不完整

fread和fwrite的返回值,很多人只看是否等于0。其实不对。它们返回的是实际读写的元素个数。如果返回值小于你请求的个数,可能出问题了。

char buf[1024];
size_t ret = fread(buf, 1, sizeof(buf), fp);
if (ret != sizeof(buf)) {
    if (feof(fp)) {
        printf("文件读完了,只读到 %zu 字节\n", ret);
    } else if (ferror(fp)) {
        perror("读取文件出错");
    }
}

你想想看,如果只检查返回值是否为0,那读到一半出错的情况就漏掉了。我见过不少新手在这里翻车。

2.3 文件关闭失败

fclose也可能失败。虽然不常见,但一旦发生,通常意味着数据没刷到磁盘上。

if (fclose(fp) != 0) {
    perror("关闭文件失败");
    // 可能的原因:磁盘I/O错误、NFS连接断开等
}

注意:fclose失败后,文件流仍然会被释放。你不能再对fp做任何操作。但数据可能没写完,所以最好在关闭前用fflush强制刷一次。

三、知识体系总览

下面这张图,是我梳理的文件操作错误处理的核心逻辑。你看一遍就能记住整个流程。

文件操作错误处理流程 文件操作函数 检查返回值 继续正常流程 进入错误处理 查看 errno 全局变量 perror() 直接打印 strerror() 转字符串

四、实战:一个完整的错误处理示例

把上面这些知识点串起来,写一个健壮的文件读写函数。这是我项目里常用的模板:

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

int safe_write_file(const char *path, const void *data, size_t len) {
    FILE *fp = fopen(path, "wb");
    if (fp == NULL) {
        fprintf(stderr, "[错误] 打开文件 %s 失败: %s\n", 
                path, strerror(errno));
        return -1;
    }
    
    size_t written = fwrite(data, 1, len, fp);
    if (written != len) {
        fprintf(stderr, "[错误] 写入文件 %s 不完整: 期望 %zu, 实际 %zu\n",
                path, len, written);
        if (ferror(fp)) {
            fprintf(stderr, "  -> 系统错误: %s\n", strerror(errno));
        }
        fclose(fp);
        return -1;
    }
    
    if (fclose(fp) != 0) {
        fprintf(stderr, "[错误] 关闭文件 %s 失败: %s\n",
                path, strerror(errno));
        return -1;
    }
    
    return 0;
}

这个函数里,我做了三层检查:打开、写入、关闭。每一层失败都能给出明确的错误信息。你想想看,如果每个文件操作都这么写,调试起来得多省心。

小技巧:在日志系统里,我习惯把errno和strerror(errno)一起记录。这样既能看到错误码(方便写自动化分析脚本),又能看到人类可读的描述(方便人工排查)。

五、总结

文件操作的错误处理,说白了就三步:

  1. 检查返回值——fopen、fread、fwrite、fclose,一个都不能少
  2. 读取errno——在确认失败后,立刻查看errno
  3. 输出错误信息——perror快速调试,strerror用于日志

嗯,记住这个流程,文件操作就不会再出“无声的故障”了。我在团队里经常说:不检查返回值的代码,就是在给自己埋雷。


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