ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FlashDB在W25Q64 SPI NOR上的KVDB/TSDB掉电保护实践

FlashDB在W25Q64 SPI NOR上的KVDB/TSDB掉电保护实践

简介:面向嵌入式存储开发与STM32物联网终端实践的实测例程,围绕中移物联万耦开发板,将FlashDB轻量数据库、SFUD通用Flash驱动、FAL分区管理与W25Q64芯片通过QSPI总线串联验证。内容覆盖键值与时序数据的存取思路、Flash分区划分与利用率优化、底层驱动适配及数据持久化等关键环节,适合已具备STM32与外设基础、希望降低Flash读写复杂度并延长器件寿命的开发者参考。压缩包共2009个文件,约50.6MB,以C源码与头文件为主体,辅以工程配置、链接脚本、说明文档及少量图片、库文件,便于按模块查阅和移植。已有1228人学习下载,可用于对照实验现象、理解存储分层结构和排查驱动适配问题。

1. 从一次掉电丢参数说起:8MB SPI NOR 上为什么需要 FlashDB 这层数据库

去年做一款低功耗采集终端,板子上挂了一颗 W25Q64,8MB 容量,SPI 接口,当时觉得参数才几百字节,直接定个地址调sfud_write写进去就行。结果现场跑了两周,最怕的就是设备在写入瞬间被拔电——那一片 4KB 扇区里的数据全变成0xFF,连带同扇区存放的校准系数一起没了。后来把这块存储重做,落到 FlashDB 上,才把「参数怎么存」「日志怎么滚」这两件事拆清楚:它提供嵌入式键值数据库 KVDB 和嵌入式时序数据库 TSDB 两种模型,底层依赖 FAL 做分区与擦写管理,再往下是 SFUD 负责芯片识别与命令时序。这套组合在 NOR Flash 上的价值不在于「数据库」三个字有多重,而在于它把擦除粒度、掉电保护、磨损均衡这些容易翻车的细节收进了库内部,应用层只需要调fdb_kv_set和fdb_tsl_append。适合手上有一块带 SPI NOR 的 MCU 板、又不想自己维护裸 Flash 读写逻辑的人。

2. 拆开四层栈:SFUD 探测、FAL 分区、KVDB 与 TSDB 的粒度对齐

2.1 四层各管一段,出问题时先定位到层

这套存储栈的责任边界比想象中清晰,麻烦的是很多人一上来就把四层混在一起改,fdb_kvdb_init返回非零之后开始乱试。按下面的分工去切,排错能省一大半时间:

层组件负责的事典型失败表现
硬件层W25Q64 + SPI电气时序、片选、模式 0/3读 ID 恒为0xFF或0x00
驱动层SFUD发 JEDEC ID 命令识别芯片、封装读/写/擦识别失败,sfud_init返回错误
抽象层FAL把物理块设备切成命名分区,统一 read/write/erase 接口分区名找不到、越界写
数据库层FlashDBKVDB 键值、TSDB 时序,负责掉电保护与磨损均衡初始化返回错误、数据读回不一致

判断顺序我一般是从下往上:先确认sfud_init()能读到EF 40 17,再确认fal_partition_find("xxx")不为 NULL,最后才看 FlashDB 的返回值。跳过前两步直接查数据库层,多半是在给驱动问题找错方向。

2.2 W25Q64 的参数决定FDB_WRITE_GRAN怎么填

W25Q64 是一颗标准 SPI NOR,容量 64Mbit,也就是 8MB。它的擦除单位不是字节,而是扇区和块,这一点直接决定 FlashDB 的配置项:

参数数值对 FlashDB 的影响
总容量8MB(8,388,608 字节)FAL 的len字段填这个值
页大小256 字节单次连续写不超过页边界,跨页要拆分
扇区大小4KBFAL 的blk_size填 4096
块大小64KB大块擦除更快,但擦除期间耗时更长
JEDEC IDEF 40 17SFUD 靠它匹配芯片表
写粒度1 bit(按位可编程,实际按字节写)FDB_WRITE_GRAN必须填 1
擦写寿命约 10 万次磨损均衡与分区大小的依据

