年前我接手一个带OTA升级的智能硬件项目,存储方案从裸指针写到环形缓冲,再到最后彻底切到LittleFS,这中间的坑足够写一篇长文了。很多人一聊嵌入式文件系统就想到FATFS或者SPIFFS,可一旦把掉电安全、磨损均衡、RAM占用这三个维度放一起考量,LittleFS几乎是目前最适合资源受限MCU的选择。这篇文章不讲空话,从设计原理到lfs_config参数怎么填,再到我实测掉电测试时遇到的各种诡异现场,一次性说清楚。
1. 嵌入式存储里的“稳”,到底稳在哪
1.1 Flash的物理限制:擦除、寿命、掉电撕裂
做嵌入式久了就知道,NOR Flash也好,NAND也好,物理层都有一堆反直觉的限制。先说擦除寿命,W25Q这类SPI NOR Flash的标称擦写次数通常是10万次左右,看起来很多,但如果你拿一个扇区当“掉电记录区”频繁写,可能设备运行几个月就把这个扇区磨穿了。Flash还有个更麻烦的特性:只能按扇区擦除,不能按字节擦除。写入前必须先擦除,而擦除是按块来的,这就导致“想改一个字节”和“必须擦掉4KB”之间的矛盾。
最要命的是掉电撕裂。MCU正在写某个扇区时突然断电,Flash内部可能停在半写状态:一部分是旧数据,一部分是新数据,甚至写命令被中断在半途。我们之前用裸指针方案,把配置信息放在固定地址,每次更新直接擦写,结果遇到二次上电读出的配置偶尔是坏的。用逻辑分析仪抓了几天,最后发现是测试人员按电源通断太快,复位释放瞬间MCU就发起了写入,但Flash内部电源还没稳定。这类问题在量产环境里根本不是小事,掉电错误会导致设备反复重启,最后只能返厂。
1.2 LittleFS为哪类场景而生
LittleFS是ARM在2017年开源的一个轻量级文件系统,设计目标非常明确:为掉电不安全的嵌入式设备和微型Flash提供可靠的文件存储。它最核心的三件事就是掉电安全、动态磨损均衡、低RAM占用。
它不需要MCU外扩DDR,不需要大块缓存,官方说运行时吃RAM很小,实际配下来把缓存压到几十字节量级也能跑,这对Cortex-M0这类小内存平台非常友好。而“掉电安全”这点,LittleFS不是靠外部电容或备用电池,而是靠内部数据结构的原子提交设计实现的,属于结构层面的稳。换句话说,哪怕写入过程中主控被直接拍死,下一次上电挂载文件系统依然能成功,已经提交的旧数据不损坏,未提交的半截数据最多丢那么一点。
适合用LittleFS的场景,我总结下来有三类:一类是配置参数、升级标志位、证书密钥这类需要频繁改写且对可靠性格外敏感的小文件;一类是掉电可能发生在任意时刻的电池类产品,比如电子锁、传感器节点、手持设备;还有一类是希望用文件系统结构组织数据,但芯片资源又撑不起FATFS那套缓冲的MCU项目。
2. LittleFS的可靠性设计:不是玄学,是三个机制
LittleFS“稳”的口碑是有技术支撑的。我建议每个打算用它的工程师都稍微了解一下底层机制,因为理解了原理,后面遇到问题才知道往哪个方向排查。
2.1 元数据提交:掉电瞬间不会写坏结构
LittleFS把目录、文件属性、分配信息这类关键数据以**元数据对(metadata pair)**的方式管理。每个元数据对由两个block组成,数据有双份镜像,同时每个block内部采用“日志式提交”的机制:在已有数据上追加修改记录,直到这个block满了才做一次全量压缩。
这种设计带来的直接好处是:写元数据这件事天然具备原子性。每次提交都会先写CRC校验信息和修订序号,掉电后再上电,LittleFS比较两边的修订版本,自动选用最新且完整的那一份。所以即使你恰好在元数据提交的边界掉电,也不会出现目录项指向一个不存在文件这种结构性损坏。重点是它不需要额外的日志分区,文件系统自己就是日志型的,省空间也省逻辑复杂度。
有件事必须说清楚:掉电安全不等于“数据不丢”。LittleFS能保证的是文件系统结构不崩、之前sync过的文件内容不损坏,但如果你写完数据后没有调用lfs_file_sync,断电后这部分数据可能丢失。这个特性跟PC上的文本编辑器一样,你按了保存才代表数据落盘,没保存就断电那就只能自认倒霉。
2.2 CTZ skip-list:低RAM代价下的文件索引
很多嵌入式文件系统用FAT表或inode数组来索引文件内容,但这类结构要么占用大量RAM,要么需要额外在Flash上维护一大块表。LittleFS则用了一种叫CTZ skip-list的结构,可以理解为针对嵌入式场景改良的链式索引。
每个文件是一串block组成的链表,链表的每第2的幂次方个节点都保存一个“跳表指针”,指向后面的block。这样查找文件尾部数据时,不是从头一个block一个block地遍历,而是通过跳表跳跃前进,复杂度接近对数级。最妙的是,这个索引结构本身也是基于写时复制设计的,每次写文件时被修改的block会被重新分配和级联更新,掉电时不会出现“旧指针指向新数据”这种不一致。
因为不需要常驻内存的完整索引表,所以LittleFS的RAM占用才能做得这么低。实际项目中,我甚至在一颗只有20KB RAM的单片机上跑得稳稳的,文件系统缓存加起来不到5KB。
2.3 磨损均衡:给Flash寿命做“雨露均沾”
Flash的擦写次数有上限,如果文件系统总在物理地址靠前的block上写,前面先磨死,后面还崭新。LittleFS做磨损均衡的思路是:用lookahead分配器维护一个“空闲块位图”,分配时优先挑最近最少使用的block;同时配合block_cycles参数,当某个block被写完一定次数后,强制把热点数据迁移到别的block,避免写放大集中在个别扇区。
这里我建议不要为了性能把block_cycles直接设成-1关闭磨损均衡。除非你的分区里全部是只读文件,比如字库、图片资源。凡是存在频繁写操作的配置分区,我都建议保留默认值或设成500左右。磨损均衡不是锦上添花,在很多设备上它直接决定Flash能不能活过产品生命周期。
3. 落地移植:从源码到板子跑通
LittleFS的使用比大家想象中简单,它本质上是一个纯C库,没有平台相关的代码,真正要你操心的是提供一个Flash驱动和一份正确的lfs_config参数。
3.1 驱动适配:四个函数打底
从GitHub拉取littlefs仓库后,你只需要在源码里加上适配层。核心是四个回调函数:read、prog、erase、sync,函数签名直接照lfs_config里的定义写就行。
// 从指定block的offset处读取size字节 int flash_read(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, void *buffer, lfs_size_t size); // 从指定block的offset处写入size字节 int flash_prog(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, const void *buffer, lfs_size_t size); // 擦除整个block int flash_erase(const struct lfs_config *c, lfs_block_t block); // 刷写操作,确保数据真正落盘 int flash_sync(const struct lfs_config *c);说个容易忽略的点:prog回调必须按prog_size的整数倍写入,不能像文件API那样随意字节长度。因为内部缓存就是按prog_size对齐的,如果你驱动里强行拼包也会触发对齐错误。我见过有同事在SPI Flash页编程时直接拿文件长度传给W25Q的页写函数,写到最后跨页边界越界,导致该block后面数据被清掉。
3.2 lfs_config参数:千万别凭感觉填
lfs_config算是整个移植里最容易翻车的部分。不夸张地说,参数填错轻则文件系统挂不上,重则把数据写到错误区域导致文件损坏。下面这份配置对应一片2MB的SPI NOR Flash,擦除扇区4KB,页编程256字节:
const struct lfs_config cfg = { .read = flash_read, .prog = flash_prog, .erase = flash_erase, .sync = flash_sync, .read_size = 256, .prog_size = 256, .block_size = 4096, .block_count = 512, .cache_size = 256, .lookahead_size = 64, .block_cycles = 500, };block_size一定要等于Flash实际擦除扇区大小,很多SPI Flash是4KB或64KB,去读数据手册确认,别想当然按页大小填。block_count是总共有多少个block,2MB除以4KB等于512。
read_size和prog_size通常是SPI Flash的最小读对齐和页大小,对W25Q这类芯片一般是256。cache_size建议等于prog_size或read_size,它是LittleFS内部缓存一个block片段用的,设太大浪费RAM,设太小会频繁触发搬移。lookahead_size跟block数量相关,用于存储空闲块位图,每个block占1bit,512个block就需要512bit也就是64字节,所以上面填64。如果lookahead_size填得比这个值小,磨损均衡和分配效率都会受影响。
3.3 格式化、挂载和文件操作
移植完成后,第一次使用要做格式化,这个过程会清空整片Flash并写入初始文件系统结构:
lfs_t lfs; int err = lfs_format(&lfs, &cfg); if (err < 0) { // 格式化失败,通常是驱动或参数问题 } err = lfs_mount(&lfs, &cfg);在实际产品里,我习惯把“挂载失败后自动格式化”的逻辑写进去:第一次烧录固件后Flash是空的,直接挂载必然失败,这时候需要格式化。但如果挂载失败是因为重启瞬间掉电,应该先尝试重新挂载,不要无脑格式化,否则真正需要的错误现场被抹掉了。
文件操作API很直白,基本沿用了POSIX风格。写入一个配置文件的示意如下:
lfs_file_t file; err = lfs_file_open(&lfs, &file, "/settings.cfg", LFS_O_CREAT | LFS_O_RDWR); if (err >= 0) { lfs_file_write(&lfs, &file, "reboot_count=12\n", 16); lfs_file_sync(&lfs, &file); // 必须sync,否则掉电可能丢 lfs_file_close(&lfs, &file); }读目录遍历也很简单:
lfs_dir_t dir; struct lfs_info info; lfs_dir_open(&lfs, &dir, "/"); while (lfs_dir_read(&lfs, &dir, &info) > 0) { if (info.type == LFS_TYPE_REG) { // 普通文件 } else if (info.type == LFS_TYPE_DIR) { // 子目录 } } lfs_dir_close(&lfs, &dir);如果你想在RTOS里用,还得处理并发问题。LittleFS默认不是线程安全的,我在FreeRTOS项目里是开LFS_THREADSAFE宏,然后给文件系统操作加一个互斥锁。如果不开这个宏,两个任务同时写文件,轻则数据错乱,重则缓存里搬移掉电后无法恢复。这个坑不值得踩一遍,提前做好规划省得后期返工。
4. 掉电实测踩坑录:三种诡异现场与修复
移植跑通只是开始,真正让人头大的是可靠性验证阶段。我把实测中印象最深的三个问题列出来,供大家参考排查思路。
4.1 擦除块配置与实际扇区尺寸不一致
一次联调时,同事把block_size填成了512,他觉得反正文件系统逻辑块小一点更灵活,512不是也能跑吗。结果格式化后挂载没问题,但写文件到一定大小后,读回的数据部分损坏。
定位过程是这样的:先在驱动回调里加了计数打印,发现touch到某些block时SPI Flash写入总会跨页失败。继续拆,发现底层Flash页写命令只支持256字节写入,而文件系统认为512字节是一个block,每次prog操作字节数超过页编程限制。把block_size改回4096后,问题消失。
这个案例的教训是:block_size不能比Flash实际擦除扇区小,也不能随意设成趋近扇区大小的任意值。文件系统的逻辑块最好等于物理擦除块,否则每次分配和擦除都要做额外换算,效率低下且容易触发实现边界问题。
4.2 掉电后启动:“挂载成功,但文件不见了半个”
在掉电测试中,出现过一种表现:设备重启后文件系统挂载成功,但读一个之前写好的OTA标志文件,偶尔读到的是旧内容。我第一次遇到还以为是Flash驱动问题,排查很久才发现,是程序里写文件后没调用lfs_file_sync。
LittleFS为了写入性能,文件写入会先落在缓存中,真正的Flash编程是在缓存刷新或sync时才发生。你调用lfs_file_close不代表数据一定落盘了。掉电时刻如果数据还在缓存里,这次改动就丢了。文件系统并没有损坏,只是丢了你以为已经保存掉的数据。
解决方法是:对关键数据文件,写完立即调用lfs_file_sync,然后把返回值作为写成功判断依据。这个动作本身会拉低一点性能,但换来的是可靠性,划算。尤其是在OTA场景里,升级状态标志必须精确落盘,否则断点续传和失败回滚都无从谈起。
4.3 RTOS多任务并发访问导致缓存错乱
这个问题是最容易让人抓狂的。现象是:跑两三个小时后,文件写入偶尔报错,错误号指向内部分配失败,但Flash分区剩余空间明明还很充足。
多线程分析下来,发现是两个任务同时在写不同文件:一个写日志,一个写配置。由于全局文件系统共享同一块内部缓存,两个任务交替调用lfs_file_write时,缓存内容被互相覆盖,文件系统的内部状态就乱了。LittleFS面对这种并发访问没有任何保护,它默认用户自己保证同一时间只有一个调用者。
修复方式两个:一是打开LFS_THREADSAFE宏并提供lfs_lock和lfs_unlock实现;二是在RTOS层给所有文件系统API调用包裹一个互斥锁。我个人更倾向后者,因为锁的粒度可以控制到“整个写事务”,而不是只锁单个函数调用。比如一个任务要写配置文件和对应的校验文件,我希望这两个操作组成一个原子事务,只靠文件系统内部互斥是做不到的。
4.4 掉电回归测试怎么设计才靠谱
可靠性这东西光靠“我随手按几次电源键”验证不出来。我现在的做法是搭一个小型自动化平台:用上位机脚本控制继电器给设备供电,按随机间隔(比如3到8秒)给设备断电重启,设备端在每次上电后执行一个固定的“写文件-校验-读取-比对”任务,并把结果记录在另一片独立分区里。跑几千次循环,最后分析失败记录。
这里要特别提醒:掉电测试的设备不能只有一个,至少准备三台,因为问题出现概率低,单台设备测试几百次可能都碰不到一次。另外测试期间日志输出别只放RAM里,否则掉电后日志一起没了,根本没法定位置错。
5. 选型与性价比:什么时候别用LittleFS
LittleFS很优秀,但我不建议无脑所有项目都用它。工程上最怕的不是技术优劣,而是方案与场景不匹配。
5.1 LittleFS、SPIFFS、FATFS和裸Flash,怎么选
| 维度 | LittleFS | SPIFFS | FATFS | 裸Flash+自管 |
|---|---|---|---|---|
| 掉电安全 | 结构级保证 | 官方对掉电鲁棒性较弱,需额外预防 | 依赖底层驱动,需额外做日志 | 很难做到可靠 |
| 磨损均衡 | 动态均衡,支持较好 | 有基本均衡 | 无 | 需自研 |
| RAM占用 | 低,可压到几KB | 中等 | 较高,需要缓冲 | 最低 |
| 目录结构 | 完整多级目录 | 支持目录但有粒度限制 | 完整,兼容生态 | 无 |
| 适合场景 | 小型Flash、掉电频繁、资源紧张 | 早期轻量产品 | SD卡/U盘、和PC交换数据 | 极致性能、连续大块读写 |
我的判断是:如果产品要跟PC交换文件,比如U盘、SD卡,FATFS依然是最优选择,因为它兼容性在那里;如果只是一片写不了几个文件的小NOR Flash,SPIFFS也能跑,但掉电安全上我踩过亏,所以现在完全不推荐新项目用;如果存储量很小、写模式非常固定,比如只记录一段循环日志,那裸Flash加环形缓冲的效率和可靠性可能比文件系统更好。
5.2 LittleFS的代价:小文件处理和时间戳缺失
LittleFS不是没有缺点。它的元数据是双block镜像,所以每个文件、目录的元数据占用实际上要翻倍。大量小文件会显著放大元数据开销,还会增加元数据对压缩频率。比如某个传感器设备每秒生成一个几十字节的事件文件,跑一小时就是3600个文件,文件系统会被创建文件的开销拖垮。
解决思路是“合并文件”。同类数据写进同一个文件,按固定格式分隔,事件边界靠读取时解析。我在实际项目里通常把配置、状态、日志分别合并成三个文件,而不是配置参数每项建一个文件。
另一个常见坑是LittleFS没有内建时间戳。你没法直接通过标准API拿到文件创建时间或修改时间,这跟PC文件系统的直觉不一样。如果业务层需要时间信息,我建议在文件内容头部自己存一个时间字段,或者干脆把时间编码进文件名。不要等到写完文件后才发现时间不可查,回头改业务逻辑很被动。
5.3 我的实践建议:分区划分与双备份思路
最后分享一个比较成熟的落地架构。对带OTA功能的设备,我一般把存储分成三个区:Bootloader区、主应用区、存储分区。存储分区放LittleFS,里面保存配置、证书、日志、升级标志等。而在OTA升级文件写入时,升级包会先写入一个独立的原始Flash分区,这个分区不走文件系统,而采用简单的块写入加CRC校验。
这样分工的原因是:LittleFS适合保存“结构化、需要目录、重要但体量小”的数据,而大固件包用纯块写入效率更高,也不会占用文件系统的元数据和磨损均衡资源。两者互补,比把所有东西塞进一个文件系统更稳。
另一个建议是给关键配置文件保留一个副本。我用两个文件,一个主配置文件,一个备用配置,每次写入时交替更新,并在文件头部写入版本号。读取时先校验主配置,如果CRC不对就切到备用配置,再不对才恢复出厂。这种双备份设计配合LittleFS的掉电安全,基本能把配置损坏导致设备变砖的概率降到极低。
穿插一句实际体会:LittleFS的可靠性最终还是要靠严格的掉电测试来闭环验证。配置参数填对、结构原理理解到位、测试逻辑设计清楚,这套组合下来,嵌入式存储的“稳”字才有了真正的底气。你把上述这些坑提前规避掉,后面的开发会顺畅很多。