简介:FlashDB 是一款面向物联网与边缘计算场景的超轻量级嵌入式数据库,适合资源受限的单片机开发者使用。它针对 Flash 介质特性设计,利用 Flash 的随机访问与掉电保持能力,在极低内存占用下提供较传统文件系统更强的性能与可靠性,并着力延长 Flash 使用寿命。压缩包内共 751 个文件,整体约 7.15MB,以 C 源码、头文件、Markdown 文档为核心,另含 STM32 平台工程配置、链接脚本与少量图片说明,便于直接移植到实际项目。资源包含完整源码树、多平台移植示例、API 使用说明及配置参考,可帮助读者理解 Flash 磨损均衡、掉电保护等关键机制,并快速集成到现有嵌入式系统中。目前已有 1028 人浏览学习,适合具有一定嵌入式基础、希望在项目中引入轻量级数据存储方案的开发者。
1. FlashDB 是什么:嵌入式场景的数据存储困境与破局点
做嵌入式产品的人多半遇到过这种尴尬:参数存 Flash 里,掉电再上电,偶尔读出全 0xFF;日志越写越多,Flash 写坏,产品用了半年就开始丢数据;为了存几个配置项,把文件系统搬进来,代码体积翻了一倍还容易翻车。FlashDB 就是绕开这些坑的一套超轻量级嵌入式数据库,它不依赖操作系统、不引入文件系统,直接在 Flash 上做键值存储和时间序列存储,专门解决嵌入式产品的数据存储方案问题。适合谁用?手里项目还在用“定址 + 结构体”裸存数据,或者正在纠结要不要为存日志上一套完整文件系统的开发者,尤其值得先看一眼这套方案的成本和收益再动手。
2. 核心机制拆解:掉电安全、磨损均衡与双组件选型
2.1 掉电安全机制:为什么嵌入式场景先看这个
嵌入式产品掉电是常态,不是异常。你在台式机上写一半断电,文件系统顶多丢一个文件;在单片机上写 Flash 写到一半断电,轻则那一条数据损坏,重则把整个扇区的数据都冲掉。FlashDB 的掉电安全主要靠两层设计:写前先写日志(WAL),数据写完后做双备份校验。
第一层是“先记账再干活”。每次写入之前,先把这次操作的目的、数据长度、目标地址记录到日志区,再写实际数据。上电后先扫描日志区,发现有不完整的写入记录就回滚或者补写。第二层是双备份。默认情况下,每一条键值数据在当前扇区写完,会在另一个扇区保留一份镜像,读的时候先校验主区的 CRC,不对就切备份。这套逻辑单独拎出来每一项都不复杂,但叠在一起之后,你不需要在应用层为“掉电写坏了怎么办”写任何处理代码。
实际移植时你会发现,FlashDB 对 Flash 驱动的接口要求极简,无非就是读、写、擦三个函数。它不关心你用的是片内 Flash、外部 SPI Flash 还是别的存储介质,只要能按扇区擦除、按字节读写就能跑。选型时值得记住:如果你的产品对存储可靠性要求高,比如电表、医疗设备、工业控制器,这类场景的落地方案重心应该优先放在掉电保护机制上,而不是数据库功能多不多。
2.2 磨损均衡与分区规划:Flash 寿命不是无限的
片内 Flash 擦写寿命普遍在 1 万到 10 万次之间,外部 SPI Flash 常见标称是 10 万次。看着挺多,但如果你每秒写一条日志,一天 86400 条,最坏情况下几天就能把固定扇区磨穿。所以嵌入式数据库必须具备磨损均衡能力。
FlashDB 的处理思路是:不把数据固定在某个地址,而是把存储区抽象成若干块,每次写入根据当前块的使用情况决定落在哪里,而不是一路往后写。配合强制均衡的策略,它会在某个块写过一定次数之后主动搬移到其他冷块,确保所有物理块的擦写次数尽量拉平。实际测试里,把存储区扩大一倍,往往能让整体寿命翻三四倍,而不是两倍,原因就是均衡算法有了更多腾挪空间。
分区规划是移植前最容易忽略的环节。FlashDB 的逻辑存储单位是分区,一个分区只服务于一种数据类型。产品里如果既要存配置参数,又要存传感器历史数据,最好不要把两类数据合在一个分区里,原因有二:配置项是小数据、低频写,历史数据是大数据、高频写,两者混在一起会让磨损不均衡;配置数据不容丢失,时间序列数据允许覆盖旧数据,两者的存储策略根本不同。合理做法是给配置开一个 4KB 到 16KB 的小分区,给历史数据单独开一个容量较大的分区,互不干扰。
2.3 FDB 和 TSDB 双组件:键值存储与时间序列存储怎么选
FlashDB 内部拆成两个组件,理解清楚它们的边界能少走很多弯路。FDB 组件做的是键值存储,接口风格类似大家熟悉的 KV 数据库,适合存设备参数、校准值、用户设置这类“一条一条的数据”。TSDB 组件做的是时间序列存储,按时间顺序追加,适合存温度曲线、电量记录、故障日志这类持续产生的数据流。
选型标准很简单:数据有没有“唯一 key”的概念,就选 FDB;数据靠时间组织、要顺序查询、允许按容量淘汰旧数据,就选 TSDB。混用也常见,比如某采集设备用 FDB 存设备地址和校准系数,用 TSDB 存每五分钟一次的采样数据。
| 对比项 | FDB 键值存储 | TSDB 时间序列存储 |
|---|---|---|
| 数据组织 | key-value | 按时间追加 |
| 典型数据 | 配置参数、设备状态 | 采样数据、操作日志 |
| 容量策略 | 固定上限,满后拒绝写或报错 | 循环覆盖最旧数据 |
| 典型场景 | 设备掉电保存、参数回读 | 周期性采集、历史追溯 |
| 查询方式 | 按 key 查 | 按时间段查 |
| 是否需定长结构 | 推荐定长 blob | 定长记录更省空间 |
实际做产品时不需要一上来就两个组件都上。第一个版本只把“掉电不能丢”的参数用 FDB 做掉,日志和数据流先用裸数组顶着都不丢人。等存储压力真正出现,再把 TSDB 引入拆成第二个分区,这种按需求渐进引入的方式才能避免过度设计。
3. 移植与跑通:在 MCU 上启动 FlashDB 的最小工程
3.1 环境准备与依赖裁剪
FlashDB 是一个源码级组件,拿到手的是一堆 C 源文件和头文件,直接放进工程编译即可。它不依赖第三方库,但要求你的工程具备一块能工作的 Flash 驱动,并且能提供一个 RTOS 环境或者裸机的时基。所谓“超轻量级”在移植阶段的直观感受是:全部源文件加起来也就十几个,不像文件系统那样要搬一堆平台适配层进来。
移植前建议把工程里的 Flash 驱动函数整理成三个基础能力:读一段数据、写一段数据、擦除一个扇区。对应的函数签名一般是这样的模式:
/* 三个基础能力:读、写、擦除,适配你的硬件平台 */ static int flash_read(uint32_t addr, void *buf, uint32_t len); static int flash_write(uint32_t addr, const void *buf, uint32_t len); static int flash_erase(uint32_t sector_addr);逻辑说明:这三个函数是 FlashDB 移植的核心对接点。读和写通常是字节级或半字级操作,擦除必须按扇区对齐。FlashDB 不关心 Flash 芯片内部用什么指令,它只调这三个函数。参数说明:addr 是绝对地址,必须落在你规划给 FlashDB 的存储区间内;len 的单位是字节;擦除函数的 sector_addr 必须传递扇区起始地址,不能传任意偏移,否则某些 Flash 芯片会返回擦除失败。
完成三个函数后,把函数指针填到一个配置结构体里,同时填写分区起始地址、分区大小、扇区大小和擦除粒度。擦除粒度是这里最容易出错的地方:片内 Flash 常见的扇区 4KB,意味着你按 4KB 对齐分区即可;但有些外部 SPI Flash 有 4KB 扇区和 64KB 块两种擦除粒度,FlashDB 需要的扇区大小应该以你的擦除函数实际能处理的最小单位为基准,否则容量计算和磨损均衡都会跑偏。
3.2 初始化流程与最小启动代码
初始化是移植是否成功的第一道验证门。推荐的最小流程分三步:先初始化底层 Flash 驱动,再调用 FlashDB 初始化接口,最后创建一个 FDB 分区并写入第一条数据。下面这段伪代码是从零跑通的最小骨架:
#include "flashdb.h" /* 分区表:给配置参数分配 8KB 空间 */ #define FDB_CONFIG_PART_ADDR 0x0807C000 #define FDB_CONFIG_PART_SIZE 8 * 1024 fdb_kvdb_t config_db; /* 分区句柄 */ struct flashdb_cfg { .device_ops = { flash_read, flash_write, flash_erase }, }; int app_db_init(void) { fdb_err_t result; /* 第一步:初始化数据库,传入设备操作函数 */ fdb_init(&config_db, "config_part", FDB_CONFIG_PART_ADDR, FDB_CONFIG_PART_SIZE, &device_ops); /* 第二步:在分区上创建一个 KV 数据库实例 */ result = fdb_kvdb_init(&config_db, "config_db", NULL, NULL); if (result != FDB_NO_ERR) { return -1; } return 0; }逻辑说明:fdb_init负责把底层 Flash 和数据库实例绑定,传入设备名和存储区间;fdb_kvdb_init在指定分区上初始化一个键值数据库实例。分区名和数据库名可以相同,也可以不同,但建议能一眼看出用途。参数说明:地址和大小必须 4KB 对齐;大小至少两个扇区,因为要留出双备份空间和日志区,一个扇区是没法跑的。设备操作函数如果是从 RTOS 环境传入,注意线程安全由调用方保证,FlashDB 内部不做加锁。
3.3 上电自检与读写验证
初始化跑通不代表万事大吉,还需要验证掉电恢复逻辑。做完初始化后写一个自检函数:上电先读一个固定的 key,读不到就写默认值,读到了就认为之前的数据还在。这是产品开机后最常规的恢复动作。
int app_db_boot_check(void) { int value = 0; size_t len = sizeof(value); fdb_err_t result = fdb_kv_get(&config_db, "boot_count", &value, &len); if (result == FDB_NOT_FOUND) { value = 1; fdb_kv_set(&config_db, "boot_count", &value, sizeof(value)); } else { value++; fdb_kv_set(&config_db, "boot_count", &value, sizeof(value)); } /* 上电次数统计工作,简单可靠 */ return value; }逻辑说明:这个自检函数兼具验证和业务功能。第一次上电时数据库中不存在boot_count这个键,走 NOT_FOUND 分支先写默认值;后续上电从 Flash 中读出上次保存的数值,加一后写回。参数说明:fdb_kv_get 的第四个参数是缓冲区长度,调用前必须初始化为缓冲区大小,调用后会被修改为实际读取到的字节数,这个细节很多新手会踩,后面避坑章节细说。
4. API 落地:键值参数存储与时间序列采集的写法
4.1 键值存储:产品参数掉电不丢的写法
产品参数存储是最常见、也是最能直接见效的应用。比如一台设备有校准系数、设备地址、工作模式三个参数,传统做法是定义结构体、固定地址、直接写 Flash。问题在于:结构体改了字段顺序,老设备的数据格式就废了;参数值写入一半掉电,整块数据作废。用 FDB 之后,每个参数是一个独立的键,互不影响。
typedef struct { float kp; float ki; float kd; } pid_params_t; /* 保存一组 PID 参数 */ void save_pid_params(const pid_params_t *params) { fdb_kv_set(&config_db, "pid_params", params, sizeof(pid_params_t)); } /* 读取 PID 参数,不存在则返回默认值 */ int load_pid_params(pid_params_t *out) { size_t len = sizeof(pid_params_t); fdb_err_t result = fdb_kv_get(&config_db, "pid_params", out, &len); if (result != FDB_NO_ERR) { /* 首次上电没有参数,回退默认 */ out->kp = 1.0f; out->ki = 0.1f; out->kd = 0.05f; return -1; } return 0; }逻辑说明:这里的save_pid_params传入结构体指针,FlashDB 会把整个结构体当成一个 blob 存入键值区。读取时同样通过结构体指针接收数据,并用返回码区分“读取成功”和“键不存在”两种情况。参数说明:结构体里最好别带指针,因为存进去的是指针值,下次上电指针指向的内存已经不存在了;数据格式建议用__attribute__((packed))避免编译器对齐在设备升级后造成解析错位。
4.2 时间序列存储:传感器采集数据循环覆盖的写法
设备运行日志和传感器历史数据是 TSDB 的用武之地。这类数据有两个特性:第一,数据本身没有业务意义,按时间排序才有意义;第二,容量有限,产品要长期运行,不可能无限增长。TSDB 的循环覆盖策略正好满足这类需求。
/* 每分钟存一条环境数据 */ typedef struct { uint32_t timestamp; int16_t temperature; uint16_t humidity; } env_sample_t; void log_env_sample(int16_t temp, uint16_t hum) { env_sample_t sample; sample.timestamp = (uint32_t)get_sys_seconds(); sample.temperature = temp; sample.humidity = hum; fdb_tsl_append(&ts_db, &sample, sizeof(sample)); } /* 按时间段读取,用于故障回溯 */ int query_env_history(uint32_t start, uint32_t end, env_sample_t *buf, int max_cnt) { fdb_tsl_iter_t iter; int count = 0; fdb_tsl_iter_init(&iter, &ts_db, start, end); while (count < max_cnt && fdb_tsl_iter_next(&iter)) { fdb_tsl_iter_get(&iter, &buf[count], sizeof(env_sample_t)); count++; } return count; }逻辑说明:fdb_tsl_append是追加写入接口,它的时间戳由调用方传入,格式是 uint32 秒级时间戳。读取时初始化一个迭代器,传入时间范围,然后反复调用 next 直到取完或者达到缓冲区上限。参数说明:记录定长比变长更省空间,因为 TSDB 内部不需要为每条记录存储长度字段;时间戳建议用相对启动时间的秒数而不是 Unix 时间戳,能省掉网络对时带来的跳变问题,也便于定位故障时间段。
4.3 容量满了之后的应用层策略
TSDB 的循环覆盖是默认行为,但覆盖之后旧数据就彻底没了。对于某些行业场景,比如医疗设备或者工业仪表,旧数据可能涉及审计要求,不能简单覆盖。处理办法是应用层分级:高频数据存 TSDB 循环覆盖,低频汇总数据存 FDB 长期保留。
我的习惯是:给每类数据做“时效分级”设计。秒级原始数据只保留 24 小时,分钟级均值保留 30 天,小时级事务记录按 key 存 FDB 永久保留。这样安排之后,存储容量、查询效率和法规要求三者才能同时照顾到。不要指望一个分区把所有需求都装了,那不是数据库设计,是逃避设计。
5. 实战避坑:Flash 寿命、写放大与掉电测试的 3 个教训
5.1 现象:日志写太频繁,新板子用两个月就出现扇区坏块
现象描述是设备运行两个月后,存储区某些扇区写入失败,返回擦除错误,之后数据反复丢失。用调试器读坏块区域,能看到擦写计数已经逼近标称上限。原因几乎可以锁定:日志写入频率过高,且没有做合并写。
解决办法分三层。第一层降低写入频率,把每次采到数据立即写,改成攒 5 条或 10 条一次批量写入;第二层减少无效写入,数据没变化就不写,只有变化超过阈值才落盘;第三层扩大分区,让磨损均衡算法有更多块可以轮换。三层都做下来,寿命往往能提升一个数量级。
另一个隐蔽的写放大来源是“改写”。FDB 的键值写入在内部不是原地改,而是把新值写到新位置,旧位置标记失效。这意味着你改写一条 4 字节的配置,实际 Flash 上可能消耗了几十字节的空间。所以高频改写的键,比如计数器,要么合并成一条批量改,要么改用 TSDB 按时间追加,让它自然走循环覆盖。
5.2 现象:掉电测试偶尔读到全 0xFF 的脏数据
有开发者反馈,产品做掉电测试,随机断电再上电,偶尔出现某个配置参数读出全 0xFF,而不是上一次保存的值。排查思路很清晰:先确认读到的内容是不是物理擦除后的初始态,如果是,说明写入根本没完成。翻代码发现,他用的 Flash 写入函数没有等“忙标志”清除就返回了,FlashDB 以为写完了,实际硬件还在写,断电就丢了。
解决方法是检查 Flash 驱动,写完之后必须等待内部编程完成。很多片内 Flash 的写入函数在轮询忙标志时会被中断打断,而掉电测试恰恰会引入这类时序问题。规范的写法是:写完后读回校验,或者确认状态寄存器。另外还有一个细节,如果芯片支持写保护,需要确认哪些扇区被保护了,被保护扇区写操作会直接失败,但你未必能在应用层捕捉到。
5.3 现象:容量估算按数据量算的,结果半年就满了
产品规划时说一天产生 200KB 数据,按 1GB 的 Flash 存五年没问题。结果用了三个多月,TSDB 就开始丢最早的数据。问题出在估算时只算了纯数据量,没算 TSDB 内部的记录头、状态标记、扇区切换带来的空间开销。
实测下来,FlashDB 的实际空间利用率和写入块大小强相关。写入块越小,单条记录的管理开销占比越高。比如每条记录 20 字节时,管理开销可能占 30% 以上;如果一条记录 512 字节,开销占比能压到 5% 以内。所以容量估算公式建议写成:有效容量 = 分区总容量 × 0.7,再拿这个值去对比纯业务数据量,而不是简单让数据量直接撞总容量。
5.4 附加提醒:擦除粒度与地址对齐的边界条件
FlashDB 对擦除粒度要求严格,但具体平台的行为不一致。有些 MCU 的片内 Flash 支持按字节擦除的模拟实现,有些外部 SPI Flash 的擦除粒度是 4KB 扇区或 64KB 块。如果你的底层驱动把擦除粒度填大了,FlashDB 内部做磨损均衡时会跳过一半的物理块,容量直接减半。查这类问题最直接的方法是打印分区内所有物理块的擦写次数,看是不是所有块都在增长,如果有某几块一直为 0,就是粒度配置出了问题。
6. 进阶技巧:按产品生命周期设计存储分区与磨损观测
6.1 分区大小按生命周期反推的估算思路
不推荐凭感觉定分区大小。合理做法是按产品生命周期目标来反推:预计产品跑几年,每年产生多少数据,期望在生命周期内不出现“新鲜数据写不进去”的尴尬。以 TSDB 分区为例,计算公式是:容量 = 年数据量 × 年数 ÷ 0.7。这个 0.7 是前面避坑章节提到管理开销和磨损腾挪的冗余系数,测试下来留 30% 余量比较稳。如果你的写入频率极端不均匀,峰值是平时的十倍以上,这个系数要放宽到 0.5。
6.2 用擦写计数做磨损预警
在磨损失效前主动发现隐患,胜过坏了之后再换片。FlashDB 内部有擦写计数的查询接口,建议在开机自检时读一次,把每个物理块的擦写次数上报。产品量产的维护阶段,按“最大擦写次数 / 标称寿命”算一个百分比,超过 80% 就触发预警事件。这块不需要在应用层做复杂逻辑,定时把数值发到后台,人工看趋势即可。
6.3 掉电测试的标准方法
最后说一个可复现的掉电测试方法。把设备设置为掉电自动重启模式,控制继电器在“写入进行中”随机断电,循环 500 次,每 100 次后检查全部键值的完整性和数值一致性。重点关注写入位置的断点状态,不要用大概率断电在其他位置的顺畅测试掩盖了问题。我的习惯是:每次掉电测试都跑在不同写入进度上,第 37 次和第 223 次最容易露出驱动层的时序破绽。测试结束只是第一步,还要连续观察掉电重启后的写入性能衰减,确认没有因为异常断电造成隐性的坏块扩散。这个习惯帮我提前排掉过多次批量返工的隐患,希望帮到你。
本文还有配套的精品资源,点击获取