Flash 上跑 littlefs 落地实测:96KB 分区挂上第三天,PWM 每隔几小时停更 118ms——不是 littlefs 不可靠,是我把 compaction 放进了 1kHz 控制环

2026年10月27日 0 By admin

上一篇《固件防逆向与混淆》把 130 篇留下的空白补完了,文末我预告了本篇。131 篇《Flash 存储与参数管理》里,选型表把 littlefs 写成”为掉电就地设备设计的”,我当时就按这句话上了——选型账算得对,不代表实测账好看。

真实过程是:分区重新划、littlefs 挂上、功能全过、实验室三天没事;到现场第四天,EMS 开始报”直流母线电压跌落”,一天两三次,每次自恢复。抓了两天才抓到根因:每次 compaction 是一次 40ms 的页擦除,而我把 lfs_file_sync 放在了 1kHz 控制环里。

结论先摆出来,五条:

  • 寿命由块数决定,不由文件系统决定。 同样的 96KB 分区,littlefs 比 48 页环形短 1.54 倍(写放大);把它塞进 131 篇那个 14KB 分区,86400 条/天只有 17 天,比原来 7 页环形的 26 天还差。
  • 写放大的主因是 sync 频率,不是 CoW。 每条都 lfs_file_sync 是 1.53×(97.9B);16 条批量一次是 1.03×(66.1B)。批量只省元数据,不省数据块擦除。
  • 时间账比寿命账先要命。 一次 compaction 25.7~47.6ms,三次擦除叠加 123.4ms;F1 擦除期间取指也停,1kHz 控制环直接丢 47 个周期,优先级和互斥量都救不了。
  • 掉电保证只到 sync 为止。 cache 256B 时,lfs_file_write 返回成功、断电后仍可能丢 3 条。
  • 上手成本要写进预算: ROM 11.8KB、RAM 984B、两个 App 槽各让 42KB。这笔钱买的是”不用自己写掉电恢复和目录”,不是”更长寿命”。

工具照例全离线:drafts/_tools/littlefs_landing_calc.py,11 个模式、21 项自检断言,文中每个数都能复算。


一、现场:从 7 页环形升级到 littlefs,账从分区开始

131 篇的现场是”一页 Flash 三天干完十年活”,最后落在 7 页环形 + seq/CRC 上。这次要换,不是因为环形页坏了,是因为数据类型从一类变成三类:

  • 参数(EMS 下发、本地设置,要能按 key 读,要能回滚一版);
  • 事件日志(过压、欠压、绝缘告警,带时间戳,现场要能按日期捞);
  • 黑匣子(故障前后 30 秒的采样快照,一条 4KB,写完就不改)。

环形页做这三件事的代价是:一套记录格式要兼容三种长度,key 查询靠自己遍历,按日期捞日志靠自己做索引,回滚要自己留两份。手写代码从 400 行涨到 1600 行,而且每加一种数据就多一次改结构的机会。 littlefs 把这些都包了,代码只剩 lfs_file_open/write/sync。

代价第一笔就落在 Flash 上。130 篇的 512KB 布局里参数区只有 14KB(7 页),littlefs 塞进去只有 7 块可用——第六节会算给你看,这个尺寸下它还不如原来的环形页。所以先重新分区:

区域 旧(130 篇) 新(本篇) 变化
Bootloader 16 KB 16 KB 0
meta(版本/回滚) 2 KB 2 KB 0
App 槽 A 240 KB 198 KB −42 KB
App 槽 B 240 KB 198 KB −42 KB
参数 / 文件系统 14 KB 96 KB +82 KB
预留 0 2 KB +2 KB
合计(2KB/页,共 256 页) 512 KB 512 KB 0

App 实测 176KB,槽从 240 缩到 198 后余量还有 22KB——这是上文件系统真正的第一笔成本:两个槽各让 42KB。如果你的 App 已经 230KB,这条路直接堵死,只能换外部 Flash(第五节最后一条)。

分区对齐有三方必须同时改:链接脚本、Bootloader 里的分区表、IAP 升级时的擦除范围。59 篇《STM32 Bootloader 与 IAP》里踩过一次”升级把参数区擦了”,这次先把三张表对死才动手。


二、配置与几何:48 块、2KB、cache 256

lfs_config 项 取值 依据
block_size 2048 必须等于 Flash 页。小于页 = 一次擦除毁掉邻居;大于页 = 你的 erase 回调要擦多页,且掉电窗口翻倍
block_count 48 96KB / 2KB。整区擦除预算 = 48 × 10000 = 480,000 次
read_size / prog_size 16 / 16 F1 最小是半字(2B),但按 16B 缓冲可把回调次数压到 1/8;16 也是 32B 提交的对齐单位
cache_size 256 4 条记录一批;挂载即占 632B,开一个文件 984B(下面有账)
lookahead_size 16 16B = 128 块的位图,48 块够用且余量大
block_cycles 500 官方建议 100~1000。设 -1 就是关掉磨损均衡,元数据两块被钉死:86400 条/天时 15 天写穿

