1. 为什么要在 NOR 与 NAND 上折腾 littlefs
第一次把 littlefs 移植到一块 SPI NOR 上跑通的时候,我以为这事儿就算完了。结果换到一块 SPI NAND 上,同样的代码,挂载直接失败,日志里全是LFS_ERR_CORRUPT。那一刻我才意识到,littlefs 虽然号称"掉电安全、磨损均衡、小体积",但它对底层块设备的假设,在 NOR 和 NAND 这两种介质上完全是两码事。
littlefs 是 ARM 官方开源的一个面向微控制器的嵌入式文件系统,核心卖点有三个:掉电恢复能力、动态磨损均衡、极小的 RAM/ROM 占用。它不像 FAT 那样依赖复杂的 FAT 表,也不像 SPIFFS 那样在大文件场景下性能塌方。它用了一套"元数据对"(metadata pair)加"写时复制"(copy-on-write)的机制,保证任何一次写入被打断,文件系统都能回滚到上一个一致状态。
但问题也恰恰出在这里。littlefs 的这套机制,是建立在对底层存储介质"可擦除块大小""读写粒度""坏块行为"的假设之上的。NOR Flash 和 NAND Flash 在这几个维度上差异巨大:
- NOR 支持字节级随机读,擦除块通常 4KB,写入前必须是 0xFF,几乎没有坏块;
- NAND 必须按页读写(常见 2KB 页 + 64B spare),擦除块 128KB 起步,出厂就可能有坏块,而且需要 ECC 校验。
所以"适配"这个词,在 littlefs 语境下不是改改配置那么简单,它涉及到块设备驱动层的抽象、擦除块几何参数的匹配、坏块管理策略、以及 cache 与 lookahead 缓冲的调优。这篇文章我会把我在 NOR 和 NAND 上踩过的坑、调过的参数、写过的适配层代码,尽量完整地摊开讲。适合正在做嵌入式存储选型、或者已经把 littlefs 跑起来但性能不理想的同行参考。
2. littlefs 的存储抽象模型与介质假设
2.1 块设备接口到底要求了什么
littlefs 通过一个lfs_config结构体和底层打交道,核心是四个回调:read、prog、erase、sync。看起来很简单,但每个回调背后都有隐含约束。
read的签名是(block, off, buffer, size),其中block是块号,off是块内偏移。注意,littlefs 内部把整个存储划分成若干个"块",这个"块"的大小由block_size配置决定,它必须等于底层介质的擦除块大小。这是第一个大坑:很多人把block_size设成 512 或者 4096,但底层 NOR 的扇区是 4KB、NAND 的擦除块是 128KB,结果就是擦除操作越界或者对齐错误。
prog回调要求写入的数据区域必须是已经擦除过的(全 0xFF)。littlefs 自己会保证这一点,但前提是你的block_size和block_count配置正确,否则它算出来的块边界和实际介质对不上。
erase回调接收的是块号,要求把整个块擦成 0xFF。对于 NOR,这通常是一条0x20扇区擦除指令;对于 NAND,是一条0xD8块擦除指令,而且擦除后还要检查坏块标记。
sync回调是可选的,用于在元数据提交后强制刷写缓存。在 NOR 上通常不需要,因为 NOR 写入就是直接落到介质;但在 NAND 上,如果你的驱动有写缓存,必须在这里 flush。
2.2 NOR 与 NAND 的几何差异对照
我把两种介质的关键参数整理成一张表,这张表决定了后面所有适配策略的走向:
| 特性 | NOR Flash | NAND Flash |
|---|---|---|
| 读取粒度 | 字节级随机读 | 页级读(2KB/4KB) |
| 写入粒度 | 字节/字 | 页级写 |
| 擦除块大小 | 4KB / 32KB / 64KB | 128KB / 256KB |
| 坏块 | 极少 | 出厂即有,需管理 |
| ECC | 通常不需要 | 必须 |
| 擦写寿命 | 约 10 万次 | 约 1 万次(SLC) |
| 典型容量 | 1MB ~ 16MB | 128MB ~ 4GB |
| 单位成本 | 高 | 低 |
这张表里最要命的是擦除块大小和坏块两项。littlefs 的磨损均衡算法是按块来做的,块越大,元数据对占用的空间比例越小,但单次擦除的代价越高。而坏块的存在,意味着 NAND 上不能简单地用"块号 × 块大小"来算物理地址,必须经过一层坏块映射。
2.3 littlefs 的元数据对机制简述
littlefs 把每个目录和文件都表示成一个"元数据对"——两个相邻的块,轮流写入。每次修改,它把新数据追加到当前活跃块,写满后擦除旧块,切换过去。这个设计的好处是:任何时刻至少有一个块是完整的,掉电后可以从另一个块恢复。
但这个机制对block_size非常敏感。如果块太小(比如 4KB),元数据对能容纳的 tag 数量有限,频繁修改大目录会导致频繁的块切换和擦除,寿命消耗快。如果块太大(比如 256KB),每次元数据提交都要擦除 256KB,延迟高,而且小文件场景下空间浪费严重。
所以 NOR 上通常选 4KB 块,NAND 上要根据实际擦除块大小来定,常见的是 128KB。这个选择不是拍脑袋,后面我会讲怎么算。
3. NOR Flash 适配实战:从配置到调优
3.1 块大小与块数量的计算
假设你手头是一块 W25Q128,16MB 容量,扇区 4KB,共 4096 个扇区。littlefs 的block_size就设 4096,block_count设 4096。但实际使用中,我一般会留出前面几个扇区给 bootloader 或者参数区,所以block_count会减掉预留部分。
这里有个细节:littlefs 需要至少 2 个块来做元数据对,所以block_count不能小于 2。另外,lookahead_size这个参数控制磨损均衡的扫描粒度,它必须是 8 的倍数,且block_count不能超过lookahead_size × 8。比如 4096 个块,lookahead_size至少要是 512 字节。
#define NOR_BLOCK_SIZE 4096 #define NOR_BLOCK_COUNT 4096 #define NOR_LOOKAHEAD 512 static uint8_t nor_read_buf[NOR_BLOCK_SIZE]; static uint8_t nor_prog_buf[NOR_BLOCK_SIZE]; static uint8_t nor_lookahead_buf[NOR_LOOKAHEAD]; const struct lfs_config nor_cfg = { .read = nor_read, .prog = nor_prog, .erase = nor_erase, .sync = nor_sync, .read_size = 1, .prog_size = 1, .block_size = NOR_BLOCK_SIZE, .block_count = NOR_BLOCK_COUNT, .cache_size = 256, .lookahead_size = NOR_LOOKAHEAD, .block_cycles = 500, .read_buffer = nor_read_buf, .prog_buffer = nor_prog_buf, .lookahead_buffer = nor_lookahead_buf, };注意read_size和prog_size在 NOR 上可以设成 1,因为 NOR 支持字节级访问。但如果你用的是 QSPI 模式,驱动层可能要求 4 字节对齐,那就得设成 4。
3.2 擦除与写入的对齐陷阱
NOR 的擦除指令通常要求地址按扇区对齐。比如0x20扇区擦除,地址低 12 位必须是 0。littlefs 传下来的块号乘以block_size就是物理地址,只要block_size等于扇区大小,天然对齐。
但写入就不一定了。有些 NOR 芯片在 QSPI 模式下要求页编程地址按 256 字节对齐,如果你prog_size设成 1,驱动层就得自己做读-改-写。我一般会在驱动里加一层缓冲,把非对齐写入攒成对齐的页写。
static int nor_prog(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, const void *buffer, lfs_size_t size) { uint32_t addr = block * c->block_size + off; const uint8_t *src = buffer; while (size > 0) { uint32_t page_remain = 256 - (addr & 0xFF); uint32_t chunk = (size < page_remain) ? size : page_remain; nor_page_program(addr, src, chunk); addr += chunk; src += chunk; size -= chunk; } return 0; }这段代码的逻辑是:每次写入不超过当前页剩余空间,跨页时自动切到下一页。这样即使 littlefs 传下来一个跨页的写请求,驱动层也能正确处理。
3.3 block_cycles 与磨损均衡的取舍
block_cycles这个参数控制元数据块在多少次擦除后强制迁移。设成 -1 表示禁用动态磨损均衡,设成 100 表示每 100 次擦除换一个块。NOR 的擦写寿命是 10 万次,如果你有一个频繁更新的配置文件,不设这个参数,那个块很快就会被擦穿。
我的经验值是:对于频繁写入的场景,设 100 到 500;对于只读或极少写入的场景,设 -1 或者 1000 以上。设太小会导致元数据频繁迁移,性能下降;设太大则磨损集中。
实测下来,一块 16MB 的 NOR,block_cycles设 500,每天写入 1000 次,理论寿命可以撑到 10 年以上。这个计算很简单:10 万次擦除 × 500 次迁移 = 5000 万次写入,除以每天 1000 次,就是 13 万年。当然这是理想值,实际还要考虑其他块的磨损。
3.4 cache_size 对性能的影响
cache_size是 littlefs 用来缓存元数据块的缓冲区大小。它必须大于等于prog_size,且是prog_size的倍数。在 NOR 上,我一般设 256 或 512。
这个参数直接影响文件创建、目录遍历的速度。设太小,每次读元数据都要多次调用read回调;设太大,RAM 占用高。在 RAM 紧张的 MCU 上,256 是个比较平衡的值。我试过设 64,创建 100 个文件耗时增加了将近 40%。
4. NAND Flash 适配实战:坏块、ECC 与页对齐
4.1 NAND 的物理结构与 littlefs 的冲突
NAND 的读写单位是页,擦除单位是块。一块典型的 SPI NAND(比如 W25N01GV)是 2KB 页 + 64B spare,64 页一块,即 128KB 擦除块。littlefs 的block_size必须设成 128KB,block_count就是总块数。
但这里有个问题:littlefs 的prog_size如果设成 2048(页大小),那它每次写入至少 2KB。对于小文件或者元数据更新,这会造成大量读-改-写。如果设成 1,驱动层就要自己处理页内偏移和跨页。
我的做法是:prog_size设成页大小,read_size也设成页大小,然后在驱动层做缓存。littlefs 内部有prog_buffer和read_buffer,大小等于cache_size,驱动层再维护一个页缓存,把非对齐访问攒成整页操作。
4.2 坏块管理的两种策略
NAND 出厂时可能有坏块,使用过程中也会产生新的坏块。littlefs 本身不处理坏块,它假设底层块设备是可靠的。所以坏块管理必须在驱动层做。
策略一:保留坏块表,逻辑块号到物理块号做映射。启动时扫描所有块,读 spare 区的坏块标记(通常是第 0 页 spare 的第 0 字节,非 0xFF 即为坏块),建立一张映射表。erase和prog时查表转换。
策略二:跳过坏块,用连续逻辑块号覆盖不连续物理块。这种方法实现简单,但需要维护一个"下一个可用物理块"的游标。
我一般用策略一,因为 littlefs 的块号是连续的,映射表可以用一个数组实现,RAM 开销是block_count × 2字节。对于 1Gb NAND(1024 个块),也就 2KB。
static uint16_t badblock_map[NAND_BLOCK_COUNT]; static uint16_t logic_to_phys[NAND_BLOCK_COUNT]; static int nand_erase(const struct lfs_config *c, lfs_block_t block) { uint16_t phys = logic_to_phys[block]; if (phys == 0xFFFF) return LFS_ERR_CORRUPT; return nand_block_erase(phys); }4.3 ECC 校验的集成位置
NAND 必须做 ECC。SPI NAND 芯片通常内置 ECC 引擎,通过配置寄存器开启,读写时自动校验。但有些裸 NAND 需要软件 ECC,那就得在驱动层实现 BCH 或者 Reed-Solomon。
我的建议是:能用硬件 ECC 就用硬件 ECC。软件 ECC 会占用大量 CPU 时间,而且实现复杂。如果芯片内置 ECC,在初始化时配置好,读写回调里直接调用芯片的页读写指令即可。
需要注意的是,开启 ECC 后,spare 区的部分字节会被 ECC 校验码占用,坏块标记的位置可能变化。一定要查芯片手册确认。
4.4 页缓存与写合并的实现
NAND 的页编程时间典型值是 200~700 微秒,块擦除是 2~10 毫秒。如果每次小写入都触发一次页编程,性能会非常差。所以驱动层必须做写合并。
我的实现是维护一个 2KB 的页缓存和一个脏标记。当 littlefs 调用prog时,如果写入区域在当前缓存页内,就更新缓存并置脏;如果跨页,先把脏页刷下去,再处理新页。sync回调里强制刷脏页。
static uint8_t page_cache[2048]; static uint32_t cache_page_addr = 0xFFFFFFFF; static bool cache_dirty = false; static int nand_prog(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, const void *buffer, lfs_size_t size) { uint32_t addr = block * c->block_size + off; uint32_t page_addr = addr & ~(2048 - 1); uint32_t page_off = addr & (2048 - 1); if (page_addr != cache_page_addr) { if (cache_dirty) nand_page_program(cache_page_addr, page_cache); nand_page_read(page_addr, page_cache); cache_page_addr = page_addr; cache_dirty = false; } memcpy(page_cache + page_off, buffer, size); cache_dirty = true; return 0; }这段代码的关键点是:切换页之前先把脏页刷下去,再读新页。读新页是因为 NAND 写入前必须是 0xFF,而我们要做部分更新,必须先读出原内容。
5. 两种介质上的性能对比与参数调优
5.1 实测数据对比
我在同一块开发板上,分别接 W25Q128(NOR)和 W25N01GV(NAND),跑了相同的测试用例:创建 100 个 1KB 文件,然后读取、删除。结果如下:
| 操作 | NOR (4KB块) | NAND (128KB块) |
|---|---|---|
| 格式化 | 1.2s | 8.5s |
| 创建100文件 | 3.4s | 6.8s |
| 读取100文件 | 0.8s | 1.5s |
| 删除100文件 | 2.1s | 4.2s |
| RAM占用 | 1.5KB | 4KB |
NAND 慢的主要原因是擦除块大,格式化要擦 1024 个块,每个块 2ms,就是 2 秒,加上坏块扫描,8.5 秒很正常。创建文件慢是因为元数据提交要擦除 128KB 块。
5.2 针对 NAND 的优化手段
手段一:减小 block_size。有些 SPI NAND 支持 64KB 擦除块(通过配置寄存器),这样block_size可以设 64KB,擦除时间减半。但不是所有芯片都支持。
手段二:增大 cache_size。NAND 上我把cache_size设成 2048,和页大小一致,减少读-改-写次数。RAM 占用增加,但性能提升明显。
手段三:调整 block_cycles。NAND 擦写寿命只有 1 万次,block_cycles要设小一点,比如 100,让磨损更均匀。
手段四:预擦除。在系统空闲时,后台预擦除一些块,减少写入时的等待。这个需要自己实现一个后台任务。
5.3 NOR 上的性能优化
NOR 的瓶颈通常在写入和擦除。优化手段包括:
- 开启 QSPI 四线模式,读取速度提升 4 倍;
- 使用页编程而不是字节编程,减少指令开销;
- 把频繁写入的数据放到 RAM 里缓存,定期刷写;
block_cycles适当调大,减少元数据迁移。
我实测过,QSPI 模式下 NOR 的读取速度从 20MB/s 提升到 80MB/s,littlefs 的目录遍历速度提升约 3 倍。
6. 常见问题与排查技巧实录
6.1 挂载失败问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| LFS_ERR_CORRUPT | block_size 不匹配 | 确认 block_size 等于擦除块大小 |
| LFS_ERR_IO | 驱动读写失败 | 用逻辑分析仪抓 SPI 波形 |
| LFS_ERR_NOSPC | block_count 太小 | 检查是否预留了坏块空间 |
| 挂载后文件丢失 | 掉电时元数据未提交 | 检查 sync 回调是否实现 |
| 写入后读回数据错 | ECC 未开启或配置错 | 读芯片状态寄存器 |
6.2 掉电测试怎么做才靠谱
掉电安全是 littlefs 的核心卖点,但很多人的测试方法不对。我见过有人直接拔电源,结果文件系统挂了,就怪 littlefs 不行。其实问题在于:拔电源的时机是随机的,可能正好在擦除过程中,这时候任何文件系统都救不了。
正确的测试方法是:在prog和erase回调里加一个计数器,每 N 次操作后触发一次复位。这样能精确控制掉电时机,覆盖各种边界情况。我一般会跑 1000 次随机复位,确认每次挂载后文件系统都一致。
6.3 NAND 坏块导致的诡异问题
有一次客户反馈,设备运行几天后文件系统就挂了。我查了半天,发现是 NAND 产生了新的坏块,而驱动层没有动态更新坏块表。解决方法是在erase失败时,把该块标记为坏块,更新映射表,并返回LFS_ERR_CORRUPT让 littlefs 处理。
但这里有个坑:littlefs 收到LFS_ERR_CORRUPT后,会尝试从元数据对的另一个块恢复。如果两个块都坏了,文件系统就真的挂了。所以坏块管理要尽量提前,不要等到擦除失败才发现。
6.4 性能不达预期的排查思路
如果你觉得 littlefs 慢,按这个顺序查:
- 确认
block_size和block_count配置正确; - 检查
cache_size是否太小; - 看
read/prog回调里有没有不必要的延时; - 用示波器测 SPI 时钟频率是否达到芯片上限;
- 确认没有频繁的元数据迁移(调大
block_cycles)。
我遇到过一个案例,客户把cache_size设成 16,结果创建 1000 个文件花了 30 秒。改成 256 后,降到 4 秒。
6.5 几个容易忽略的细节
lookahead_size必须是 8 的倍数,且block_count ≤ lookahead_size × 8;read_buffer和prog_buffer如果不用静态分配,可以用malloc,但要注意对齐;- NAND 的 spare 区读写需要单独的指令,不要和主区混在一起;
- littlefs 的
format会擦除所有块,NAND 上耗时很长,建议在工厂预格式化。
7. 从适配到优化:我的经验总结
7.1 选型建议:什么时候用 NOR,什么时候用 NAND
如果你的数据量小于 16MB,写入频率不高,选 NOR。它的字节级访问、无坏块、简单驱动,能省掉大量适配工作。littlefs 在 NOR 上的表现非常稳定,我做过连续 72 小时的压力测试,没有出现一次挂载失败。
如果数据量大于 128MB,或者需要存储音频、图像等大文件,选 NAND。但要做好心理准备:坏块管理、ECC、页缓存,每一项都要花时间调试。littlefs 在 NAND 上的性能不如在 NOR 上,但胜在容量和成本。
7.2 参数配置的通用原则
我把 littlefs 的参数配置总结成几条原则:
block_size必须等于物理擦除块大小,没有例外;block_count要减去预留块和坏块余量,NAND 上建议留 2%;cache_size至少 256,NAND 上建议等于页大小;lookahead_size按block_count / 8向上取整到 8 的倍数;block_cycles根据擦写寿命和写入频率算,NOR 设 500,NAND 设 100。
7.3 后续可以扩展的方向
如果你已经把 littlefs 跑通了,可以考虑这几个方向继续优化:
- 实现一个块设备抽象层,让同一套代码同时支持 NOR 和 NAND,通过配置切换;
- 加入后台垃圾回收任务,在系统空闲时预擦除块;
- 对 NAND 实现动态坏块管理,运行时更新坏块表;
- 结合 RTOS,把 littlefs 的读写操作放到独立线程,避免阻塞主循环。
我个人在实际操作中的体会是:littlefs 的适配工作,80% 的时间花在驱动层,20% 花在参数调优。驱动层写好了,参数调优就是水到渠成的事。反过来,如果驱动层有隐患,再怎么调参数都是白搭。所以我的建议是,先把read/prog/erase三个回调写扎实,用单元测试覆盖各种边界情况,再上 littlefs 跑业务逻辑。这样能省掉后面大量的排查时间。