FDB_WRITE_GRAN是最容易填错的一项。它表示存储介质一次最小可写入的位数,NOR Flash 的特性是写1变0可以,0变1只能靠擦除,所以最小写入单元是 1 bit,必须定义为 1。如果照抄 NAND 或 EEPROM 的配置填成 8,FlashDB 内部的状态位管理就会按字节粒度算偏移,表现出来就是写进去的 KV 读出来是一串乱码,或者fdb_kv_set成功但fdb_kv_get长度对不上。

2.3 分区表怎么切:KVDB 给 32KB,TSDB 给 1MB

FAL 的分区表是整份移植里唯一需要「提前想清楚」的地方,因为分区一旦定下来,后续改动的代价是把数据全擦掉重来。我的切法是把 8MB 按用途分成三块,留足余量:

/* fal_cfg.h —— 分区表配置,每个分区 8 个字段 */ #ifndef _FAL_CFG_H_ #define _FAL_CFG_H_ #include "fal_def.h" #define FAL_PART_HAS_TABLE_CFG /* 启用静态分区表,不启用则需运行时注册 */ /* 物理块设备表:片内 Flash 与外部 W25Q64 */ #define FAL_FLASH_DEV_TABLE \ { \ &onchip_flash, \ &w25q64_flash, \ } #define FAL_PART_TABLE \ { \ /* 分区名 所属设备 偏移地址 分区大小 保留 */ \ {FAL_PART_MAGIC_WORD, "w25q64_kvdb", "w25q64", 0, 32 * 1024, 0}, \ {FAL_PART_MAGIC_WORD, "w25q64_tsdb", "w25q64", 32 * 1024, 1024 * 1024, 0}, \ {FAL_PART_MAGIC_WORD, "w25q64_user", "w25q64", 1056 * 1024, 2 * 1024 * 1024, 0}, \ } #endif

三个参数值得展开说。偏移地址从 0 开始,第一个分区直接占住 W25Q64 的起始位置,后面的分区必须严格接在前一个分区的尾部,中间不能留空也不能重叠——FAL 在初始化时会校验,重叠会直接报错退出。分区大小必须是对齐blk_size的整数倍,32KB 的 KVDB 分区等于 8 个 4KB 扇区,FlashDB 的 KVDB 至少需要 2 个扇区才能做「写满一个、切换到另一个」的掉电保护,8 个扇区留出了足够的冗余。TSDB 给 1MB,按每条记录 128 字节算,理论能存 8000 条左右,实际因为每条记录要占一个独立的扇区槽位,可用条数会少一些,这个后面第 6 章会算。

FAL_PART_MAGIC_WORD是分区表的校验字,四个分区都必须是这个值,写错一个字符就会导致整张表解析失败。剩下那 2MB 的w25q64_user分区留给固件升级或其它裸操作,不交给 FlashDB 管,避免数据库和业务代码抢同一片地址空间。

3. 移植落笔:三个钩子函数把 W25Q64 接进 FAL 与 FlashDB

3.1 SFUD 端口:读写分离 + 互斥锁

SFUD 的移植量在所有组件里最小,核心是实现两个函数:一个是底层 SPI 的收发,一个是把sfud_spi结构体挂到设备上。新版 SFUD 把读写拆成了两个回调,不再是早期的write_read合并形式,这一点在移植老教程里经常对不上。

/* w25q64_port.c —— SFUD 端口层实现 */ #include "sfud.h" #include "bsp_spi.h" /* 片选控制,SFUD 内部不直接操作 CS,需在此封装 */ static void w25q64_cs_low(void) { bsp_spi_cs(0); } static void w25q64_cs_high(void) { bsp_spi_cs(1); } /* 写回调:SFUD 只在拉低 CS 后调用,内部负责拼命令字 */ static sfud_err w25q64_write(const struct sfud_flash *flash, uint32_t addr, const uint8_t *buf, size_t size) { w25q64_cs_low(); if (bsp_spi_transmit(buf, size) != 0) { w25q64_cs_high(); return SFUD_ERR_WRITE; } w25q64_cs_high(); return SFUD_SUCCESS; } /* 读回调:先发命令再收数据,同样由 SFUD 组织 buf 内容 */ static sfud_err w25q64_read(const struct sfud_flash *flash, uint32_t addr, uint8_t *buf, size_t size) { w25q64_cs_low(); if (bsp_spi_receive(buf, size) != 0) { w25q64_cs_high(); return SFUD_ERR_READ; } w25q64_cs_high(); return SFUD_SUCCESS; } /* 互斥锁:裸机下可空实现,RTOS 下必须用信号量保护 */ static void w25q64_lock(const struct sfud_flash *flash) { bsp_spi_mutex_take(); } static void w25q64_unlock(const struct sfud_flash *flash) { bsp_spi_mutex_give(); } /* 延时回调:SFUD 在等待写完成、擦除完成时调用 */ static void w25q64_delay(void) { bsp_delay_ms(1); } /* 把上述钩子登记到 SFUD 的 spi 结构 */ static sfud_spi w25q64_spi = { .write = w25q64_write, .read = w25q64_read, .lock = w25q64_lock, .unlock = w25q64_unlock, .delay = w25q64_delay, .retry_times = 3, /* 探测失败重试次数,默认 3 足够 */ }; /* 探测并初始化,返回 0 表示识别成功 */ int w25q64_sfud_init(void) { sfud_err ret = sfud_device_init(&sfud_dev, &w25q64_spi); return (ret == SFUD_SUCCESS) ? 0 : -1; }

