LittleFS 嵌入式存储移植:掉电安全与磨损均衡调优

发布时间:2026/9/18 5:54:22
LittleFS 嵌入式存储移植:掉电安全与磨损均衡调优
做嵌入式存储选型这些年最难受的场景不是 Flash 容量不够而是设备在野外被直接拔电第二天上电发现配置区全变成 0xFF只能返厂。后来我把项目里那套自己写的裸块管理全部换成了 LittleFS三年下来现场返修记录里跟存储相关的条目基本清零。LittleFS 是 ARM 官方维护的轻量级文件系统核心实现就几个 C 文件代码量不大但它解决的问题很硬核掉电安全、动态磨损均衡、不需要大块 RAM 缓存也不需要动态内存。如果你正在用 MCU 挂 SPI NOR、NAND 或者片上 Flash需要存配置、日志、录音片段、固件差分包又不想被 FAT 掉电后目录表错乱折磨那这套嵌入式存储方案值得花一个下午搞明白。这篇按我自己的移植路径讲从机制到参数计算再到踩坑一次说透。1. 为什么嵌入式项目需要LittleFS这一层存储抽象1.1 从裸块读写到文件系统中间省下的是维护成本裸块管理听起来最简单配置放 0 号扇区日志从 1 号扇区往后滚读写各自封装一个函数总共不到两百行代码。我最早的项目就是这么干的也确实跑了两三年没出事。问题出在产品线扩展之后客户要求配置项从 12 个涨到 40 个还要支持导入导出、按日期分文件存日志、掉电后能查到最后一条记录。这时候裸块方案的每一个新需求都要重新设计一遍数据布局偏移表越写越长版本兼容逻辑越堆越乱最后没人敢动那块代码。文件系统带来的最大价值不是能建文件而是把空间怎么分配、数据怎么定位、异常怎么恢复这三件事全部契约化了。上层只调用 open/write/read/close底下怎么摆块、怎么记账、怎么在掉电后自愈都由文件系统负责。LittleFS 的代码量大概是 FATFS 同功能实现的一半不到但它的每一行都在为掉电后还能正确挂载服务。这就是它和 FAT 的根本区别FAT 的设计前提是运行在有人看管的台式机上LittleFS 的设计前提是随时可能断电。1.2 四类主流方案横向对比在选型阶段我把手上的候选方案拉了个表横向比了六个维度。这张表后来成了我们团队内部的选型模板你可以直接拿去用。维度裸块加偏移表FATFSSPIFFSLittleFS掉电安全完全取决于自己写的代码弱FAT 表更新非原子较好页级日志强元数据对加 CRC 校验磨损均衡一般没有基本没有动态均衡动态均衡加元数据搬迁目录结构无完整扁平命名空间完整支持层级目录RAM 占用极低几十字节中等受扇区缓冲影响低低可配到几百字节最小分区任意几十 KB 起几十 KB 起4 个块起步可交换性差好PC 直接读差一般需专用工具看这张表要抓住三个判断点。第一如果你的分区小于 32KB直接别考虑 FAT光 FAT 表加目录表就占掉了大半空间。第二如果你的数据是写完就存着、极少改写的固件区那裸块加 CRC 反而最省事LittleFS 的价值体现不出来。第三只要你的场景里存在运行中随时可能断电和数据会被反复覆盖写入这两个条件同时成立LittleFS 就是当前开源方案里最平衡的那个。SPIFFS 是 LittleFS 的前身同一批人做的它的定位已经被 LittleFS 完全取代了。SPIFFS 的文件一旦写入容量就不能增长只能删了重写它的垃圾回收是整块扫描文件一多就慢得离谱。所以现在新项目没有理由再选 SPIFFS。1.3 什么场景不适合搬LittleFS进来工具是好工具但滥用会难受。我在两个项目里试过把 LittleFS 用在不太合适的地方结果都换回来了。第一个是音频缓存。设备要循环存 30 秒的 16kHz 采样每秒 32KB一个文件 1MB。LittleFS 写入 1MB 文件时虽然数据块是顺序分配的但每次文件大小元数据更新都要走一遍元数据对提交加上 4KB 扇区擦除实测写 1MB 耗时比裸块写入慢了将近四倍。后来这块改成了环形裸块缓冲加一个索引头只在文件结束时提交一次元数据性能立刻回来了。第二个是要求 PC 端直接读取的场景。客户要求把 TF 卡拔下来插到电脑上直接看数据这种需求 FAT 才是唯一答案。LittleFS 有 FUSE 挂载工具但要求现场人员装驱动不现实。还有一种情况要谨慎片内 Flash 直接跑代码加文件系统。如果你的程序是从片内 Flash 执行的擦写同一个 Bank 时总线会阻塞 CPU 取指表现为程序莫名卡死。这种情况要么把文件系统放到独立扇区并确认硬件支持读写同步要么干脆外挂一颗 SPI Flash。2. LittleFS的核心机制掉电安全到底怎么做到的2.1 元数据对两个块互相备份的记账本理解 LittleFS 只需要抓住一个核心概念元数据对metadata pair。它把两个连续的擦除块绑定成一对在这两个块之间交替写入。写入时永远不改动正在生效的那个块而是把新内容写进另一个块写完后追加一条带 CRC 的提交记录CRC 通过才算这次修改生效。如果写到一半断电新块里的内容不完整CRC 校验不过下次挂载时自动回退到另一个块里的旧版本。这就是所谓的写时复制。它带来的直接好处是任何一次修改都是原子的不存在改了一半的中间状态。传统 FAT 修改一个文件需要同时更新 FAT 表和目录表这两次更新之间断电表就对不上了。LittleFS 的每次修改都收敛成追加一条提交记录这一个动作而 Flash 的按位编程特性保证了追加写是天然有序的要么这条记录写完整了要么它根本没写进去。超级块本身也存在一对元数据块里通常落在 0 号块和 1 号块。根目录又是一对。所以分区最小要能放下两对也就是 4 个块。按 4KB 块算小于 16KB 的分区就别折腾了直接裸块管理更划算。2.2 一次完整写入的链路拆解拿往已有文件末尾追加 100 字节这个最常见的操作举例LittleFS 内部大致走了这么几步。第一步是读元数据。通过缓存在 RAM 里的元数据块副本找到目标文件的目录项取出它当前的块指针链和文件大小。如果这个文件足够小小到能整个塞进元数据块的空闲空间里LittleFS 会把它内联存储也就是文件内容直接写在元数据块的尾部这一步就省掉了数据块的分配。第二步是分配数据块。从空闲块里找出一块把新增的 100 字节写进去。注意这里写的是新块如果原有数据块还有剩余空间也会追加在里面直到块被填满。第三步是提交元数据。把更新后的目录项新的文件大小、新的块指针作为一条新记录追加到元数据对中当前活跃的那个块后面。这条记录带 CRC。第四步是同步。只有当你调用了lfs_file_sync或者lfs_file_close缓存里的数据才会真正落盘。这一点特别容易踩坑很多人写完数据立刻拔电发现数据没了以为是文件系统不靠谱其实是数据还躺在 RAM 缓存里。2.3 动态磨损均衡block_cycles在背后做了什么Flash 的每个擦除块都有寿命上限NOR 一般是十万次擦写NAND 更低。如果每次修改都往同一块元数据上写这块很快就会报废。LittleFS 的对策是给元数据对设一个计数器当这一对块被擦写了block_cycles次以后就整体搬迁到一对空闲块上原来的块被擦除后归还到空闲池。block_cycles的默认值是 500官方建议区间是 100 到 1000。这个值调太小元数据频繁搬家写入性能明显下降而且搬家本身也要擦写调太大元数据块的磨损就集中了。我一般在小容量分区上用 200 左右大分区上用 500 到 1000因为它和分区总块数要匹配分区越大能分担磨损的块越多单块的搬迁频率就可以放低。同时数据块的分配用的是动态策略。LittleFS 内部维护一张空闲块位图lookahead分配时不是从头扫而是从一个随机位置开始循环扫描这样冷热数据会自然分散到整个分区的不同位置避免连续写入集中在某一段。注意LittleFS 是动态磨损均衡不是完全静态均衡。如果你的应用是写入几十个文件以后就再也不改只是反复读那些冷数据的块永远不动所有磨损都发生在剩余的少量空闲块上。这种情况要么定期主动重写一遍把数据摊开要么给文件系统留出 30% 以上的空闲空间让空闲块数量足够分摊。2.4 三种断电时间点的结果分析我用一个可编程电源配合继电器做过一组断电测试把断电时刻卡在写入过程的不同阶段反复几百次观察到的结果可以归纳成这样。断电时刻挂载结果数据表现新数据块写入过程中正常挂载旧版本完好残缺的新块被视为空闲元数据提交记录写入中正常挂载CRC 失败回退到上一次有效提交提交记录已完整写入正常挂载新版本生效数据完整未调用 sync 就断电正常挂载丢失上次 sync 之后写入的内容文件系统不损伤这张表是 LittleFS 最大的卖点任何时刻断电挂载都能成功文件系统结构不会损坏最坏情况只是丢最后一次未同步的写入。对比 FAT 在元数据更新时断电经常出现目录项指向已释放簇、整个分区需要格式化的情况这个差距是数量级的。3. 移植实战从零把LittleFS挂到SPI NOR上3.1 源码组织与工程集成LittleFS 的发布包结构很干净需要往工程里加的其实只有三个文件lfs.c、lfs.h、lfs_util.c和对应的lfs_util.h。我的习惯是在工程里建一个third_party/littlefs目录把源码原样放进去然后用一个独立的lfs_port.c放块设备适配层最后在 Keil 或 CMake 里把这两个目录加进编译。这样以后升级版本直接替换third_party下的文件就行自己写的适配代码不会被动到。编译时有两个宏值得关注。第一个是LFS_NO_MALLOC如果你的工程禁用动态内存或者要求所有内存都在编译期确定务必打开这个宏然后通过lfs_file_opencfg显式传入每个打开文件所需的缓存。第二个是LFS_NO_ASSERT量产固件里可以打开但调试阶段一定别开断言会帮你抓出大量参数配置错误。还有一个LFS_THREADSAFE后面单独讲。3.2 块设备适配四个回调的写法这是整个移植里唯一需要你自己写的部分一共四个函数读、写、擦除、同步。下面是我在 SPI NOR 上用的实现骨架去掉业务相关的判忙逻辑后大致就是这样。#include lfs.h #include spi_nor.h static int bd_read(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, void *buffer, lfs_size_t size) { uint32_t addr (uint32_t)block * c-block_size off; return (spi_nor_read(addr, buffer, size) 0) ? LFS_ERR_OK : LFS_ERR_IO; } static int bd_prog(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, const void *buffer, lfs_size_t size) { uint32_t addr (uint32_t)block * c-block_size off; if (spi_nor_write(addr, buffer, size) ! 0) { return LFS_ERR_IO; } return (spi_nor_wait_ready(500) 0) ? LFS_ERR_OK : LFS_ERR_IO; } static int bd_erase(const struct lfs_config *c, lfs_block_t block) { uint32_t addr (uint32_t)block * c-block_size; if (spi_nor_sector_erase(addr) ! 0) { return LFS_ERR_IO; } return (spi_nor_wait_ready(2000) 0) ? LFS_ERR_OK : LFS_ERR_IO; } static int bd_sync(const struct lfs_config *c) { (void)c; return LFS_ERR_OK; }四个函数有三个关键点。第一读函数在 SPI NOR 上可以支持任意字节长度所以read_size设成 1 是合法的设成 16 是性能折中实际影响不大。第二写函数必须等 Flash 内部编程完成再返回wait_ready里的超时值要覆盖最坏情况W25Q 系列页编程最坏能到 3ms扇区擦除最坏能到 400ms。第三缓冲区必须按prog_size对齐如果用的是带 cache 的 MCU还要确认 DMA 不能访问的 TCM 区域不要拿来当目标缓冲。实操心得bd_prog里我吃过一次大亏。当时为了省时间写完不等就返回靠后面的读操作去隐式等待。结果 LittleFS 的提交校验读回来的数据还是旧值CRC 当然过不了表现成随机挂载失败查了整整两天。Flash 的写后等待不能省。3.3 lfs_config逐字段填每个参数都是算出来的配置结构体是移植的第二道门槛参数填错会直接导致lfs_format或lfs_mount返回LFS_ERR_INVAL。下面是我在一块 8MB SPI NOR 上的实际配置每个字段后面都附了取值依据。#define BLOCK_SIZE 4096 #define BLOCK_COUNT (8 * 1024 * 1024 / BLOCK_SIZE) /* 2048 */ static uint8_t read_buf[256]; static uint8_t prog_buf[256]; static uint8_t lookahead_buf[32]; const struct lfs_config cfg { .context NULL, .read bd_read, .prog bd_prog, .erase bd_erase, .sync bd_sync, .read_size 16, /* 最小读粒度NOR 可到 1 */ .prog_size 256, /* 必须等于 Flash 页大小 */ .block_size BLOCK_SIZE, .block_count BLOCK_COUNT, .block_cycles 500, /* 元数据磨损均衡阈值 */ .cache_size 256, /* 读缓存和写缓存各一份 */ .lookahead_size 32, /* 必须为 8 的倍数 */ .read_buffer read_buf, .prog_buffer prog_buf, .lookahead_buffer lookahead_buf, };几个参数的推导过程。prog_size必须等于 Flash 的页大小这一点不能图省事填 1。SPI NOR 的页编程指令在一次操作内不能跨页如果你填 1LittleFS 会以为可以任意字节写某次写入跨过 256 字节边界时数据就会在页边界处回绕覆盖产生极难定位的数据错乱。block_size用 4KB对应 NOR 的扇区擦除粒度。也可以填得比物理擦除单位大但只要填错就会破坏对齐我一般直接对齐物理值。cache_size有三个约束必须是read_size的整数倍、必须是prog_size的整数倍、必须能整除block_size。256 同时满足这三个条件。它的实际作用是决定一次能缓存多少数据直接影响性能第 4 章会专门讲怎么调。lookahead_size必须能被 8 整除因为它是一片位图的字节数。32 字节的位图能描述 256 个块意味着一次分配扫描能覆盖分区八分之一的块。分区越大这个值应该按比例往上加否则空闲空间碎片化时分配会反复扫描写入延迟抖动明显。block_count一定要和实际分区大小严格对应。这里有个很坑的点如果你先用 2048 个块格式化了后来把分区改小了再挂载lfs_mount会返回LFS_ERR_CORRUPT因为超级块里记录的块数和传入的不一致。3.4 挂载、格式化与读写的最小可运行示例把配置填好之后最核心的十几行代码就是这样。注意lfs_mount失败后再格式化的顺序这是量产固件的标准写法。lfs_t lfs; lfs_file_t file; int fs_init(void) { int err lfs_mount(lfs, cfg); if (err) { /* 首次上电或分区被破坏重新格式化 */ err lfs_format(lfs, cfg); if (err) { return err; } err lfs_mount(lfs, cfg); } return err; }写入和读取的完整流程如下这里用的是带配置参数的版本配合LFS_NO_MALLOC使用内存全部在编译期确定。static uint8_t file_buf[256]; static struct lfs_file_config fcfg { .buffer file_buf, }; int log_append(const char *msg, lfs_size_t len) { lfs_file_t file; int err; err lfs_file_opencfg(lfs, file, log/app.log, LFS_O_WRONLY | LFS_O_CREAT | LFS_O_APPEND, fcfg); if (err) { return err; } lfs_ssize_t written lfs_file_write(lfs, file, msg, len); if (written 0 || (lfs_size_t)written ! len) { lfs_file_close(lfs, file); return LFS_ERR_IO; } /* 关键不 sync 的话数据还在缓存里 */ err lfs_file_close(lfs, file); return err; }这里有一个需要特别注意的细节LFS_O_APPEND加lfs_file_close的组合每次追加都要走一遍完整的元数据提交。如果你一秒写十条日志这个开销是很可观的。更好的做法是保持文件打开攒够一批再lfs_file_sync后面第 4 章会给出具体的批处理写法。3.5 中断与实时操作系统环境下的线程安全LittleFS 本身不是线程安全的。如果你的工程里有多个任务同时操作文件系统比如一个任务写日志、另一个任务读配置必须做互斥否则会出现元数据缓存被并发修改导致的诡异问题。最省事的方案是在应用层加一把全局锁所有 LittleFS 调用都从同一个互斥量进出。这种做法牺牲了一点并发度但胜在可靠而且实际上文件系统本来就是个串行资源并发收益很小。具体写法就是在每个 API 调用前后包一层宏不要试图只锁住写操作读操作同样会修改 RAM 里的元数据缓存也必须互斥。第二种方案是打开LFS_THREADSAFE宏然后给配置里的lock和unlock两个回调填上实现。这里有个坑LittleFS 内部存在公共 API 互相调用的路径同一个线程可能会重复进入加锁函数用普通互斥量会直接自死锁。所以要么用可重入互斥量FreeRTOS 里的xSemaphoreCreateRecursiveMutex就是干这个的要么老老实实用第一种全局锁方案。还有一个必须处理的问题bd_erase里那次扇区擦除可能阻塞几百毫秒。在实时系统里这么长时间不让出 CPU轻则任务抖动重则看门狗复位。我的做法是在擦除前释放锁、让出 CPU 一小会儿或者在wait_ready的轮询循环里插入一个osDelay(1)。前者要注意释放锁期间不能有其他任务动文件系统后者会拖慢擦除的整体耗时。两种都是权衡看你更在意抖动还是吞吐。4. 调优把RAM、速度、寿命这三个指标调到合适的点4.1 缓存与位图的RAM预算表LittleFS 的 RAM 占用是可以精确算出来的这在资源紧张的 MCU 上是刚需。计算方式是读缓存加写缓存各一份cache_size每个同时打开的文件再各占一份cache_size加上lookahead_size最后加上lfs_t本身大约两百字节的固定开销。cache_sizelookahead_size同时打开文件数总 RAM适用场景1681约 300 字节极小 RAM只存几个配置项64161约 500 字节8 位或低端 32 位 MCU256322约 1.2 KB主流 Cortex-M 应用512642约 2.2 KB高写入频率的日志场景10241283约 5 KB大数据块顺序写带 RTOS这张表里的固定开销还包含一个容易忽略的部分lfs_dir_t和lfs_file_t这些结构体在部分 API 里是分配在栈上的。我遇到过栈只有 1KB 的任务里调用lfs_dir_read直接爆栈的情况排查了很久才发现是栈溢出而不是文件系统出错。所以调用文件系统 API 的任务栈至少留 1.5KB 以上。4.2 写入性能优化从每次提交到批量落盘LittleFS 写入慢慢的从来不是数据本身而是元数据提交和块擦除。优化思路就是减少这两件事的发生次数。第一招是批量提交。不要每写一条记录就lfs_file_sync而是攒够一批或者定时再同步。下面这段是日志模块里实际在用的写法。#define FLUSH_INTERVAL_MS 2000 static lfs_file_t g_log; static uint32_t g_last_flush; void log_write(const char *line, lfs_size_t len) { lfs_file_write(lfs, g_log, line, len); uint32_t now tick_get(); if (now - g_last_flush FLUSH_INTERVAL_MS) { lfs_file_sync(lfs, g_log); /* 一次提交带上这一批全部数据 */ g_last_flush now; } }这样做的代价是断电时最多丢失 2 秒的数据。这个取舍要提前和业务方确认比如计量类数据不能承受这个损失就得改成每条都同步或者把数据先写到一块独立的裸扇区做双备份。第二招是减少文件数量。LittleFS 查找一个文件需要线性扫描目录项一个目录里放几百个小文件lfs_stat的耗时是肉眼可见的。我的做法是按天或者按小时滚动每个文件写到一定大小就换下一个目录里保持十几个文件。第三招是提高底层 SPI 时钟并考虑使用四线模式。LittleFS 的很多开销最终都落在读元数据上时钟从 20MHz 提到 80MHz整体写入耗时能砍掉差不多三分之一。第四招是控制目录层级。层级深意味着路径解析要逐级查找每级都是一次元数据读取。两层以内足够用了。4.3 寿命估算写多少年才需要担心Flash 寿命估算不需要精确但要有数量级的概念否则设计评审时心里没底。用一个真实案例算一遍一块 8MB 的 SPI NOR擦写寿命按 10 万次算是保守的实际大多数标称 10 万次分区 8MB 分成 2048 个 4KB 块。如果应用每天写入 100KB 数据每次写入触发一次元数据提交4KB 块内那么每天的块擦除次数大概是 100KB 除以 4KB 等于 25 次数据块擦除加上元数据提交最多 25 次总共约 50 次块擦除。分散到 2048 个块上平均每块每天的擦写次数是 0.024 次跑满 10 万次需要约 11000 天也就是三十年。这个量级完全不需要担心。但如果每天写入 100MB那每天就是约 25000 次块擦除平均每块每天 12 次十万次寿命只能撑二十多年听起来还行可这是理想平均。实际上如果文件系统只留了 10% 的空闲空间磨损会集中在 200 个空闲块上寿命直接缩水十倍两年多就该出问题了。注意真正决定寿命的不是总写入量而是空闲块占比。我给自己定的规矩是文件系统长期空闲块不低于 25%。低于这个数先删旧日志再考虑扩容。4.4 掉电一致性测试怎么做才可信很多项目号称支持掉电保护实际从来没测过。我用的方法很简单但很有说服力。测试装置是一台可编程直流电源加一个继电器继电器由外部信号控制切断电源的时机由一个循环写入的测试固件触发。测试固件持续以随机间隔写入数据每写 100 次触发一次随机延时后的断电延时范围覆盖 0 到 20 毫秒正好把写入过程的各个阶段都覆盖到。上电后固件自动挂载、遍历目录、读取并校验每条记录的 CRC把结果通过串口打出来。连续跑几千次如果出现任何一次挂载失败或者数据校验不通过就说明有问题。真正的 LittleFS 配置正确的情况下这个测试的结果应该是挂载永远成功数据最多丢最后几条且不会出现中间状态。这个测试帮我抓出过两个问题一个是前面提到的写不等就返回另一个是 Flash 擦除期间看门狗复位复位后重新挂载时正好撞上未完成的擦除表现为偶发损坏。4.5 上层业务需要配合的几条约定文件系统的可靠性再强也救不了错误的用法。我在项目里定了几条硬性约定后来写进了团队的编码规范。一是所有关键写入必须以lfs_file_sync或lfs_file_close结尾不允许写完就返回。二是配置文件全部采用先写临时文件、再重命名覆盖的方式更新利用lfs_rename的原子性避免出现半截配置。三是每条记录自带长度和 CRC落盘格式自身可校验不依赖文件系统保证内容正确。四是任何文件系统 API 的返回值都必须处理LFS_ERR_NOSPC出现时要有降级策略比如删掉最旧的日志文件再重试一次。第三条尤其重要。文件系统保证的是结构一致不保证你写的业务数据一定是完整的。业务层自己带校验是最后一道防线。5. 踩坑实录挂载失败、空间不足、性能异常的排查路径5.1 挂载返回LFS_ERR_CORRUPT的四个原因这个错误代码出现的频率最高我按排查顺序整理一下。第一个原因是配置参数变了。挂了 2048 个块的分区改成 1024 个块或者块大小从 4KB 改成 64KB超级块里记录的参数和传入的不一致直接报错。解决办法是保持参数不变或者重新格式化。第二个原因是分区里存在历史遗留数据。典型场景是上一版固件用的是别的文件系统或者用了不同的块大小格式化过。首次切换文件系统时只擦除了分区头部后面残留的旧数据会被新文件系统当成有效块解析。正确的做法是升级流程里对整个分区做一次完整擦除再格式化。第三个原因是擦除粒度不匹配。有些 Flash 的擦除指令有 4KB 和 64KB 两种如果你在bd_erase里误用了 64KB 擦除就会把相邻的块一起干掉破坏元数据对。第四个原因是底层读函数返回了错误但没上报。比如 SPI 通信偶发失败读回来一屏 0x00CRC 自然过不了。所以bd_read里一定要判断底层返回值不能直接返回 OK。5.2 明明显示有空间却写不进去lfs_fs_size报告只用了 60% 的空间写入却返回LFS_ERR_NOSPC这是我遇到过最反直觉的问题。原因有三个。一是元数据块的空间被目录项占满了。一个目录下的文件太多或者文件名太长元数据块里塞不下新的目录项即使有大量空闲数据块也没用。解决办法是控制单个目录的文件数量同时把LFS_NAME_MAX从默认的 255 调小到 32 或者 64这样每条目录项占用的元数据空间会明显减少。二是文件的最小占用粒度。LittleFS 里的非内联文件至少占用block_size减去cache_size的空间按 4KB 块和 256 字节缓存算一个空文件也要占掉接近 4KB。如果你打算存一万个 100 字节的小文件按 4KB 一个算需要 40MB而实际数据量只有 1MB。这种情况要么把多个小数据合并到一个文件里按行存要么调小block_size。三是元数据对搬迁时需要的临时空间。LittleFS 在搬迁元数据时需要先找到一对空闲块如果分区几乎写满找不到连续可用的块对搬迁就失败了。这也是为什么我一直强调留 25% 空闲空间不只是为了寿命也是为了这些内部操作能顺利进行。5.3 写一个小文件耗时上百毫秒这个问题通常不是 LittleFS 的锅而是几个因素叠加的结果。下面这张清单是我排查写入延迟时的标准动作按影响从大到小排。第一步量底层。在bd_erase和bd_prog里打时间戳把耗时打出来。如果单次扇区擦除就占了 300 毫秒那问题在 Flash 或者 SPI 时钟上不在文件系统。我遇到过 SPI 时钟被误配成 1MHz 的情况改到 40MHz 后整体耗时降了二十倍。第二步看缓存大小。cache_size只有 16 字节的时候一次元数据更新要拆成几十次小读和小写每次都要走一遍 SPI 事务开销。把cache_size提到 256同样的操作耗时会明显下降。第三步看提交次数。用逻辑分析仪抓 SPI 波形数一下扇区擦除的次数如果一次小文件写入触发了多次擦除说明block_cycles设得太低元数据在频繁搬家。第四步看目录规模。目录里文件越多查找越慢。这一点在前面说过不重复。5.4 常见问题速查表现象可能原因排查动作处理方式挂载返回 LFS_ERR_INVAL配置参数不满足约束检查 cache_size 是否为 read_size 和 prog_size 的倍数按约束重算参数挂载返回 LFS_ERR_CORRUPT参数变更或残留旧数据对比历史格式化参数整片擦除后重新格式化写入返回 LFS_ERR_NOSPC元数据空间耗尽或空闲块不足看目录文件数量看 lfs_fs_size减少目录项释放空闲块断电后数据丢失未调用 sync检查写入后是否 close增加 sync 或改为批处理偶发读取乱码写后未等待 Flash 就绪在写函数里加时间戳补上 wait_ready运行中随机死机任务栈溢出查看栈水位文件系统任务栈提到 1.5KB 以上写入延迟抖动大扇区擦除阻塞用逻辑分析仪统计擦除次数调大 cache_size降低搬迁频率擦除时看门狗复位单次阻塞时间过长检查擦除超时设置轮询中让出 CPU 或喂狗5.5 PC端工具量产前的预置与验证量产时经常需要预置一些文件比如默认配置、字库、语音片段。用固件跑一遍写入再导出镜像太麻烦我一般用两个工具搞定。mklittlefs可以在 PC 上直接生成一个 LittleFS 镜像参数和固件里一致就行。生成之后直接烧到分区里设备上电就能挂载。要注意块大小和块数量必须和固件配置严格一致否则挂载必然失败。littlefs-fuse可以把生成的镜像在 Linux 上挂载成一个目录往里拖文件、改内容、看目录结构都跟普通目录一样。排查现场返回的镜像时特别有用把设备里的 Flash 读出来做成镜像文件挂到 PC 上一看就知道数据是不是真的写进去了比在 MCU 上打串口日志效率高得多。我个人在实际操作中的体会是LittleFS 这类嵌入式存储方案的价值不在于它的功能有多花哨而在于它把容错做进了每一次写入的路径里。参数配置那几分钟的谨慎换来的是几年不用管它这笔买卖很划算。真正需要花心思的地方其实在业务层什么时候提交、丢了数据能不能接受、空间快满的时候降级做什么这些是文件系统替你做不了的判断。