几何账:

项目 数值 说明
数据块可用 2032 B 块头被 CTZ 跳表指针吃掉约 16B(块号不同 4~24B 浮动,按 16B 记账)
每块记录数 31 条 31×64 = 1984,块尾剩 48B
元数据提交 32 B 净 28B,按 prog_size 对齐到 32B
每元数据块提交次数 63 次 (2048−32)/32 = 63,之后必须 compaction
每条记录擦除 0.04813 次 元数据 1/63 + 数据 1/31
每个目录开销 2 块 = 4.2% 分区 目录也是元数据对,建 8 个目录吃掉 16 块

RAM / ROM 实测(v2.9,-Os,LFS_NO_MALLOC,关 attr/mtime):

场景 占用
挂载即占 lfs_t 104B + 读/写 cache 256+256B + lookahead 16B = 632 B
开 1 个写者(1 文件) 632 + 96 + 256 = 984 B
再加 1 个目录遍历 +352B = 1336 B
再加 1 个并发读文件 +352B = 1688 B
ROM 11.8 KB(手写环形页约 1.0KB)
lfs_mount 栈峰值 1.1 KB

这张表里有两个容易被忽略的坑:两处同时 lfs_file_open 就是两倍 cache,在 64KB RAM 的 F103 上要提前算;cache_size 必须同时是 read_size/prog_size 的整数倍和 block_size 的约数,2048 % 256 = 0 才成立——我第一次配 300,lfs_init 直接返回 -22。


三、挂载 2.4ms:别为它优化,别在它后面扫全盘

lfs_mount 只读根目录的元数据对(2 块 = 4KB),逐条提交算 CRC32,找到最后一个合法提交。实测 2.4ms,拆开:

环节 时间 占比
CRC32 校验 4KB(表驱动,约 12 周期/字节) 0.68 ms 28%
搬运 4KB(内部 Flash 直读,约 6 周期/字节) 0.34 ms 14%
tag 解析与目录项匹配 1.38 ms 58%
合计 2.4 ms 100%

两个认知:

  • CRC 不是瓶颈,tag 解析才是。 想省挂载时间,先减目录项数量,别去换 CRC 算法。
  • 挂载是惰性的:子目录不进 lfs_mount,进 lfs_stat/open 才付账,每个子目录约 2.4ms。 所以”启动时遍历一遍所有文件做自检”这个习惯在 littlefs 上很贵——8 个目录 = 20ms 起步,还没算每个文件的元数据。

挂载这 2.4ms 从来不值得优化;值得优化的是你挂载之后干了什么。


四、一条记录的写放大:1.53×,主因是 sync 不是 CoW

littlefs 是 CoW,但 CoW 本身不贵:数据只写一次,元数据是一段 32B 的追加提交。真正贵的是“每条记录都 sync”,因为它把每次提交都变成一次独立的元数据写:

sync 间隔 数据编程 元数据提交 compaction 摊分 合计 放大
1 条 64 B 32.0 B 1.90 B 97.9 B 1.53×
4 条 64 B 8.0 B 0.48 B 72.5 B 1.13×
16 条 64 B 2.0 B 0.12 B 66.1 B 1.03×
63 条 64 B 0.5 B 0.03 B 64.5 B 1.01×

读法:批量只省元数据,一分钱也不省数据块擦除。 数据块该 31 条擦一次还是 31 条擦一次,所以擦除账(0.04813 次/条)跟 sync 频率无关,只有编程字节数在变。

sync 频率的另一头是掉电窗口,这张表比写放大更该看:

cache_size 一次能缓冲 掉电最多丢
64 B 1 条 0 条
256 B 4 条 3 条
512 B 8 条 7 条
1024 B 16 条 15 条

lfs_file_write 返回成功不等于落盘,这一点和 fwrite 的直觉冲突。我们的处置是:记录类数据允许丢 3 条(批量 4 条、cache 256),参数类数据写两份(A/B 各一条记录,lfs_file_sync 后才置”写入完成”标志),所以参数永远不会读到半截。


五、时间账:一次 compaction 是 40ms 的全局停摆

这是本篇真正的事故。lfs_file_sync 的耗时不是一个数,是四种情况:

场景 擦除 编程内容 典型 最大
A 无 compaction,只落盘 0 记录 64B + 提交 32B 2.52 ms 3.36 ms
B 命中 compaction(每 63 次提交一次) 1 块 120B 活元数据 + 96B 25.7 ms 47.6 ms
C 同一笔里又赶上 block_cycles 迁移 2 块 同上 45.7 ms 87.6 ms
D 分配 + compaction + 迁移撞一起 3 块 96B — 123.4 ms