这段代码有两个点需要说明。retry_times = 3是 SFUD 在发 JEDEC ID 命令后,如果回读的 ID 不在芯片表里,会重新上电复位再试的次数,调试阶段可以调大方便抓问题,量产建议保持 3 以内,避免上电时如果芯片没焊好,卡在这里几十毫秒。另一个是lock/unlock回调,裸机单线程直接留空没问题,但只要系统里有任何一个中断或任务可能同时碰 SPI 总线,就必须加保护,否则 FlashDB 在擦除时被中断打断,会出现「擦除命令发出去了、CS 被别的设备抢走」这种极难复现的故障。

还有个容易被忽略的前置动作:bsp_spi_init()里的 SPI 模式要配成 Mode 0 或 Mode 3,W25Q64 两种都支持,但必须在 SFUD 初始化之前配好,配置晚了读出来的 ID 是错位的。我一般习惯在主频允许的范围内开到 30MHz 以上,擦除 4KB 扇区的时间能明显缩短。

3.2 FAL 设备注册:把 W25Q64 包装成块设备

SFUD 识别到芯片之后,FAL 才有一个可以被分区的「物理设备」。这一步要填一个fal_flash_dev结构体,四个操作函数里 read 和 write 直接转发给 SFUD,erase 要调用sfud_erase。

/* fal_flash_w25q64.c —— 注册 FAL 物理设备 */ #include "fal_def.h" #include "sfud.h" #define W25Q64_ADDR 0 #define W25Q64_LEN (8 * 1024 * 1024) #define W25Q64_BLK_SIZE 4096 static int w25q64_fal_init(void) { return w25q64_sfud_init(); /* 复用上一节的探测函数 */ } static int w25q64_fal_read(long offset, uint8_t *buf, size_t size) { /* 参数校验:越界直接返回错误,避免把别的分区数据读出来 */ if (offset + size > W25Q64_LEN) return -1; if (sfud_read(&sfud_dev, W25Q64_ADDR + offset, size, buf) != SFUD_SUCCESS) return -1; return size; } static int w25q64_fal_write(long offset, const uint8_t *buf, size_t size) { if (offset + size > W25Q64_LEN) return -1; if (sfud_write(&sfud_dev, W25Q64_ADDR + offset, size, buf) != SFUD_SUCCESS) return -1; return size; } static int w25q64_fal_erase(long offset, size_t size) { /* 擦除必须按扇区对齐,非对齐由 FAL 上层保证,这里再兜一次底 */ if (offset % W25Q64_BLK_SIZE != 0) return -1; if (sfud_erase(&sfud_dev, W25Q64_ADDR + offset, size) != SFUD_SUCCESS) return -1; return size; } /* 导出给分区表引用的设备对象 */ const struct fal_flash_dev w25q64_flash = { .name = "w25q64", .addr = W25Q64_ADDR, .len = W25Q64_LEN, .blk_size = W25Q64_BLK_SIZE, .ops = {w25q64_fal_init, w25q64_fal_read, w25q64_fal_write, w25q64_fal_erase}, .write_gran = 1, /* 与 FDB_WRITE_GRAN 保持一致 */ };

四个函数的返回值语义要记牢:成功返回实际读写的字节数,失败返回负数。FAL 上层靠这个返回值判断是否继续,如果 write 返回 0 而不是size,上层会认为一个字节都没写进去,直接报错。

