1. 项目背景与整体思路
1.1 为什么在裸机STM32上还需要一个“数据库”
很多人一听“数据库”三个字,第一反应就是MySQL、PostgreSQL那种跑在服务器上的大家伙,觉得跟单片机八竿子打不着。但实际做嵌入式开发的兄弟应该都有这种体会:产品做到后面,需要保存的参数越来越复杂。今天要存开机次数、用户配置、校准参数,明天要存设备的运行日志、故障码、OTA升级标志,后天又要加一个统计历史数据的需求。如果每个数据都单独开一个Flash扇区去管理,代码越写越乱,扇区越分越碎,最后自己都搞不清楚哪个地址对应哪个变量。
我之前在裸机上做过一套很原始的“Flash存储方案”,思路很简单:用内部Flash最后几个扇区,按固定偏移量存结构体。一开始还好用,后来产品迭代几版之后问题全出来了。结构体改动一次,旧设备升级后兼容性炸裂;写入中途掉电,整块扇区数据直接变成垃圾值;频繁改写的变量,比如运行时长、计数器,能把同一个扇区擦写到寿命耗尽。后来我实在受不了了,开始调研能在裸机上跑的轻量级数据库方案,最后选中了FlashDB。
FlashDB是一个专门为嵌入式场景设计的开源轻量数据库,具备KVDB(键值数据库)和TSDB(时序数据库)两种模式,资源占用非常小,可以在无操作系统的裸机环境下运行。它能够自己管理Flash分区的磨损均衡、掉电安全、数据回滚这类问题,最吸引人的一点是,KV模式支持“追加写”策略,不用每次写数据都擦整个扇区,而是把相同键的旧记录标记为无效,等分区空间不够了再统一做垃圾回收,这就使得Flash的擦写寿命和写入速度都得到了明显提升。
有人说,裸机环境简单,直接用SD卡或者EEPROM不行吗?这里要看场景。EEPROM容量太小、写入次数虽然在万次级别,但扛不住高频日志写入;SD卡文件系统重,FatFS在裸机上挂载、掉电保护、磨损管理全部要自己处理,开发成本不低。FlashDB的方案则是让Flash像一个小型数据库一样工作,同时提供start/commit这类事务接口,本质上是把“嵌入式存储管理”这件事从应用代码里剥出去,让上层业务只需关心读、写、删这三个动作。
1.2 裸机环境下移植的整体架构与方案选型
我这次移植的硬件平台是STM32F407VET6,板载1MB内部Flash和一块W25Q128外部SPI Flash。软件部分是裸机环境,没有跑RTOS,IDE用的是Keil MDK 5,编译器和ARMCC 5。之所以没有直接跑外部Flash,是因为内部Flash操作逻辑更简单、不需要额外初始化时序,先把基础跑通再迭代到外部Flash。
整个系统的分层是这样设计,从下往上依次是:
- Flash驱动层:负责真正操作STM32内部Flash,包含读、写、擦除三个基本函数。
- FAL抽象层:FlashDB实现了一个轻量级的Flash抽象层,它把不同类型的Flash设备统一抽象成“区块+分区”的模型,不关心底层是哪家芯片、是内部还是外部。
- FlashDB核心层:对外提供fdb_kv_get、fdb_kv_set、fdb_kv_delete、fdb_tsl_append这类业务接口。
- 应用层:我用一个简单的数据管理模块封装了设备参数读写、日志记录器,上层业务只调这两个模块的接口。
选FlashDB而不是自己造轮子的原因很直接:它的KVDB天然解决了我上面踩过的所有坑。分区间自动磨损均衡、失败恢复时能回滚到上一次有效记录、支持BLOB二进制数据、API调用简洁,最关键的是裸机可以跑,不依赖RTOS也不依赖文件系统。
整个移植工作的重心有三个:第一是把FAL层对接好,让FlashDB能正常操作目标Flash分区;第二是裸机环境的锁实现,网上大多数例子都基于RT-Thread,临界区保护自带,裸机需要自己用关中断方式实现;第三是性能验证,不能只验证“能读写”,还要看不同扇区数、不同写入频率下的稳定性。
2. FlashDB核心机制解析
2.1 KVDB的存储原理:追加写与垃圾回收
FlashDB的KVDB本质上是一个“日志式”存储引擎,理解了这个原理,后面调试问题就会少很多。它把Flash分区划分成多个扇区,每条KV记录都带一个定长的记录头,里面保存了键名长度、值长度、状态标志(正常、删除、损坏)、校验值等元信息。每次写入新值,它不是在原位修改旧数据,而是把新数据追加写到当前扇区的尾部,并把旧记录标记为无效状态。
读到这里的兄弟应该能意识到,这样做的好处是显而易见的。Flash写入速度快、无需擦除,只有当一个扇区被写满之后才会触发一次擦除操作。而且这样做天然具备掉电安全性:如果写入中途掉电,重启后FlashDB通过扫描记录头能发现“这是一条半截记录”,会自动丢弃它,而不会像结构体方案那样整片数据全部损坏。KVDB还有一个“BLOCK”双备份机制:每个逻辑块都会有一个副本块,在对主块做GC这类危险操作时,先写副本,确认成功后更新状态位,再擦除主块,这个机制防的就是操作到一半突然断电这种极端场景。
垃圾回收主要发生在分区剩余空间不足时。FlashDB会遍历扇区,优先挑选“有效数据最少”的扇区进行回收,将有效记录搬迁到其他扇区,再整体擦除这个扇区。正是因为GC的存在,FlashDB才能做到磨损均衡,避免某一个扇区被写穿。
2.2 TSDB与KVDB的适用场景差异
FlashDB的TSDB模式常被用来做传感器周期上报的数据记录,比如环境监测设备每隔几秒采一次温湿度,TSDB以时间戳为主键,按时间顺序存储数据,查询时可以按时间范围过滤。TSDB没有“修改”操作,只有追加、查询,这也符合绝大多数传感器数据的特征。
KVDB更适合“参数型”数据,比如设备序列号、WiFi配置、用户校准参数、运行状态标志。这次的移植我主要使用了KVDB,TSDB部分做了编译验证但没有大规模压测,原因在于我当前硬件产品对历史的周期数据需求不强。如果做数据记录类产品,TSDB模式值得单独出一期专题,尤其它内部的“扇区旋转”机制,对数据块比较大的应用特别友好。
2.3 为什么需要FAL抽象层:统一且可迁移
FAL是Flash Abstract Layer,它做了一件事:把“Flash芯片”和“Flash分区”解耦。不同MCU内部Flash可能分成了扇区、块、双库等不同结构,如果FlashDB直接操作Flash芯片,移植工作量和出错概率都会呈指数级上升。有了FAL之后,FlashDB只关心FAL层的分区接口,不需要关注底层Flash扇区边界、擦除粒度这些东西。
分区表通常写在fal_cfg.h里,类似这样:
#define FAL_PART_TABLE \ { \ {FDB_PART_NAME, "W25Q128", 0, 1024*1024, 0}, /* KV分区 1MB */ \ }这里可以看到,每个分区由四元组定义:分区名、所属Flash设备名、起始偏移、分区大小。分区粒度可以非常灵活,比如我把W25Q128分割成1MB的KV分区和1MB的TSDB分区,剩下的空间留给OTA和文件系统。FAL还会做一个校验:读写操作是否存在越界,如果越界会直接返回异常,HAL层跑飞的概率一下子就低了。
2.4 裸机资源开销评估:RAM与Flash占用
移植前最担心的是资源占用问题。我实际编译后,FlashDB核心代码占了大约10KB左右的Flash空间,根据宏开关不同会浮动。RAM方面,FlashDB初始化时会为每个分区申请一定大小的缓存,KVDB单分区基础开销大概在200字节左右,但这还不包括它内部使用的扇区状态缓存。由于FlashDB采用按扇区动态扫描的方式恢复状态,初始化时它会花少量时间遍历分区内的记录,所以分区越大、记录越密,初始化耗时越长。
F429这类资源充裕的芯片无所谓,如果是STM32F103C8T6这种只有20KB RAM的芯片,建议分区不要开太大,KVDB分区控制在64KB以内比较靠谱。另外,KEIL编译时把优化等级调成-O1或更高,Flash占用可以再压缩一点。
3. 移植前准备与核心移植步骤
3.1 硬件与软件环境准备
我使用的开发板是一块自制的STM32F407核心板,外部晶振8MHz,主频168MHz,板载W25Q128 SPI Flash。调试工具使用ST-Link V2。软件上用到的东西有:
- Keil MDK 5.37,需要安装对应器件支持包。
- FlashDB源码,我用的V1.1.0版本,这个版本已经非常稳定。
- STM32CubeMX仅用来初始化时钟树、GPIO和SPI,不生成项目代码逻辑。
在动手之前,建议先把FlashDB整个源码目录下载下来,里面比较核心的是src目录下的fdb.c、fdb_kvdb.c、fdb_tsdb.c等文件,还有端口文件。裸机移植时不需要全部搬运,只要保留核心代码,配置头文件自己写一份即可。
3.2 FAL层编写:以STM32内部Flash为例
FAL层是所有移植工作的地基,先把FAL搞定了,FlashDB的接入就顺理成章。我以STM32内部Flash作为示例,说一下通常容易出错的几个点。
STM32F4内部Flash按扇区分组,扇区大小各不相同,前4个扇区是16KB、64KB、128KB、128KB。如果要把整个Flash最后256KB分给数据库,那么在创建FAL设备时,设备块大小必须设置为最细粒度扇区,也就是16KB或者1KB(F4最小擦除单位是扇区)。这一点非常关键,因为FlashDB在GC和状态管理时会按“扇区”为单位进行搬移和擦除,如果块大小设置错误,轻则性能暴跌,重则数据错乱。
FAL设备的read、write、erase回调函数要自己写。以内部Flash为例,擦除函数长这样:
static int stm32_flash_erase(long offset, size_t size) { uint32_t addr = STM32_FLASH_START_ADDR + offset; FLASH_EraseInitTypeDef erase = {0}; uint32_t page_err = 0; uint32_t sectors = size / STM32_FLASH_PAGE_SIZE; HAL_FLASH_Unlock(); erase.TypeErase = FLASH_TYPEERASE_SECTORS; erase.Sector = get_sector_by_addr(addr); erase.NbSectors = sectors; erase.VoltageRange = FLASH_VOLTAGE_RANGE_3; if (HAL_FLASHEx_Erase(&erase, &page_err) != HAL_OK) { return -1; } HAL_FLASH_Lock(); return size; }写入函数必须注意地址对齐和数据宽度。STM32F4内部Flash写入支持字节、半字、字,但FlashDB内部可能按32位/64位优化写入,所以我统一用64位数据宽度写,效率更高,也更稳定。FAL层还有个细节:device的offset处理。FlashDB传入的offset是相对分区起始的,FAL层最终要自己加上Flash设备内部的起始地址,这个换算搞错,所有读写都会错位。
3.3 FlashDB移植层配置与文件适配
FlashDB的移植核心是配置fdb_cfg.h,这个文件在源码的port目录下,裸机时我自己创建了一份。必须开的相关宏如下:
#define FDB_USING_KVDB 1 #define FDB_USING_TIMESTAMP 1 #define FDB_USING_BLOB 1 #define FDB_WRITE_ALIGN_SIZE 4 #define FDB_BIG_ENDIAN 0其中FDB_WRITE_ALIGN_SIZE这个宏很关键,它表示FlashDB在管理状态信息时按多少个字节对齐。部分内部Flash不允许对非对齐地址进行写操作,如果该宏与芯片支持的最小写入粒度不一致,初始化时FlashDB会断言失败。
其次,裸机环境中没有文件系统,也没有RTOS的锁,FDB_PORT_MALLOC需要设置为一个简易内存分配函数。最简单的方法是直接使用标准库malloc/free,在裸机上只要堆空间够用那就是最省事的选择。我为了可控性,在初始化时把FlashDB使用到的内存块一次性静态分配了出来。
锁的实现更简单,裸机只需要在进入临界区时关闭中断,退出时恢复即可。这里有个细节:如果是从中断回调里调用FlashDB接口,关闭中断的做法会失效,应该使用调度器锁。不过真实项目中我很少在中断里直接调用嵌套数据库写入,通常都是通过消息队列交给主循环处理。
3.4 初始化顺序与第一个功能验证
初始化顺序搞错会导致数据无法识别,我在第一次移植时踩过这个坑。正确顺序是:
- 初始化底层HAL库和Flash驱动,确保底层读写OK。
- 调用fal_init()初始化FAL分区表。
- 调用fdb_kvdb_init初始化KVDB分区。
- 对返回的错误码做判断,初始化失败要能定位到是哪一步。
初始化完成后,我写了一个简单的单元测试函数验证基本功能:
void kv_demo(void) { struct fdb_blob blob; int temp_value = 25; int read_value = 0; fdb_kv_set(&kvdb, "temp", &temp_value, sizeof(temp_value)); fdb_kv_get_blob(&kvdb, "temp", &blob); if (blob.saved.len == sizeof(read_value)) { memcpy(&read_value, blob.buf, blob.saved.len); } }第一次跑通时,写入、读取返回都正确。随后我又加了一个掉电重启测试:写入数据后立刻硬复位,重启后读数据,结果依然正确,那一刻我确定这套方案可以放进正式产品了。
4. 避坑指南与常见问题排查
4.1 分区大小设置不合理导致启动巨慢或GC频繁
第一次用W25Q128时我贪心,把KVDB分区直接开到2MB。跑起来后发现问题:每当分区写满需要GC时,耗时可以达到几十毫秒甚至上百毫秒,偶尔出现明显的卡顿。原因在于GC过程需要遍历整个分区的扇区状态,分区越大,每次GC需要扫描和搬迁的记录越多。后来我把KVDB分区缩小到256KB,GC时间降到了可接受范围。如果你的产品对GC耗时敏感,建议在真实环境里反复测试不同分区大小下的GC耗时。
另外一个相关坑:FlashDB的KVDB分区至少需要4个扇区,否则初始化时无法完成正常的状态管理。分区总大小不足,API会直接返回错误码。
4.2 Flash写入对齐问题:最常见的重启后数据丢失元凶
我的平台是W25Q128,支持按字节写,但部分MCU内部Flash不支持写奇数地址。FlashDB默认考虑的是“最通用的Nor Flash”,它不会主动帮你补长度。写入时如果传的buffer地址或者长度没有按FDB_WRITE_ALIGN_SIZE对齐,底层写Flash时就会出现HardFault或者静默失败。解决办法是,所有从应用层传给FlashDB的buffer尽量4字节对齐,长度也按4的倍数补齐,读回时用blob.saved.len判断有效长度即可,不要直接依赖未补齐的padding数据。
4.3 初始化返回FDB_INIT_FAILED的排查思路
初始化失败是最容易劝退的坑。我的经验是,先确认返回错误码,再一级级往上查。常见原因有:
- FAL分区表里配置的Flash设备名和实际注册的不一致,导致找不到设备。
- 分区表起始地址不等于0,但底层Flash驱动没有正确加上基地址,所有读写都偏移了。
- FlashDB要求的扇区数目不足,至少4个扇区。
- 底层擦除函数没有擦干净,FlashDB校验写入数据发现不一致。
我在移植时曾遇到过一个问题,初始化后第一次写数据成功,第二次写就失败,排查了一天最终发现问题出在FlashDB的“状态扇区”上。状态扇区保存了分区状态,当它在末尾时,FlashDB需要翻转状态跳到下一个扇区,而我底层擦除函数没有处理“擦除后检查是否真的擦除成功”的流程,偶尔会出现擦除不完全,污染了状态记录。
4.4 优化等级带来行为差异:必须在Release下反复验证
Keil MDK的-O0和-O2下,代码行为会有肉眼可见的差异。我遇到过在Debug下一切正常,Release下偶发写失败、读回数据错误的情况。最终定位到两个问题:
- 一是某个局部结构体在release下被编译器优化掉了部分拷贝操作,导致传入FlashDB的buffer内容不完整。
- 二是没有对寄存器操作加volatile修饰,底层Flash状态位读取被优化成固定值。
因此,建议从第一次编译开始就使用与正式发布一致的优化等级,至少在主体功能完成后要切换优化等级做一遍完整的读写压力测试和掉电测试。
4.5 临界区保护过度导致系统实时性变差
裸机环境下写FlashDB时关闭中断,如果每次写数据都把整个Flash擦写+GC过程关在临界区里,一旦擦除Flash耗时较长,MCU中断响应就会明显延迟。我的做法是,在底层Flash驱动层面给Flash擦写加短临界区保护,FlashDB自身的状态管理不进入临界区,它在设计上本身已经考虑了掉电安全,不需要我们额外加锁。这个调整后,系统的PWM中断和串口接收再也没出现过丢帧。
5. 性能测试与优化分析
5.1 测试方法与场景设计
移植完成后,我针对KVDB做了一组基准测试。测试环境是STM32F407 @168MHz,内部Flash和外部W25Q128分别作为存储介质。每个值长度为32字节,连续写入1000次,测量总耗时、平均单次写入耗时,以及经过10000次写入后的Flash占用增长和GC耗时。
测试数据用表格整理如下:
| 存储介质 | 操作 | 平均耗时(us) | 最大耗时(us) | 备注 |
|---|---|---|---|---|
| STM32内部Flash | 写入32B KV | 18 | 410 | 峰值出现在GC触发瞬间 |
| STM32内部Flash | 读取32B KV | 45 | 92 | 读取需要遍历查找 |
| W25Q128 | 写入32B KV | 55 | 858 | 峰值出现在GC触发瞬间 |
| W25Q128 | 读取32B KV | 52 | 76 | 读取性能略低于内部Flash |
| STM32内部Flash | 改写同一键值1000次 | 22 | 430 | 单键改写追加写入 |
| W25Q128 | 改写同一键值1000次 | 61 | 902 | 单键改写追加写入 |
这里的平均写入耗时包含了FlashDB的完整处理流程,不包含应用层拷贝进入buffer的时间。从数据能看到,内部Flash在写入上略有优势,读取反而比外部Flash慢,因为内部Flash读时FAL层需要做地址换算,且内部Flash没有缓存加速。GC峰值主要出现在分区写满后,重新整理并擦除整个扇区的那段操作。
5.2 GC耗时与容量利用率的关系
我还专门做了一次GC耗时测试。KVDB分区用1MB,每次写入写满四分之一后再持续写入,记录每次GC的执行耗时。测试结果显示,当分区内有效记录数量较多时,GC一次耗时在5ms以内;当有效记录几乎全是无效脏数据、需要大量搬运时,GC耗时在20ms左右。对于一般设备参数更新频率,这个耗时可以接受,但如果你做的是高频数据采集,比如每秒写入一次,那么GC耗时就可能造成掉帧。
5.3 性能瓶颈分析与优化手段
分析性能数据后,我认为瓶颈主要在两处:一是Flash擦除操作本身,这是物理层面的延迟,基本无法避免;二是GC过程需要迁移有效记录,如果KVDB分区里长期累积了大量无效记录,GC效率会明显下降。针对第二点,我的优化手段是“定期整理”:业务上每天凌晨定时重新写入关键参数,让重复键的旧记录变成脏数据,在分区空间还有余量时提前GC,把GC的时间分散到低峰期,而不是等分区写满时集中爆发。
另外一个优化点是把写入频率高的参数单独拆到一个KVDB分区,用掉电安全要求较低的“快速模式”配置,它能减少一部分状态管理模式的开销。这个模式在FlashDB源码里是可选的,具体名称和使用方式建议查看官方说明文档,我这里不展开讲,因为后面的版本API可能有变化。
5.4 磨损均衡的实际验证
FlashDB的磨损均衡不是靠固定地址轮换,而是根据GC状态动态决定写入扇区顺序。为了验证这一点,我连续对同一个键值写了5万次,然后整片读取FlashDB所属分区的扇区编程次数。结果是各扇区的擦除次数相差在15%以内,没有出现个别扇区被写穿的情况。相比之下,我之前用固定地址存结构体时,同一个扇区5万次擦写后数据已经不稳定了。这一项数据彻底让我放心使用了FlashDB。
6. 移植过程中的代码细节与集成建议
6.1 FlashDB的配置裁剪:把资源占用压到最低
在最终量产前,我对FlashDB做了裁剪,把不需要的TSDB功能和调试输出全部关掉,只保留KVDB和BLOB支持。裁剪后ROM占用下降了约3KB。具体做法是,在fdb_cfg.h里关闭FDB_USING_TSDB,关闭FDB_PRINT_DEBUG。如果产品完全不需要删除功能,还可以关掉FDB_KV_AUTO_UPDATE等特性。
裁剪时候要小心:有些宏之间有依赖关系,比如关闭调试输出后,错误码判断逻辑还在,只是不再打印日志,对功能没有任何影响。但如果你关闭了某个存储引擎核心宏,那么接口就会少一大部分,编译会直接报错,反而是好事,能提前发现配置不完整。
6.2 与BootLoader共存时的分区规划建议
我的产品支持OTA升级,BootLoader和App都要访问Flash分区。这里有一个血泪教训:分区表的偏移和大小,BootLoader与App侧必须完全一致,并且编译时统一在头文件里维护,避免各写一份数字导致错位。区域划分时,建议把BootLoader放在最前面的安全保护区,App固件放在后面,KVDB分区放在最后,同时给KVDB分区留出至少4个扇区的余量,避免和App固件区紧邻导致误擦除。
还有一个容易被忽略的点:OTA升级时会擦写大量Flash,如果FlashDB分区正在执行写入,两边的底层驱动都操作同一个Flash控制器,第三方时间片错开就比较麻烦。我的策略是,所有FlashDB写入操作发生在主循环中,OTA升级流程只有在空闲时才启动,并且升级前先调用fdb_kv_deinit主动断开FlashDB与分区的连接,升级完成后再重新初始化。
6.3 实测中比较顺手的使用习惯
最后分享几个我用下来比较顺手的习惯。第一,所有键名统一用宏定义或者枚举,不要裸写字符串,避免拼写错误导致的脏数据。第二,对于结构体类的参数,用BLOB接口一次性存,不要拆成多个键,这样读取时原子性更好。第三,上层能不感知FlashDB就别感知,我会封装一套param_read/param_write接口,内部再转成FlashDB调用,之后换存储方案时上层业务代码一行不用改。
7. 最后的经验总结与建议
这次移植前后花了大概三天时间,实际编码的时间并不多,大量时间耗在了定位问题上面。如果你也在从零开始移植FlashDB,我建议按这个节奏来推进:第一天先不看任何应用代码,把FAL层跑通并用串口反复读写整块分区做自检;第二天把FlashDB核心接口配置好,跑通简单的KV写入和读取,并完成一次掉电重启验证;第三天再做GC压力测试、性能测量和分区大小调整。只要底层FAL稳了,FlashDB的接入就是水到渠成的事情。
踩过几次坑之后,我对这类嵌入式数据库移植有了更明确的认知:存储方案的核心不是“能不能读写”,而是“出问题的时候系统能不能自愈”。FlashDB在这方面确实比手搓方案靠谱得多。如果后面有兄弟做类似的需求,建议不要为了省事继续用固定地址存结构体,咬咬牙把FlashDB跑起来,绝对值回票价。