1. 项目背景与核心需求拆解
1.1 为什么要在实时操作系统上折腾文件系统
做过嵌入式开发的人都有一个共识:裸机跑程序的时候,数据存储怎么简单怎么来,直接操作Flash扇区或者EEPROM读写就行。但一旦项目复杂度上来,尤其是引入实时操作系统之后,事情就变了。你可能会遇到这样的场景:设备需要同时记录运行日志、保存用户配置、还要支持通过串口导出数据文件——这些需求背后都指向同一个东西:文件系统。
我最早接触这个需求是在一个数据采集项目上。主控跑的是RT-Thread,采集模块每200毫秒往存储介质写一批数据,同时还有一个通信任务需要读取配置文件。最开始我用的是自己写的简易存储管理,直接按固定地址读写Flash。结果问题很快就来了:配置文件一改,日志区的地址就得跟着调;日志写满之后要循环覆盖,逻辑越写越复杂;更头疼的是,两个线程同时操作存储区域的时候,偶尔会出现数据错乱。后来痛定思痛,决定上FatFs。
FatFs是ChaN老师开发的一个通用FAT文件系统模块,完全用ANSI C写成,跟平台无关。它的设计目标就是给小型嵌入式系统提供一个可裁剪、可移植的文件系统方案。支持FAT12、FAT16、FAT32,甚至exFAT(需要单独配置),代码量小,RAM占用可控,对于资源受限的MCU来说非常友好。
但问题在于,FatFs本身的设计是非重入的。什么意思?就是说如果你在多个线程里同时调用f_open、f_write这些接口,底层的数据结构会被破坏,轻则文件损坏,重则整个文件系统挂掉。这在裸机时代不是问题,因为裸机只有一个执行流。但在RT-Thread这种多线程环境下,这就是一个必须解决的核心矛盾。
1.2 重入功能到底解决什么问题
先把这个概念说清楚。重入指的是一个函数在被多个执行流(线程或中断)同时调用时,仍然能保证正确性的能力。FatFs的API大部分是不可重入的,原因在于它内部维护了一个全局的文件系统对象数组(FATFS结构体),每个逻辑驱动器对应一个。当你调用f_mount挂载一个驱动器后,后续的f_open、f_read等操作都会访问这个全局对象。如果两个线程同时操作同一个驱动器,就会出现竞态条件。
举个具体的例子:线程A正在执行f_write,刚把文件大小更新到FATFS对象里,还没来得及写回目录项,线程B调用f_read读取同一个文件。这时候线程B看到的文件大小是更新后的值,但实际数据可能还没完全落盘,读出来的内容就是错的。更严重的情况是,两个线程同时操作FAT表,可能导致FAT链断裂,整个分区就废了。
所以重入功能的核心目标就是:让多个线程能够安全地并发访问文件系统。注意这里说的是“安全”,不是“高性能并发”。在嵌入式场景下,我们通常不需要真正的并行读写,只需要保证当一个线程在操作文件系统时,其他线程要么等待,要么被正确阻塞,不会破坏数据一致性。
1.3 方案选型的几个关键考量
解决重入问题,业界常见的有三种思路:
第一种是全局互斥锁。在FatFs的每个API入口加一把大锁,谁进来谁加锁,出去解锁。这是最简单粗暴的方案,优点是实现简单、不容易出错;缺点是并发度低,一个线程在读写大文件时,其他线程全部阻塞。对于大多数嵌入式应用来说,这个缺点其实可以接受,因为文件操作通常不是高频操作。
第二种是每个驱动器一把锁。RT-Thread的DFS框架(设备文件系统)就是这种思路,它为每个挂载点维护一个互斥量。这样不同驱动器之间的操作可以并行,同一个驱动器的操作仍然串行。这个方案比全局锁灵活一些,适合有多存储介质的场景。
第三种是真正的可重入设计,比如为每个线程维护独立的文件系统上下文。这个方案实现复杂度极高,RAM开销也大,在MCU上基本不现实。
综合来看,基于RT-Thread的DFS框架,利用其提供的互斥保护机制,是最务实的方案。RT-Thread的DFS本身就对下层文件系统有重入保护的要求,它会在调用底层文件系统接口前后进行加锁解锁。我们需要做的,就是正确配置DFS的锁机制,并确保FatFs的移植层与DFS框架正确对接。
这里有一个关键点需要说明:RT-Thread的DFS框架在调用底层文件系统的操作接口时,会自动获取对应挂载点的互斥量。但这个互斥量保护的是DFS层面的操作,如果FatFs内部还有自己的全局状态(比如FATFS对象数组),那么仅靠DFS的锁是不够的。所以我们需要确保FatFs的配置中,每个逻辑驱动器对应独立的FATFS对象,并且DFS的锁能够覆盖到所有对FatFs的调用路径。
2. FatFs移植的核心细节与实操要点
2.1 源码获取与目录结构规划
FatFs的源码可以从其官方网站获取,当前稳定版本是R0.14系列。下载下来之后,核心文件就两个:ff.c和ff.h,以及可选的ffconf.h配置文件。另外还有diskio.c和diskio.h,这两个是移植层需要自己实现的。
在RT-Thread项目中,我建议的目录结构是这样的:
project/ ├── applications/ │ └── main.c ├── drivers/ │ └── diskio.c # 移植层实现 ├── packages/ │ └── fatfs/ │ ├── ff.c │ ├── ff.h │ ├── ffconf.h │ ├── diskio.h │ └── SConscript └── rtconfig.h把FatFs放在packages目录下,通过SConscript脚本控制编译,这样在menuconfig里可以方便地开关。diskio.c放在drivers目录下,因为它是跟具体硬件相关的,换一个板子可能就需要改这个文件。
注意:ffconf.h是FatFs的配置文件,里面有很多宏定义控制功能裁剪。不要直接改ff.c和ff.h,所有配置都通过ffconf.h来做,这样后续升级FatFs版本的时候会轻松很多。
2.2 ffconf.h的关键配置项
ffconf.h里有几十个配置项,但真正影响重入和RTOS适配的,主要是下面这几个:
#define FF_FS_READONLY 0 #define FF_FS_MINIMIZE 0 #define FF_USE_STRFUNC 1 #define FF_USE_FIND 1 #define FF_USE_MKFS 1 #define FF_USE_FASTSEEK 1 #define FF_USE_LABEL 0 #define FF_USE_FORWARD 0 #define FF_CODE_PAGE 936 #define FF_USE_LFN 2 #define FF_MAX_LFN 255 #define FF_LFN_UNICODE 0 #define FF_FS_RPATH 1 #define FF_VOLUMES 2 #define FF_STR_VOLUME_ID 0 #define FF_MULTI_PARTITION 0 #define FF_MIN_SS 512 #define FF_MAX_SS 4096 #define FF_FS_TINY 0 #define FF_FS_EXFAT 0 #define FF_FS_NORTC 1 #define FF_FS_LOCK 0 #define FF_FS_REENTRANT 1 #define FF_FS_TIMEOUT 1000 #define FF_SYNC_t rt_mutex_t重点说几个:
FF_FS_REENTRANT:这个宏是重入功能的总开关。设置为1时,FatFs会要求你提供一套同步机制(通过ff_req_grant、ff_rel_grant等宏)。设置为0时,FatFs完全不考虑重入问题。我们这里必须设为1。
FF_FS_TIMEOUT:同步超时时间,单位是系统节拍。1000表示等待1000个tick,在RT-Thread默认配置下就是1秒。这个值需要根据实际场景调整,如果文件操作比较频繁,可以适当加大。
FF_FS_LOCK:这个宏控制文件锁功能。设置为0时,FatFs不检查文件是否被重复打开。在多线程环境下,我建议至少设置为1,这样可以防止一个线程打开的文件被另一个线程意外关闭。但要注意,这个锁是文件级别的,不是线程级别的,它只能防止重复打开,不能替代互斥量。
FF_VOLUMES:逻辑驱动器数量。根据实际需要设置,不要设太大,每个驱动器都会占用一个FATFS结构体的RAM。
FF_USE_LFN:长文件名支持。设置为2表示使用动态内存分配长文件名缓冲区。如果你的系统RAM比较紧张,可以设置为1,使用静态缓冲区,但那样就不支持重入了(因为静态缓冲区是全局的)。
2.3 同步机制的实现
当FF_FS_REENTRANT设置为1时,FatFs会调用下面这几个宏来实现同步:
#define ff_req_grant(fp) (rt_mutex_take(fp, FF_FS_TIMEOUT) == RT_EOK) #define ff_rel_grant(fp) rt_mutex_release(fp) #define ff_del_syncobj(fp) rt_mutex_delete(fp) #define ff_cre_syncobj(bvol, fp) \ do { \ char name[8]; \ rt_snprintf(name, sizeof(name), "fat%c", '0' + bvol); \ *fp = rt_mutex_create(name, RT_IPC_FLAG_PRIO); \ } while(0)这几个宏定义在ffconf.h的末尾,或者单独放在一个头文件里。注意ff_cre_syncobj的第二个参数是FF_SYNC_t类型的指针,在RT-Thread下就是rt_mutex_t的指针。
这里有一个细节:FatFs在挂载驱动器的时候会调用ff_cre_syncobj创建互斥量,在卸载的时候调用ff_del_syncobj删除。所以如果你反复挂载卸载同一个驱动器,要注意互斥量的创建和删除是否配对。
实操心得:rt_mutex_create的第二个参数我推荐用RT_IPC_FLAG_PRIO而不是RT_IPC_FLAG_FIFO。因为文件操作通常有优先级继承的需求,比如低优先级线程持有文件锁的时候,高优先级线程来请求,优先级继承可以避免优先级反转问题。
2.4 diskio.c的移植要点
diskio.c是FatFs与底层存储介质的桥梁,需要实现以下几个函数:
DSTATUS disk_initialize(BYTE pdrv); DSTATUS disk_status(BYTE pdrv); DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count); DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count); DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff);这些函数的实现跟具体的存储介质相关。如果是SD卡,就调用SD卡的读写接口;如果是SPI Flash,就调用SPI Flash的驱动。这里不展开讲具体驱动的实现,重点说几个容易踩坑的地方。
第一个坑是扇区大小。FatFs默认的扇区大小是512字节(FF_MIN_SS),但有些SPI Flash的擦除单位是4KB。如果你直接按512字节读写,驱动层需要自己做缓冲。我建议在disk_ioctl里正确返回GET_SECTOR_SIZE,让FatFs知道实际的扇区大小。如果FF_MIN_SS和FF_MAX_SS都设置为512,那FatFs就只认512字节的扇区。
第二个坑是写操作的原子性。disk_write在写入过程中如果被打断(比如被高优先级中断抢占),可能导致数据写坏。所以disk_write内部需要做好临界区保护。但注意,这个保护跟FatFs的重入锁是两回事:FatFs的锁保护的是文件系统层面的数据结构,disk_write的临界区保护的是硬件操作层面。
第三个坑是disk_ioctl的CTRL_SYNC命令。这个命令要求把缓存的数据全部写入介质。对于有写缓存的存储设备,必须正确实现这个命令,否则在断电等异常情况下可能丢数据。
3. 与RT-Thread DFS框架的对接实现
3.1 DFS框架的工作机制
RT-Thread的DFS(Device File System)是一个虚拟文件系统层,它向上提供标准的POSIX文件操作接口(open、read、write、close等),向下对接具体的文件系统实现(FatFs、LittleFS、YAFFS等)。DFS的核心是struct dfs_filesystem_ops,每个文件系统需要实现这个结构体里定义的操作函数。
DFS的重入保护机制是这样的:每个挂载点(struct dfs_mount_tbl)在挂载时会创建一个互斥量,保存在struct dfs_filesystem的lock字段里。当应用程序调用open、read等接口时,DFS会先获取这个互斥量,然后调用底层文件系统的对应函数,完成后释放互斥量。
但这里有一个关键问题:DFS的锁是在VFS层加的,它保护的是从VFS到具体文件系统的调用路径。如果两个线程同时调用read,DFS会串行化这两个调用。但如果一个线程调用read,另一个线程直接调用FatFs的f_read(绕过DFS),那就没有保护了。所以移植时必须确保所有文件操作都走DFS接口,不要混用。
3.2 dfs_fatfs的移植实现
RT-Thread已经提供了FatFs的DFS移植层,在components/dfs/filesystems/elmfat目录下。这个移植层实现了dfs_filesystem_ops结构体:
static const struct dfs_filesystem_ops _fatfs_ops = { "elm", DFS_FS_FLAG_DEFAULT, &_fatfs_fops, dfs_fatfs_mount, dfs_fatfs_unmount, dfs_fatfs_mkfs, dfs_fatfs_statfs, dfs_fatfs_stat, dfs_fatfs_rename, dfs_fatfs_unlink, dfs_fatfs_ioctl, ... };其中dfs_fatfs_mount会调用FatFs的f_mount,dfs_fatfs_unmount调用f_mount(NULL),其他函数对应FatFs的f_open、f_read等。
如果你使用的是RT-Thread官方版本,这个移植层已经做好了,你只需要在menuconfig里使能elmfat即可。但如果你用的是自己移植的FatFs版本,或者需要对移植层做定制,那就需要自己实现这些函数。
我遇到过一个情况:项目使用的RT-Thread版本比较老,自带的elmfat移植层不支持FF_FS_REENTRANT。这时候就需要手动修改移植层,在mount的时候创建互斥量,在unmount的时候删除。
3.3 重入配置的完整检查清单
配置完成后,建议按下面的清单逐项检查:
| 检查项 | 预期值 | 说明 |
|---|---|---|
| FF_FS_REENTRANT | 1 | 重入总开关 |
| FF_FS_TIMEOUT | 1000 | 超时时间,根据场景调整 |
| FF_FS_LOCK | ≥1 | 文件锁,防止重复打开 |
| FF_VOLUMES | 实际需要 | 驱动器数量 |
| FF_USE_LFN | 2 | 动态缓冲区,支持重入 |
| RT_USING_DFS | 使能 | DFS框架 |
| RT_USING_DFS_ELMFAT | 使能 | FatFs移植层 |
| RT_DFS_ELMFAT_REENTRANT | 使能 | 移植层重入支持 |
| RT_USING_MUTEX | 使能 | 互斥量支持 |
这个表格里的每一项都需要在rtconfig.h或者menuconfig里确认。特别是最后三项,如果RT_USING_MUTEX没有使能,ff_cre_syncobj里的rt_mutex_create会编译失败。
注意:有些RT-Thread版本里,DFS的重入支持是通过
RT_USING_DFS_REENTRANT宏控制的,而不是RT_DFS_ELMFAT_REENTRANT。具体用哪个宏,要看你的RT-Thread版本。建议直接在源码里搜索REENTRANT关键字确认。
4. 多线程并发读写的实操验证
4.1 测试方案设计
配置完成之后,怎么验证重入功能真的生效了?我设计了一个简单的测试方案:创建三个线程,一个持续写文件,一个持续读文件,一个持续创建和删除文件。三个线程操作同一个驱动器上的不同文件,运行一段时间后检查文件系统是否损坏。
测试代码的大致结构如下:
static rt_mutex_t test_lock; static volatile rt_bool_t test_running = RT_TRUE; void write_thread_entry(void *param) { int fd; char buf[256]; rt_memset(buf, 'W', sizeof(buf)); while (test_running) { fd = open("/sdcard/write_test.txt", O_WRONLY | O_CREAT | O_TRUNC); if (fd >= 0) { for (int i = 0; i < 100; i++) { write(fd, buf, sizeof(buf)); } close(fd); } rt_thread_mdelay(10); } } void read_thread_entry(void *param) { int fd; char buf[256]; while (test_running) { fd = open("/sdcard/write_test.txt", O_RDONLY); if (fd >= 0) { while (read(fd, buf, sizeof(buf)) > 0) { /* 校验数据 */ } close(fd); } rt_thread_mdelay(5); } } void create_delete_thread_entry(void *param) { int fd; char filename[32]; while (test_running) { for (int i = 0; i < 10; i++) { rt_snprintf(filename, sizeof(filename), "/sdcard/tmp_%d.txt", i); fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC); if (fd >= 0) { write(fd, "test", 4); close(fd); } unlink(filename); } rt_thread_mdelay(20); } }三个线程的优先级设置要有差异,这样才能测试优先级继承是否正常工作。我一般把写线程设为最高优先级,读线程中等,创建删除线程最低。
4.2 测试结果分析
在没有启用重入功能的情况下,这个测试跑不了几分钟就会出现问题。常见现象包括:文件内容错乱、目录项丢失、甚至整个分区无法挂载。用调试器抓取的时候,经常能看到FATFS结构体里的fsi(文件系统信息)字段被破坏。
启用重入功能之后,同样的测试可以稳定运行数小时不出错。但要注意,稳定运行的前提是所有文件操作都通过DFS接口。如果某个线程直接调用了f_open,那就绕过了DFS的锁,仍然可能出问题。
我还做过一个对比测试:把FF_FS_TIMEOUT分别设置为100、1000、10000,观察不同超时时间下的表现。结果发现,超时时间太短(100)的时候,高优先级线程偶尔会超时失败;超时时间太长(10000)的时候,系统响应变慢。最终我选择了1000,在RT-Thread默认的1000Hz节拍下就是1秒,这个值在大多数场景下比较均衡。
4.3 性能影响评估
加锁必然会带来性能开销。我实测的数据是:单线程读写的时候,启用重入功能前后性能差异在5%以内,基本可以忽略。多线程并发的时候,由于串行化,总吞吐量会比单线程略低,但这是保证数据一致性的必要代价。
具体来说,写一个256字节的块,单线程情况下大约需要200微秒(不包括存储介质本身的写入时间),加锁解锁的开销大约在10微秒左右。对于大多数嵌入式应用来说,这个开销完全可以接受。
实操心得:如果你的应用对文件操作性能要求极高,可以考虑把文件操作集中到一个专用线程里,其他线程通过消息队列发送请求。这样虽然增加了一次消息传递的开销,但避免了锁竞争,整体吞吐量可能反而更高。
5. 常见问题与排查技巧实录
5.1 挂载失败问题排查
挂载失败是最常见的问题,表现是f_mount返回FR_NOT_READY或者FR_NO_FILESYSTEM。排查思路如下:
首先检查disk_initialize是否返回0(表示成功)。如果返回STA_NOINIT,说明底层存储初始化失败。这时候需要检查硬件连接、供电、时钟配置等。
如果disk_initialize成功但f_mount仍然失败,用disk_read读取0扇区,检查是否有有效的引导记录。FAT文件系统的0扇区最后两个字节应该是0x55AA。如果不是,说明存储介质上没有有效的文件系统,需要先格式化。
格式化可以通过f_mkfs来完成。但要注意,f_mkfs需要较大的栈空间,默认的线程栈可能不够。我遇到过在512字节栈的线程里调用f_mkfs导致栈溢出的情况,后来把栈加大到2048才解决。
5.2 文件操作返回FR_INT_ERR
FR_INT_ERR表示内部错误,通常是因为文件系统数据结构被破坏。在多线程环境下,这个错误几乎可以肯定是重入问题导致的。排查步骤:
确认FF_FS_REENTRANT是否设置为1。确认ff_req_grant和ff_rel_grant宏是否正确实现。确认所有文件操作是否都走了DFS接口。确认DFS的互斥量是否正常工作(可以在加锁解锁的地方加打印)。
还有一个容易被忽略的点:如果使用了FF_USE_LFN=2(动态长文件名),需要确保ff_memalloc和ff_memfree正确实现。这两个宏默认使用malloc和free,但在RT-Thread下,如果使用了内存池或者特殊的内存管理,需要做相应适配。
5.3 系统运行一段时间后死机
这种情况通常是死锁导致的。可能的原因有:
一是互斥量嵌套获取。比如线程A持有DFS的锁,然后在某个回调里又调用了文件操作,试图再次获取同一个锁。RT-Thread的互斥量默认不支持递归获取,所以会死锁。解决办法是避免在持有文件锁的情况下调用文件操作。
二是优先级反转。低优先级线程持有文件锁,高优先级线程等待,中等优先级线程抢占CPU,导致高优先级线程一直等不到锁。解决办法是使用支持优先级继承的互斥量(RT_IPC_FLAG_PRIO)。
三是超时时间设置不当。如果FF_FS_TIMEOUT设置为0,表示无限等待,一旦出现异常就会永久阻塞。建议设置为一个合理的值,超时后返回错误,让上层有机会处理。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 挂载失败 | 存储未初始化 | 检查disk_initialize返回值 | 修复底层驱动 |
| 挂载失败 | 无文件系统 | 读0扇区检查0x55AA | 执行f_mkfs格式化 |
| FR_INT_ERR | 重入冲突 | 检查FF_FS_REENTRANT配置 | 启用重入并检查锁实现 |
| 数据错乱 | 绕过DFS直接调用FatFs | 搜索代码中的f_open调用 | 统一走DFS接口 |
| 系统死锁 | 互斥量嵌套 | 检查回调中的文件操作 | 避免嵌套获取 |
| 系统死锁 | 优先级反转 | 检查互斥量创建标志 | 使用RT_IPC_FLAG_PRIO |
| 超时失败 | 超时时间太短 | 观察超时发生频率 | 适当增大FF_FS_TIMEOUT |
| 栈溢出 | f_mkfs栈需求大 | 检查线程栈使用情况 | 增大线程栈 |
5.5 几个容易被忽略的细节
第一个细节是ff_cre_syncobj的返回值。这个宏在创建互斥量失败时应该返回0,但很多移植版本直接忽略了返回值。如果互斥量创建失败,后续的ff_req_grant会操作一个空指针,直接导致硬件异常。建议在ff_cre_syncobj里加上返回值检查。
第二个细节是卸载时的资源释放。f_mount(NULL)会调用ff_del_syncobj删除互斥量。但如果此时还有其他线程在等待这个互斥量,删除操作会导致未定义行为。正确的做法是先确保所有文件操作都已完成,再执行卸载。
第三个细节是FF_FS_LOCK和DFS锁的关系。FF_FS_LOCK是FatFs层面的文件锁,它记录的是哪个文件被打开了。DFS锁是VFS层面的互斥量,它保证同一时间只有一个线程在操作文件系统。两者配合使用才能既保证数据一致性,又防止文件被意外关闭。
第四个细节是长文件名的内存分配。FF_USE_LFN=2时,每次文件操作都会动态分配长文件名缓冲区。如果内存碎片化严重,可能导致分配失败。在长时间运行的系统里,建议使用内存池而不是malloc来管理这部分内存。
6. 进阶优化与扩展思路
6.1 性能优化:减少锁竞争
如果文件操作比较频繁,锁竞争会成为瓶颈。一个有效的优化手段是减小锁的粒度。RT-Thread的DFS默认是在整个操作期间持有锁,比如f_write从开始到结束都持有锁。但实际上,f_write内部可以分为几个阶段:参数检查、FAT表更新、数据写入、目录项更新。其中数据写入阶段是最耗时的,但这个阶段其实不需要持有文件系统锁,只需要保证同一文件的写入互斥即可。
不过,修改DFS的锁粒度需要改动框架代码,风险较大。更稳妥的做法是在应用层做优化:把大文件拆分成多个小文件,减少单次操作的时间;或者把文件操作分散到不同的驱动器上,利用多驱动器的并行能力。
6.2 掉电保护:数据安全加固
嵌入式设备经常面临意外掉电的情况。FatFs本身有一定的掉电保护能力,但不够完善。我通常会在应用层做几件事:
一是定期调用f_sync,把缓存数据刷到存储介质。f_sync比f_close轻量,不会关闭文件,只是把脏数据写回。
二是使用双备份策略。重要的配置文件保存两份,读取的时候先读主文件,校验失败再读备份文件。
三是在文件内容里加校验和。每次写入的时候计算CRC,读取的时候验证。如果校验失败,说明数据损坏,可以从备份恢复。
6.3 多驱动器配置
如果系统有多个存储介质(比如SD卡和SPI Flash),可以配置多个逻辑驱动器。每个驱动器有独立的FATFS对象和独立的互斥量,可以并行操作。
配置方法是在ffconf.h里把FF_VOLUMES设置为2或更大,然后在diskio.c里根据pdrv参数区分不同的物理驱动器。挂载的时候分别挂载到不同的路径:
f_mount(&fs[0], "0:", 1); // SD卡 f_mount(&fs[1], "1:", 1); // SPI Flash在DFS层面,可以通过dfs_mount把不同的FatFs驱动器挂载到不同的目录:
dfs_mount("sd0", "/sdcard", "elm", 0, 0); dfs_mount("flash0", "/flash", "elm", 0, 0);这样应用程序就可以通过路径来区分不同的存储介质了。
6.4 与LittleFS的对比选型
FatFs不是唯一的选择。RT-Thread还支持LittleFS,这是一个专为嵌入式设计的日志型文件系统,具有更好的掉电保护能力和更低的RAM占用。但LittleFS的兼容性不如FatFs,PC上不能直接读取。
我的选型建议是:如果设备需要跟PC交换数据(比如通过USB导出日志),用FatFs;如果纯粹是设备内部存储,对掉电保护要求高,用LittleFS。两者在RT-Thread下都支持重入,配置方式类似。
6.5 调试技巧:用SystemView分析锁竞争
RT-Thread支持SEGGER的SystemView工具,可以实时显示线程状态和互斥量操作。在调试重入问题的时候,我经常用SystemView来观察:
哪个线程持有文件锁、持有了多长时间、哪些线程在等待、等待了多久。通过这些信息可以快速定位锁竞争的热点,有针对性地优化。
如果没有SystemView,也可以在ff_req_grant和ff_rel_grant里加打印,输出时间戳和线程ID。虽然会影响性能,但调试阶段很有用。
7. 实际项目中的经验总结
7.1 配置阶段的关键决策
回顾我经手的几个项目,在FatFs重入配置上,有几个决策点值得分享。
第一个是FF_FS_TIMEOUT的值。我最初设置的是100,觉得1秒太长了。结果在一个高频写入的场景下,偶尔会出现超时失败。后来分析发现,当存储介质正在进行垃圾回收或者坏块管理的时候,单次写操作可能超过100毫秒。改成1000之后问题消失。所以这个值要根据最坏情况下的操作耗时来定,不能拍脑袋。
第二个是FF_USE_LFN的选择。如果项目不需要长文件名,果断设置为0,可以省下不少RAM和代码空间。如果确实需要,设置为2(动态分配)而不是1(静态缓冲区),因为静态缓冲区不支持重入。但设置为2之后,要确保内存管理是线程安全的。
第三个是FF_FS_LOCK的值。我一般设置为FF_VOLUMES的2倍,这样每个驱动器可以同时打开多个文件。如果设置为0,虽然省了一点RAM,但失去了文件保护,在多线程环境下风险很大。
7.2 测试阶段的有效方法
功能测试通过之后,一定要做压力测试和异常测试。
压力测试就是前面说的多线程并发读写,跑至少24小时。我一般会写一个脚本,自动记录每次操作的返回值和耗时,跑完之后分析有没有异常。
异常测试包括:在文件操作过程中突然断电、在写入过程中拔掉SD卡、在挂载状态下复位系统。这些异常场景最能暴露问题。我遇到过一个bug:在写入过程中断电,重新上电后文件系统无法挂载。后来发现是FAT表更新到一半被打断,导致FAT链损坏。解决办法是在disk_write里实现掉电保护,确保FAT表的更新是原子的。
7.3 维护阶段的注意事项
系统上线之后,文件系统的维护也很重要。我建议在应用层加一个文件系统健康检查功能,定期执行以下检查:
检查剩余空间是否充足、检查文件数量是否超过预期、检查关键文件是否存在且完整、检查文件系统是否可读写。
如果发现异常,及时告警或者自动修复。比如剩余空间不足的时候,自动删除最旧的日志文件;关键文件损坏的时候,从备份恢复。
还有一个经验:不要频繁挂载卸载。有些开发者习惯在每次文件操作前挂载,操作完卸载。这样做虽然省电,但会增加文件系统损坏的风险。正确的做法是系统启动时挂载一次,运行期间一直保持挂载状态,只有在确定不再使用的时候才卸载。
7.4 一个真实的踩坑案例
最后分享一个我踩过的坑。在一个项目里,我按照标准流程配置了FatFs的重入功能,测试也通过了。但产品小批量试产的时候,有几台设备出现了文件系统损坏的问题。
排查了很久,最后发现是中断里调用了文件操作。这个项目有一个定时器中断,每10毫秒触发一次,在中断里往文件里写一条记录。中断的优先级高于所有线程,所以当线程持有文件锁的时候,中断来了,直接调用f_write,绕过了互斥量,导致数据结构被破坏。
解决办法是把中断里的文件操作改成发送消息到队列,由专门的线程来处理。这个案例告诉我们:重入保护只对线程有效,对中断无效。任何在中断里调用文件系统的行为都是危险的。
实操心得:如果你确实需要在中断里记录数据,可以在中断里把数据写入一个环形缓冲区,然后由一个低优先级线程定期把缓冲区的内容写入文件。这样既保证了实时性,又避免了中断里的文件操作。
7.5 关于RT-Thread版本的兼容性提醒
不同版本的RT-Thread在DFS和elmfat的实现上有差异。比如RT-Thread 3.x和4.x的DFS接口就有变化,4.x引入了dfs_file结构体,文件操作接口从struct dfs_fd改成了struct dfs_file。移植的时候要注意版本匹配。
另外,RT-Thread的软件包中心里有elmfat包,可以直接通过menuconfig添加。但我建议还是把FatFs源码放在项目里,因为软件包中心的版本可能跟你的RT-Thread版本不兼容,而且出了问题不好调试。
如果你使用的是RT-Thread Nano版本,那就要注意了:Nano版本默认不包含DFS组件,需要手动添加。而且Nano版本的内存管理比较简单,可能不支持动态内存分配,这时候FF_USE_LFN就只能设置为0或者1。
整个移植过程下来,我的体会是:FatFs的重入功能本身不复杂,配置项就那么几个,但要把整个链路都打通,需要同时对FatFs、RT-Thread的DFS框架、以及底层存储驱动都有清晰的理解。任何一个环节出问题,都会表现为文件系统异常,排查起来比较费劲。所以建议在项目初期就把这套机制搭好、测好,不要等到产品快发布的时候再来补。