.write_gran = 1这个字段和 FlashDB 的FDB_WRITE_GRAN不是一回事,前者描述 FAL 这一层的写粒度,后者描述 FlashDB 内部状态表按什么粒度计算,两个值必须一致,否则会在写入状态标记时出现半字节覆盖。W25Q64 这颗芯片这两个都填 1,没有例外。

3.3fdb_cfg.h的开关组合

FlashDB 的配置文件决定了编译进哪些功能,配错不会报错,只会让某些 API 链接时找不到符号:

/* fdb_cfg.h —— FlashDB 编译期配置 */ #ifndef _FDB_CFG_H_ #define _FDB_CFG_H_ #define FDB_USING_KVDB /* 启用键值数据库 */ #define FDB_USING_TSDB /* 启用时序数据库 */ /* 存储模式:必须选 FAL,另一个是文件模式,裸机用不上 */ #define FDB_USING_FAL_MODE /* 写入粒度,NOR Flash 固定为 1 */ #define FDB_WRITE_GRAN 1 /* KVDB 每次启动自动补齐默认键值,配合初始化时传入的默认表使用 */ #define FDB_KV_AUTO_UPDATE /* 调试阶段打开,串口能打出每个扇区的使用情况,量产关掉 */ #define FDB_KVDB_DEBUG //#define FDB_TSDB_DEBUG /* 状态表缓存大小,分区越大这个值可以适当调小 */ #define FDB_KV_CACHE_TABLE_SIZE 256 #define FDB_TSL_STATUS_TABLE_SIZE 128 #endif

FDB_USING_FAL_MODE这一项最容易漏。如果只定义了 KVDB 和 TSDB,编译能过,但fdb_kvdb_init内部调用分区查找时会直接返回错误,报的是「分区不存在」,让人误以为是分区表写错了。FDB_KV_AUTO_UPDATE打开之后,初始化时传入的默认键值表会在设备首次启动或检测到缺失键时自动写入,省掉一堆「第一次跑要手动插一条记录」的麻烦。

4. 两类库跑通:KVDB 掉电计数与 TSDB 日志追加的实测代码

4.1 KVDB 初始化与默认键值表

KVDB 的用法很像一个持久化的字典,初始化时给一份默认值,之后set/get任意类型的数据,掉电不丢。初始化流程要放在 FAL 初始化之后,因为内部要按分区名去找对应的块设备:

/* fdb_app.c —— KVDB 初始化与基础读写 */ #include "flashdb.h" static struct fdb_kvdb kvdb = {0}; static uint32_t boot_count = 0; /* 默认键值表:设备第一次启动时自动写入 */ static struct fdb_default_kv_node default_kv_set[] = { {"boot_count", &boot_count, sizeof(boot_count), 0}, {"dev_id", "SN00000000", 10, 0}, {"sample_ms", &(uint32_t){1000}, sizeof(uint32_t), 0}, }; int app_kvdb_init(void) { struct fdb_default_kv default_kv; fdb_err_t err; default_kv.kvs = default_kv_set; default_kv.num = sizeof(default_kv_set) / sizeof(default_kv_set[0]); /* 分区名必须与 fal_cfg.h 中的 w25q64_kvdb 完全一致 */ err = fdb_kvdb_init(&kvdb, "env", "w25q64_kvdb", &default_kv, NULL); return (err == FDB_NO_ERR) ? 0 : -1; } void app_kvdb_demo(void) { uint32_t cnt = 0; size_t len = sizeof(cnt); /* 读上一次的计数,读不到说明是首次启动 */ if (fdb_kv_get(&kvdb, "boot_count", &cnt, &len) > 0) { cnt++; } else { cnt = 1; } fdb_kv_set(&kvdb, "boot_count", &cnt, sizeof(cnt)); /* 读字符串类型的键 */ char sn[16] = {0}; len = sizeof(sn) - 1; if (fdb_kv_get(&kvdb, "dev_id", sn, &len) > 0) { /* sn 中即为上次写入的设备编号 */ } }

fdb_kvdb_init的第二个参数"env"是数据库自身的名字,用来在内部做区分,同一块分区上不能有两个同名数据库;第三个参数才是 FAL 分区名,这两个名字没有必然联系,但名字写错都会导致初始化失败。默认表里fdb_kv_get的第四个参数是缓冲长度指针,传入时是缓冲区大小,返回时被改写为实际读到的长度,判断是否读到要用返回值是否大于 0,而不是看长度。

