ARTICLE DETAIL

资讯详情

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

STM32裸机移植FlashDB:嵌入式数据库的掉电安全与存储实践

STM32裸机移植FlashDB:嵌入式数据库的掉电安全与存储实践 1. 移植前的清醒认知FlashDB到底解决了什么问题先聊点实在的。很多做嵌入式的朋友一听到“数据库”三个字就头皮发麻总觉得这是跑在服务器上的东西跟MCU八竿子打不着。但实际做产品的时候你会发现只要设备需要保存参数、记录日志、存储历史数据就避不开“怎么在Flash上高效地存数据”这个问题。早期方案无非就是定义几个结构体变量直接往Flash固定地址写。简单场景凑合能用但一旦遇到参数数量变化、需要掉电保存的历史记录增多、甚至要支持远程升级配置这种“裸写Flash”的做法就开始让你头疼了——地址规划混乱、磨损不均、数据损坏后没法恢复、扩展一个字段就要动整个存储布局。FlashDB就是冲着这些痛点来的。它是一个专门为嵌入式设备设计的轻量级数据库开源、支持掉电安全、具备磨损均衡和掉电保护能力最关键的是能在裸机环境下跑起来不依赖RTOS。我这次选择在STM32F407上做移植原因很简单这块芯片的Flash有1MB片内RAM有192KB做数据库压力测试空间足够而且F407在工业产品和DIY项目里都极其常见参考价值比较高。整个移植工作的技术栈包括STM32标准外设库也可以换成HAL库思路一样、一个串口调试口、一个Flash驱动。FlashDB本身不关心你用的是片内Flash还是外部SPI Flash它只要求你提供“读、写、擦除”三个底层接口。注意FlashDB有两种工作模式一种是键值对存储KVDB适合存配置参数一种是时序数据存储TSDB适合存日志和传感器历史数据。这次移植我把两种模式都跑通了下面会分别讲。2. 裸机环境下的移植前置准备2.1 硬件选型与Flash分区规划我手头用的是一块STM32F407VET6核心板板载8MHz晶振通过PLL倍频到168MHz主频这是F407的常规配置。串口用USART1波特率115200用来输出日志和测试结果。FlashDB移植前第一个决定成败的步骤是Flash分区规划。这一步很多人容易忽略直接参考Demo默认配置就开跑结果不是把程序区覆盖了就是两个数据库互相挤占存储空间。STM32F407的片内Flash从0x08000000开始总大小1MB。我的程序编译出来大概占了前64KB也就是0x08000000到0x0800FFFF这一片区域。规划如下区域起始地址大小用途Bootloader App0x0800000064KB应用程序代码KVDB 分区0x08010000128KB键值对数据库TSDB 分区0x08030000512KB时序数据库预留日志备份区0x080B0000256KB暂未使用后续OTA用需要注意不同型号的STM32扇区大小不一样。F407的前4个扇区每个16KB第5到第12扇区每个128KB。我在规划时避开了16KB小扇区直接用了128KB大扇区起始地址0x08010000正好是第五扇区的起点这样擦除逻辑更简单也避免跨扇区边界带来的麻烦。再强调一个关键点DB分区的起始地址必须是Flash擦除粒度的整数倍。STM32F407的最小擦除单位是扇区sector所以分区地址对齐到扇区边界是必须的。如果你的MCU擦除粒度更小比如某些芯片支持sub-sector擦除对齐要求可以放宽但无论如何都要确保数据库分区不跨越两个物理扇区否则FlashDB内部的擦写逻辑会出问题。2.2 需要准备的软件资源清单在动手写代码之前先把需要的软件资源列出来避免边写边找FlashDB源码我用的v1.0.0稳定版直接去Gitee/GitHub拉取即可注意不要用最新dev分支开发分支有时候会有API变动对初学者不友好keil5工程MDK-ARM这个版本比较成熟编译速度快调试方便STM32F4xx标准外设库如果你习惯HAL库也没问题FlashDB根本不依赖这些只要自己封装好Flash的三接口就行一个串口调试助手用来观察测试输出数据FlashDB源码目录结构比较简单核心文件就几个flashdb/ ├── inc/ │ ├── flashdb.h // 总头文件包含所有API声明 │ ├── fdb_cfg.h // 配置头文件裁剪开关 │ ├── fdb_kvdb.h // KVDB接口声明 │ ├── fdb_tsdb.h // TSDB接口声明 ├── src/ │ ├── fdb_kvdb.c // KVDB实现 │ ├── fdb_tsdb.c // TSDB实现 │ ├── fdb_utils.c // 公共工具函数 │ └── fdb_file.c // 文件模式实现裸机环境用不到可以忽略移植时你只需要关心fdb_cfg.h这个配置文件和底层的Flash读写擦除接口。2.3 KEIL工程中必须改的几个配置项把源码加入Keil工程后还有几个编译配置需要处理否则会报一堆莫名其妙的错误。首先是C99标准。FlashDB源码里用了很多C99语法比如for(int i0;...)这种循环内声明变量、//行注释等。在Keil的Options for Target - C/C - Language C 里勾选“C99 Mode”不然后面编译会报语法错误。然后是宏定义。在C/C的Define栏里我添加了FDB_USING_KVDB1 FDB_USING_TSDB1 FDB_WRITE_GRAN1这三个宏的含义分别是启用KVDB、启用TSDB、Flash最小写入粒度为1字节。最后是优化等级。调试阶段建议把Optimization设为-O0或者-O1我自己的经验是调试阶段用-O0正式发布再切-O2。因为FlashDB在-O2优化下虽然也能正常运行但对新手来说单步调试时变量被优化没了会非常痛苦。3. Flash驱动封装的三个关键接口3.1 为什么FlashDB不自带Flash驱动嵌入式圈子里有句老话“硬件千差万别驱动各写各的。”FlashDB作为一个通用中间件它的设计哲学就是把底层的存储介质驱动完全剥离开只定义好接口契约由使用者在具体芯片上实现。这种设计的好处显而易见不管是ST的片内Flash、NOR Flash、还是SD卡里的Flash模拟存储只要你能提供“读、写、擦除”三件套FlashDB就能跑。代价就是你必须对自家芯片的Flash控制器比较熟悉至少要知道怎么擦、怎么写、有什么对齐限制。我这次用的是STM32F407的标准外设库自带FLASH驱动函数把它们封装成FlashDB要求的unified接口。如果你的开发环境是HAL库也很好办HAL库的HAL_FLASH_Program和HAL_FLASH_Erase接口同样能封装只是字节写入和半字写入的函数切换要注意一下。3.2 三个接口的原始实现与踩坑注解FlashDB要求移植层提供以下三个函数不同版本可能命名略有差异但逻辑是一样的/* 从指定地址读取数据到buf */ void fdb_port_read(uint32_t addr, void *buf, size_t size) { uint8_t *pbuf (uint8_t *)buf; for (size_t i 0; i size; i) { pbuf[i] *(volatile uint8_t *)(addr i); } } /* 向指定地址写入数据并不需要先擦除 */ void fdb_port_write(uint32_t addr, const void *buf, size_t size) { const uint8_t *pbuf (const uint8_t *)buf; FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPERR); for (size_t i 0; i size; i) { while (FLASH_GetStatus() FLASH_BUSY); FLASH_ProgramByte(addr i, pbuf[i]); } FLASH_Lock(); } /* 擦除一个扇区 */ void fdb_port_erase(uint32_t addr, size_t size) { FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPERR); uint32_t sector 0; if (addr 0x08000000 addr 0x08010000) { sector FLASH_Sector_0; } else if (addr 0x08010000 addr 0x08030000) { sector FLASH_Sector_1; } else if (addr 0x08030000 addr 0x08050000) { sector FLASH_Sector_2; } // 注意这里只写了前几个扇区的映射实际要根据地址范围补全所有扇区 FLASH_EraseSector(sector, VoltageRange_3); FLASH_Lock(); }这里有个很重要的细节擦除函数的size参数。FlashDB在批量擦除时可能会传入大于单个扇区的大小但是一个扇区擦完才能擦下一个。我实际使用的是标准外设库的FLASH_EraseSector逐扇区擦除会循环根据addr递增来做。上面的简化代码只是示意你真在工程里用的时候建议写成循环遍历扇区千万别只擦一个扇区就返回。还有一点需要特别注意Flash写入之前必须是擦除状态0xFFFlashDB内部自己会管理这一点但你的写接口必须保证只写不擦——不要把fdb_port_write里加上擦除逻辑否则数据库的掉电保护机制就失效了。3.3 片内Flash和外部Flash移植的区别我这次为了测试方便直接用片内Flash。如果你做产品时程序比较大片内Flash空间紧张想改用外部SPI NOR Flash比如W25Q64思路完全一样但有三个区别要额外注意第一外部Flash的写入通常需要“读改写”。SPI NOR Flash的一次写入往往以256字节为一页FlashDB基本以较小的块写入所以底层驱动要实现“先读一页到RAM缓冲改部分字节再整页写回”的逻辑。第二外部Flash擦除更慢。W25Q64擦除一个扇区大概要几百毫秒FlashDB在擦除期间会阻塞等待。如果是裸机单线程环境这个时间必须计算在业务逻辑的响应时间里。如果用了RTOS可以考虑把擦除放到低优先级任务里但FlashDB裸机版没这个能力只能阻塞。第三外部Flash的地址是独立的。你在fdb_cfg.h里填写的分区起始地址不再是STM32的0x08000000而是外部Flash的偏移地址比如0x00000000到0x00010000。4. fdb_cfg.h配置详解哪些开关必须打开FlashDB之所以能适配这么多平台很大程度上要归功于fdb_cfg.h里的宏配置。我在第一次编译时因为配置不对折腾了整整一下午。这里把关键配置项逐个讲清楚。/* 是否使用KVDB */ #define FDB_USING_KVDB 1 /* 是否使用TSDB */ #define FDB_USING_TSDB 1 /* 是否使用文件模式裸机不需要 */ #define FDB_USING_FILE_MODE 0 /* 是否使用小端模式 */ #ifndef FDB_BIG_ENDIAN #define FDB_BIG_ENDIAN 0 #endif /* Flash最小写入粒度单位是字节 */ #define FDB_WRITE_GRAN 1 /* KVDB分区大小 */ #define FDB_KVDB_PART_SIZE (128 * 1024) /* KVDB分区的起始地址 */ #define FDB_KVDB_PART_ADDR 0x08010000 /* TSDB分区大小 */ #define FDB_TSDB_PART_SIZE (512 * 1024) /* TSDB分区的起始地址 */ #define FDB_TSDB_PART_ADDR 0x08030000 /* FlashDB内部日志开关 */ #define FDB_PRINT_DEBUG 1核心是FDB_WRITE_GRAN这个宏。它的含义是底层Flash支持的最小写入宽度字节。对STM32片内Flash来说按字节写入是没问题的所以设1。但如果你的外部Flash只能按16位或32位写入这里就要相应改动否则数据库的掉电恢复算法会失效。另一个坑是分区大小不能设得太大。KVDB分区和TSDB分区加一起必须小于实际可用Flash空间。我测试时把KVDB设为128KB、TSDB设为512KB还剩256KB做备份绰绰有余。如果你把分区设得比实际Flash还大FlashDB初始化时会计算校验和失败表现出来就是“拿到一个没有初始化的DB”让你误以为移植失败了。关于“大端小端”STM32默认小端保持FDB_BIG_ENDIAN为0即可。如果你拿到一个在RISC-V或PowerPC上移植过的FlashDB工程这里一定要检查否则KVDB里读出来的数据会完全对不上数量级都不对的那种。5. KVDB键值库的移植与验证5.1 初始化序列和参数注册流程先看KVDB的初始化。我在main函数里加了如下代码#include flashdb.h fdb_kvdb_t kvdb; static void kvdb_init(void) { fdb_err_t result; /* 初始化FlashDB内部依赖的扇区信息 */ result fdb_init(); if (result ! FDB_NO_ERR) { printf(FlashDB init fail, err%d\r\n, result); while(1); } /* 初始化KVDB */ result fdb_kvdb_init(kvdb, env, NULL, NULL); if (result ! FDB_NO_ERR) { printf(KVDB init fail, err%d\r\n, result); while(1); } }注意fdb_kvdb_init的第三个参数table。如果设为NULLFlashDB就会把所有KV数据视为一个公共的“主体”这也是最简单的用法。如果你的产品需要多个独立的KV数据库比如“设备配置”和“用户设置”分开管理可以传入一个fdb_kvdb_t的表每个表有不同的名字。但多数场景下一个KVDB就够用了。初始化完成后往KVDB里写一条数据试试void kvdb_write_example(void) { // 写入一个整数 int temp 25; fdb_kv_set(kvdb, temperature, temp, sizeof(temp)); // 写入一个字符串 const char *name flashdb_test; fdb_kv_set(kvdb, device_name, name, strlen(name) 1); }读出来void kvdb_read_example(void) { int temp 0; size_t len sizeof(temp); fdb_kv_get(kvdb, temperature, temp, len); printf(temp %d\r\n, temp); char name[32] {0}; len sizeof(name); fdb_kv_get(kvdb, device_name, name, len); printf(device_name %s\r\n, name); }这里要注意fdb_kv_get的最后一个参数传入的是缓冲区大小返回时是对应key实际占用的数据长度。如果你传入的buf太小放不下这条数据函数会返回FDB_READ_SIZE_ERR但数据前缀部分仍然会复制到buf中。这个设计对读取定长结构体很友好但读字符串时要注意buf给足空间别费尽心思算长度。5.2 掉电保护测试写入一半突然断电KVDB最大的卖点是掉电安全。FlashDB的实现原理是在KV的存储区域内维护了一套“状态机”每条记录在写入过程中会经历不同的状态标记断电后重启会通过扫描这些状态标记自动回滚未完成的事务。我做了个简单粗暴的测试写一个大结构体比如1KB的JSON配置在写入过程中随机按下复位键重启后读取数据检查是旧值还是新值#define TEST_ATTR_SIZE 1024 uint8_t attr[TEST_ATTR_SIZE]; void power_loss_test_kv(void) { static uint32_t counter 0; uint8_t new_attr[TEST_ATTR_SIZE]; memset(new_attr, counter 0xFF, sizeof(new_attr)); printf(write round %d, size%d\r\n, counter, sizeof(new_attr)); fdb_kv_set(kvdb, large_attr, new_attr, sizeof(new_attr)); size_t len sizeof(attr); fdb_kv_get(kvdb, large_attr, attr, len); printf(read first byte 0x%02X, counter 0x%02X\r\n, attr[0], counter 0xFF); MVP_INCREASE_COUNTER(); }我跑了50次随机断电每次重启后读到的数据要么是完整的旧值要么是完整的新值没有一次出现“半新半旧”的数据。这就是FlashDB在KV写入流程里加了两阶段提交的效果第一步写数据内容第二步写“提交标记”只有看到提交标记系统才认为这条记录有效。断电发生在第一步之后重启后旧记录依然是有效的断电发生在第二步之后新记录整体生效。5.3 KVDB性能实测数据我测了一下KVDB在STM32F407上的写入速度。写一个16字节的KV数据内部包含擦除扇区需要的时候、写入头部、写入数据、写入提交标记。因为F407片内Flash擦除128KB扇区大约要1秒左右实测在0.8~1.2秒波动所以当写入触发扇区擦除时单次耗时会被拉长到1秒级别不触发擦除时一个16字节的KV写入耗时大约在5~8毫秒。场景单次耗时说明不触发扇区擦除写入16字节KV5~8ms主要是Flash编程耗时和状态标记写入触发扇区擦除写入16字节KV0.9~1.2s擦除128KB扇区占绝对大头读取任意KV值1ms只需从Flash读出并校验CRC所以如果你的产品需要频繁写入KV但每次写入量都不大FlashDB会把多个KV存放在同一扇区直到扇区写满才触发擦除平均写入耗时会稳定在几十毫秒以内表现完全可用。提示千万别在中断服务函数里调用FlashDB的写接口。片内Flash写入时CPU会被硬件强制暂停访问Flash某些中断处理不及时就出问题了。6. TSDB时序数据库的移植与数据写入6.1 TSDB初始化与时间戳的坑TSDB主要用于记录有时间属性的数据比如每隔几分钟记录一次温湿度、每秒钟记录一次电能参数。它和KVDB最大的区别是数据是追加写入的不支持修改只能追加和查询带时间范围过滤。TSDB初始化比KVDB多了一个环节设置时间戳回调函数。#include time.h fdb_tsdb_t tsdb; static int32_t get_time_cb(void) { /* 返回一个自增的时间戳单位是秒。这里注意必须是单调递增且不重复的 */ return fake_time_counter; } void tsdb_init(void) { fdb_err_t result fdb_tsdb_init(tsdb, logger, get_time_cb, NULL, NULL); if (result ! FDB_NO_ERR) { printf(TSDB init fail, err%d\r\n, result); while(1); } }这里必须强调get_time_cb的返回值要求它必须返回一个单调递增的秒级时间戳。如果返回的时间戳回退了TSDB内部会认为是异常数据可能会导致数据写入失败或后面查询时的位置错乱。我测试时用的是软件模拟的秒计数器每次上电从0开始。实际产品里一般会接RTC芯片或者从网络校时然后把RTC时间转换成一个绝对秒数传进来。我之前用某个RTC芯片它的秒寄存器和当前实际时间对不上导致TSDB查询时时间排序全乱了最后才发现是时区没换算。6.2 写入数据结构的对齐问题TSDB写入的数据需要打包成一个结构体typedef struct { float temperature; uint8_t humidity; uint32_t device_id; } env_data_t; void tsdb_write_example(void) { env_data_t data; data.temperature 26.5f; data.humidity 60; data.device_id 0xA1B2C3D4; fdb_tsl_append(tsdb, data, sizeof(data)); }这里有个容易被忽视的细节TSDB查询返回的数据是直接引用Flash里的原始内容的不经过格式化。因为Flash是只读映射所以你的数据结构体必须是紧凑排列的不能有隐藏的字节对齐填充。比如上面的结构体如果编译器默认按4字节对齐那uint8_t humidity后面会填3个padding字节整个结构体大小变成12而不是9。如果结构体有padding写入Flash的是对齐后的数据读取时也是对齐后的数据看起来好像没什么问题但如果你用sizeof(env_data_t)去和Flash里存储的blob大小比对会莫名不相等。而且一旦跨平台共享数据比如两个芯片一个Cortex-M3一个Cortex-M0对齐规则不同数据就解读不出来了。我的建议是给TSDB里存储的结构体显式加__packed或者#pragma pack(1)。比如#pragma pack(push, 1) typedef struct { float temperature; uint8_t humidity; uint32_t device_id; } env_data_t; #pragma pack(pop)6.3 TSDB查询接口实测例子TSDB查询接口有按时间范围查询、按索引查询、顺序遍历等几种。写一个最简单的顺序遍历把最新10条数据打印出来void tsdb_read_example(void) { fdb_tsl_iter_t iter; fdb_tsl_t tsl; fdb_tsl_iter_init(tsdb, iter); uint32_t count 0; while (fdb_tsl_iter_next(iter, tsl) FDB_NO_ERR) { env_data_t *data (env_data_t *)tsl.data; printf([%lu] temp%.2f, hum%d, dev0x%08X\r\n, (unsigned long)tsl.time, >__disable_irq(); fdb_kv_set(kvdb, key, data, len); __enable_irq();但这样会导致中断延迟变大如果你的中断里有时钟源喂狗之类的操作可能会误触发看门狗复位。更稳妥的做法是把FlashDB写入放到一个单独的低优先级任务里并确保该任务执行期间不会被更高优先级的中断打断。8.3 Bug3TSDB时间戳回调返回数值过大导致查询错乱我用了一个time(NULL)函数直接返回系统时间这个时间通常是一个很大的绝对秒数比如17亿。在flashdb内部处理时会做时间戳差值计算如果回调返回的数据和TSDB第一次初始化时记录的时间差太大内部的一些游标计算会溢出或者判断错乱导致查询出来的数据顺序不对。解决办法是在设备刚上电时记录一个基准时间TSDB回调返回的是“当前时间 - 基准时间”的相对值。只要相对值保持单调递增即可查询时再加回基准时间。8.4 Bug4擦除函数误把应用区擦掉这是我测试初期犯过最严重的错。最初的fdb_port_erase函数里我直接写死了擦除地址范围没做任何边界校验。某次因为KVDB分区地址写错擦除函数被调用时拿到了0x08000000这个起始地址直接把应用代码所在的第一个扇区擦掉了——程序瞬间跑飞。从那以后我给自己定了一条规则所有Flash擦除调用都要先校验addr和size是否落在合法的DB分区范围内不在就断言报错绝不继续执行。void fdb_port_erase(uint32_t addr, size_t size) { /* 安全校验只允许擦除KVDB和TSDB分区范围内的地址 */ if (!((addr FDB_KVDB_PART_ADDR addr FDB_KVDB_PART_ADDR FDB_KVDB_PART_SIZE) || (addr FDB_TSDB_PART_ADDR addr FDB_TSDB_PART_ADDR FDB_TSDB_PART_SIZE))) { printf(ERROR: erase addr out of range, addr0x%08X\r\n, addr); return; } // ... }这一步虽然看起来多余但在调试阶段真的能救你一条命。9. 裸机环境下的内存开销与优化方向9.1 FlashDB占用多少RAM和FlashFlashDB在F407上的资源占用如下-O2优化资源占用备注Flash代码段约12KBKVDBTSDB全部功能RAM全局变量约4KB主要是DB控制结构和缓冲区RAM栈空间建议至少1KB主要在擦除回调中用到对STM32F407来说这个开销非常小完全不用考虑裁剪。但如果你用的是F103这种128KB Flash、20KB RAM的芯片也能跑不过建议把TSDB关掉只保留KVDBRAM开销能控制到2KB以内。9.2 如何裁剪到极致FlashDB的配置宏支持裁剪。如果你只需要KVDB存配置参数那#define FDB_USING_KVDB 1 #define FDB_USING_TSDB 0 #define FDB_USING_FILE_MODE 0代码段瞬间减少大约5KBRAM减少约2KB。另外FlashDB的调试日志FDB_PRINT_DEBUG如果关掉还能省几百字节。这个宏会在每个关键操作里打印调试信息正式发布时建议关掉。10. 关于FlashDB选型的一点个人感触最后聊点不太能在官方文档里看到的经验。第一FlashDB不是万能的。它的KVDB适合存配置TSDB适合存时序数据但如果你要存的是带复杂查询逻辑的表格数据比如产品订单、用户列表需要跨字段筛选FlashDB会非常吃力你应该往SQLite等方向想。嵌入式领域选数据库匹配数据模型比追逐功能列表更重要。第二掉电保护这个特性不是白送的。它牺牲了一定的写入性能每次写入都要多写状态标记和存储空间每条记录都有额外的元数据。如果你的产品掉电概率极低或者掉电后数据丢失可接受用裸Flash直接写可能更快、更省空间。FlashDB的价值恰恰在于“不确定断电”的场景比如车载设备、工业控制器现场环境很恶劣。第三移植前最好认真读一遍FlashDB的docs目录。我第二次移植到GD32F303时只花了半小时因为接口模式已经完全清楚了。第一次踩的那些坑在文档的FAQ里其实有部分提及但我当时没细看。做嵌入式这行一味靠“试错”效率太低文档里的“注意”往往是前人用惨痛代价换来的。这几个接口封装好后整个工程从新建到跑通,一共花了不到一个下午。如果你正在为“怎么在STM32上优雅地存数据”发愁希望这份记录能帮你少走我走过的弯路。
返回列表