文件操作模块:文件的打开/关闭/读写封装、目录遍历、文件锁的基本使用

文件操作,说白了就是嵌入式系统和外部存储打交道的「接口」。我做了这么多年嵌入式,发现很多同事在文件操作上栽过跟头——不是忘了关文件句柄,就是多任务下文件内容被写乱。今天咱们就把这块彻底捋清楚。

一、文件打开与关闭的封装

我个人习惯,从来不会在业务代码里直接调用 fopen()fclose()。为什么?因为裸调用太容易出问题了。你想想看,如果文件打开失败,你是返回 NULL 还是打印个日志?如果中途断电,文件句柄谁来回收?

所以我会封装一层,像这样:

typedef struct {
    FILE *fp;
    char  filename[64];
    int   open_mode;
    int   ref_count;   /* 引用计数,支持共享打开 */
} FileHandle;

FileHandle* file_open(const char *path, const char *mode) {
    FileHandle *fh = (FileHandle*)malloc(sizeof(FileHandle));
    if (!fh) return NULL;

    fh->fp = fopen(path, mode);
    if (!fh->fp) {
        free(fh);
        return NULL;
    }

    strncpy(fh->filename, path, sizeof(fh->filename)-1);
    fh->open_mode = (mode[0]=='r') ? 0 : 1;
    fh->ref_count = 1;

    return fh;
}

void file_close(FileHandle *fh) {
    if (!fh) return;

    if (--fh->ref_count == 0) {
        fclose(fh->fp);
        free(fh);
    }
}

我的小技巧:引用计数这个设计,是我在一个多线程日志模块里用上的。当时多个任务同时写同一个日志文件,如果谁都用完就关,别的任务就崩了。加上 ref_count,只有最后一个使用者才真正关闭文件。

二、读写操作的封装与缓冲策略

读写操作,我建议统一封装成「行读写」和「块读写」两个接口。别让业务代码直接操作 fread/fwrite 的细节。

int file_read_line(FileHandle *fh, char *buf, int max_len) {
    if (!fh || !fh->fp) return -1;
    if (fgets(buf, max_len, fh->fp) == NULL) return -1;
    return strlen(buf);
}

int file_write_block(FileHandle *fh, const void *data, int len) {
    if (!fh || !fh->fp) return -1;
    size_t written = fwrite(data, 1, len, fh->fp);
    if (written != len) {
        /* 我曾经遇到过写一半磁盘满的情况,这里必须做错误处理 */
        return -1;
    }
    return (int)written;
}

嗯,这里要注意:嵌入式环境下的文件系统往往是 FAT32 或 LittleFS,写操作如果频繁小数据量写入,会产生严重的碎片。我建议在封装层内部加一个 512 字节的缓冲区,攒够了再 flush 到磁盘。

核心原则:文件读写封装要做到「三统一」——错误码统一、缓冲策略统一、生命周期管理统一。

三、目录遍历的实现

目录遍历在嵌入式里用得不多,但一旦需要,往往就是关键功能——比如固件升级时扫描 SD 卡里的 .bin 文件。我习惯用 POSIX 的 opendir/readdir,但要注意可移植性。

#include <dirent.h>

int traverse_directory(const char *dir_path, 
                       void (*callback)(const char *filename, void *arg),
                       void *arg) {
    DIR *dir = opendir(dir_path);
    if (!dir) return -1;

    struct dirent *entry;
    while ((entry = readdir(dir)) != NULL) {
        /* 跳过 . 和 .. */
        if (strcmp(entry->d_name, ".") == 0 || 
            strcmp(entry->d_name, "..") == 0) {
            continue;
        }
        callback(entry->d_name, arg);
    }

    closedir(dir);
    return 0;
}

我曾经踩过的坑:在某个 RTOS 上,readdir 返回的 d_type 字段不可靠,导致我把目录当文件处理了。后来我改成用 stat() 判断文件类型,虽然慢一点,但稳。

四、文件锁的基本使用

多任务环境下,文件锁是保命用的。我见过最惨的一次,两个任务同时写同一个配置文件,结果文件内容变成两段数据交错在一起,整个系统配置全乱了。

嵌入式 Linux 下,我常用 flock()fcntl() 做建议性锁:

#include <sys/file.h>

int file_lock(FileHandle *fh, int type) {
    if (!fh || !fh->fp) return -1;
    /* type: LOCK_SH 共享锁, LOCK_EX 排他锁 */
    return flock(fileno(fh->fp), type | LOCK_NB);
}

int file_unlock(FileHandle *fh) {
    if (!fh || !fh->fp) return -1;
    return flock(fileno(fh->fp), LOCK_UN);
}

注意,LOCK_NB 是非阻塞模式。我建议在封装层默认加上非阻塞,如果获取锁失败,返回一个明确的错误码,让上层决定是重试还是放弃。

我的习惯:文件锁的粒度要尽量小。只锁真正需要保护的写操作,不要一锁锁整个文件生命周期。否则性能会很难看。

五、整体模块结构图

下面这张图,是我自己设计文件操作模块时画的架构图。你看一眼就明白各层的关系了:

应用层(业务代码) 文件操作封装接口层 file_open / file_close / file_read_line / file_write_block file_lock / file_unlock / traverse_directory 文件生命周期 打开/关闭 引用计数管理 数据读写 行读写/块读写 缓冲策略 目录操作 遍历/过滤 回调机制 并发控制 文件锁 非阻塞/阻塞 底层系统调用(fopen/fread/fwrite/flock/opendir)

六、避坑指南与经验总结

最后,我把这些年积累的几个关键经验列出来,你写代码时对照着检查:

场景 常见问题 我的建议
文件打开 忘记检查返回值,直接使用 NULL 指针 封装层统一返回 NULL 并记录错误码
文件关闭 多路径下忘记关闭,造成句柄泄漏 用引用计数 + RAII 思想管理生命周期
文件写入 写入一半掉电,文件损坏 先写临时文件,rename 原子替换
目录遍历 递归遍历导致栈溢出 限制递归深度,或用迭代方式实现
文件锁 忘记解锁,导致死锁 在封装层用 goto cleanup 模式保证解锁

重要提醒:文件锁在 NFS 或某些网络文件系统上可能不可靠。如果你的嵌入式设备挂载了远程文件系统,一定要做充分的压力测试。我曾经在一个项目里因为这个原因,线上数据出现了不一致,排查了整整三天。

好了,文件操作模块的核心内容就这些。记住一句话:封装不是为了让代码变复杂,而是为了让错误无处藏身。你按这个思路去设计,文件操作这块基本不会出大问题。


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