fdb_kv_set对小数据量是直接覆写,内部不会立刻擦除扇区,而是把新值写到当前扇区的下一个空闲位置,把旧值标记为失效,等整个扇区写满才统一擦除一次。这正是它比裸写 Flash 耐用的原因——应用层改了 100 次参数,物理上可能只擦除了一个扇区一次。

4.2 掉电计数验证:三次断电重启的现象

验证 KVDB 是否真的扛得住掉电,做法比想象中暴力:让设备每 500ms 把boot_count加一写进去,然后用串口终端连续打印,随机拔电,至少十次。我这边实测的现象是:

第一次上电,串口打印boot_count = 1,说明默认表写入了。断电,重新上电,打印2。在打印3的瞬间拔电,重新上电后读到3或者4都算正常,读到0或乱码才算失败。连续做了十几次,计数始终单调递增,没有出现回退。

这个测试的价值在于覆盖了「写操作进行到一半掉电」的场景。FlashDB 的 KVDB 在写新值之前会先写一个状态标记,上电时如果检测到标记不完整,就把这条记录视为无效并回滚到上一个有效版本,所以最终读到的永远是某个完整的历史值,不会是半截数据。

要提升这个测试的强度,可以把写入间隔压到 50ms,同时在写入循环里穿插一次fdb_kv_set_blob存一段 200 字节的数组,模拟参数结构体较大的情况。此时会看到串口打印的擦除日志频率明显上升,因为大块数据的记录占用的扇区空间更多。

4.3 TSDB 追加日志与滚动覆盖

TSDB 是另一套模型,它按时间顺序追加记录,适合存传感器历史数据、运行日志这类只增不改的内容。它需要一个返回当前时间的回调函数,时间戳必须单调递增,这一点是硬性要求:

/* fdb_app.c —— TSDB 初始化与写入 */ static struct fdb_tsdb tsdb = {0}; /* 时间戳回调:返回自 1970 起的秒数,无 RTC 时用单调计时器也行 */ static fdb_time_t get_time(void) { return (fdb_time_t)bsp_rtc_get_seconds(); } int app_tsdb_init(void) { /* 每条记录最大 128 字节,超过需在上层拆分 */ fdb_err_t err = fdb_tsdb_init(&tsdb, "log", "w25q64_tsdb", get_time, 128, NULL); return (err == FDB_NO_ERR) ? 0 : -1; } void app_tsdb_append_demo(void) { struct sensor_record { uint16_t temp; uint16_t humi; uint32_t seq; } rec = {2530, 6180, 1}; struct fdb_blob blob; fdb_tsl_append(&tsdb, fdb_blob_make(&blob, &rec, sizeof(rec))); } /* 遍历最近写进去的记录,回调返回 true 表示继续遍历 */ static bool tsdb_iter_cb(fdb_tsl_t tsl, void *arg) { struct fdb_blob blob; struct sensor_record rec; fdb_blob_read((fdb_db_t)tsdb.parent, fdb_tsl_to_blob(tsl, fdb_blob_make(&blob, &rec, sizeof(rec)))); printf("ts=%u temp=%u\r\n", (unsigned)tsl->time, rec.temp); return false; /* 只取最新一条 */ } void app_tsdb_dump_last(void) { fdb_tsl_iter_reverse(&tsdb, tsdb_iter_cb, NULL); }

fdb_tsdb_init的第五个参数max_len = 128是单条记录的长度上限,这个值不是随便填的,它决定了 TSDB 内部每条记录预留的槽位大小。填大了浪费空间,填小了fdb_tsl_append会返回长度超限的错误。实际项目里我一般按业务结构体的最大可能长度再加上几字节的对齐余量来定。

写入时不需要手动管理时间和位置,fdb_tsl_append会自动取当前时间戳、找到分区内下一个可用槽位。当分区写满之后,TSDB 默认进入滚动覆盖模式,最早的那条记录会被标记为无效,新记录复用它所在的扇区。这个行为由分区大小决定,不需要额外的代码去清理旧数据——但也意味着分区写满后老日志会无声无息地消失,需要保留更长的历史就得在分区表里把空间给够。

