模块化编程中的文件系统操作

文件操作,说白了就是嵌入式系统和外部存储打交道的接口。我做了这么多年嵌入式,发现很多项目出问题,最后追根溯源,都是文件读写那点事儿没处理好。今天咱们就把文件读写封装、目录遍历、配置文件和日志模块这几个硬骨头啃下来。

一、文件读写封装:别让底层API裸奔

我见过太多代码,fopen、fread、fwrite直接散落在业务逻辑里。这样写一时爽,后期维护火葬场。你想想看,如果哪天要换文件系统,或者从SD卡换成SPI Flash,你得改多少地方?

我的习惯是,做一层薄薄的封装。不搞复杂的抽象工厂,就一个简单的文件操作接口。

/* file_io.h */
#ifndef FILE_IO_H
#define FILE_IO_H

#include <stdint.h>
#include <stdbool.h>

typedef struct {
    void *handle;      /* 文件句柄,对上层透明 */
    char mode[4];      /* 打开模式 */
    uint32_t pos;      /* 当前读写位置 */
    uint32_t size;     /* 文件大小 */
} FileObj;

/* 打开文件,成功返回true */
bool File_Open(FileObj *file, const char *path, const char *mode);

/* 关闭文件 */
void File_Close(FileObj *file);

/* 读取数据,返回实际读取字节数 */
int32_t File_Read(FileObj *file, uint8_t *buf, uint32_t size);

/* 写入数据,返回实际写入字节数 */
int32_t File_Write(FileObj *file, const uint8_t *buf, uint32_t size);

/* 定位到指定位置 */
bool File_Seek(FileObj *file, int32_t offset, int whence);

/* 获取文件大小 */
uint32_t File_GetSize(FileObj *file);

#endif /* FILE_IO_H */

你看,这个接口把文件句柄藏起来了。上层代码根本不知道底层用的是标准C库还是FatFS。我曾经在一个项目里,前期用SD卡调试,后期换成SPI Flash,只改了File_Open和File_Read这两个函数的实现,上层业务代码一行没动。

我的小技巧: 在FileObj结构体里加一个uint32_t magic字段,初始化为0xDEADBEEF。每次操作前检查magic是否被破坏,能快速定位野指针问题。

二、目录遍历模块:递归还是迭代?

目录遍历,听起来简单,做起来坑不少。嵌入式环境里,目录深度可能很深,文件名可能很长,还可能遇到符号链接死循环。

我个人倾向于用迭代方式实现,不用递归。为什么?嵌入式系统的栈空间通常只有几KB,递归深了直接栈溢出。我在一个RTOS项目里吃过这个亏,递归遍历到第8层就挂了。

/* dir_walk.h */
#ifndef DIR_WALK_H
#define DIR_WALK_H

#include <stdint.h>
#include <stdbool.h>

/* 回调函数,返回false停止遍历 */
typedef bool (*DirWalkCallback)(const char *fullPath, 
                                const char *fileName, 
                                bool isDir, 
                                uint32_t fileSize,
                                void *userData);

/* 遍历目录,返回遍历的文件/目录总数 */
int32_t Dir_Walk(const char *rootPath, 
                 DirWalkCallback callback, 
                 void *userData);

#endif /* DIR_WALK_H */

实现的时候,我用一个循环队列来模拟栈。每次遇到子目录,就把路径压入队列尾部,然后从队列头部取出下一个要处理的路径。这样不管目录多深,内存占用都是固定的。

注意: 回调函数里不要做耗时操作,比如写日志、网络传输。否则遍历一个大目录会卡死整个系统。我建议回调函数只做收集信息的工作,处理逻辑放到外面。

三、配置文件模块:别用INI,用结构体

很多嵌入式工程师喜欢用INI文件做配置。说实话,我一开始也这么干。但后来发现,INI解析库太臃肿,而且字符串解析容易出bug。你想想看,一个键值对解析,要处理空格、注释、转义字符,代码量轻松上千行。

我的做法是:直接用二进制结构体。把配置定义成一个结构体,整个读写。简单、高效、可靠。

/* config.h */
#ifndef CONFIG_H
#define CONFIG_H

#include <stdint.h>
#include <stdbool.h>

/* 配置结构体,注意字节对齐 */
#pragma pack(push, 1)
typedef struct {
    uint32_t magic;          /* 魔数,校验用 */
    uint32_t version;        /* 版本号 */
    uint16_t baudrate;       /* 波特率 */
    uint8_t  parity;         /* 校验位 */
    uint8_t  stopBits;       /* 停止位 */
    char     deviceName[32]; /* 设备名称 */
    uint32_t crc32;          /* CRC校验 */
} AppConfig;
#pragma pack(pop)

