
做单片机存数据这件事我前后折腾了快十年前八年基本都在凑合。参数要掉电不丢就在 Flash 某个固定地址上硬写一个结构体日志要留几条就自己划一块区域做环形覆盖设备序列号、校准系数、运行小时数各写各的散落在不同的扇区里谁也管不着谁。直到去年接了一个要长期记录环境数据的项目样机跑了三个月参数区读出来全是 0xFF日志指针也回绕到了不该去的地方——那一刻我才认真去 GitHub 上找现成的轮子。后来锁定的那个开源库叫FlashDB2.4k Star 上下嵌入式圈子里做数据落盘的人基本绕不开它。它不是什么黑魔法但把存数据这件事里我一直用裸代码硬扛的脏活——磨损均衡、掉电原子性、容量管理、版本兼容——全都封装到了一层薄薄的 API 后面。这篇东西不打算写成 API 手册。我想说的是为什么过去那套凑合写法迟早会出事、一个正经的嵌入式存储库到底帮你挡掉了哪些坑、把它移植到 STM32 或国产 32 位单片机上要动哪几个地方、以及我实测中真正踩过的几个坑。不管你现在用的是 51、STC、STM32 还是 RISC-V 的片子只要涉及设备断电之后数据还得在这里面的思路都能直接用。1. 那些年我们凑合过的存数据方案问题都出在哪一环先把老账翻一遍。不是为了怀旧是因为不搞清楚这些方案到底哪里漏你用上了新库也还是会写出错的用法。下面这三种写法我敢说做单片机的人至少中过一种。1.1 固定地址直接写路径最短命也最短最经典的写法是这样在链接脚本或者某块预留区域里挑一个地址#define PARAM_ADDR 0x0800F000然后要存参数的时候HAL_FLASH_Unlock(); /* 先擦除整页 */ FLASH_EraseInitTypeDef erase { .TypeErase FLASH_TYPEERASE_PAGES, .PageAddress PARAM_ADDR, .NbPages 1 }; uint32_t err_page; HAL_FLASHEx_Erase(erase, err_page); /* 再写入 */ HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, PARAM_ADDR, (uint64_t)word); HAL_FLASH_Lock();这段代码本身没写错问题在于它的物理代价。片内 Flash 的擦除单位是页或扇区不是字节。你想改一个标志位代价是擦掉整个扇区。所以这条路径的擦写次数是 1:1 的——写一次擦一次。拿 STM32F1 的片内 Flash 来说官方手册给的擦写寿命是 1 万次量级。1 万次听起来很多换算一下就不乐观了设备每 10 分钟保存一次运行状态就是 10000 × 10 分钟 ≈ 69 天。也就是说这台设备的参数区寿命连三个月都撑不到而且它坏得毫无征兆某一天上电就变成了全0xFF。还有一个更隐蔽的问题结构体直写。很多人喜欢定义一个struct直接往 Flash 里丢typedef struct { uint8_t magic; float kp; uint32_t run_hours; char sn[16]; } sys_param_t;看着挺整齐对吧。但sizeof(sys_param_t)会因为编译器不同、对齐策略不同而变成 28 或者 32字段之间还可能塞进填充字节。换一次编译器版本、改一个打包选项读出来的数据就整体错位了。这种 bug 在实验室里永远复现不了只会出现在量产的某一批设备上。1.2 双区备份加魔术字解决了掉电没解决磨损被上面那个坑教育过之后稍微讲究一点的写法就出来了A/B 两个扇区交替写每块数据前面加一个魔术字和 CRC 校验。读的时候先读 ACRC 不过就读 B谁新用谁。这套方案在掉电原子性上确实管用——擦 A 的时候 B 还在写 A 写到一半掉电B 还在。逻辑闭环了思路是对的。但它的账本一点没变好。A/B 轮换只是把擦写次数稀释了一倍每次保存还是要擦一整个扇区。还是那台每 10 分钟保存一次的设备寿命从 69 天变成 138 天。而且这套代码的维护成本会随着需求长大今天加个新参数明天改个结构体版本后天要求能回滚到上一版配置你那几百行手写逻辑就开始互相打架了。我见过最惨的一个项目参数版本演进到第 7 版代码里全是if (ver 3) {...} else if (ver 5) {...}最后没人敢动。1.3 手搓环形日志指针和 CRC 全自己扛日志类的需求更麻烦。传感器每隔几分钟采一条数据要存下来要能按时间段查空间满了要自动淘汰最老的。于是就有了自己写的环形缓冲一个头指针、一个尾指针、每条记录带时间戳和长度、写满了绕回去覆盖。写的时候还得小心不能跨扇区边界——跨了就得处理一条记录一半在扇区尾、一半在扇区头的情况。再往后需求变成查最近 24 小时的数据把过期数据删掉统计一共有多少条你就会发现自己在手写一个数据库的查询层。这类代码最要命的地方是故障暴露得特别慢。指针错位一个字节可能要连续跑三十天才表现出来而那时候设备已经装在客户现场了。2. 一个 2.4k Star 的库替我挡掉了哪四件事FlashDB 的定位很明确面向单片机的超轻量级嵌入式数据库整个库编译进去非常小跑在几十 KB Flash、几 KB RAM 的片子上没压力。它把存储拆成两条独立的线——KV 数据库和时序数据库TSDB——这个拆分是我觉得设计上最聪明的地方。2.1 参数和记录是两种东西别用一套代码硬撑很多人的直觉是反正都是往 Flash 里写数据一套接口就够了。不对。参数和记录在访问模式上完全是两回事参数类数据校准系数、设备序列号、Wi-Fi 配置、运行小时数写入频率极低读取频率高用键值随机访问最看重一致性——读到半截数据等于设备跑飞。记录类数据传感器采样、异常日志、事件流水写入频率高只追加不修改按时间顺序读写空间满了要能滚动淘汰。FlashDB 对应给了两套 API 和两套存储结构。KV 那边是fdb_kv_set/fdb_kv_get这种字符串键的接口支持字符串、定长数值和任意二进制 blobTSDB 那边是fdb_tsl_append追加、fdb_tsl_iter_by_time按时间区间遍历、fdb_tsl_query_count统计条数。你不用再为查询最近一天的数据这种需求去手写遍历逻辑。2.2 磨损均衡和掉电保护其实藏在不擦除这三个字里这是 FlashDB 和我之前手搓方案最本质的区别值得单独说清楚。手搓方案的逻辑是原地改写数据放在扇区的固定位置改了就把这个扇区擦掉重写。FlashDB 用的是日志式追加新数据永远是往当前空闲的位置追加一条记录不擦除。每条记录自带状态标记和 CRC状态从PRE_WRITE变成WRITE才算真正生效。只有当一个扇区的空间被追加的记录填满之后才会触发一次空间回收把还有效的记录搬走再擦掉整个扇区。这带来两个直接好处第一掉电安全。写一条记录的过程是先写数据、再把状态改成WRITE。如果在这中间断电上电后扫描时发现一条记录状态是PRE_WRITE或者 CRC 校验不过直接丢弃。要么完整写入要么完全不存在没有中间态。这就是所谓的原子性而且是靠数据结构设计出来的不依赖任何硬件特性。第二磨损被摊平了。一次擦除动作对应的是整个扇区的空间被写满而不是一次写入。这还是那台每 10 分钟保存一次的设备我们来算笔账方案单次保存的擦除代价分区 64 KB、寿命 1 万次擦写下的总写入量固定地址原地写擦 1 个扇区约 1 万次保存双区备份擦 0.5 个扇区平均约 2 万次保存日志式追加FlashDB写满一个扇区才擦 1 次约 64 KB × 10000 / 每条记录 32 B ≈ 2048 万次2048 万次按每小时保存一条算是两万三千多年。数字悬殊到这个程度本质原因就是擦除这个昂贵动作被记录条数给摊薄了。2.3 几种常见落盘方案的边界对比光说 FlashDB 好没意义得看它在整个选项里处在什么位置。下面这张表是我自己在几个项目里对比后的结论参数是经验值不同片子会有出入方案掉电安全磨损均衡查询能力移植工作量适合场景片内 Flash 原地写无无无极小一次性写死的出厂参数外挂 EEPROM如 24C02弱无无小几百字节的配置项写入不频繁裸机 NVS 分区有部分弱中RTOS 自带方案够用但功能有限FlashDB强有强中参数 记录共存需要按时间查询文件系统如 FatFs弱无强大要存大文件、要 PC 端读写看到那张表里文件系统掉电安全弱可能会有人不服。这是实话通用文件系统是为块设备和缓存的写入模型设计的它假设写入是成批的、可缓存的掉电时容易在 FAT 表和目录项之间产生不一致。用它来做每秒都可能有写入、随时可能断电的现场数据落盘是选错工具了。3. 从零把它跑起来移植到 STM32 上要动的几个地方理论讲完说实操。这一节按我实际做过一遍的顺序来每一步都标出容易翻车的位置。3.1 依赖准备绕不开的 FAL 抽象层FlashDB 默认跑在一层叫FALFlash Abstraction Layer的抽象层上。这层的存在意义是让你的数据库代码不关心底下是片内 Flash、SPI Nor Flash 还是别的什么只要按分区表配好就行。这个设计是对的但也意味着你要多理解一层结构很多人第一次移植就是卡在这一步。目录大致是这么组织的FlashDB/ fdb_cfg.h - 你自己写的配置文件 src/ fdb.c fdb_kvdb.c fdb_tsdb.c fdb_utils.c FAL/ inc/fal.h inc/fal_cfg.h src/fal.c src/fal_flash.c src/fal_part.c samples/port/fal_flash_stm32f2_port.c - 参考移植文件fdb_cfg.h里几个开关必须先确认#define FDB_USING_KVDB #define FDB_USING_TSDB #define FDB_USING_FAL_MODE /* 用 FAL 作为底层驱动 */ #define FDB_WRITE_GRAN 8 /* 写粒度按你的物理 Flash 填 */ #define FDB_ERASE_GRAN 4096 /* 擦除粒度按你的物理 Flash 填 */FDB_WRITE_GRAN这个参数很多人随手填 1这是个典型的坑。它的含义是一次最小写入单位是多少位片内 Flash 通常是 32 位或 64 位也就是填 4 或 8。填错了不会立刻报错但会在跑一段时间之后出现莫名其妙的记录损坏。填之前请翻你片子的数据手册别猜。FDB_DEBUG这类调试开关实验室里开着方便看日志量产固件里一定要关掉它会在每次操作时输出大量字符串Flash 占用和运行开销都不小。3.2 底层三个接口read / write / eraseFAL 层要求你实现最底层的三个动作参考fal_flash_stm32f2_port.c改写即可。核心是一个fal_flash_dev结构体struct fal_flash_dev stm32_onchip_flash { .name onchip_flash, .addr 0x08000000, .len 256 * 1024, .blk_size 2048, /* 擦除块大小 */ .ops { init, read, write, erase }, .write_gran 32 /* 写粒度单位 bit这里 32bit 4 字节 */ };三个函数里write和erase是重点。片内 Flash 的写入要求目标区域已经被擦干净全1并且写入地址按写粒度对齐擦除必须整块擦。移植的时候不要偷懒直接调 HAL 而不做参数检查我建议在write里加一段static int flash_write(long offset, const uint8_t *buf, size_t size) { /* 地址必须按写粒度对齐 */ if (offset % 4 ! 0 || size % 4 ! 0) { return -1; } /* 目标区域必须是已擦除状态否则 HAL 会返回错误 */ for (size_t i 0; i size; i 4) { if (*(volatile uint32_t *)(FLASH_BASE offset i) ! 0xFFFFFFFF) { /* 这里返回错误别硬写 */ return -1; } } /* 再调 HAL_FLASH_Program ... */ }这段检查看着多余但它能在调试阶段把忘了先擦除这类错误直接暴露出来而不是等到运行几个月后发现数据坏了。写驱动的原则就一条宁可返回错误也不要让硬件处在未定义状态。3.3 分区表起始地址必须按擦除粒度对齐分区表在fal_cfg.h里定义一个典型的划分#define FAL_PART_TABLE \ { \ {FAL_PART_MAGIC_WORD, kvdb, onchip_flash, 0 * 1024, 64 * 1024, 0},\ {FAL_PART_MAGIC_WORD, tsdb, onchip_flash, 64 * 1024, 128 * 1024, 0},\ }这里有个特别容易被忽略的坑STM32F4 系列的片内 Flash 扇区大小是不等的——前面几个扇区是 16 KB中间跳到 64 KB后面是 128 KB。如果你按每 16 KB 一个扇区的直觉去划分区起始地址落在了一个 64 KB 扇区的中间那么对这个分区的擦除会把前一个分区的数据一起带走。表现出来就是KV 能写进去但一擦 TSDBKV 就读不出来了。规避方法很土但有效拿片子的参考手册把每个扇区的起止地址抄在纸上分区边界只落在扇区边界上而且分区长度用扇区长度标注一下。我是直接在配置文件里写注释比如/* 64KB对应 F4 的 Sector 4 */下次改分区的时候一眼就知道能不能动。3.4 最小闭环先让 KV 和 TSDB 各跑通一次移植完成之后别急着往业务代码里塞先写个最小验证。KV 部分fdb_kvdb_t kvdb; void storage_init(void) { fal_init(); fdb_err_t err fdb_kvdb_init(kvdb, env, kvdb, NULL, NULL); if (err ! FDB_NO_ERR) { /* 初始化失败一定要处理比如点亮故障灯 */ fault_led_on(); return; } /* 写入两个不同形态的值 */ fdb_kv_set(kvdb, boot_count, 1); sys_cfg_t cfg { .kp 1.25f, .ki 0.03f }; struct fdb_blob blob; fdb_blob_make(blob, cfg, sizeof(cfg)); fdb_kv_set_blob(kvdb, sys_cfg, blob); }注意fdb_blob_make这一步存取结构体、数组这类二进制数据必须用 blob 接口不能当字符串处理。原因是 FlashDB 内部对字符串和 blob 的存储策略不同——字符串会额外处理结尾符blob 是原样存原始字节。我见过有人把结构体当字符串set进去读出来在某个字节处被截断查了两天才发现问题。TSDB 部分需要一个时间戳回调static uint32_t get_timestamp(void) { return (uint32_t)(xTaskGetTickCount() / configTICK_RATE_HZ); } fdb_tsdb_t tsdb; void log_init(void) { fdb_tsdb_init(tsdb, log, tsdb, get_timestamp, NULL, NULL); sensor_rec_t rec { .temp 25.3f, .humi 60 }; struct fdb_blob blob; fdb_blob_make(blob, rec, sizeof(rec)); fdb_tsl_append(tsdb, blob); }遍历查询的时候回调函数的返回值语义要特别注意static bool query_cb(fdb_tsl_t tsl, void *arg) { sensor_rec_t rec; struct fdb_blob blob; fdb_blob_make(blob, rec, sizeof(rec)); fdb_tsl_to_blob(tsl, blob); /* 处理 rec */ return false; /* 返回 false 表示继续遍历返回 true 表示终止 */ } /* 查询最近一小时 */ uint32_t now get_timestamp(); fdb_tsl_iter_by_time(tsdb, now - 3600, now, query_cb, NULL);注意这个回调的返回值是返回 false 继续、返回 true 停止和不少人直觉里的写法是反的。头一回用的时候很容易记反结果只处理了一条数据就退出遍历还以为是查询条件写错了。fdb_tsl_append之后还有一个原子提交的动作你不需要手动调库里已经处理好了。但如果你想减少掉电窗口可以在批量写入之后调用一次fdb_tsdb_control(tsdb, FDB_TSDB_CTRL_SET_SEC_SIZE, ...)之类的控制接口调整行为——具体可用参数以你手上版本的fdb.h为准。4. 实测踩过的四个坑以及每个坑的排查链路这一节是我最想写的地方。上面那些是应该怎么做下面这些是我实际怎么翻车的。4.1 扇区大小与分区配置不一致能写进去读出来是乱码第一次移植的时候我参考的是一个 F1 的配置FDB_ERASE_GRAN填了 2048但手上这块 F4 的扇区最小是 16 KB。现象很诡异初始化成功fdb_kv_set返回也没报错甚至重启之后第一次fdb_kv_get还能读出来。但连续写几次之后读出来的内容开始出现部分乱码。排查过程是这样的先确认底层 write/erase 是否真的操作到了我期望的地址——用一个简单的测试函数往分区首地址写一个已知的 pattern然后手工 dump 出来看是对的。再确认 KV 层写入的数据是否真的落到了该落的位置——打印出记录头和状态标记发现状态是WRITECRC 也对。那问题只能出在库认为的扇区和物理扇区不一致上。结论就是FDB_ERASE_GRAN与实际 Flash 的擦除粒度对不上。库按 2048 字节算扇区回收时只擦了 2048 字节但硬件实际是整块 16 KB 擦除导致相邻记录被一起抹掉读出来自然是乱的。改对参数之后问题消失。这里给一条经验配置阶段宁愿多花半小时对着数据手册核参数也别指望运行时能自动补救。这类参数错配不会立刻报错它会在你放松警惕之后才发作。4.2 掉电测试怎么做才算数磨损和原子性这种东西不测是看不出来的。但掉电测试很多人的做法是错的——直接拔电源测十次没坏就认为过关了。这几乎测不出问题。原因在于写入窗口非常短你随机拔十次大概率一次都没落在写入的那几毫秒里。我的做法是用一个受控电源在写入操作正在执行时切断供电。具体实现是在写数据之前拉高一个 GPIO写完拉低然后用示波器或者逻辑分析仪看这个 GPIO手动在它高电平期间断电。重复至少几十次每次上电都做一次完整的数据校验。更工程化的做法是写一段自动化代码让设备在写入中断电这件事上做循环。比如用一颗 IO 控制一颗 MOS 管给整板供电MCU 上电后立刻执行一次写入随机延迟若干微秒后主动断电如此循环几百次。跑完之后统计有多少次出现了校验失败。合格的标准是每次上电读到的数据要么是完整的旧值要么是完整的新值绝不能出现第三种情况。我按这个方法跑了 500 次随机断电FlashDB 那一侧没有出现过一次数据损坏同一个测试下我之前手写的那版双区方案出现了 3 次读到半新半旧的情况。这个对比是我彻底换掉旧方案的直接原因。4.3 分区写满之后会怎样容量该怎么估日志类数据是会写满的。写满之后的默认行为是滚动淘汰最老的记录但你要提前知道容量有多少否则设备在客户现场跑两个月之后突然发现历史数据只剩最近三小时那是要出事的。KV 和 TSDB 的容量估算公式不一样KV 场景每条记录的开销 键名长度 数据长度 文件头约 20 到 30 字节。所以可容纳的键数量 ≈ 分区大小 / 平均单条记录占用TSDB 场景每条记录开销 时间戳 4 字节 数据长度 记录头。假设分区 128 KB一条传感器记录 16 字节加上开销按 32 字节算那么大约能存 4096 条。如果每 5 分钟采一条覆盖时间就是 4096 × 5 分钟 ≈ 14 天。这个数字在立项的时候就该算出来然后反推分区该切多大。我见过一个项目因为分区只给了 16 KB日志覆盖时间不到一天最后只能把采样周期从 1 分钟改成 10 分钟业务上直接不达标。4.4 中断和主循环同时碰数据库最后一个坑是并发。KV 数据库的写入不是原子的内存操作它涉及多次 Flash 访问。如果你在主循环里fdb_kv_set中断服务函数里又读同一个键就可能读到中间态。FlashDB 提供了可选的锁机制在fdb_kvdb_init的最后一个参数里传一个加解锁回调进去。但对绝大多数项目我更推荐简单粗暴的做法把数据库的所有访问都收敛到一个任务或者一个上下文里用队列传请求。这样锁都不用加心智负担最小。如果你用的是 RTOS那就更简单——建一个存储任务其他任务通过消息队列发请求存储任务顺序执行。这比到处加锁、还要小心死锁要可靠得多。裸机项目没有任务概念那就退而求其次在访问数据库前关中断访问完开中断。但要注意关中断的时间窗口TSDB 遍历几千条记录的时候长时间关中断是会丢串口数据的遍历这种操作一定要放到中断外做。5. 什么时候该用它什么时候别硬上用了两年多我对它的适用边界摸得比较清楚了。这几个判断标准可以直接拿去用。5.1 每秒几十次的高频写入它也不是银弹日志式追加虽然比原地改写省擦除但每次写仍然是一次真实的 Flash 编程操作。片内 Flash 单次编程的耗时在几十微秒到几毫秒之间而且频繁的编程会占用 CPU 和外设总线。如果你要每秒写入几十次片内 Flash 会被拖得非常吃力而且擦除配额消耗得也快。这种场景有三个选择改用外挂的 FRAM 或者 MRAM写入快、寿命几乎无限缺点是贵、在 RAM 里做缓冲攒够一批再批量落盘、或者降低采样频率改用滑动平均。我的默认选择是第二种——RAM 里放一个几百字节的缓冲攒够 32 条或者每 30 秒批量提交一次写入吞吐立刻降下来掉电最多丢最近 30 秒的数据。这个取舍必须和业务方明确说清楚别自己拍脑袋决定。5.2 需要 PC 端直接读写的还是老实上文件系统FlashDB 的分区结构是为设备自己读设计的PC 端插上之后不认识。如果你的需求是设备插到电脑上电脑能直接看到一个可移动磁盘里面的文件能复制出来那它的分层结构不适合应该走文件系统。但反过来如果只是设备通过串口或者蓝牙把数据传出来上位机解析那 FlashDB 更合适——它按记录存读取的时候一条一条出天然适合流式传输不用处理 FAT 表、目录项、文件碎片这一堆东西。5.3 我实测的几组数据下面这组数字来自一块 STM32F4 板子片内 Flash 作为存储介质KV 分区 64 KB、TSDB 分区 128 KB运行在 168 MHz 主频下。不同片子、不同编译优化等级会有出入仅供参考量级操作平均耗时备注fdb_kv_get读一个数值约 20 微秒命中缓存时更快fdb_kv_set写一个数值约 1.2 毫秒含一次 Flash 编程 状态提交fdb_tsl_append追加一条 16 字节记录约 1.5 毫秒含时间戳和记录头遍历 1000 条记录约 45 毫秒建议放到低优先级任务里做触发一次空间回收约 80 到 200 毫秒与分区内有效记录数量强相关最后一项值得单独提醒空间回收的耗时是波动的最坏情况下要搬运整个分区的有效记录。如果你的系统对实时性有硬要求别让回收动作在时间敏感的任务里触发。我的做法是把 TSDB 的遍历和潜在回收动作都放进一个低优先级任务靠空闲时间去消化。真要说这套东西给我最大的改变是什么不是省了几百行代码而是它让我在写业务逻辑的时候不用再分心去考虑存储层会不会崩。以前每加一个参数我要想这个参数多大、放哪个地址、要不要重新划分区、版本升级怎么兼容现在这些都是fdb_kv_set一句话的事剩下的交给库。这种把不擅长的事交给专门做这件事的代码的思维转变比学会一个库本身要值钱得多。如果你现在手上还压着一堆自己手搓的存储代码我的建议是先别急着全换。挑一个最痛的地方——比如参数区——先把它挪过来跑通、跑稳、看着它在掉电测试里不出错你自然就有底气把剩下的也搬过去。