fdb_tsl_iter_reverse是从最新一条往前遍历,配合回调返回false可以只取最近一条,适合做「上电后恢复上次运行状态」这类场景。

5. 避坑与排查:移植期最常卡住的五个点

5.1 驱动层:读 ID 恒为0xFF,问题多半不在代码里

现象:串口打印sfud_init失败,读出的 JEDEC ID 是0xFF、0x00或者0xFF,0xFF,0xFF。

原因:0xFF说明 MISO 线一直处于高电平,主机根本没收到芯片的响应,常见于片选没拉低、SPI 模式配错、或者 MISO 引脚复用没配对。0x00则相反,说明 MISO 被拉低,通常是芯片没焊好或者供电异常。还有一种情况是 SPI 时钟开得太高,杜邦线接的飞线在 50MHz 下信号劣化,回读的 ID 高位丢失,表现为EF 40 17读成了EF 00 00。

解决:先把 SPI 时钟降到 1MHz 再读一次,如果此时 ID 正确,说明是布线或速率问题,正式配置里按实际能力往下降一档。再用示波器看 CS 和 CLK 的时序,确认 CS 拉低发生在第一个时钟沿之前,且整个命令期间没有抖动。如果手上没有示波器,就在w25q64_cs_low()之后加一段几微秒的延时试试,很多低成本板子的片选信号走线较长,需要一点建立时间。

另外还有一个隐蔽的坑:SFUD 在探测阶段会发「释放掉电」命令0xAB并把 CS 拉高,如果这个函数的实现中途被打断,芯片可能停在准备状态,后续所有命令都不响应。排查时可以在sfud_device_init之前先手动发一次0xAB,看能否恢复。

5.2 分区与配置层:初始化返回错误码,先查分区表

现象:fdb_kvdb_init返回FDB_ERR或负数,串口打印「partition not found」。

原因:分区表里的名字和初始化时传进去的名字不一致,哪怕差一个字母。另一种情况是FAL_PART_HAS_TABLE_CFG宏没开,此时 FAL 走的是运行时注册流程,fal_partition_find自然找不到静态表里的分区。还有一种是分区大小不是blk_size的整数倍,FAL 在解析分区表时会直接判定非法并跳过整张表。

解决:把fal_cfg.h里的分区名和初始化代码里的字符串放在一起对比,注意大小写和容易混的字符。确认FAL_PART_HAS_TABLE_CFG已定义在编译宏里而不是只写在头文件里。分区大小这一项建议直接按4096的倍数去算,不要写30000这种看着整齐但不对齐的值。最后在fal_init()之后加一段打印,遍历所有分区并输出名字、偏移、长度,一眼就能看出表有没有被正确加载。

5.3 时序层:TSDB 时间戳回退,记录写不进去

现象:设备重启后fdb_tsl_append一直返回错误,或者写入的记录时间戳是 0,遍历时顺序错乱。

原因:TSDB 要求时间戳严格单调递增,如果 RTC 没有后备电池,每次上电都从 1970 年开始计时,第二次启动的时间反而比上一次写入记录的时间早,FlashDB 会拒绝写入。这个设计是为了防止乱序数据破坏时序索引,但对没有 RTC 的板子就成了拦路虎。

解决:没有 RTC 时,用「上电后读取 TSDB 中最新一条记录的时间戳,以此为基准继续递增」的方式构造时间回调。如果 TSDB 是空的,就从 0 开始。这种做法牺牲了绝对时间,但保住了单调性,日志的相对顺序仍然有效。如果板子上有 RTC 但没装电池,可以在get_time里加一层保护:读到的值小于上次返回的值时,直接返回上次值加一。

还有一种情况是多个任务同时调用fdb_tsl_append,时间戳相同,也会触发写入失败。此时需要在应用层加锁,或者干脆把 TSDB 的写入收敛到单个任务里。

5.4 性能层:擦除时间拖垮看门狗

现象:系统运行一段时间后复位,日志显示是在fdb_kv_set或者fdb_tsl_append期间触发的看门狗。

原因:KVDB 在扇区写满时需要擦除一整个 4KB 扇区,NOR Flash 的扇区擦除时间典型值在几十毫秒,最大可达数百毫秒;TSDB 在滚动覆盖时同样要擦除扇区。如果看门狗的超时时间设在 1 秒以内,正好赶上擦除,就会误复位。W25Q64 的 64KB 块擦除耗时更长,某些驱动在sfud_erase里对连续区域会合并成块擦除,实测能到 1 秒以上。

