FlashDB 与 LittleFS 如何在 MCU Flash 上管理数据
FlashDB 是面向 MCU Flash 的数据管理组件,LittleFS(Little File System) 是为微控制器设计的文件系统。两者经常一起出现在 MCU 的 Flash 存储方案里。
它们解决的问题不同:
- FlashDB:面向 KV 数据和日志的数据管理层
- LittleFS:文件系统
FlashDB 与 LittleFS 通常使用不同分区并列工作:
应用
+------+------+
| |
FlashDB LittleFS
KV / TSDB 文件与目录
| |
FAL 块设备接口
| |
数据库分区 文件分区
+------ Flash ------+FlashDB 也支持 POSIX 或 C 标准库文件模式。采用文件模式时,FlashDB 经文件接口访问底层文件系统;移植层提供相应文件接口适配后,该文件系统可以是 LittleFS。两者存在并列分区和文件模式两种组合,不能固定画成唯一的上下层关系。
一、LittleFS 的底层原理
LittleFS 是为 MCU NOR Flash 设计的文件系统。
它主要解决三个问题:
- Flash 擦写寿命有限
- 掉电保护
- 在有限 RAM 下管理小块更新
1. Flash 的限制
普通 Flash:
读取:
addr -> data很简单。
但是写入:
1 -> 0在满足芯片 program size、对齐和编程次数限制时可以直接写。
但是:
0 -> 1必须:
擦除整个 sector例如:
Flash:
Sector 0
+----------------+
|1111111111111111|
+----------------+
写入:
11111111 -> 11110000
OK
00001111 -> 11111111
必须 eraseerase 操作:
- 慢
- 有次数限制
具体擦除寿命由 Flash 型号和数据手册决定。存储层需要避免少量 sector 承担全部高频更新。
LittleFS 如何保存文件
LittleFS 保存一个文件时,需要记住两类内容:
- 文件说明:文件名、文件大小,以及文件内容存放在哪里。
- 文件内容:用户真正写入的数据。
很小的文件可以直接和文件说明放在一起。文件较大时,文件说明只记录内容的位置,真正的数据会放在其他数据块中。
源码把存放文件说明的成对空间称为 metadata pair,把连接大文件数据块的结构称为 CTZ skip-list。理解后面的过程只需记住:前者负责指出文件在哪里,后者负责保存较大的文件内容。
下面用单个数据块表示较大文件的一次更新。它只展示提交顺序,不代表 LittleFS 每次修改都会复制整个文件:
修改前:
文件说明中记录的位置
|
v
+-----------+
| block 10 |
| AAAA |
| 有效数据 |
+-----------+
写入新数据:
旧引用 -------------> block 10(旧数据仍然有效)
block 20
+-----------+
| BBBB |
| 正在写入 |
+-----------+
提交完成:
更新后的文件说明
|
v
+-----------+
| block 20 |
| BBBB |
| 新数据 |
+-----------+
block 10 不再被文件引用,之后可重新分配写入期间,旧文件状态仍可读取。文件内容及相关属性会在 sync 或 close 时提交到文件系统:
写入新的数据块 → 在 metadata pair 中追加一条带 CRC 的提交,记录新的文件位置 → 旧数据块失去引用 → 以后重新使用旧空间这种“先写新内容,再追加提交新的文件位置”的做法叫作 copy-on-write,也就是写时复制。较大文件的 CTZ skip-list 本身含有块之间的引用,但 LittleFS 不会原地改写一张统一的指针表:新数据块写好后,新的 CTZ 位置会随 metadata commit 一起追加,并由 CRC 判断提交是否完整。小文件以内联形式保存在 metadata pair 中,更新时同样通过 metadata commit 提交。它的好处是新内容确认完成前,旧内容仍然有效。即使写入失败或途中断电,系统仍能使用旧文件;只有新内容完整写入且提交有效后,文件位置才会切换过去。
2. Metadata Pair(LittleFS 核心)
成对保存的直接好处是:更新过程中始终保留一个已经写完整的版本。平时追加新内容时,旧内容仍然存在;需要擦除、整理其中一块时,另一块仍保留可用内容。即使此时断电,重新启动后也能回到最近一次完整提交。
一个 metadata pair 由两个 block 地址组成。这两个 block 不要求物理连续。每个 metadata block 自身是一段可追加的提交日志,一次提交由若干 metadata entry 和末尾的 CRC 组成。
metadata pair = {block 5, block 21}
当前活动 block:
revision | commit A + CRC | commit B + CRC | 未写空间空间足够时,更新会直接追加到当前 metadata log,不需要在两个 block 之间来回切换。当前 block 需要压缩时,LittleFS 擦除 pair 中作为目标的另一个 block,把仍然有效的最新条目和递增后的 revision count 写进去,并用 commit CRC 完成提交。原活动 block 会保留为旧版本,等以后角色再次轮换时才被擦除。
挂载时,LittleFS 使用 revision count 比较两个 block,并通过 CRC 排除掉电时未完成的 commit。metadata pair 的掉电安全来自旧提交仍然存在、commit CRC 检错和压缩时的双 block 冗余,不能只概括成“先写 block 6,再切换指针”。
3. 均衡磨损
Flash 的每一小块空间都有擦除寿命。如果配置文件始终占用同一个位置,每次修改都让这一块承担擦除,其他位置却很少使用,这一块会最先老化。
LittleFS 使用一小段 lookahead buffer 记录当前扫描范围内哪些块可以使用。需要新空间时,它从当前范围继续向后寻找,走到 Flash 末尾后再从头开始。挂载时,LittleFS 会根据已经通过 CRC 检查的提交计算一个 seed,再用它选择扫描起点;随着有效提交发生变化,起点也可能变化,因此不会固定从 block 0 开始。
只使用固定位置:
block 10:擦除、擦除、擦除、擦除……
block 11:几乎不用
block 12:几乎不用
LittleFS 分散使用:
第一次使用 block 10
下一次使用 block 11
继续使用 block 12
走到末尾后再从前面寻找空闲位置目录等信息如果长期占用同一块,LittleFS 也会在达到一定使用次数后把它换到其他位置。
这种做法只能分散“允许换位置”的数据。一个文件写入后长期不变,LittleFS 不会为了平衡寿命主动搬动它。因此这里的“均衡”表示尽量分散擦除次数,不表示每一块的使用次数完全相同。
4. 垃圾回收
垃圾回收要解决的问题很直接:新内容写到新位置后,Flash 中会留下旧版本。旧版本不能立刻擦除,因为新版本可能还没有写完;确认新版本完整后,旧空间才可以重新使用。
LittleFS 需要处理两类旧内容。
第一类是目录、文件名和文件位置等信息。每次修改都会在后面追加一个新版本。空间逐渐写满后,LittleFS 把仍然有效的最新内容整理到成对的另一块中:
整理前:
A旧 | B有效 | A新 | C有效 | 剩余空间很少
整理后:
A新 | B有效 | C有效 | 空闲空间新的一块写完并确认有效后,原来那块暂时保留为旧版本,以后轮换到它时再擦除使用。这样即使整理途中掉电,旧版本仍能用于恢复。
第二类是文件内容。文件修改时,新内容写到新位置,文件记录随后改为指向新内容。旧位置失去引用,下一次寻找可用空间时就可以被识别出来:
修改前:文件记录 -> block 10
修改后:文件记录 -> block 20
block 10 已无文件引用
以后需要空间:擦除 block 10,再写入其他内容LittleFS 不会把所有有效数据集中搬到一片连续区域。它主要整理写满的目录信息,并在寻找空闲空间时重新利用已经没有文件引用的块。
二、FlashDB 原理
FlashDB 提供面向 Flash 的数据库接口,不提供目录、路径和普通文件语义。它包含两种数据库:
- KVDB
- TSDB
1. KVDB
Key Value Database
例如:
wifi_ssid = "home"
motor_speed = 100
device_id = 123456KV 不需要绑定固定物理地址:
地址:
0x1000 wifi_ssid
0x1100 motor_speed更新后的记录会写入新的空闲位置:
Flash:
Record1:
key=wifi_ssid
value=home
Record2:
key=motor_speed
value=100
Record3:
key=wifi_ssid
value=officeKVDB 可选用 KV cache 直接定位记录。未启用缓存或缓存未命中时,find_kv 会遍历 sector 中的记录,寻找名称匹配、CRC 正确且状态为 FDB_KV_WRITE 的记录。读取逻辑不能简化成每次扫描并取“最后一条同名记录”。
2. FlashDB 的核心:追加写与单向状态机
FlashDB 会先把数据库分区切成多个 sector,再在每个 sector 中顺序存放 KV:
数据库分区
+------------+------------+------------+
| sector 0 | sector 1 | sector 2 |
+------------+------------+------------+
一个 sector 内部
+-------------+----------+----------+----------+
| sector 说明 | KV 记录1 | KV 记录2 | 空闲空间 |
+-------------+----------+----------+----------+
一条 KV 记录
+------+-------------------+------+-------+
| 状态 | 长度、校验值等说明 | key | value |
+------+-------------------+------+-------+sector 开头的说明记录这一块当前是否正在使用、是否含有旧数据。每条 KV 开头也有自己的状态字段,后面才是 key 和 value。因此,“单向状态机”并非一个单独的控制器,它就保存在 Flash 中每条 KV 记录的开头,设备重启后仍然可以读到。
状态变化利用 NOR Flash 可从 1 写成 0 的特点。每向前推进一个状态,只需把更多位写成 0,不用先擦除整个 sector;已经推进的状态无法原路改回,所以称为“单向”。KV 的主体数据仍采用追加写,旧记录只改变状态,新值写入后面的空闲位置。
修改一个已有 KV 时,源码中的顺序是:
旧 KV:WRITE → PRE_DELETE
新 KV:PRE_WRITE → 写入 header/name/value/CRC → WRITE
旧 KV:PRE_DELETE → DELETED如果在更新中途掉电,初始化恢复会处理这些中间状态:CRC 正确的 PRE_DELETE 旧记录会被搬移恢复,停留在 PRE_WRITE 的未完成新记录会被标记为错误。这个状态机才是 KV 更新掉电恢复的关键。
3. 为什么不用直接改?
因为 Flash:
修改:
00001111想变:
11111111需要:
erase追加新记录时:
只需要:
写新位置4. FlashDB 的垃圾回收
比如:
Flash:
A=1
B=2
A=3
C=4
A=5状态仍为 WRITE 的有效记录:
A=5
B=2
C=4已经删除或写入失败的记录:
A=1
A=3KV 更新多次以后,同一个 sector 里会混合有效记录、已删除记录和写入失败的记录。完整空 sector 所剩不多时,KVDB 开始回收:
1. 给准备回收的 sector 做标记
2. 逐条检查里面的记录
3. 把确认写完整、仍未删除的 KV 搬到其他可写位置
4. 擦除原 sector,使它重新变成空闲空间第一步的标记用于应对掉电。如果设备在搬运途中断电,下次启动时能发现回收尚未完成,并继续执行。FlashDB 会搬走一个 sector 里的有效 KV;LittleFS 则分别整理目录信息和寻找已经没有文件使用的旧块,两者做法不同。
5. TSDB(时间序列数据库)
TSDB 用来保存不断产生、按时间排列的数据。例如传感器每隔一段时间产生一条温度记录:
time temp
10:01 25
10:02 26
10:03 27每条记录由两部分组成:
- 索引:记录状态、时间戳,以及实际数据的长度和位置。
- 实际数据:温度、湿度、错误信息等用户内容。
FlashDB 在一个 sector 的两端同时写入。索引从前向后增加,实际数据从后向前增加,中间是尚未使用的空间:
低地址 高地址
+------------+--------+--------+----------+--------+--------+
| sector说明 | 索引1 | 索引2 | 空闲空间 | 数据2 | 数据1 |
+------------+--------+--------+----------+--------+--------+
→ ←
索引向后增长 数据向前增长这样安排有两个好处:实际数据可以保持不同长度,不必给每条记录预留同样大的空间;索引长度固定,第 N 条索引的位置可以直接用“起始地址 + N × 索引长度”算出,也就可以在索引区使用二分查找。
追加一条记录时,FlashDB 按下面的顺序写入:
1. 在新索引中标记“正在写”
2. 写入时间戳、数据长度和数据位置
3. 把实际数据写到 sector 尾部
4. 将索引状态改成“写入完成”最后一步相当于正式提交。设备在前三步中途断电,这条记录仍带有“正在写”标记,不会变成“写入完成”状态。重新启动后,FlashDB 能据此识别它没有完成。当前版本没有给每条时间序列记录单独保存 CRC,掉电时主要依靠这个写入顺序区分完整记录和半条记录。
TSDB 要求新记录的时间戳大于上一条记录,这为二分查找提供了前提。实际代码不会对整个数据库直接二分:它先逐个检查 sector 的起止时间,找到可能包含目标时间的 sector;随后在这个 sector 的定长索引区中二分查找起点;找到后再按固定步长顺序读取索引,直到超出查询时间范围,并根据索引中的地址读取实际数据。它支持顺序查询、倒序查询和指定时间范围查询。
一个 sector 写满后,TSDB 会继续写下一个 sector。默认开启循环覆盖:所有 sector 都用完后,擦除最旧的 sector,继续保存新数据。
sector 0 → sector 1 → sector 2 → 回到 sector 0
↓
擦除最旧 sector 后继续写关闭循环覆盖后,所有 sector 写满就停止追加并返回空间不足,旧记录会一直保留。
这种结构适合:
- IoT
- 传感器
- 日志
三、FlashDB 和 LittleFS 的区别
| LittleFS | FlashDB | |
|---|---|---|
| 类型 | 文件系统 | 数据库 |
| 对象 | 文件 | Key/Record |
| 访问 | open/read/write | get/set |
| 目录 | 有 | 无 |
| 随机文件 | 支持 | 不适合 |
| 掉电保护 | 有 | 有 |
| 磨损均衡 | 尽量把可移动数据分散到不同空闲块 | KVDB 通过垃圾回收搬走有效记录,再轮流擦除和复用 sector;TSDB 开启循环覆盖后按顺序擦除最旧 sector;文件模式的物理擦写由底层文件系统负责分散 |
| 日志 | metadata pair 使用日志提交 | KVDB 日志式布局,TSDB 顺序追加 |
| 适合 | 配置文件、资源 | 参数、日志、状态 |
两者分散磨损的方式不同。LittleFS 用 block_cycles 控制 metadata 达到一定使用次数后主动换块,并通过空闲块分配分散动态数据;FlashDB 不记录每个 sector 的擦除次数,KVDB 的分散效果来自垃圾回收和 sector 轮转,TSDB 则来自开启循环覆盖后的顺序复用。
四、实际 MCU 项目怎么选?
例如 ESP32:
存配置:
wifi账号
设备参数
校准值
↓
FlashDB KVDB存升级文件:
web页面
图片
模型文件
↓
LittleFS存运行日志:
温度历史
错误记录
↓
FlashDB TSDB实际工程常见组合:
APP
|
+-------+-------+
| |
FlashDB LittleFS
| |
参数/日志 文件资源
|
SPI NOR Flash五、源码阅读入口
- LittleFS源码里的 block allocator + metadata pair
- FlashDB源码里的 sector manager + KV cache + GC
这两组模块分别覆盖空间分配、元数据提交、记录检索和垃圾回收,是理解两套存储机制的主要入口。
本文核对的源码基线:
六、FlashDB 和 LittleFS 如何处理数据错误
掉电中断和数据存放多年后发生位翻转,是两类不同的错误。前者发生在写入过程中,后者可能发生在一条记录或一个文件早已写完之后。
| 存储方式 | 写入中途断电 | 写完后发生位翻转 |
|---|---|---|
| FlashDB KVDB | 根据记录状态和 CRC32 排除未写完的 KV,并在启动时恢复未完成的更新或垃圾回收 | CRC32 覆盖 key、value 和长度信息,读取时通常能发现内容变化 |
| FlashDB TSDB | 根据记录状态区分准备写入和已经写完的记录 | 用户数据默认没有 CRC,需要在每条 payload 中自行保存 CRC |
| LittleFS | metadata pair 保留旧提交,并用 commit CRC 判断新提交是否完整 | 能检查元数据提交;普通文件内容默认没有逐块 CRC,内容位翻转可能无法发现 |
这些机制主要负责发现错误并避开不完整的数据,无法凭一份损坏的数据还原原值。需要长期冷存储时,可以在 TSDB payload 或 LittleFS 文件格式中加入 CRC;重要数据还应保存副本,检测到一份损坏后才能用另一份修复。