关键不在数字,在 F1 的物理事实:内部 Flash 在擦除/编程期间总线停住,取指也停。 这不是”这个任务被阻塞”,是”整个 MCU 停住”——RTOS 优先级、互斥量、看门狗喂狗全都没用,因为谁都不在跑。1kHz 控制环 = 1ms 一个周期,47.6ms 的空洞就是丢 47 个周期,PWM 占空比在 47 个周期里保持旧值。

现场负载(4G 上线后):EMS 每 5 分钟下发 18 个参数、每个参数一次 sync,加事件日志,实测 1240 次 sync/天。于是:

  • compaction 19.7 次/天,每天累计停顿 0.9 秒;
  • 平均每天 19.7 次 48ms 级停顿,其中两三次恰好撞上负载突变,被 EMS 抓成”母线电压跌落”;
  • 最坏一次抓到 118ms(对应上表 D 的 123.4ms,差值来自 DMA 抢占和 cache 命中)。

修法四条,按性价比排:

  1. 写操作全部离开控制环。 Modbus 写参数的回调只做三件事:入队、返回、置标志。队列深度 32,满则丢最旧并计数——丢一条日志远比停一次 PWM 便宜。写者任务优先级放在控制环和通信之间,lfs_* 全在里面调。
  2. 批量 sync:16 条一次。 提交从 1240 次/天降到 77.5 次/天,compaction 从 19.7 次/天降到 1.23 次/天,寿命从 22.0 年抬到 31.9 年。注意:单次最坏耗时不变(还是 47.6ms),变的是频次和它所在线程——这条最容易被误解成”批量就能变快”。
  3. 参数双写 + 标志后置。 A/B 两份记录,sync 之后才置”有效”标志。掉电最多回退一版,不会读到半截。
  4. 如果控制环要求 <100µs 抖动,内部 Flash 上就别放文件系统。 换外部 SPI Flash(W25Q64:4KB 扇区、45ms 擦除、100k 次)或 FRAM;内部 Flash 只留 Bootloader、参数单页和 OTA 槽。这一条不是优化,是架构决定——内部 Flash 的擦除时间物理上不可能压到 100µs 以内。

六、掉电:littlefs 保证什么,不保证什么

littlefs 的保证是”任意时刻掉电,挂载都能成功,回到上一个一致状态”。实测五个窗口:

掉电时刻 重新挂载 数据表现
数据块编程中 成功 旧版本完好;半截块下次当垃圾回收
提交写到一半 成功 CRC 不过,回退到上一次有效提交
提交完整 成功 新版本生效
未调用 sync 成功 丢 cache 里最多 3 条(cache 256)
compaction 进行中 成功 旧块仍是有效副本(元数据双块的意义就在这)

它不保证的三件事,必须自己兜:

  • 两个元数据块同时坏:返回 -84(CORRUPT),分区要重建。概率极低但非零,靠”参数双写 + 分区外留一份出厂参数”兜底。
  • 驱动吞掉错误:prog/erase 回调恒定返回 0,littlefs 会以为写成功,元数据指向一个空块,下次挂载读到全 0xFF。131 篇那页 0x0807E000 擦除失败就是这么被藏了两年——回调必须把 HAL 的返回值原样翻译成负码。
  • 分区压到禁区:擦除地址落进选项字节/RDP 区(130 篇),表现是”部分块擦不掉 → 被标坏 → 可用块耗尽 → -28 NOSPC,而 lfs_fs_size 才 70%”。这个症状特别容易误判成”文件系统碎片化”。

七、反模式与选型对账:littlefs 买的不是寿命

六个反模式,我们踩过前四个:

反模式 为什么坏 替代
用 littlefs 做定长环形日志 删除最旧记录要重写整块,CoW 下等于一次 compaction 按日期分文件,定期删整个文件
一条记录一个文件 1 个文件 2 次提交 + 1 个目录项,31 个文件就把元数据块填满 一个文件装一天
单个目录塞上千文件 每目录一对块(4.2%),stat 逐级扫,挂载后遍历极贵 按日期/类型分目录,控制在 30 个以内
90% 满盘 最坏一次写要 3 个空闲块(分配 + compaction + 迁移),95% 时只剩 3 块 留 30% 空闲 = 14 块 / 28KB
block_cycles = -1 元数据两块钉死,86400 条/天下 15 天写穿 500(或 100~1000)
在中断/控制环里调 lfs_* 擦除期间全局停摆 队列 + 低优先级写者

同一负载、不同分区尺寸的对账(96 条/天是写合并后的常态,86400 条/天是每秒一条的极端):

