Flash 上跑 littlefs 落地实测:96KB 分区挂上第三天,PWM 每隔几小时停更 118ms——不是 littlefs 不可靠,是我把 compaction 放进了 1kHz 控制环
上一篇《固件防逆向与混淆》把 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 命中)。
修法四条,按性价比排:
- 写操作全部离开控制环。 Modbus 写参数的回调只做三件事:入队、返回、置标志。队列深度 32,满则丢最旧并计数——丢一条日志远比停一次 PWM 便宜。写者任务优先级放在控制环和通信之间,
lfs_*全在里面调。 - 批量 sync:16 条一次。 提交从 1240 次/天降到 77.5 次/天,compaction 从 19.7 次/天降到 1.23 次/天,寿命从 22.0 年抬到 31.9 年。注意:单次最坏耗时不变(还是 47.6ms),变的是频次和它所在线程——这条最容易被误解成”批量就能变快”。
- 参数双写 + 标志后置。 A/B 两份记录,sync 之后才置”有效”标志。掉电最多回退一版,不会读到半截。
- 如果控制环要求 <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 篇),表现是”部分块擦不掉 → 被标坏 → 可用块耗尽 →
-28NOSPC,而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 内存映射模式到底能不能用来跑文件系统。