ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32裸机移植FlashDB数据库:从底层适配到性能测试完整指南

STM32裸机移植FlashDB数据库:从底层适配到性能测试完整指南 做STM32裸机开发最头疼的事情之一就是数据存储。以前我都是自己写一套Flash读写管理后来项目里需要同时维护几十个参数还要周期记录传感器的历史数据自己写的存储代码越来越扛不住最后从零开始移植了FlashDB数据库到STM32裸机上。整个过程踩了不少坑也拿到了一份真实的性能数据。今天把这套从零开始的移植过程、底层适配思路、性能测试结果都整理出来希望给想在STM32裸机上做参数存储、日志记录、历史数据采集的朋友省点时间。这篇文章适合已经能用Keil建STM32工程、对Flash读写有基本概念但还没接触过嵌入式数据库的开发者。1. 为什么要在裸机上引入FlashDB1.1 手写Flash存储管理的痛传统的裸机方案一般是定义结构体然后直接调用Flash驱动往固定地址写入读的时候再做一次memcpy回来。刚开始数据量小感觉还能用但随着产品功能变多三个问题越来越突出。第一个是参数扩展很痛苦。今天加一个校准系数明天加一个设备地址每次都要重新划分Flash地址段还要保证旧固件能兼容。只要地址表变动一个字节整个存储区的数据就可能错乱轻则参数丢失重则设备升级后直接跑飞。第二个是Flash擦写寿命和磨损问题。STM32内部Flash擦写寿命是1万次左右外部SPI Flash一般是10万次。如果每次更新参数都执行“擦除-写入”那些频繁变化的参数比如运行累计时间会很快把同一个扇区写坏。产品跑了一年多就出现写不进去的情况排查起来非常恼火。第三个是掉电保护。最原始的方案在写一半断电时Flash里可能留下半条记录下次启动读到的数据就是脏数据。现场设备最怕这种偶发问题复现难、定位难用户那边只要出现一次对产品的信任度就会大打折扣。这也是我最想换掉自研存储方案的原因。1.2 FlashDB能解决什么问题FlashDB是一个专门给嵌入式设备设计的轻量级数据库不依赖文件系统也不需要RTOS在裸机环境里就能跑。它的核心能力分两大块。KVDB键值数据库适合存参数。写入一个key之后自动完成地址管理、空间回收、磨损均衡甚至支持掉电安全。对项目来说再也不用关心参数具体存在Flash哪一页直接按key做增删改查。新增一个参数加一行代码就行不用动存储布局。TSDB时序数据库适合存周期性的历史数据。比如传感器采集的温度、湿度、电压曲线。它把每条数据按时间顺序追加写入自动处理扇区翻转和空间覆盖很适合“只关心最近N条历史记录”的需求。这一点对嵌入式设备尤其实用因为Flash空间有限不可能无限累积历史数据TSDB自带滚动覆盖策略。我选择FlashDB的另一个原因是它针对MCU做了大量优化代码量和内存占用都在可接受范围内。整个FlashDB核心库编译下来也就十几KB的Flash占用RAM消耗主要是数据库实例和操作缓存对STM32F4这种资源不算紧张的芯片来说完全没压力。相比SQLite这种重量级数据库FlashDB更符合单片机的气质。1.3 裸机移植和RTOS移植有什么不同网上的FlashDB教程大多配合RT-Thread使用默认系统里已经有信号量、互斥锁、线程调度。但纯裸机环境下没有这些同步机制移植的时候需要自己留意两点。一是临界区保护。FlashDB在内部做扇区切换、垃圾回收时会有比较长的流程如果中断里也在读写数据库就可能出现数据错乱。裸机上我建议数据库操作只放在主循环或任务级代码里调用不要在中断回调里直接读写。如果确实需要在中断里触发保存可以用“先记录标志位、主循环再执行”的方式间接控制。二是延时和阻塞处理。FlashDB内部有些流程依赖延时等待Flash操作完成比如擦除一个扇区需要几十毫秒。底层驱动要提供等待Flash设备就绪的能力不要用纯忙等把MCU卡死。好在STM32 SPI Flash场景下等待时间都是固定的只要把W25Q64的忙状态检查写对这块就不会有问题。后面的实操部分我会专门按裸机场景来写所有代码都是可以直接放进裸机工程里编译运行的。2. 移植前的软硬件准备2.1 硬件选型与存储规划我的测试平台是STM32F407ZGT6开发板主频168MHz外挂一片W25Q64 SPI NOR Flash容量8MB。选外部SPI Flash而不是直接用芯片内部Flash有两点考虑。第一是容量。FlashDB需要至少预留几个扇区作为日志区和磨损均衡区如果准备存的数据比较多STM32内部1MB Flash根本不够用。第二是擦写寿命。W25Q64的擦写次数是10万次比STM32内部Flash高一个量级跑测试和实际部署都更放心。SPI Flash的接线也简单就MISO、MOSI、SCK、CS四根线任意GPIO都能模拟软件SPI硬件SPI速度还能更快。实际存储规划上我把W25Q64分成了三个分区。最前面4MB留给应用程序远程升级备用中间1MB给FlashDB使用最后3MB留作其他数据缓存。给FlashDB分配1MB空间对大多数嵌入式项目的参数和历史记录需求来说已经非常充裕了。2.2 软件工具与源码编译环境我用的是Keil MDK 5.35配合STM32CubeMX生成底层初始化代码。如果你习惯用IAR或者GCC流程完全一样差异只在工程文件组织方式。FlashDB源码直接从GitHub拉最新release版本。解压后核心代码在src目录下包含fdb.c、fdb_kvdb.c、fdb_tsdb.c、fdb_utils.c以及对应的头文件。另外一个必须准备的组件是FAL全称Flash Abstraction LayerFlash抽象层。很多人在这一步会犯迷糊搞不清FlashDB和FAL的关系。我的理解是FAL负责把不同型号的Flash统一成一套接口让上层不用关心你用的是芯片内部Flash还是外部W25Q64。FlashDB负责在FAL提供的分区之上做数据库管理。所以移植FlashDB时FAL不是可选项而是标配依赖FlashDB的底层读写和擦除操作都是通过FAL的分区接口下发到具体Flash驱动的。2.3 目录结构整理建议把下载的源码按下面的方式整理进工程后续升级或者排查问题都方便。APP/ ├── FAL/ │ ├── inc/ │ │ ├── fal.h │ │ └── fal_cfg.h │ └── src/ │ ├── fal.c │ ├── fal_part.c │ ├── fal_flash.c │ └── ... ├── FlashDB/ │ ├── inc/ │ │ ├── flash_db.h │ │ ├── fdb_cfg.h │ │ └── ... │ └── src/ │ ├── fdb.c │ ├── fdb_kvdb.c │ └── ... └── BSP/ ├── w25q64.c └── w25q64.h把FAL和FlashDB分成两个独立目录不要混在一起。最重要的两个头文件是fdb_cfg.h和fal_cfg.h前者管数据库功能开关后者管Flash设备和分区表配置。后面我会讲到大多数移植路上的坑都是在调整这两个文件的参数时埋下的。3. 从零开始的移植实操3.1 第一步完成W25Q64底层驱动FlashDB上层逻辑再完善最终操作的都是底层Flash的读、写、擦除函数。所以第一步必须先把W25Q64驱动调通裸读写没问题再谈数据库移植。我用STM32CubeMX配置SPI1时钟分频选4分频SPI时钟大概10.5MHz模式0。W25Q64使用标准4线SPI接线是PA5接SCKPA6接MISOPA7接MOSIPA4接CS。CubeMX生成的SPI初始化代码先保持默认。然后写W25Q64的底层函数。关键代码就是读、写、擦除三个// 读数据 void W25Q64_Read(uint32_t addr, uint8_t *buf, uint32_t len) { W25Q64_CS_LOW(); SPI1_SendByte(0x03); // Read Data命令 SPI1_SendByte((addr 16) 0xFF); SPI1_SendByte((addr 8) 0xFF); SPI1_SendByte(addr 0xFF); for (uint32_t i 0; i len; i) { buf[i] SPI1_RecvByte(); } W25Q64_CS_HIGH(); } // 写数据写之前目标区域必须已擦除 void W25Q64_Write(uint32_t addr, const uint8_t *buf, uint32_t len) { uint32_t remain len; uint32_t curAddr addr; const uint8_t *curBuf buf; while (remain 0) { W25Q64_CS_LOW(); SPI1_SendByte(0x06); // Write Enable W25Q64_CS_HIGH(); uint32_t pageRemain 256 - (curAddr % 256); uint32_t writeLen remain pageRemain ? pageRemain : remain; W25Q64_CS_LOW(); SPI1_SendByte(0x02); // Page Program命令 SPI1_SendByte((curAddr 16) 0xFF); SPI1_SendByte((curAddr 8) 0xFF); SPI1_SendByte(curAddr 0xFF); for (uint32_t i 0; i writeLen; i) { SPI1_SendByte(curBuf[i]); } W25Q64_CS_HIGH(); W25Q64_WaitBusy(); // 等待写完成 curAddr writeLen; curBuf writeLen; remain - writeLen; } } // 擦除addr和size必须先按4KB扇区对齐 void W25Q64_Erase(uint32_t addr, uint32_t size) { for (uint32_t i 0; i size; i 4096) { W25Q64_CS_LOW(); SPI1_SendByte(0x06); // Write Enable W25Q64_CS_HIGH(); W25Q64_CS_LOW(); SPI1_SendByte(0x20); // Sector Erase命令 SPI1_SendByte(((addr i) 16) 0xFF); SPI1_SendByte(((addr i) 8) 0xFF); SPI1_SendByte((addr i) 0xFF); W25Q64_CS_HIGH(); W25Q64_WaitBusy(); } }补充一个细节W25Q64的页编程一次最多写256字节如果数据跨越页边界必须拆成多次Page Program。上面代码已经规避了这个问题。另外擦除操作必须按4KB扇区对齐上层传下来的起始地址和长度如果不是4KB整数倍很容易出现数据错乱后面适配FAL时我会特别检查这条。底层驱动写完建议先单独做一轮读写擦环回测试在特定地址写入0x5A5A5A5A然后擦除再读回来确认是0xFFFFFFFF。这一步能过滤掉一大半硬件接线问题先确认驱动没问题再继续往下走。3.2 第二步把驱动封装成FAL接口FAL层向上层提供统一的Flash设备接口。需要定义一个描述Flash设备的结构体并把刚才实现的功能填进去#include fal.h static int w25q64_init(void) { /* 复位或初始化W25Q64 */ return 0; } static int w25q64_read(long offset, uint8_t *buf, size_t size) { W25Q64_Read((uint32_t)offset, buf, (uint32_t)size); return size; } static int w25q64_write(long offset, const uint8_t *buf, size_t size) { W25Q64_Write((uint32_t)offset, buf, (uint32_t)size); return size; } static int w25q64_erase(long offset, size_t size) { W25Q64_Erase((uint32_t)offset, (uint32_t)size); return size; } const struct fal_flash_dev w25q64 { .name w25q64, .addr 0, .len 8 * 1024 * 1024, .blk_size 4096, .ops {w25q64_init, w25q64_read, w25q64_write, w25q64_erase}, .write_gran 1 };write_gran这个参数值得多说一句。它表示底层Flash的最小写入粒度W25Q64能按字节写所以填1如果用的是一些按4字节写的最小粒度Flash这里就要填4。FlashDB会根据这个参数判断写入对齐要求填错轻则写入被跳过重则返回错误码。配置完设备之后FAL还需要一张分区表把逻辑分区映射到物理Flash上#define FAL_PART_TABLE \ { \ {FAL_PART_MAGIC_WORD, app, w25q64, 0, 4*1024*1024, 0}, \ {FAL_PART_MAGIC_WORD, easyflash, w25q64, 4*1024*1024, 1*1024*1024, 0}, \ {FAL_PART_MAGIC_WORD, download, w25q64, 5*1024*1024, 3*1024*1024, 0}, \ }分区表里的每个分区包含名字、所属Flash设备、起始地址、长度等字段。FlashDB后续操作的是easyflash这个分区而不是整个Flash芯片。这样即使以后调整Flash布局也只是改分区表数据库内部数据不受影响。3.3 第三步配置FlashDB并加入工程编译打开fdb_cfg.h这里是FlashDB的功能开关总闸。我按裸机KVDB场景把关键配置做了裁剪#define FDB_USING_KVDB /* #define FDB_USING_TSDB */ #define FDB_USING_FAL_MODE #define FDB_WRITE_GRAN 1 #define FDB_SECTOR_SIZE 4096 #define FDB_BLOCK_SIZE 4096如果只需要键值存储TSDB可以先关掉这样可以少编译不少代码。FDB_USING_FAL_MODE表示通过FAL访问底层Flash这是最推荐的模式等于把FlashDB和具体硬件完全解耦。FDB_SECTOR_SIZE和FDB_BLOCK_SIZE必须和底层W25Q64的扇区大小保持一致这里都是4096字节对不齐会导致区块管理异常。然后把FAL目录和FlashDB目录里的.c文件全部加入Keil工程注意include路径要覆盖到FAL/inc、FlashDB/inc、BSP目录。比较麻烦的是头文件包含顺序fal_cfg.h里引用了数组定义fdb_cfg.h里又引用了FAL的头文件容易出现循环包含。解决办法是在Keil的C/C Include Paths里把几个目录都加上同时保证fal_cfg.h中#include fal.h放在前面。编译时如果报找不到fdb_err_t这类类型检查一下fdb_cfg.h里有没有定义FDB_USING_KVDB。FlashDB有些头文件是根据这些宏来裁剪类型的宏没开启类型自然就不存在。这是新手移植第一个常见的编译错误。3.4 第四步裸机main函数初始化与读写验证初始化顺序有一定讲究必须先初始化FAL抽象层再初始化FlashDB的KVDB实例#include flash_db.h #include fal.h static fdb_kvdb_t kvdb; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_SPI1_Init(); /* 1. 初始化FAL抽象层 */ fal_init(); /* 2. 初始化FlashDB KVDB绑定到easyflash分区 */ fdb_err_t ret fdb_kvdb_init(kvdb, env, NULL, easyflash, NULL); if (ret ! FDB_NO_ERROR) { /* 错误处理通常是分区名错误或Flash读异常 */ } /* 3. 写入一条KV数据 */ int32_t temp 36; ret fdb_kv_set(kvdb, temp, temp, sizeof(temp)); /* 4. 读取刚才写入的值 */ int32_t readVal 0; struct fdb_blob blob; ret fdb_kv_get_blob(kvdb, temp, blob, readVal, sizeof(readVal)); while (1) { /* 主循环可处理其他业务 */ } }第一次跑通后强烈建议先做一个断电保持测试写入数据重启开发板再读取确认数据还在。这一关过了再考虑大规模写入和性能测试。需要提醒一下KVDB在第一次初始化时会把分区格式化如果你发现设备第一次上电串口打印了一堆初始化日志这是正常现象不代表移植失败。4. 裸机环境下的性能测试4.1 测试环境说明FlashDB在裸机上的性能主要受两个因素影响SPI时钟频率和Flash擦除次数。我的测试条件是STM32F407主频168MHzSPI时钟10.5MHzW25Q64。测试分两项连续写入1000条KV数据以及随机读取1000条KV数据。测试代码用一个简单的循环uint32_t start HAL_GetTick(); for (int i 0; i 1000; i) { char key[16]; sprintf(key, key%d, i); uint32_t value i; fdb_kv_set(kvdb, key, value, sizeof(value)); } uint32_t elapsed HAL_GetTick() - start;读取测试类似只是把fdb_kv_set换成fdb_kv_get_blob。每组测试跑三次取平均值避免偶发波动。4.2 写入性能数据实测下来在Flash初始为空的状态下连续写入1000条KV数据耗时大约9秒多。如果是更新同一个key一千次由于涉及旧数据回收和扇区擦除耗时会长一些大概15秒左右。换算下来单条KV写入平均在10毫秒到20毫秒之间。这个数据对大多数嵌入式参数存储场景完全够用用户改一个设置项根本感知不到延迟。为什么KVDB写入比直接操作Flash慢关键在于FlashDB每写一条记录不只是把数据写到Flash里还要维护数据索引、状态标记必要时触发垃圾回收和磨损均衡。代码里大部分时间花在扇区擦除上一次4KB擦除实测大约要20到30毫秒如果数据库需要先腾出空扇区再写耗时就上来了。这是数据库为可靠性和寿命付出的合理代价。为了更清楚表达测试结果我把几组典型数据整理成了表格测试项数据量平均耗时单条平均耗时连续写入不同key1000条9.6秒9.6毫秒连续更新同一key1000次16.3秒16.3毫秒随机读取不同key1000条1.2秒1.2毫秒4.3 读取性能数据读取速度就快多了。随机读取1000条KV数据整个测试耗时大约1.2秒单条读取平均1.2毫秒。这个性能主要取决于FlashDB内部索引的查找效率它在内存里维护了键值索引的快照比直接在Flash里顺序扫描记录快了两个数量级。实际项目中如果只是读取单个参数这个耗时几乎感知不到。读取耗时的波动很小因为不涉及Flash擦除。但如果你读取的是一个超长blob耗时就会和Flash传输时间成正比增长。我建议在设计数据结构时把大块数据拆成多个小KV避免单次读取占用太长时间否则会让主循环的实时性变差。4.4 性能差异背后的原因与优化方向同样是FlashDB有人跑出来的性能和我的差距很大最常见的原因是SPI时钟配置。我试过把SPI分频从4改成2时钟升到21MHz连续写入1000条KV数据从9秒多降到了6秒左右。所以第一步优化就是把SPI时钟调到Flash支持的上限。W25Q64的标准读指令时钟上限是50MHz写和擦除时序也支持40MHz以上STM32F4的SPI完全能跑上去。另一个优化点是分区大小。FlashDB做磨损均衡时空余扇区越多回收策略就越从容但空余扇区太多也会让某些操作变慢。根据我的观察给KVDB分配8个4KB扇区是比较均衡的选择空间利用率和性能都能兼顾没必要一开始就给1MB。如果还是觉得慢可以考虑关掉KVDB的日志功能或者调整提交缓冲区的配置参数。不过裸机场景下通常优化到SPI时钟这一步就足够了。我不会为了一点性能去牺牲数据库的稳定性毕竟嵌入式设备的首要目标是可靠。5. 避坑指南移植中最容易踩的10个坑5.1 编译与配置类问题坑1fdb_cfg.h里的扇区大小和FAL设备blk_size不一致。这个坑最普遍表现是FlashDB初始化失败返回FDB_INIT_FAILED。排查方法很简单确认FDB_SECTOR_SIZE、FDB_BLOCK_SIZE和fal_flash_dev结构体里的blk_size全部是4096三个数值必须完全一致。坑2编译全部通过但运行到fdb_kv_set时程序卡死。多数情况是底层写函数没有等Flash上一次擦写完成。W25Q64执行一次扇区擦除需要几十毫秒期间不响应新命令。我专门封装了W25Q64_WaitBusy()每次发起命令前都确认设备不忙这样基本能杜绝卡死问题。如果你用的是其他型号的SPI Flash一定要检查有没有类似的状态寄存器等待机制。坑3FAL分区表起始地址没有按扇区对齐。分区表的地址和长度都应该是扇区大小的整数倍。有些人从0x80000开始分一个5KB分区看起来没什么问题但5KB不能被4KB整除FlashDB内部计算扇区号时就会错乱一开始跑得好好的数据一多就冒出各种怪问题。配分区表前先拿计算器算一遍对齐。5.2 运行与功能类问题坑4掉电重启后KV数据偶尔丢失。先别急着怀疑FlashDB先看电源是否稳定。SPI Flash在擦除期间对电压比较敏感如果3.3V跌到2.7V以下写操作可能实际没成功。排查方法是检查每次写入的返回值并在上电后对关键参数做CRC校验双保险才能避免现场偶发数据丢失。坑5读取出来的数据在内存里看起来没问题但结构体里有padding字节是随机值。FAL和FlashDB按字节流处理数据直接把结构体写进去会带上编译器填充的padding字节。如果跨编译器版本或跨MCU平台这些padding就是脏数据。解决办法是序列化成固定格式再存储或者用__attribute__((packed))定义存储结构体。后者会牺牲一点访问效率但嵌入式里这点开销可以忽略。坑6TSDB写入了数据但按时间范围查询结果为空。这多半是时间戳问题。TSDB默认依赖RTC提供时间如果MCU的RTC没有初始化时间戳全是0范围查询自然查不到数据。裸机移植时记得先把RTC校准好。这是TSDB用户最常见的困惑我一开始也在这个坑里蹲了半个下午。5.3 容易被忽视的细节坑坑7在中断里直接调用FlashDB API。裸机虽然没有调度器但中断里操作数据库仍然危险。FlashDB内部可能正在处理某条记录的写入流程两个执行上下文同时访问同一片Flash区域会造成数据错乱。建议所有数据库操作都放主循环中断只置标志位主循环检测到标志位后再执行数据库写入。坑8栈空间给得太小。FlashDB内部有一些临时缓冲和较大的函数调用栈对栈空间的要求比普通MCU程序略高。Keil工程默认的0x400栈空间在频繁写入和垃圾回收时可能溢出。我实测把裸机工程栈调整到0x1000之后各种边界情况都稳定了很多。这个坑特别隐蔽因为栈溢出往往不是每次都复现而是在连续运行很久之后才冒出奇怪故障。坑9日志输出过于频繁影响时序。FlashDB默认带调试日志如果接的是一个低速串口每写一条KV数据就打印一屏幕信息会严重拖慢实测性能。正式测试前记得把日志等级调高或者在fdb_cfg.h里关掉调试输出。我记得第一次跑1000条写入测试耗时近半分钟关了日志之后瞬间回到十几秒前后的性能数据完全不在一个量级。坑10误以为FlashDB是文件系统。FlashDB不是文件系统不能直接创建文件或文件夹。它的粒度是KV键值对和TS时间序列点。如果你的需求是像SD卡那样管理多个文件应该考虑LittleFS或FatFS。这个选型错误我在项目评审时见过不止一次用错工具付出的是几周的返工代价。写到最后说点个人体会。FlashDB在STM32裸机上的移植难度其实不在数据库本身而在底层Flash驱动是否可靠、配置参数是否对齐。我建议准备移植的朋友先把W25Q64这类外部Flash的读、写、擦除驱动当成独立模块自测到位再把它接给FAL最后才碰FlashDB。这样即使出问题也能快速定位是不是数据库层的问题。另外第一次运行时看到FlashDB打印初始化格式化日志不用慌那是它在建立初始布局等第二次运行就会安静下来。先跑通“写-重启-读”这个最基础用例再去做性能优化和复杂业务这条路我在几个项目里验证过是最稳的。
返回列表