方案 分区 块/页 擦/条 96 条/天 86400 条/天 掉电一致性
固定单页 2 KB 1 0.03125 9.1 年 4 天 无
环形页(131 原方案) 14 KB 7 0.03125 63.9 年 26 天 seq+CRC 自建
环形页(同尺寸重算) 96 KB 48 0.03125 438.4 年 178 天 seq+CRC 自建
littlefs(本篇) 96 KB 48 0.04813 284.6 年 115 天 CRC+双块
littlefs(塞进 14KB) 14 KB 7 0.04813 41.5 年 17 天 CRC+双块
FatFS(2 热点块) 96 KB 2 0.03125 18.3 年 7 天 无

三行结论:

  • 同尺寸下 littlefs 的寿命是环形页的 0.65 倍,也就是写放大 1.54 倍。多出来的擦除就是它买”目录 + 掉电一致性”付的钱。
  • 131 篇那个 63.9 年不是”littlefs 类方案”给的,是那 7 个块给的;而 littlefs 塞进同样 7 块只有 41.5 年。 选型表里比”63.9 年”的时候,一定要连”分给文件系统多少块”一起比。
  • 给够块数,两个方案都远超设备寿命。 96 条/天这条常态负载下,284.6 年 vs 438.4 年没有工程意义;真正拉开差距的是 86400 条/天那种”每秒一条”的错误设计——115 天 vs 178 天,都在一年内报废。

所以本篇的选型结论是:littlefs 买的是”11.8KB 代码换掉 1200 行手写逻辑 + 掉电一致性 + 目录能力”,寿命靠分区尺寸买。 两个都要,就得像第一节那样先把 82KB 从 App 槽里挪出来。


八、验收清单

  • ☐ block_size 等于 Flash 页,且分区起止都在页边界上
  • ☐ 分区表三方对齐:链接脚本 / Bootloader / IAP 擦除范围
  • ☐ 擦除与编程回调把 HAL 错误码翻译成负码,绝不返回 0
  • ☐ 分区范围不覆盖选项字节、RDP 区、Bootloader
  • ☐ 所有 lfs_* 调用只在写者任务里,控制环/中断零调用
  • ☐ 写队列有界(本篇 32),满时丢最旧并计数上报
  • ☐ 参数类数据双写 A/B,lfs_file_sync 后才置有效标志
  • ☐ block_cycles 显式设为 500,不依赖默认值
  • ☐ 高水位有告警:空闲块 < 5 时上报(95% 满盘时最坏写会失败)
  • ☐ 出厂前做过 200 次随机时刻断电测试,lfs_mount 全部成功、参数无半截

最后一条是唯一能验证”掉电一致性”的方法。用继电器切 3.3V,随机延时 0~200ms,200 次里只要有一次挂载失败,就说明你的驱动或分区有问题——littlefs 不会让你挂载失败,让挂载失败的一定是驱动。


最后收三句话。第一,寿命是块数问题,不是文件系统问题。 同一个写放大系数下,给 48 块就是给 7 块的 6.86 倍;想靠换文件系统换寿命,方向从一开始就错了。

第二,时间账要先算。 内部 Flash 的 40ms 擦除是物理常数,任何”优化”都动不了它;能动的只有”谁在什么上下文里付这笔钱”。把 compaction 放进 1kHz 控制环,是我这个项目里最贵的一行代码——它不报错、不复位、只在负载突变时表现为一次电压跌落。

第三,掉电保证只到 sync 为止。 write 成功、sync 才落盘,中间那段窗口由 cache_size 决定,256B 就是 3 条。知道这个数字,比记住”littlefs 掉电安全”有用得多。

工具:drafts/_tools/littlefs_landing_calc.py(11 个模式、21 项自检断言,纯标准库、全离线)。口径:擦除 20/40ms、16 位编程 52.5/70µs、擦写 10k 次取自 STM32F103xE 数据手册 Table 29/30;CTZ 头部按 16B 记账(实际随块号 4~24B 浮动);compaction 的”活元数据 120B”按”1 个文件 + 3 个块指针”估算;耗时按半字逐个编程的串行模型,未计 DMA 与取指重叠。上片前请以你自己的工程实测为准。

文中设备型号与参数为写作口径下的重构,不针对任何具体厂商产品;文内数字均可用配套工具复算,与你的器件数据手册冲突时以数据手册为准。

下一篇预告《把文件系统搬到外部 SPI Flash》:本篇第五节第四条只给了一句”换外部 Flash”,账没算完——W25Q64 是 4KB 扇区、45ms 擦除、100k 次擦写,看着都比内部 Flash 好,但挂载要过 SPI(2.4ms 变多少?)、要处理 BUSY 轮询和 DMA、还要防”擦除期间掉电”。下一篇把同一套账在外部器件上重算一遍,包括 QSPI 内存映射模式到底能不能用来跑文件系统。