/* 加载配置,成功返回true */
bool Config_Load(AppConfig *cfg, const char *path);

/* 保存配置 */
bool Config_Save(const AppConfig *cfg, const char *path);

/* 恢复默认配置 */
void Config_SetDefault(AppConfig *cfg);

#endif /* CONFIG_H */

这里有个关键点:魔数和CRC校验。我遇到过配置文件被意外写坏的情况,如果没有校验,系统会用垃圾数据启动,后果很严重。魔数用来快速判断文件是不是我们的配置,CRC用来检查数据完整性。

经验之谈: 版本号字段一定要留。产品迭代过程中,配置结构体肯定会变。有了版本号,你可以在加载时做兼容处理,比如旧版本配置自动升级到新版本。

四、日志文件模块:环形缓冲区的妙用

日志模块,我做了不下十次。每次都有新感悟。嵌入式日志和PC端不一样,不能随便写,要考虑Flash寿命、实时性、掉电保护。

我的方案是:内存里开一个环形缓冲区,日志先写到缓冲区,后台任务再批量刷到文件。这样既保证了实时性,又减少了写Flash的次数。

/* logger.h */
#ifndef LOGGER_H
#define LOGGER_H

#include <stdint.h>
#include <stdbool.h>

/* 日志级别 */
typedef enum {
    LOG_LEVEL_DEBUG = 0,
    LOG_LEVEL_INFO,
    LOG_LEVEL_WARN,
    LOG_LEVEL_ERROR,
    LOG_LEVEL_FATAL
} LogLevel;

/* 初始化日志系统 */
bool Log_Init(const char *logDir, uint32_t maxFileSize);

/* 写日志 */
void Log_Write(LogLevel level, const char *file, int line, 
               const char *fmt, ...);

/* 刷缓冲区到文件 */
void Log_Flush(void);

/* 宏定义,方便使用 */
#define LOG_DEBUG(fmt, ...) \
    Log_Write(LOG_LEVEL_DEBUG, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_INFO(fmt, ...)  \
    Log_Write(LOG_LEVEL_INFO, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_ERROR(fmt, ...) \
    Log_Write(LOG_LEVEL_ERROR, __FILE__, __LINE__, fmt, ##__VA_ARGS__)

#endif /* LOGGER_H */

环形缓冲区的大小,我一般设成4KB。太小了容易丢日志,太大了浪费内存。缓冲区满了怎么办?我采用覆盖策略,新日志覆盖最旧的日志。反正掉电后缓冲区内容就没了,重要的日志已经刷到文件里了。

一个实用技巧: 在Log_Flush函数里,检查当前日志文件大小。如果超过maxFileSize,就自动切换到新文件。文件名加上时间戳,比如"log_20240101_120000.txt"。这样日志不会无限增长,也方便按时间查找。

五、知识体系总览

下面这张图,是我对文件系统操作模块的整体理解。四个模块各司其职,又相互配合。

文件系统操作模块架构 文件操作封装层 文件读写封装 File_Open/Read/Write 目录遍历模块 Dir_Walk + 回调 配置文件模块 二进制结构体 日志文件模块 环形缓冲区 底层存储介质(SD卡 / SPI Flash / EEPROM) 关键设计原则 接口统一 · 错误处理 · 资源管理 · 可移植性

你看,文件操作封装层在最上面,它屏蔽了底层存储介质的差异。四个子模块都通过封装层来读写文件,不直接操作底层API。这样设计的好处是,换存储介质只需要改封装层,其他模块完全不受影响。

总结一下我的核心思路:
  • 文件读写要封装,别让底层API裸奔
  • 目录遍历用迭代,别用递归
  • 配置文件用二进制结构体,别用INI
  • 日志用环形缓冲区,兼顾实时性和Flash寿命

这些模块看起来简单,但每个我都踩过坑。比如配置文件,我一开始没加CRC校验,结果有一次写入过程中掉电,配置数据半残不残,系统启动后行为诡异,查了两天才找到原因。从那以后,CRC就成了我的标配。

嗯,文件系统操作这块,说白了就是「稳」字当头。嵌入式环境不比PC,没有那么多容错空间。把基础模块做扎实了,上层业务才能跑得放心。


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