解决:先确认擦除的粒度,如果 FAL 的erase实现里把多个扇区合并成了块擦除,可以在w25q64_fal_erase里拆成逐扇区擦除,用时间换稳定。看门狗超时时间至少留 2 秒余量。更彻底的做法是在擦除前喂一次狗,并在w25q64_delay()回调里周期喂狗,因为 SFUD 在等待擦除完成时会调用这个函数。调试阶段可以把FDB_KVDB_DEBUG打开,日志里会打出每次擦除的耗时,方便和看门狗时间对比。

5.5 数据层:写进去的 KV 读出来长度对不上

现象:fdb_kv_set返回成功,但fdb_kv_get读到的长度比写入时短,或者内容尾部多了几个字节。

原因:fdb_kv_get的第四个参数是输入输出参数,传入时必须是一个有效的长度变量,返回时被改写成实际长度。如果传入的是一个未初始化的变量,或者把sizeof(buf)写成了sizeof(指针),就会得到错误的长度。还有一种情况是写入时用了fdb_kv_set_blob,读取时却用fdb_kv_get,这两个接口对数据的处理方式不同,混用会导致长度信息丢失。

解决:统一用fdb_kv_set_blob和fdb_kv_get_blob这一对接口来处理结构体和二进制数据,字符串和定长基本类型才用fdb_kv_set。每次读取前把长度变量显式赋值为缓冲区大小,读取后再校验返回值是否与预期一致。对于结构体,建议在结构体头部加一个魔数和一个 CRC 字段,读出来先校验再使用,能挡住大部分因为长度错位导致的数据误用。

6. 进阶验证:把擦除寿命与分区容量算成能写进文档的数

移植跑通只是及格线,能不能在产品里长期用,取决于两个数:分区够不够大、Flash 能扛多久。这两个数不能靠感觉,得算。

先算 TSDB 的容量。假设每条记录 32 字节,加上 FlashDB 内部的记录头(时间戳与状态标记)后约 48 字节,W25Q64 的最小擦除单位是 4KB,一条记录不能跨扇区存放,所以每个 4KB 扇区实际能放4096 / 48 ≈ 85条。1MB 的 TSDB 分区有 256 个扇区,理论容量约 21,000 条。如果设备每 10 分钟写一条,一天 144 条,能存 145 天左右。这个结果如果不够,就要么减小记录长度,要么把分区扩大。

再算寿命。KVDB 的写入是「追加 + 扇区写满后擦除」,32KB 分区有 8 个扇区,假设每条参数记录占 32 字节,一个扇区能追加 128 条记录后触发一次擦除,8 个扇区轮转一遍是 1024 次写入。W25Q64 的擦写寿命约 10 万次,理论上单个扇区能承受 10 万次擦除,按每天写入 100 次算,寿命远超设备本身的设计年限。但这是理想情况,实际还要考虑 KVDB 内部的磨损均衡是否均匀——如果分区只有 2 个扇区,擦除次数会集中在两个块上,寿命直接打对折。这也是我建议 KVDB 分区至少给 32KB 的原因。

验证方法上,我一般写一个压力测试循环,把fdb_kv_set和fdb_kv_get交替跑十万次,中间随机插fdb_tsl_append,每跑一万次打印一次耗时和失败计数。测试跑完再用fal_partition_read把整个分区的原始数据 dump 出来,检查开头几个字节是否还是有效的状态标记,如果出现异常,说明某次掉电或者擦除过程中断导致了元数据损坏。这个 dump 文件留着,下次出问题时可以直接对比现场数据。

最后一件事是掉电测试的强化。前面第 4 章做的是十几次拔电,正式验证建议做 500 次以上,用继电器控制电源,每次上电后自动读一次boot_count和最新一条 TSDB 记录的时间戳,记录到日志里。只要计数单调递增、时间戳不倒序,就说明这套存储栈在真实掉电场景下是可靠的。

从那以后我每次接新的 SPI NOR 项目,都强制先跑一遍「读 ID → 分区表打印 → 十万次 KV 压测 → 500 次掉电」这套流程,代码还没写一行业务逻辑,先确认存储层是干净的。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进